From 6921329fdca58f94bdb6f86a4d0b0ed4c7fffed2 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 12 Aug 2026 15:12:08 +0800 Subject: [PATCH 001/334] Unify model delete dialog controls --- src/ui/preferences_behavior_dialog.c | 10 ++------- src/ui/preferences_dialog.c | 22 +++++++------------ src/ui/ui_catime.c | 33 ++++++++++++++++++---------- src/ui/ui_catime.h | 3 +++ 4 files changed, 35 insertions(+), 33 deletions(-) diff --git a/src/ui/preferences_behavior_dialog.c b/src/ui/preferences_behavior_dialog.c index 0eff69d8..26e41052 100644 --- a/src/ui/preferences_behavior_dialog.c +++ b/src/ui/preferences_behavior_dialog.c @@ -81,14 +81,8 @@ static bool draw_header(BongoCatPreferences *value, struct nk_context *context, tr(value, "pages.preference.model.behaviorModal.title", "Motions and expressions"), value->ui.label_font, alpha(p.text, opacity)); struct nk_rect close = nk_rect(panel.x + panel.w - 52, panel.y + 17, 32, 32); - bool hover = enabled && nk_input_is_mouse_hovering_rect(&context->input, close); - nk_fill_rect(canvas, close, 8, alpha(hover ? p.hover : p.field, opacity)); - bongo_cat_preferences_icon_draw(value, canvas, BONGO_CAT_UI_ICON_CLOSE, - nk_rect(close.x + 7, close.y + 7, 18, 18), - alpha(hover ? p.accent : p.muted, opacity)); - if (hover) bongo_cat_ui_cursor_hover_rect(context, close, - BONGO_CAT_UI_CURSOR_POINTER); - return hit(context, close, enabled); + return bongo_cat_ui_close_button(context, canvas, close, + alpha(p.muted, opacity), alpha(p.accent, opacity), enabled); } static void draw_segments(BongoCatPreferences *value, diff --git a/src/ui/preferences_dialog.c b/src/ui/preferences_dialog.c index 7e805976..0e5ea1ea 100644 --- a/src/ui/preferences_dialog.c +++ b/src/ui/preferences_dialog.c @@ -80,10 +80,10 @@ static bool button(BongoCatApp *app, struct nk_context *context, struct nk_command_buffer *canvas, struct nk_rect bounds, const char *label, bool danger, BongoCatUIPalette p, float opacity, bool enabled) { bool hover = enabled && nk_input_is_mouse_hovering_rect(&context->input, bounds); - struct nk_color background = danger ? - (hover ? p.danger : p.danger_background) : (hover ? p.hover : p.field); - struct nk_color foreground = danger ? (hover ? nk_rgb(255, 255, 255) : - p.danger) : (hover ? p.accent : p.text); + struct nk_color background = danger ? p.danger : + (hover ? p.hover : p.field); + struct nk_color foreground = danger ? nk_rgb(255, 255, 255) : + (hover ? p.accent : p.text); nk_fill_rect(canvas, bounds, 10, alpha(background, opacity)); nk_stroke_rect(canvas, bounds, 10, 1, alpha(danger ? p.danger : (hover ? p.accent : p.border_subtle), opacity)); @@ -94,19 +94,13 @@ static bool button(BongoCatApp *app, struct nk_context *context, return hit(context, bounds, enabled); } -static bool close_button(BongoCatApp *app, struct nk_context *context, +static bool close_button(struct nk_context *context, struct nk_command_buffer *canvas, struct nk_rect panel, BongoCatUIPalette p, float opacity, bool enabled) { struct nk_rect bounds = nk_rect(panel.x + panel.w - 52, panel.y + 17, 32, 32); - bool hover = enabled && nk_input_is_mouse_hovering_rect(&context->input, bounds); - nk_fill_rect(canvas, bounds, 8, alpha(hover ? p.hover : p.field, opacity)); - bongo_cat_preferences_icon_draw(app->preferences, canvas, - BONGO_CAT_UI_ICON_CLOSE, nk_rect(bounds.x + 7, - bounds.y + 7, 18, 18), alpha(hover ? p.accent : p.muted, opacity)); - if (hover) bongo_cat_ui_cursor_hover_rect(context, bounds, - BONGO_CAT_UI_CURSOR_POINTER); - return hit(context, bounds, enabled); + return bongo_cat_ui_close_button(context, canvas, bounds, + alpha(p.muted, opacity), alpha(p.accent, opacity), enabled); } static void remove_model(BongoCatApp *app) { @@ -157,7 +151,7 @@ void bongo_cat_preferences_remove_dialog_draw(BongoCatApp *app, remove_bounds.y, 88, 36); bool enabled = !closing && bongo_cat_preferences_overlay_input_ready( context, &remove_dialog.input_armed); - bool close = close_button(app, context, canvas, frame.panel, + bool close = close_button(context, canvas, frame.panel, p, opacity, enabled); if (button(app, context, canvas, cancel_bounds, cancel, false, p, opacity, enabled)) close = true; diff --git a/src/ui/ui_catime.c b/src/ui/ui_catime.c index 9b993460..dee7f51f 100644 --- a/src/ui/ui_catime.c +++ b/src/ui/ui_catime.c @@ -166,19 +166,30 @@ bool bongo_cat_ui_content_header(struct nk_context *context, nk_draw_text(canvas, text, title, nk_strlen(title), font, nk_rgba(0, 0, 0, 0), p.accent); struct nk_rect close = nk_rect(bounds.x + bounds.w - 48, bounds.y + 11, 34, 34); - bool hover = interactive && nk_input_is_mouse_hovering_rect(&context->input, close); - struct nk_color color = hover ? p.accent : p.muted; - if (!bongo_cat_ui_draw_icon(canvas, BONGO_CAT_UI_ICON_CLOSE, - nk_rect(close.x + 7, close.y + 7, 20, 20), color)) { - nk_stroke_line(canvas, close.x + 10, close.y + 10, - close.x + 24, close.y + 24, 2, color); - nk_stroke_line(canvas, close.x + 24, close.y + 10, - close.x + 10, close.y + 24, 2, color); + return bongo_cat_ui_close_button(context, canvas, close, p.muted, + p.accent, interactive); +} + +bool bongo_cat_ui_close_button(struct nk_context *context, + struct nk_command_buffer *canvas, struct nk_rect bounds, + struct nk_color normal, struct nk_color hover, bool interactive) { + bool is_hovered = interactive && nk_input_is_mouse_hovering_rect( + &context->input, bounds); + struct nk_color color = is_hovered ? hover : normal; + float icon_size = NK_MAX(0.0f, NK_MIN(bounds.w, bounds.h) - 14.0f); + struct nk_rect icon = nk_rect(bounds.x + (bounds.w - icon_size) * .5f, + bounds.y + (bounds.h - icon_size) * .5f, icon_size, icon_size); + if (!bongo_cat_ui_draw_icon(canvas, BONGO_CAT_UI_ICON_CLOSE, icon, color)) { + float inset = icon_size * .22f; + nk_stroke_line(canvas, icon.x + inset, icon.y + inset, + icon.x + icon.w - inset, icon.y + icon.h - inset, 2, color); + nk_stroke_line(canvas, icon.x + icon.w - inset, icon.y + inset, + icon.x + inset, icon.y + icon.h - inset, 2, color); } - if (hover) bongo_cat_ui_cursor_hover_rect(context, close, + if (is_hovered) bongo_cat_ui_cursor_hover_rect(context, bounds, BONGO_CAT_UI_CURSOR_POINTER); - return hover && nk_input_is_mouse_click_in_rect(&context->input, - NK_BUTTON_LEFT, close); + return is_hovered && nk_input_is_mouse_click_in_rect(&context->input, + NK_BUTTON_LEFT, bounds); } bool bongo_cat_ui_close_hit(float x, float y, float width) { diff --git a/src/ui/ui_catime.h b/src/ui/ui_catime.h index a941f0a3..3c4da09a 100644 --- a/src/ui/ui_catime.h +++ b/src/ui/ui_catime.h @@ -44,6 +44,9 @@ bool bongo_cat_ui_header(struct nk_context *context, const char *title, bool *title_clicked, bool interactive, bool dark); bool bongo_cat_ui_content_header(struct nk_context *context, const char *title, int icon, bool interactive, bool dark); +bool bongo_cat_ui_close_button(struct nk_context *context, + struct nk_command_buffer *canvas, struct nk_rect bounds, + struct nk_color normal, struct nk_color hover, bool interactive); void bongo_cat_ui_tabs(struct nk_context *context, const char *const *labels, int count, int *active, bool interactive, bool dark, float available_height, From 46b666394b195f2d5df34db1d7e210b20afd322d Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 12 Aug 2026 15:21:05 +0800 Subject: [PATCH 002/334] Refine model deletion prompt --- resources/assets/locales/de-DE.json | 2 +- resources/assets/locales/en-US.json | 2 +- resources/assets/locales/es-ES.json | 2 +- resources/assets/locales/fr-FR.json | 2 +- resources/assets/locales/ja-JP.json | 2 +- resources/assets/locales/ko-KR.json | 2 +- resources/assets/locales/pt-BR.json | 2 +- resources/assets/locales/ru-RU.json | 2 +- resources/assets/locales/zh-CN.json | 2 +- resources/assets/locales/zh-TW.json | 2 +- src/ui/preferences_dialog.c | 21 ++++++++++++++++----- 11 files changed, 26 insertions(+), 15 deletions(-) diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index a00445b2..dadd8452 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -80,7 +80,7 @@ }, "hints": { "deleteSuccess": "Erfolgreich gelöscht", - "deleteModel": "Möchten Sie dieses Modell wirklich löschen?", + "deleteModel": "Möchten Sie \"%s\" wirklich löschen?", "importSuccess": "Erfolgreich importiert", "clickOrDragToImport": "Zum Importieren klicken oder hierher ziehen", "openDirectoryFailed": "Modellordner konnte nicht geöffnet werden" diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index da82d6a2..855fe456 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -80,7 +80,7 @@ }, "hints": { "deleteSuccess": "Deleted Successfully", - "deleteModel": "Are you sure you want to delete this model?", + "deleteModel": "Are you sure you want to delete \"%s\"?", "importSuccess": "Imported Successfully", "clickOrDragToImport": "Click or drag here to import", "openDirectoryFailed": "Unable to open model directory" diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index f2f55a22..0d6ea125 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -80,7 +80,7 @@ }, "hints": { "deleteSuccess": "Eliminado correctamente", - "deleteModel": "¿Seguro que quiere eliminar este modelo?", + "deleteModel": "¿Seguro que quiere eliminar \"%s\"?", "importSuccess": "Importado correctamente", "clickOrDragToImport": "Haga clic o arrastre aquí para importar", "openDirectoryFailed": "No se pudo abrir la carpeta del modelo" diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index 9aa0feed..af1cbed2 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -80,7 +80,7 @@ }, "hints": { "deleteSuccess": "Suppression réussie", - "deleteModel": "Voulez-vous vraiment supprimer ce modèle ?", + "deleteModel": "Voulez-vous vraiment supprimer \"%s\" ?", "importSuccess": "Importation réussie", "clickOrDragToImport": "Cliquez ou déposez ici pour importer", "openDirectoryFailed": "Impossible d'ouvrir le dossier du modèle" diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index eeae10f7..24ae61ef 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -80,7 +80,7 @@ }, "hints": { "deleteSuccess": "削除しました", - "deleteModel": "このモデルを削除しますか?", + "deleteModel": "「%s」を削除しますか?", "importSuccess": "インポートしました", "clickOrDragToImport": "クリックまたはここへドラッグしてインポート", "openDirectoryFailed": "モデルフォルダーを開けません" diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index 3ad007fc..3ce20c64 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -80,7 +80,7 @@ }, "hints": { "deleteSuccess": "삭제 완료", - "deleteModel": "이 모델을 삭제하시겠습니까?", + "deleteModel": "\"%s\" 모델을 삭제하시겠습니까?", "importSuccess": "가져오기 완료", "clickOrDragToImport": "클릭하거나 여기로 끌어와 가져오기", "openDirectoryFailed": "모델 폴더를 열 수 없습니다" diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index 9846659d..fc8beb59 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -80,7 +80,7 @@ }, "hints": { "deleteSuccess": "Excluído com sucesso", - "deleteModel": "Tem certeza de que deseja excluir este modelo?", + "deleteModel": "Tem certeza de que deseja excluir \"%s\"?", "importSuccess": "Importação bem-sucedida", "clickOrDragToImport": "Clique ou arraste para importar", "openDirectoryFailed": "Não foi possível abrir a pasta do modelo" diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index 3c3ee100..898f6b58 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -80,7 +80,7 @@ }, "hints": { "deleteSuccess": "Успешно удалено", - "deleteModel": "Удалить эту модель?", + "deleteModel": "Удалить модель \"%s\"?", "importSuccess": "Успешно импортировано", "clickOrDragToImport": "Щёлкните или перетащите сюда для импорта", "openDirectoryFailed": "Не удалось открыть папку модели" diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index c6e5baa4..a028a5ed 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -80,7 +80,7 @@ }, "hints": { "deleteSuccess": "删除成功", - "deleteModel": "你确定要删除此模型吗?", + "deleteModel": "你确定要删除“%s”吗?", "importSuccess": "导入成功", "clickOrDragToImport": "点击或拖动至此区域导入", "openDirectoryFailed": "无法打开模型目录" diff --git a/resources/assets/locales/zh-TW.json b/resources/assets/locales/zh-TW.json index 5cd40323..76959bf4 100644 --- a/resources/assets/locales/zh-TW.json +++ b/resources/assets/locales/zh-TW.json @@ -80,7 +80,7 @@ }, "hints": { "deleteSuccess": "刪除成功", - "deleteModel": "您確定要刪除此模型嗎?", + "deleteModel": "您確定要刪除「%s」嗎?", "importSuccess": "匯入成功", "clickOrDragToImport": "點擊或拖曳至此區域匯入", "openDirectoryFailed": "無法開啟模型目錄" diff --git a/src/ui/preferences_dialog.c b/src/ui/preferences_dialog.c index 0e5ea1ea..73252cfa 100644 --- a/src/ui/preferences_dialog.c +++ b/src/ui/preferences_dialog.c @@ -13,6 +13,7 @@ typedef struct RemoveDialog { BongoCatApp *app; char model_id[BONGO_CAT_ID_CAP]; + char model_name[BONGO_CAT_ID_CAP]; uint64_t opened_ns; uint64_t closing_ns; bool input_armed; @@ -29,6 +30,11 @@ static struct nk_color alpha(struct nk_color color, float amount) { return bongo_cat_preferences_overlay_alpha(color, amount); } +static const char *model_name(const BongoCatModelEntry *entry) { + return entry && entry->display_name[0] ? entry->display_name : + (entry ? entry->id : "model"); +} + static void text(struct nk_command_buffer *canvas, struct nk_rect bounds, const char *value, const struct nk_user_font *font, struct nk_color color) { nk_draw_text(canvas, bounds, value, nk_strlen(value), font, @@ -58,6 +64,9 @@ void bongo_cat_preferences_remove_dialog_open(BongoCatApp *app, if (!app || !id || !id[0]) return; remove_dialog.app = app; snprintf(remove_dialog.model_id, sizeof(remove_dialog.model_id), "%s", id); + const BongoCatModelEntry *entry = bongo_cat_models_find(&app->models, id); + snprintf(remove_dialog.model_name, sizeof(remove_dialog.model_name), "%s", + model_name(entry)); remove_dialog.opened_ns = SDL_GetTicksNS(); remove_dialog.closing_ns = 0; remove_dialog.input_armed = false; @@ -85,8 +94,8 @@ static bool button(BongoCatApp *app, struct nk_context *context, struct nk_color foreground = danger ? nk_rgb(255, 255, 255) : (hover ? p.accent : p.text); nk_fill_rect(canvas, bounds, 10, alpha(background, opacity)); - nk_stroke_rect(canvas, bounds, 10, 1, alpha(danger ? p.danger : - (hover ? p.accent : p.border_subtle), opacity)); + if (danger) nk_stroke_rect(canvas, bounds, 10, 1, + alpha(p.danger, opacity)); centered(canvas, bounds, label, app->preferences->ui.caption_font, alpha(foreground, opacity)); if (hover) bongo_cat_ui_cursor_hover_rect(context, bounds, @@ -138,10 +147,12 @@ void bongo_cat_preferences_remove_dialog_draw(BongoCatApp *app, text(canvas, nk_rect(frame.panel.x + 20, frame.panel.y + 21, frame.panel.w - 74, 24), tr(app, "native.delete", "Delete"), app->preferences->ui.label_font, alpha(p.text, opacity)); - text(canvas, nk_rect(frame.panel.x + 20, frame.panel.y + 75, - frame.panel.w - 40, 44), tr(app, + char prompt[sizeof(remove_dialog.model_name) + 128]; + snprintf(prompt, sizeof(prompt), tr(app, "pages.preference.model.hints.deleteModel", - "Delete this custom model?"), app->preferences->ui.body_font, + "Are you sure you want to delete \"%s\"?"), remove_dialog.model_name); + text(canvas, nk_rect(frame.panel.x + 20, frame.panel.y + 75, + frame.panel.w - 40, 44), prompt, app->preferences->ui.body_font, alpha(p.text, opacity)); const char *cancel = tr(app, "native.cancel", "Cancel"); const char *remove = tr(app, "native.delete", "Delete"); From 066d9cca1003286b5319973fbe2964756054cc01 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 12 Aug 2026 17:56:27 +0800 Subject: [PATCH 003/334] Include model name in deletion notice --- resources/assets/locales/de-DE.json | 2 +- resources/assets/locales/en-US.json | 2 +- resources/assets/locales/es-ES.json | 2 +- resources/assets/locales/fr-FR.json | 2 +- resources/assets/locales/ja-JP.json | 2 +- resources/assets/locales/ko-KR.json | 2 +- resources/assets/locales/pt-BR.json | 2 +- resources/assets/locales/ru-RU.json | 2 +- resources/assets/locales/zh-CN.json | 2 +- resources/assets/locales/zh-TW.json | 2 +- src/ui/preferences_dialog.c | 6 ++++-- 11 files changed, 14 insertions(+), 12 deletions(-) diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index dadd8452..0c4e7c5f 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -79,7 +79,7 @@ "nearbyModels": "Benachbarte Ordner" }, "hints": { - "deleteSuccess": "Erfolgreich gelöscht", + "deleteSuccess": "\"%s\" wurde gelöscht", "deleteModel": "Möchten Sie \"%s\" wirklich löschen?", "importSuccess": "Erfolgreich importiert", "clickOrDragToImport": "Zum Importieren klicken oder hierher ziehen", diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index 855fe456..982f5bb9 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -79,7 +79,7 @@ "nearbyModels": "Nearby folders" }, "hints": { - "deleteSuccess": "Deleted Successfully", + "deleteSuccess": "Deleted \"%s\"", "deleteModel": "Are you sure you want to delete \"%s\"?", "importSuccess": "Imported Successfully", "clickOrDragToImport": "Click or drag here to import", diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index 0d6ea125..21adb30b 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -79,7 +79,7 @@ "nearbyModels": "Carpetas cercanas" }, "hints": { - "deleteSuccess": "Eliminado correctamente", + "deleteSuccess": "Se eliminó \"%s\"", "deleteModel": "¿Seguro que quiere eliminar \"%s\"?", "importSuccess": "Importado correctamente", "clickOrDragToImport": "Haga clic o arrastre aquí para importar", diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index af1cbed2..ac17f90b 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -79,7 +79,7 @@ "nearbyModels": "Dossiers voisins" }, "hints": { - "deleteSuccess": "Suppression réussie", + "deleteSuccess": "« %s » a été supprimé", "deleteModel": "Voulez-vous vraiment supprimer \"%s\" ?", "importSuccess": "Importation réussie", "clickOrDragToImport": "Cliquez ou déposez ici pour importer", diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index 24ae61ef..c5fd28fb 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -79,7 +79,7 @@ "nearbyModels": "近くのフォルダー" }, "hints": { - "deleteSuccess": "削除しました", + "deleteSuccess": "「%s」を削除しました", "deleteModel": "「%s」を削除しますか?", "importSuccess": "インポートしました", "clickOrDragToImport": "クリックまたはここへドラッグしてインポート", diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index 3ce20c64..f1f3b63b 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -79,7 +79,7 @@ "nearbyModels": "주변 폴더" }, "hints": { - "deleteSuccess": "삭제 완료", + "deleteSuccess": "\"%s\" 모델을 삭제했습니다", "deleteModel": "\"%s\" 모델을 삭제하시겠습니까?", "importSuccess": "가져오기 완료", "clickOrDragToImport": "클릭하거나 여기로 끌어와 가져오기", diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index fc8beb59..99908713 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -79,7 +79,7 @@ "nearbyModels": "Pastas próximas" }, "hints": { - "deleteSuccess": "Excluído com sucesso", + "deleteSuccess": "Modelo \"%s\" excluído", "deleteModel": "Tem certeza de que deseja excluir \"%s\"?", "importSuccess": "Importação bem-sucedida", "clickOrDragToImport": "Clique ou arraste para importar", diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index 898f6b58..f3d3fc94 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -79,7 +79,7 @@ "nearbyModels": "Соседние папки" }, "hints": { - "deleteSuccess": "Успешно удалено", + "deleteSuccess": "Модель \"%s\" удалена", "deleteModel": "Удалить модель \"%s\"?", "importSuccess": "Успешно импортировано", "clickOrDragToImport": "Щёлкните или перетащите сюда для импорта", diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index a028a5ed..2948a6d0 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -79,7 +79,7 @@ "nearbyModels": "附近目录下" }, "hints": { - "deleteSuccess": "删除成功", + "deleteSuccess": "已删除“%s”", "deleteModel": "你确定要删除“%s”吗?", "importSuccess": "导入成功", "clickOrDragToImport": "点击或拖动至此区域导入", diff --git a/resources/assets/locales/zh-TW.json b/resources/assets/locales/zh-TW.json index 76959bf4..f3d1862e 100644 --- a/resources/assets/locales/zh-TW.json +++ b/resources/assets/locales/zh-TW.json @@ -79,7 +79,7 @@ "nearbyModels": "附近資料夾" }, "hints": { - "deleteSuccess": "刪除成功", + "deleteSuccess": "已刪除「%s」", "deleteModel": "您確定要刪除「%s」嗎?", "importSuccess": "匯入成功", "clickOrDragToImport": "點擊或拖曳至此區域匯入", diff --git a/src/ui/preferences_dialog.c b/src/ui/preferences_dialog.c index 73252cfa..8606cb23 100644 --- a/src/ui/preferences_dialog.c +++ b/src/ui/preferences_dialog.c @@ -118,9 +118,11 @@ static void remove_model(BongoCatApp *app) { &error) != BONGO_CAT_OK) { bongo_cat_preferences_notice_show(app, error.message, true); } else { - bongo_cat_preferences_notice_show(app, tr(app, + char message[sizeof(remove_dialog.model_name) + 128]; + snprintf(message, sizeof(message), tr(app, "pages.preference.model.hints.deleteSuccess", - "Deleted successfully"), false); + "Deleted \"%s\""), remove_dialog.model_name); + bongo_cat_preferences_notice_show(app, message, false); } bongo_cat_preferences_remove_dialog_close(app); } From 52a1bdcabf739b30cdc07611893b0ed1b6f71f63 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 12 Aug 2026 18:58:28 +0800 Subject: [PATCH 004/334] Harden model switching state restoration --- include/bongo_cat/app.h | 4 ++ include/bongo_cat/overlay.h | 1 + src/core/app_state.c | 67 ++++++++++++++++++++++++++++++ src/render/mver_pointer_overlay.c | 4 ++ src/render/mver_pointer_overlay.h | 1 + src/render/overlay.c | 68 +++++++++++++++++++++++++++---- src/runtime/model_files.c | 57 +++++++++++++++++++------- src/runtime/window.c | 17 +++++++- src/runtime/window_menu_preview.c | 25 ++++++++++-- 9 files changed, 218 insertions(+), 26 deletions(-) diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index b529964e..bac894bc 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -121,11 +121,15 @@ typedef struct BongoCatApp { float left_stick_x, left_stick_y; float right_stick_x, right_stick_y; bool left_stick_pressed, right_stick_pressed; + BongoCatInputEvent active_inputs[ + BONGO_CAT_INPUT_KEY_STATE_CAP + BONGO_CAT_INPUT_RECOVERY_CAP]; + size_t active_input_count; uint32_t active_gamepad; } BongoCatApp; int bongo_cat_app_run(int argc, char **argv); void bongo_cat_app_apply_input(BongoCatApp *app, const BongoCatInputEvent *event); +void bongo_cat_app_reapply_input(BongoCatApp *app); void bongo_cat_app_reset_gamepad(BongoCatApp *app); void bongo_cat_gamepad_event(BongoCatApp *app, const void *sdl_event); void bongo_cat_app_shortcuts(BongoCatApp *app, const BongoCatInputEvent *event); diff --git a/include/bongo_cat/overlay.h b/include/bongo_cat/overlay.h index b749ad23..2a05efa6 100644 --- a/include/bongo_cat/overlay.h +++ b/include/bongo_cat/overlay.h @@ -7,6 +7,7 @@ typedef struct BongoCatOverlay BongoCatOverlay; BongoCatOverlay *bongo_cat_overlay_create(BongoCatError *error); void bongo_cat_overlay_destroy(BongoCatOverlay *overlay); +void bongo_cat_overlay_clear(BongoCatOverlay *overlay); BongoCatResult bongo_cat_overlay_load(BongoCatOverlay *overlay, const char *model_directory, BongoCatError *error); int bongo_cat_overlay_key(BongoCatOverlay *overlay, const char *name, bool pressed); diff --git a/src/core/app_state.c b/src/core/app_state.c index 9d0e72aa..05265f35 100644 --- a/src/core/app_state.c +++ b/src/core/app_state.c @@ -4,6 +4,50 @@ #include #include +static size_t active_input_index(const BongoCatApp *app, + BongoCatInputKind kind, const char *name) { + if (!app || !name) return app ? app->active_input_count : 0; + for (size_t i = 0; i < app->active_input_count; ++i) { + const BongoCatInputEvent *active = &app->active_inputs[i]; + if (active->kind == kind && strcmp(active->name, name) == 0) return i; + } + return app->active_input_count; +} + +static void active_input_remove(BongoCatApp *app, size_t index) { + if (!app || index >= app->active_input_count) return; + if (index + 1 < app->active_input_count) + memmove(&app->active_inputs[index], &app->active_inputs[index + 1], + (app->active_input_count - index - 1) * sizeof(app->active_inputs[0])); + memset(&app->active_inputs[--app->active_input_count], 0, + sizeof(app->active_inputs[0])); +} + +static void active_input_update(BongoCatApp *app, + const BongoCatInputEvent *event) { + if (!app || !event || !event->name[0]) return; + bool tracked = event->kind == BONGO_CAT_INPUT_KEY_DOWN || + event->kind == BONGO_CAT_INPUT_KEY_UP || + event->kind == BONGO_CAT_INPUT_GAMEPAD_BUTTON || + event->kind == BONGO_CAT_INPUT_GAMEPAD_AXIS; + if (!tracked) return; + bool active = event->kind == BONGO_CAT_INPUT_KEY_DOWN || + (event->kind == BONGO_CAT_INPUT_GAMEPAD_BUTTON && event->value > 0.5f) || + (event->kind == BONGO_CAT_INPUT_GAMEPAD_AXIS && fabsf(event->value) > .001f); + BongoCatInputKind kind = event->kind == BONGO_CAT_INPUT_KEY_DOWN || + event->kind == BONGO_CAT_INPUT_KEY_UP + ? BONGO_CAT_INPUT_KEY_DOWN : event->kind; + size_t index = active_input_index(app, kind, event->name); + if (!active) { active_input_remove(app, index); return; } + if (index == app->active_input_count) { + if (app->active_input_count >= + sizeof(app->active_inputs) / sizeof(app->active_inputs[0])) return; + index = app->active_input_count++; + } + app->active_inputs[index] = *event; + app->active_inputs[index].kind = kind; +} + static bool stick_active(float x, float y, bool pressed) { return pressed || fabsf(x) > 0.001f || fabsf(y) > 0.001f; } @@ -79,12 +123,17 @@ void bongo_cat_app_reset_gamepad(BongoCatApp *app) { "RightPaddle2", "LeftPaddle2", "Touchpad", "LeftThumb", "RightThumb"}; for (size_t i = 0; i < sizeof(buttons) / sizeof(buttons[0]); ++i) apply_key(app, buttons[i], false); + for (size_t i = app->active_input_count; i > 0; --i) + if (app->active_inputs[i - 1].kind == BONGO_CAT_INPUT_GAMEPAD_BUTTON || + app->active_inputs[i - 1].kind == BONGO_CAT_INPUT_GAMEPAD_AXIS) + active_input_remove(app, i - 1); update_hands(app); app->dirty = true; } void bongo_cat_app_apply_input(BongoCatApp *app, const BongoCatInputEvent *event) { if (!app || !event || !app->live2d) return; + active_input_update(app, event); switch (event->kind) { case BONGO_CAT_INPUT_KEY_DOWN: apply_key(app, event->name, true); break; case BONGO_CAT_INPUT_KEY_UP: apply_key(app, event->name, false); break; @@ -107,3 +156,21 @@ void bongo_cat_app_apply_input(BongoCatApp *app, const BongoCatInputEvent *event default: break; } } + +void bongo_cat_app_reapply_input(BongoCatApp *app) { + if (!app || !app->live2d) return; + bongo_cat_live2d_set_parameter(app->live2d, "ParamMouseLeftDown", + app->left_mouse_down ? 1.0f : 0.0f); + bongo_cat_live2d_set_parameter(app->live2d, "ParamMouseRightDown", + app->right_mouse_down ? 1.0f : 0.0f); + for (size_t i = 0; i < app->active_input_count; ++i) { + BongoCatInputEvent *event = &app->active_inputs[i]; + if (event->kind == BONGO_CAT_INPUT_KEY_DOWN) + apply_key(app, event->name, true); + else if (event->kind == BONGO_CAT_INPUT_GAMEPAD_BUTTON || + event->kind == BONGO_CAT_INPUT_GAMEPAD_AXIS) + apply_gamepad(app, event); + } + update_hands(app); + app->dirty = true; +} diff --git a/src/render/mver_pointer_overlay.c b/src/render/mver_pointer_overlay.c index 1b3a874e..bf378894 100644 --- a/src/render/mver_pointer_overlay.c +++ b/src/render/mver_pointer_overlay.c @@ -72,6 +72,10 @@ void bongo_cat_mver_pointer_overlay_destroy(BongoCatMverPointerOverlay *value) { free(value); } +void bongo_cat_mver_pointer_overlay_clear(BongoCatMverPointerOverlay *value) { + if (value) clear_textures(value); +} + static bool safe_relative(const char *path) { if (!path || !path[0] || path[0] == '/' || path[0] == '\\' || strchr(path, ':')) return false; diff --git a/src/render/mver_pointer_overlay.h b/src/render/mver_pointer_overlay.h index 206eccc8..5fff103c 100644 --- a/src/render/mver_pointer_overlay.h +++ b/src/render/mver_pointer_overlay.h @@ -8,6 +8,7 @@ typedef struct BongoCatMverPointerOverlay BongoCatMverPointerOverlay; BongoCatMverPointerOverlay *bongo_cat_mver_pointer_overlay_create( BongoCatError *error); void bongo_cat_mver_pointer_overlay_destroy(BongoCatMverPointerOverlay *value); +void bongo_cat_mver_pointer_overlay_clear(BongoCatMverPointerOverlay *value); bool bongo_cat_mver_pointer_overlay_load(BongoCatMverPointerOverlay *value, const char *directory, BongoCatError *error); bool bongo_cat_mver_pointer_overlay_enabled( diff --git a/src/render/overlay.c b/src/render/overlay.c index 757500c8..ecc463b3 100644 --- a/src/render/overlay.c +++ b/src/render/overlay.c @@ -63,6 +63,7 @@ static void clear_textures(BongoCatOverlay *value) { value->composite = 0; value->clean_paws = false; value->composed_cover = false; + value->composite_dirty = false; for (size_t i = 0; i < 4; ++i) { if (value->cache[i].texture) glDeleteTextures(1, &value->cache[i].texture); memset(&value->cache[i], 0, sizeof(value->cache[i])); @@ -72,6 +73,7 @@ static void clear_textures(BongoCatOverlay *value) { value->left_name[0] = value->right_name[0] = '\0'; value->left_path[0] = value->right_path[0] = '\0'; value->effect_path[0] = '\0'; + value->background_path[0] = '\0'; } BongoCatOverlay *bongo_cat_overlay_create(BongoCatError *error) { @@ -115,25 +117,75 @@ void bongo_cat_overlay_destroy(BongoCatOverlay *value) { free(value); } -BongoCatResult bongo_cat_overlay_load(BongoCatOverlay *value, const char *directory, BongoCatError *error) { - if (!value || !directory) return BONGO_CAT_ERROR_ARGUMENT; +void bongo_cat_overlay_clear(BongoCatOverlay *value) { + if (!value) return; clear_textures(value); - snprintf(value->directory, sizeof(value->directory), "%s", directory); + bongo_cat_mver_pointer_overlay_clear(value->mver_pointer); + value->directory[0] = '\0'; +} + +BongoCatResult bongo_cat_overlay_load(BongoCatOverlay *value, const char *directory, BongoCatError *error) { + if (!value || !directory) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, + "Missing model overlay state or directory"); + return BONGO_CAT_ERROR_ARGUMENT; + } + BongoCatError local = {0}; + BongoCatError *failure = error ? error : &local; + *failure = (BongoCatError){0}; + BongoCatMverPointerOverlay *pointer = + bongo_cat_mver_pointer_overlay_create(failure); + if (!pointer) return failure->code ? failure->code : BONGO_CAT_ERROR_MEMORY; char path[BONGO_CAT_PATH_CAP]; /* Without the licensed Cubism runtime there is no model renderer. Use the model's composed preview so the desktop pet remains visually complete; Cubism builds keep the background-only layer behind the animated model. */ #ifdef BONGO_CAT_HAS_CUBISM - bongo_cat_path_join(path, sizeof(path), directory, "resources/background.png"); + if (!bongo_cat_path_join(path, sizeof(path), directory, + "resources/background.png")) { + bongo_cat_mver_pointer_overlay_destroy(pointer); + bongo_cat_error_set(failure, BONGO_CAT_ERROR_IO, "Model overlay path is too long"); + return BONGO_CAT_ERROR_IO; + } #else - bongo_cat_path_join(path, sizeof(path), directory, "resources/cover.png"); + if (!bongo_cat_path_join(path, sizeof(path), directory, + "resources/cover.png")) { + bongo_cat_mver_pointer_overlay_destroy(pointer); + bongo_cat_error_set(failure, BONGO_CAT_ERROR_IO, "Model overlay path is too long"); + return BONGO_CAT_ERROR_IO; + } +#endif + GLuint background = 0; + BongoCatError background_error = {0}; + if (bongo_cat_path_is_file(path)) background = bongo_cat_image_texture(path, + NULL, NULL, &background_error); + BongoCatError pointer_error = {0}; + if (!bongo_cat_mver_pointer_overlay_load(pointer, directory, &pointer_error)) { + bongo_cat_mver_pointer_overlay_destroy(pointer); + if (background) glDeleteTextures(1, &background); + *failure = pointer_error; + if (!failure->message[0] && background_error.message[0]) + *failure = background_error; + if (!failure->message[0]) bongo_cat_error_set(failure, BONGO_CAT_ERROR_IO, + "Unable to load model overlay"); + return BONGO_CAT_ERROR_IO; + } + if (!background && background_error.message[0]) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", background_error.message); + BongoCatMverPointerOverlay *previous_pointer = value->mver_pointer; + clear_textures(value); + value->mver_pointer = pointer; + value->background = background; + value->composed_cover = false; + value->clean_paws = false; +#ifndef BONGO_CAT_HAS_CUBISM value->composed_cover = true; value->clean_paws = true; #endif + snprintf(value->directory, sizeof(value->directory), "%s", directory); snprintf(value->background_path, sizeof(value->background_path), "%s", path); - if (bongo_cat_path_is_file(path)) value->background = bongo_cat_image_texture(path, NULL, NULL, error); - return bongo_cat_mver_pointer_overlay_load(value->mver_pointer, - directory, error) ? BONGO_CAT_OK : BONGO_CAT_ERROR_IO; + if (previous_pointer) bongo_cat_mver_pointer_overlay_destroy(previous_pointer); + return BONGO_CAT_OK; } #ifdef BONGO_CAT_HAS_CUBISM diff --git a/src/runtime/model_files.c b/src/runtime/model_files.c index 120b77b7..104f48b7 100644 --- a/src/runtime/model_files.c +++ b/src/runtime/model_files.c @@ -2,6 +2,7 @@ #include "model_import.h" #include "model_storage.h" #include "bongo_cat/file.h" +#include "bongo_cat/audio.h" #include "bongo_cat/overlay.h" #include "bongo_cat/path.h" @@ -54,7 +55,10 @@ static void apply_model_aspect(BongoCatApp *app, } static void commit_model(BongoCatApp *app, - const BongoCatModelEntry *entry) { + const BongoCatModelEntry *entry, bool force_refresh) { + bool changed = strcmp(app->config.current_model, entry->id) != 0 || + app->config.current_mode != entry->mode; + if (!changed && !force_refresh) return; select_model_state(app, entry); app->model_selection_serial++; request_model_frame(app); @@ -77,7 +81,7 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, return false; } if (app->loaded_model[0] && strcmp(app->loaded_model, entry->id) == 0) { - commit_model(app, entry); + commit_model(app, entry, false); return true; } BongoCatError optional = {0}; @@ -138,16 +142,23 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, free(behaviors); optional = (BongoCatError){0}; if (bongo_cat_overlay_load(app->overlay, entry->adapter_directory, - &optional) != BONGO_CAT_OK && optional.message[0]) - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", optional.message); + &optional) != BONGO_CAT_OK) { + if (optional.message[0]) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", optional.message); + bongo_cat_overlay_clear(app->overlay); + } snprintf(app->loaded_model, sizeof(app->loaded_model), "%s", entry->id); app->pointer_known = false; bongo_cat_live2d_resize(app->live2d, pixel_width, pixel_height); + bongo_cat_audio_stop(app->audio); + commit_model(app, entry, true); + bongo_cat_app_reapply_input(app); + bongo_cat_app_apply_mouse(app); if (restore_context && previous_window && previous_context && !SDL_GL_MakeCurrent(previous_window, previous_context)) SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Cannot restore the previous OpenGL context: %s", SDL_GetError()); - bongo_cat_memory_policy_model_loaded(); commit_model(app, entry); + bongo_cat_memory_policy_model_loaded(); return true; } @@ -279,20 +290,38 @@ BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, "Built-in models cannot be removed: %s", id); return BONGO_CAT_ERROR_ARGUMENT; } - bool selected = strcmp(id, app->config.current_model) == 0; + bool selected = strcmp(id, app->config.current_model) == 0 || + strcmp(id, app->loaded_model) == 0; char directory[BONGO_CAT_PATH_CAP]; snprintf(directory, sizeof(directory), "%s", entry->storage_directory); - if (!bongo_cat_model_remove_tree(directory, error)) return BONGO_CAT_ERROR_IO; - bongo_cat_app_rescan_models(app); if (selected) { BongoCatError load_error = {0}; + bool replacement = false; for (size_t i = 0; i < app->models.count; ++i) - if (bongo_cat_app_select_model_with_error(app, - app->models.entries[i].id, &load_error)) return BONGO_CAT_OK; - bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, - "Model was removed, but no replacement could be displayed: %s", - load_error.message[0] ? load_error.message : "no installed models"); - return BONGO_CAT_ERROR_CUBISM; + if (strcmp(app->models.entries[i].id, id) != 0 && + bongo_cat_app_select_model_with_error(app, + app->models.entries[i].id, &load_error)) { + replacement = true; + break; + } + if (!replacement) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, + "Cannot delete the active model because no replacement could be displayed: %s", + load_error.message[0] ? load_error.message : "no installed models"); + return BONGO_CAT_ERROR_CUBISM; + } } + if (!bongo_cat_model_remove_tree(directory, error)) { + bongo_cat_app_rescan_models(app); + if (selected && bongo_cat_models_find(&app->models, id)) { + BongoCatError restore_error = {0}; + if (!bongo_cat_app_select_model_with_error(app, id, &restore_error)) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Unable to restore model after deletion failed: %s", + restore_error.message[0] ? restore_error.message : "unknown error"); + } + return BONGO_CAT_ERROR_IO; + } + bongo_cat_app_rescan_models(app); return BONGO_CAT_OK; } diff --git a/src/runtime/window.c b/src/runtime/window.c index 4760e047..b911b096 100644 --- a/src/runtime/window.c +++ b/src/runtime/window.c @@ -2,6 +2,7 @@ #include "window_menu.h" #include "bongo_cat/i18n.h" #include "bongo_cat/preferences.h" +#include "../ui/preferences_notice.h" #include #include @@ -101,6 +102,20 @@ static const char *tr(BongoCatApp *app, const char *key, const char *fallback) { return bongo_cat_i18n_get(app->i18n, key, fallback); } +static bool select_model_action(BongoCatApp *app, const char *id) { + BongoCatError error = {0}; + if (bongo_cat_app_select_model_with_error(app, id, &error)) return true; + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Model switch failed: %s", + error.message[0] ? error.message : "unknown error"); + const char *message = tr(app, "native.modelLoadFailed", + "Unable to display this model"); + if (app->preferences && bongo_cat_preferences_visible(app->preferences)) + bongo_cat_preferences_notice_show(app, message, true); + else SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, BONGO_CAT_NAME, + message, app->window); + return false; +} + static void context_menu(BongoCatApp *app) { BongoCatWindowMenuPreview preview; bongo_cat_window_menu_preview_init(&preview, app); @@ -177,7 +192,7 @@ void bongo_cat_window_menu_action(BongoCatApp *app, BongoCatMenuAction action) { action < BONGO_CAT_MENU_MODEL_FIRST + BONGO_CAT_MODEL_CAP) { size_t index = (size_t)(action - BONGO_CAT_MENU_MODEL_FIRST); if (index < app->models.count) - bongo_cat_app_select_model(app, app->models.entries[index].id); + select_model_action(app, app->models.entries[index].id); } else if (action == BONGO_CAT_MENU_EXIT) app->running = false; bongo_cat_preferences_invalidate(app->preferences); } diff --git a/src/runtime/window_menu_preview.c b/src/runtime/window_menu_preview.c index 0ba96482..96733037 100644 --- a/src/runtime/window_menu_preview.c +++ b/src/runtime/window_menu_preview.c @@ -57,7 +57,22 @@ void bongo_cat_window_menu_preview(void *userdata, BongoCatMenuAction action) { } state->last = action; const BongoCatModelEntry *model = menu_model(app, action); - if (model) bongo_cat_app_select_model(app, model->id); + if (model) { + BongoCatError error = {0}; + if (!bongo_cat_app_select_model_with_error(app, model->id, &error)) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Model preview failed: %s", + error.message[0] ? error.message : "unknown error"); + if (strcmp(app->config.current_model, state->model) != 0) { + BongoCatError restore_error = {0}; + if (!bongo_cat_app_select_model_with_error(app, state->model, + &restore_error)) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Model preview recovery failed: %s", + restore_error.message[0] ? restore_error.message : + "unknown error"); + } + } + } else if (action >= BONGO_CAT_MENU_SCALE_50 && action <= BONGO_CAT_MENU_SCALE_200) bongo_cat_window_set_scale(app, @@ -95,8 +110,12 @@ void bongo_cat_window_menu_restore(void *userdata, BongoCatMenuAction selected) (selected >= BONGO_CAT_MENU_EXPRESSION_FIRST && selected < BONGO_CAT_MENU_EXPRESSION_FIRST + BONGO_CAT_BEHAVIOR_CAP); if (!keep_model && strcmp(app->config.current_model, state->model) != 0) { - bongo_cat_app_select_model(app, state->model); - changed = true; + BongoCatError error = {0}; + if (bongo_cat_app_select_model_with_error(app, state->model, &error)) + changed = true; + else SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Model preview restore failed: %s", + error.message[0] ? error.message : "unknown error"); } if (!keep_scale && SDL_fabsf(app->config.window.scale_percent - state->scale) > .01f) { From 1fc078a943076299f4ae2fe1d534afdbdc2af4ec Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 12 Aug 2026 19:50:25 +0800 Subject: [PATCH 005/334] Include SDL logging declarations --- src/render/overlay.c | 1 + 1 file changed, 1 insertion(+) diff --git a/src/render/overlay.c b/src/render/overlay.c index ecc463b3..6693203b 100644 --- a/src/render/overlay.c +++ b/src/render/overlay.c @@ -3,6 +3,7 @@ #include "bongo_cat/image.h" #include "bongo_cat/path.h" #include "mver_pointer_overlay.h" +#include #include #include #include From 34c13d1cbddfc3e42b6ac39cafc4334abe5d949a Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 13 Aug 2026 21:22:46 +0800 Subject: [PATCH 006/334] Fix behavior controls and reusable editors --- CMakeLists.txt | 11 +- include/bongo_cat/common.h | 3 + include/bongo_cat/config.h | 15 +- include/bongo_cat/model.h | 12 ++ include/bongo_cat/platform.h | 8 +- src/core/config_defaults.c | 64 +++++- src/core/config_io.c | 34 +++- src/core/model_catalog.c | 16 ++ src/live2d/cubism_bridge.cpp | 26 +-- src/live2d/cubism_model.hpp | 44 ++++- src/live2d/cubism_model_load.cpp | 36 +--- src/live2d/cubism_model_update.cpp | 130 ++++++++++--- src/live2d/cubism_motion_state.cpp | 148 ++++++++++++++ src/live2d/live2d_stub.c | 19 ++ src/platform/linux_menu.c | 9 +- src/platform/macos_menu.m | 13 +- src/platform/windows_borderless.c | 8 +- src/platform/windows_menu.c | 12 +- src/runtime/config_store.c | 3 + src/runtime/live2d_visual_audit.c | 14 +- src/runtime/model_files.c | 1 + src/runtime/model_import_metadata.c | 2 +- src/runtime/model_import_portable.c | 66 +++++-- src/runtime/model_import_portable_identity.c | 115 +++++++++++ src/runtime/model_import_portable_internal.h | 14 ++ src/runtime/model_import_portable_migration.c | 165 ++++++++++++++++ src/runtime/window.c | 21 +- src/runtime/window_menu.h | 13 +- src/runtime/window_menu_behavior.c | 183 ++++++++++++++++-- src/runtime/window_menu_preview.c | 31 ++- src/ui/preferences.c | 19 +- src/ui/preferences_behavior_dialog.c | 42 ++-- src/ui/preferences_behavior_rename.c | 84 ++++---- src/ui/preferences_behavior_row.c | 28 +-- src/ui/preferences_dialog.c | 7 +- src/ui/preferences_internal.h | 12 ++ src/ui/preferences_model.c | 26 ++- src/ui/preferences_model_glyphs.c | 2 +- src/ui/preferences_model_rename.c | 170 ++++++++++++++++ src/ui/preferences_overlay.c | 2 +- src/ui/preferences_scrollbar.c | 130 +++++++++++++ src/ui/preferences_scrollbar.h | 31 +++ src/ui/preferences_state.h | 8 + src/ui/preferences_text_edit.c | 143 ++++++++++++++ src/ui/preferences_text_edit.h | 29 +++ tests/test_config.c | 19 ++ tests/test_mver_import.c | 114 +++++++++++ tests/test_mver_pointer_import.c | 25 ++- 48 files changed, 1872 insertions(+), 255 deletions(-) create mode 100644 src/live2d/cubism_motion_state.cpp create mode 100644 src/runtime/model_import_portable_identity.c create mode 100644 src/runtime/model_import_portable_internal.h create mode 100644 src/runtime/model_import_portable_migration.c create mode 100644 src/ui/preferences_model_rename.c create mode 100644 src/ui/preferences_scrollbar.c create mode 100644 src/ui/preferences_scrollbar.h create mode 100644 src/ui/preferences_text_edit.c create mode 100644 src/ui/preferences_text_edit.h diff --git a/CMakeLists.txt b/CMakeLists.txt index d53037bb..0fc5e1bd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -44,7 +44,7 @@ if(EXISTS "${CUBISM_CORE_HEADER}") include(cmake/Cubism.cmake) set(BONGO_CAT_LIVE2D_SOURCES src/live2d/cubism_bridge.cpp src/live2d/cubism_model_load.cpp - src/live2d/cubism_model_update.cpp src/live2d/cubism_viewer_look.cpp + src/live2d/cubism_model_update.cpp src/live2d/cubism_motion_state.cpp src/live2d/cubism_viewer_look.cpp src/live2d/cubism_model_bounds.cpp src/live2d/cubism_model_validate.cpp) set(BONGO_CAT_CUBISM_ENABLED ON) @@ -94,6 +94,8 @@ add_library(bongo_cat_runtime STATIC src/runtime/model_import_discovery.c src/runtime/model_import_format.c src/runtime/model_import_portable.c src/runtime/model_import_portable_scan.c + src/runtime/model_import_portable_identity.c + src/runtime/model_import_portable_migration.c src/runtime/model_import_mver_patch.c src/runtime/model_import_mver.c src/runtime/model_import_mver_image.c src/runtime/model_import_mver_shortcut.c src/runtime/model_import_mver_effect.c @@ -117,9 +119,10 @@ add_library(bongo_cat_runtime STATIC src/ui/preferences_assets.c src/ui/preferences_about.c src/ui/preferences_about_community.c src/ui/preferences_live_resize.c src/ui/preferences_dialog.c src/ui/preferences_import.c src/ui/preferences_overlay.c - src/ui/preferences_behavior_dialog.c src/ui/preferences_behavior_rename.c src/ui/preferences_behavior_row.c + src/ui/preferences_behavior_dialog.c src/ui/preferences_behavior_rename.c src/ui/preferences_behavior_row.c src/ui/preferences_scrollbar.c + src/ui/preferences_text_edit.c src/ui/preferences_notice.c - src/ui/preferences_model.c src/ui/preferences_model_cover.c src/ui/preferences_model_glyphs.c + src/ui/preferences_model.c src/ui/preferences_model_rename.c src/ui/preferences_model_cover.c src/ui/preferences_model_glyphs.c src/ui/preferences_pages.c src/ui/preferences_theme.c src/ui/preferences_shortcuts.c src/ui/preferences_shortcut_clear.c src/ui/preferences_smoke.c src/ui/preferences_widgets.c src/ui/preferences_controls.c src/ui/preferences_combo.c src/ui/preferences_toggle.c @@ -319,7 +322,7 @@ if(BUILD_TESTING) target_link_libraries(bongo_cat_mver_import_tests PRIVATE bongo_cat_runtime) target_compile_definitions(bongo_cat_mver_import_tests PRIVATE BONGO_CAT_NATIVE_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") if(MSVC) - set_property(SOURCE tests/test_mver_import.c tests/test_windows_capture.c APPEND PROPERTY COMPILE_OPTIONS "/experimental:c11atomics") + set_property(SOURCE tests/test_mver_import.c tests/test_mver_pointer_import.c tests/test_windows_capture.c APPEND PROPERTY COMPILE_OPTIONS "/experimental:c11atomics") endif() add_test(NAME model-import-unit COMMAND bongo_cat_mver_import_tests) if(WIN32) diff --git a/include/bongo_cat/common.h b/include/bongo_cat/common.h index 7d8d5876..c7a22530 100644 --- a/include/bongo_cat/common.h +++ b/include/bongo_cat/common.h @@ -19,6 +19,9 @@ #define BONGO_CAT_SHORTCUT_CAP 128 #define BONGO_CAT_MODEL_CAP 128 #define BONGO_CAT_BEHAVIOR_CAP 128 +#define BONGO_CAT_BEHAVIOR_ID_CAP 384 +#define BONGO_CAT_BEHAVIOR_BINDING_CAP 256 +#define BONGO_CAT_MENU_LABEL_CAP (BONGO_CAT_ID_CAP + BONGO_CAT_SHORTCUT_CAP + 4) #define BONGO_CAT_AUTO_RELEASE_CAP 64 typedef enum BongoCatResult { diff --git a/include/bongo_cat/config.h b/include/bongo_cat/config.h index 57a44a30..47737139 100644 --- a/include/bongo_cat/config.h +++ b/include/bongo_cat/config.h @@ -68,18 +68,25 @@ typedef struct BongoCatShortcutOptions { } BongoCatShortcutOptions; typedef struct BongoCatBehaviorShortcut { - char id[BONGO_CAT_PATH_CAP]; + char id[BONGO_CAT_BEHAVIOR_ID_CAP]; char shortcut[BONGO_CAT_SHORTCUT_CAP]; char label[BONGO_CAT_ID_CAP]; } BongoCatBehaviorShortcut; +typedef struct BongoCatModelLabel { + char id[BONGO_CAT_ID_CAP]; + char label[BONGO_CAT_ID_CAP]; +} BongoCatModelLabel; + typedef struct BongoCatConfig { BongoCatModelOptions model; BongoCatWindowOptions window; BongoCatAppOptions app; BongoCatShortcutOptions shortcuts; - BongoCatBehaviorShortcut behavior_shortcuts[BONGO_CAT_BEHAVIOR_CAP]; + BongoCatBehaviorShortcut behavior_shortcuts[BONGO_CAT_BEHAVIOR_BINDING_CAP]; size_t behavior_shortcut_count; + BongoCatModelLabel model_labels[BONGO_CAT_MODEL_CAP]; + size_t model_label_count; char current_model[BONGO_CAT_PATH_CAP]; BongoCatModelMode current_mode; } BongoCatConfig; @@ -92,6 +99,10 @@ void bongo_cat_config_defaults(BongoCatConfig *config); void bongo_cat_config_validate(BongoCatConfig *config); bool bongo_cat_config_shortcut_conflicts(const BongoCatConfig *config, const char *shortcut, const char *exclude); +const char *bongo_cat_config_model_label(const BongoCatConfig *config, + const char *id); +bool bongo_cat_config_set_model_label(BongoCatConfig *config, + const char *id, const char *label); BongoCatResult bongo_cat_preferences_load(const char *path, BongoCatConfig *config, BongoCatError *error); BongoCatResult bongo_cat_preferences_save(const char *path, diff --git a/include/bongo_cat/model.h b/include/bongo_cat/model.h index b0dd0cc3..48ab82e2 100644 --- a/include/bongo_cat/model.h +++ b/include/bongo_cat/model.h @@ -52,6 +52,9 @@ BongoCatResult bongo_cat_models_scan(BongoCatModelCatalog *catalog, const char * bool preset, BongoCatError *error); const BongoCatModelEntry *bongo_cat_models_find(const BongoCatModelCatalog *catalog, const char *id); +const char *bongo_cat_model_default_name(const BongoCatModelEntry *entry); +const char *bongo_cat_model_name(const BongoCatConfig *config, + const BongoCatModelEntry *entry); BongoCatResult bongo_cat_behaviors_load(BongoCatBehaviorCatalog *catalog, const BongoCatModelEntry *model, BongoCatError *error); @@ -102,6 +105,15 @@ bool bongo_cat_live2d_set_parameter(BongoCatLive2D *live2d, const char *id, floa bool bongo_cat_live2d_parameter(BongoCatLive2D *live2d, const char *id, BongoCatParameterRange *range); bool bongo_cat_live2d_start_motion(BongoCatLive2D *live2d, const char *group, int index); +bool bongo_cat_live2d_preview_motion(BongoCatLive2D *live2d, + const char *group, int index); +bool bongo_cat_live2d_restore_motion_preview(BongoCatLive2D *live2d); +bool bongo_cat_live2d_commit_motion_preview(BongoCatLive2D *live2d, + const char *group, int index); +bool bongo_cat_live2d_motion_selected(const BongoCatLive2D *live2d, + const char *group, int index); +bool bongo_cat_live2d_motion_visible(const BongoCatLive2D *live2d, + const char *group, int index); bool bongo_cat_live2d_set_expression(BongoCatLive2D *live2d, int index); int bongo_cat_live2d_expression(const BongoCatLive2D *live2d); bool bongo_cat_live2d_visual_state(const BongoCatLive2D *live2d, diff --git a/include/bongo_cat/platform.h b/include/bongo_cat/platform.h index b01a2958..d7dca16b 100644 --- a/include/bongo_cat/platform.h +++ b/include/bongo_cat/platform.h @@ -62,8 +62,12 @@ typedef struct BongoCatMenuLabels { const char *preferences, *hide, *pass_through, *always_on_top; const char *window_size, *opacity, *model, *add_model, *exit; const char *wheel_size_hint, *wheel_opacity_hint, *motion, *expression; - const char *const *model_names, *const *motion_names, *const *expression_names; - size_t model_count, current_model, motion_count, expression_count, current_expression; + const char *const *model_names; + const char (*motion_names)[BONGO_CAT_MENU_LABEL_CAP]; + const char (*expression_names)[BONGO_CAT_MENU_LABEL_CAP]; + const bool *motion_checked; + size_t model_count, current_model, motion_count; + size_t expression_count, current_expression; float scale_percent, opacity_percent; bool pass_through_checked, always_on_top_checked, dark_theme; BongoCatMenuPreview preview; diff --git a/src/core/config_defaults.c b/src/core/config_defaults.c index 2877b5d0..66405afe 100644 --- a/src/core/config_defaults.c +++ b/src/core/config_defaults.c @@ -2,6 +2,7 @@ #include "bongo_cat/utf8.h" #include +#include #include static float clampf(float value, float low, float high) { @@ -33,6 +34,50 @@ bool bongo_cat_config_shortcut_conflicts(const BongoCatConfig *config, return false; } +const char *bongo_cat_config_model_label(const BongoCatConfig *config, + const char *id) { + if (!config || !id) return NULL; + for (size_t i = 0; i < config->model_label_count; ++i) + if (!strcmp(config->model_labels[i].id, id)) + return config->model_labels[i].label; + return NULL; +} + +bool bongo_cat_config_set_model_label(BongoCatConfig *config, + const char *id, const char *label) { + if (!config || !id || !id[0]) return false; + size_t index = config->model_label_count; + for (size_t i = 0; i < config->model_label_count; ++i) + if (!strcmp(config->model_labels[i].id, id)) { + index = i; break; + } + if (!label || !label[0]) { + if (index == config->model_label_count) return false; + if (index + 1 < config->model_label_count) + memmove(&config->model_labels[index], + &config->model_labels[index + 1], + (config->model_label_count - index - 1) * + sizeof(config->model_labels[0])); + config->model_label_count--; + memset(&config->model_labels[config->model_label_count], 0, + sizeof(config->model_labels[0])); + return true; + } + if (index < config->model_label_count) { + if (!strcmp(config->model_labels[index].label, label)) return false; + snprintf(config->model_labels[index].label, + sizeof(config->model_labels[index].label), "%s", label); + return true; + } + if (config->model_label_count >= BONGO_CAT_MODEL_CAP) return false; + BongoCatModelLabel *value = + &config->model_labels[config->model_label_count++]; + memset(value, 0, sizeof(*value)); + snprintf(value->id, sizeof(value->id), "%s", id); + snprintf(value->label, sizeof(value->label), "%s", label); + return true; +} + static void validate_shortcuts(BongoCatConfig *config) { char *global[] = {config->shortcuts.visible_cat, config->shortcuts.visible_preferences, config->shortcuts.mirror, @@ -93,10 +138,11 @@ void bongo_cat_config_validate(BongoCatConfig *config) { config->app.language = BONGO_CAT_LANG_EN_US; if (config->current_mode > BONGO_CAT_MODE_GAMEPAD) config->current_mode = BONGO_CAT_MODE_STANDARD; config->current_model[sizeof(config->current_model) - 1] = '\0'; - if (config->behavior_shortcut_count > BONGO_CAT_BEHAVIOR_CAP) - config->behavior_shortcut_count = BONGO_CAT_BEHAVIOR_CAP; + if (config->behavior_shortcut_count > BONGO_CAT_BEHAVIOR_BINDING_CAP) + config->behavior_shortcut_count = BONGO_CAT_BEHAVIOR_BINDING_CAP; for (size_t i = 0; i < config->behavior_shortcut_count; ++i) { - config->behavior_shortcuts[i].id[BONGO_CAT_PATH_CAP - 1] = '\0'; + config->behavior_shortcuts[i].id[ + sizeof(config->behavior_shortcuts[i].id) - 1] = '\0'; config->behavior_shortcuts[i].shortcut[BONGO_CAT_SHORTCUT_CAP - 1] = '\0'; config->behavior_shortcuts[i].label[BONGO_CAT_ID_CAP - 1] = '\0'; char normalized[BONGO_CAT_ID_CAP] = {0}; @@ -107,6 +153,18 @@ void bongo_cat_config_validate(BongoCatConfig *config) { sizeof(normalized)); else config->behavior_shortcuts[i].label[0] = '\0'; } + if (config->model_label_count > BONGO_CAT_MODEL_CAP) + config->model_label_count = BONGO_CAT_MODEL_CAP; + for (size_t i = 0; i < config->model_label_count; ++i) { + config->model_labels[i].id[BONGO_CAT_ID_CAP - 1] = '\0'; + config->model_labels[i].label[BONGO_CAT_ID_CAP - 1] = '\0'; + char normalized[BONGO_CAT_ID_CAP] = {0}; + if (config->model_labels[i].id[0] && bongo_cat_utf8_normalize_legacy( + config->model_labels[i].label, normalized, sizeof(normalized))) + memcpy(config->model_labels[i].label, normalized, + sizeof(normalized)); + else config->model_labels[i].label[0] = '\0'; + } validate_shortcuts(config); } diff --git a/src/core/config_io.c b/src/core/config_io.c index 0d8243f0..a4e8b8c6 100644 --- a/src/core/config_io.c +++ b/src/core/config_io.c @@ -79,7 +79,7 @@ static void read_behaviors(yyjson_val *array, BongoCatConfig *config) { const char *shortcut = get_string(item, "shortcut"); const char *label = get_string(item, "label"); if (!id || (!shortcut && !label) || - config->behavior_shortcut_count >= BONGO_CAT_BEHAVIOR_CAP) + config->behavior_shortcut_count >= BONGO_CAT_BEHAVIOR_BINDING_CAP) continue; BongoCatBehaviorShortcut *entry = &config->behavior_shortcuts[config->behavior_shortcut_count++]; @@ -89,6 +89,22 @@ static void read_behaviors(yyjson_val *array, BongoCatConfig *config) { } } +static void read_model_labels(yyjson_val *array, BongoCatConfig *config) { + if (!yyjson_is_arr(array)) return; + config->model_label_count = 0; + size_t index, count; yyjson_val *item; + yyjson_arr_foreach(array, index, count, item) { + const char *id = get_string(item, "id"); + const char *label = get_string(item, "label"); + if (!id || !id[0] || !label || !label[0] || + config->model_label_count >= BONGO_CAT_MODEL_CAP) continue; + BongoCatModelLabel *entry = + &config->model_labels[config->model_label_count++]; + copy_string(entry->id, sizeof(entry->id), id); + copy_string(entry->label, sizeof(entry->label), label); + } +} + BongoCatResult bongo_cat_preferences_load(const char *path, BongoCatConfig *config, BongoCatError *error) { if (!path || !config) return BONGO_CAT_ERROR_ARGUMENT; @@ -102,6 +118,7 @@ BongoCatResult bongo_cat_preferences_load(const char *path, read_app(yyjson_obj_get(root, "app"), &loaded.app); read_shortcuts(yyjson_obj_get(root, "shortcuts"), &loaded.shortcuts); read_behaviors(yyjson_obj_get(root, "behaviorShortcuts"), &loaded); + read_model_labels(yyjson_obj_get(root, "modelLabels"), &loaded); yyjson_doc_free(document); bongo_cat_config_validate(&loaded); *config = loaded; @@ -157,6 +174,20 @@ static void write_behaviors(yyjson_mut_doc *doc, yyjson_mut_val *root, } } +static void write_model_labels(yyjson_mut_doc *doc, yyjson_mut_val *root, + const BongoCatConfig *config) { + yyjson_mut_val *array = yyjson_mut_arr(doc); + yyjson_mut_obj_add_val(doc, root, "modelLabels", array); + for (size_t i = 0; i < config->model_label_count; ++i) { + const BongoCatModelLabel *value = &config->model_labels[i]; + if (!value->id[0] || !value->label[0]) continue; + yyjson_mut_val *item = yyjson_mut_obj(doc); + yyjson_mut_obj_add_strcpy(doc, item, "id", value->id); + yyjson_mut_obj_add_strcpy(doc, item, "label", value->label); + yyjson_mut_arr_add_val(array, item); + } +} + BongoCatResult bongo_cat_preferences_save(const char *path, const BongoCatConfig *config, BongoCatError *error) { if (!path || !config) return BONGO_CAT_ERROR_ARGUMENT; @@ -170,6 +201,7 @@ BongoCatResult bongo_cat_preferences_save(const char *path, write_app(doc, yyjson_mut_obj_add_obj(doc, root, "app"), &config->app); write_shortcuts(doc, yyjson_mut_obj_add_obj(doc, root, "shortcuts"), &config->shortcuts); write_behaviors(doc, root, config); + write_model_labels(doc, root, config); BongoCatResult result = bongo_cat_config_write_document(path, doc, "preferences file", error); yyjson_mut_doc_free(doc); diff --git a/src/core/model_catalog.c b/src/core/model_catalog.c index 3065d324..bf2747b3 100644 --- a/src/core/model_catalog.c +++ b/src/core/model_catalog.c @@ -183,3 +183,19 @@ const BongoCatModelEntry *bongo_cat_models_find(const BongoCatModelCatalog *cata } return NULL; } + +const char *bongo_cat_model_default_name(const BongoCatModelEntry *entry) { + if (!entry) return "model"; + if (entry->display_name[0]) return entry->display_name; + if (!strcmp(entry->id, "standard")) return "Standard"; + if (!strcmp(entry->id, "keyboard")) return "Keyboard"; + if (!strcmp(entry->id, "gamepad")) return "Gamepad"; + return entry->id; +} + +const char *bongo_cat_model_name(const BongoCatConfig *config, + const BongoCatModelEntry *entry) { + if (!entry) return "model"; + const char *custom = bongo_cat_config_model_label(config, entry->id); + return custom && custom[0] ? custom : bongo_cat_model_default_name(entry); +} diff --git a/src/live2d/cubism_bridge.cpp b/src/live2d/cubism_bridge.cpp index 3f86f2e3..c129feeb 100644 --- a/src/live2d/cubism_bridge.cpp +++ b/src/live2d/cubism_bridge.cpp @@ -260,17 +260,14 @@ extern "C" void bongo_cat_live2d_draw(BongoCatLive2D *runtime) { if (runtime && runtime->model) runtime->model->draw(); } extern "C" void bongo_cat_live2d_set_mirror(BongoCatLive2D *runtime, bool mirror) { - if (runtime && runtime->model) runtime->model->set_mirror(mirror); -} + if (runtime && runtime->model) runtime->model->set_mirror(mirror); } extern "C" void bongo_cat_live2d_set_render_options(BongoCatLive2D *runtime, const BongoCatLive2DRenderOptions *options) { if (runtime && runtime->model && options) - runtime->model->set_render_options(*options); -} + runtime->model->set_render_options(*options); } extern "C" void bongo_cat_live2d_set_dragging(BongoCatLive2D *runtime, float x, float y) { - if (runtime && runtime->model) runtime->model->set_dragging(x, y); -} + if (runtime && runtime->model) runtime->model->set_dragging(x, y); } extern "C" void bongo_cat_live2d_prepare_viewer_audit(BongoCatLive2D *runtime) { if (runtime && runtime->model) runtime->model->prepare_viewer_audit(); } @@ -285,13 +282,18 @@ extern "C" bool bongo_cat_live2d_parameter(BongoCatLive2D *runtime, const char * extern "C" bool bongo_cat_live2d_start_motion(BongoCatLive2D *runtime, const char *group, int index) { return runtime && runtime->model && runtime->model->start_motion(group, index); } +extern "C" bool bongo_cat_live2d_preview_motion(BongoCatLive2D *runtime, const char *group, int index) { + return runtime && runtime->model && runtime->model->preview_motion(group, index); } +extern "C" bool bongo_cat_live2d_restore_motion_preview(BongoCatLive2D *runtime) { return runtime && runtime->model && runtime->model->restore_motion_preview(); } +extern "C" bool bongo_cat_live2d_commit_motion_preview(BongoCatLive2D *runtime, const char *group, int index) { + return runtime && runtime->model && runtime->model->commit_motion_preview(group, index); } +extern "C" bool bongo_cat_live2d_motion_selected(const BongoCatLive2D *runtime, const char *group, int index) { return runtime && runtime->model && runtime->model->motion_selected(group, index); } +extern "C" bool bongo_cat_live2d_motion_visible(const BongoCatLive2D *runtime, const char *group, int index) { + return runtime && runtime->model && runtime->model->motion_visible(group, index); } extern "C" bool bongo_cat_live2d_set_expression(BongoCatLive2D *runtime, int index) { - return runtime && runtime->model && runtime->model->set_expression(index); -} + return runtime && runtime->model && runtime->model->set_expression(index); } extern "C" int bongo_cat_live2d_expression(const BongoCatLive2D *runtime) { - return runtime && runtime->model ? runtime->model->expression() : -1; -} + return runtime && runtime->model ? runtime->model->expression() : -1; } extern "C" bool bongo_cat_live2d_visual_state(const BongoCatLive2D *runtime, BongoCatLive2DVisualState *state) { - return runtime && runtime->model && runtime->model->visual_state(state); -} + return runtime && runtime->model && runtime->model->visual_state(state); } diff --git a/src/live2d/cubism_model.hpp b/src/live2d/cubism_model.hpp index f296ddb9..740df4b8 100644 --- a/src/live2d/cubism_model.hpp +++ b/src/live2d/cubism_model.hpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -39,12 +40,28 @@ class NativeModel final : public Csm::CubismUserModel { bool set_parameter(const char *id, float value); bool parameter(const char *id, float *minimum, float *maximum, float *value); bool start_motion(const char *group, int index); + bool preview_motion(const char *group, int index); + bool restore_motion_preview(); + bool commit_motion_preview(const char *group, int index); + bool motion_selected(const char *group, int index) const; + bool motion_visible(const char *group, int index) const; bool set_expression(int index); int expression() const { return expression_index_; } bool visual_state(BongoCatLive2DVisualState *state) const; -private: +public: using MotionMap = std::map; + using MotionSignatures = std::map; + struct MotionStateCurve { + std::string target, id; + float start = 0.0f, end = 0.0f; + }; + struct MotionState { + std::string group; + int index = -1; + std::vector curves; + }; +private: struct ModelBounds { float min_x = 0.0f; float min_y = 0.0f; @@ -52,11 +69,6 @@ class NativeModel final : public Csm::CubismUserModel { float max_y = 0.0f; bool valid = false; }; - struct LockMotion { - std::vector parameters; - std::vector initial_values; - bool enabled = false; - }; bool load_model(BongoCatError *error); void load_expressions(); void load_effects(); @@ -64,9 +76,13 @@ class NativeModel final : public Csm::CubismUserModel { void start_idle_motion(); ModelBounds capture_visible_bounds() const; void record_visible_state(Csm::CubismMatrix44 &projection); - void load_lock_motion(const std::string &key, + void capture_motion_preview(); + void restore_motion_preview_state(); + void load_motion_state(const std::string &key, const char *group, int index, const std::vector &bytes); - bool toggle_lock_motion(const std::string &key, Csm::ACubismMotion *motion); + void pair_motion_states(); + std::string motion_to_play(const std::string &key, bool *selected) const; + void select_motion(const std::string &key, bool selected); void release_textures(); void release_renderer(); void bind_textures(); @@ -76,7 +92,11 @@ class NativeModel final : public Csm::CubismUserModel { Csm::CubismModelSettingJson *setting_ = nullptr; MotionMap motions_; - std::map lock_motions_; + MotionSignatures motion_signatures_; + std::map motion_states_; + std::map motion_toggle_partners_; + std::map motion_toggle_owners_; + std::set selected_motion_keys_; MotionMap expressions_; std::vector expression_names_; std::vector textures_; @@ -85,6 +105,8 @@ class NativeModel final : public Csm::CubismUserModel { std::vector part_snapshot_; std::vector pending_parameter_values_; std::vector pending_parameters_; + std::vector motion_preview_parameters_; + std::vector motion_preview_parts_; Csm::csmVector eye_blink_ids_; Csm::csmVector lip_sync_ids_; std::string directory_; @@ -106,6 +128,10 @@ class NativeModel final : public Csm::CubismUserModel { ViewerLookUpdater *viewer_look_ = nullptr; int last_idle_motion_ = -1; float opacity_snapshot_ = -1.0f; + float motion_preview_opacity_ = 1.0f; + std::string motion_preview_key_; + bool motion_preview_selected_ = false; + bool motion_preview_active_ = false; }; } // namespace bongo_cat diff --git a/src/live2d/cubism_model_load.cpp b/src/live2d/cubism_model_load.cpp index 035343d3..55d6da39 100644 --- a/src/live2d/cubism_model_load.cpp +++ b/src/live2d/cubism_model_load.cpp @@ -13,14 +13,11 @@ #include #include #include -#include #include #include #include #include #include -#include -#include namespace bongo_cat { @@ -155,6 +152,11 @@ void NativeModel::load_effects() { void NativeModel::load_motions() { idle_motion_keys_.clear(); + motion_signatures_.clear(); + motion_states_.clear(); + motion_toggle_partners_.clear(); + motion_toggle_owners_.clear(); + selected_motion_keys_.clear(); for (int group_index = 0; group_index < setting_->GetMotionGroupCount(); ++group_index) { const char *group = setting_->GetMotionGroupName(group_index); for (int i = 0; i < setting_->GetMotionCount(group); ++i) { @@ -167,38 +169,14 @@ void NativeModel::load_motions() { if (!motion) continue; motion->SetEffectIds(eye_blink_ids_, lip_sync_ids_); motions_[key] = motion; + load_motion_state(key, group, i, bytes); if (std::strcmp(group, "Idle") == 0) idle_motion_keys_.push_back(key); - size_t suffix = std::strlen(group); - if (suffix >= 5 && std::strcmp(group + suffix - 5, "_lock") == 0) - load_lock_motion(key, bytes); } } + pair_motion_states(); _motionManager->StopAllMotions(); } -void NativeModel::load_lock_motion(const std::string &key, - const std::vector &bytes) { - yyjson_doc *document = yyjson_read( - reinterpret_cast(bytes.data()), bytes.size(), 0); - yyjson_val *root = document ? yyjson_doc_get_root(document) : nullptr; - yyjson_val *curves = yyjson_is_obj(root) ? yyjson_obj_get(root, "Curves") : nullptr; - LockMotion lock; - size_t index, count; - yyjson_val *curve; - if (yyjson_is_arr(curves)) yyjson_arr_foreach(curves, index, count, curve) { - const char *target = yyjson_get_str(yyjson_obj_get(curve, "Target")); - const char *id = yyjson_get_str(yyjson_obj_get(curve, "Id")); - if (!target || std::strcmp(target, "Parameter") != 0 || !id) continue; - Csm::CubismIdHandle handle = Csm::CubismFramework::GetIdManager()->GetId(id); - int parameter = _model->GetParameterIndex(handle); - if (parameter < 0 || parameter >= _model->GetParameterCount()) continue; - if (std::find(lock.parameters.begin(), lock.parameters.end(), parameter) == - lock.parameters.end()) lock.parameters.push_back(parameter); - } - if (document) yyjson_doc_free(document); - if (!lock.parameters.empty()) lock_motions_[key] = std::move(lock); -} - bool NativeModel::load_textures(BongoCatError *error) { release_textures(); textures_.assign((size_t)setting_->GetTextureCount(), 0); diff --git a/src/live2d/cubism_model_update.cpp b/src/live2d/cubism_model_update.cpp index 03bc7a26..e478101b 100644 --- a/src/live2d/cubism_model_update.cpp +++ b/src/live2d/cubism_model_update.cpp @@ -11,7 +11,6 @@ #include #include #include - namespace bongo_cat { void NativeModel::resize(int width, int height) { @@ -165,14 +164,15 @@ bool NativeModel::parameter(const char *id, float *minimum, float *maximum, floa bool NativeModel::start_motion(const char *group, int index) { if (!group || index < 0) return false; std::string key = std::string(group) + "_" + std::to_string(index); - auto found = motions_.find(key); + bool selected = false; + std::string playback = motion_to_play(key, &selected); + auto found = motions_.find(playback); if (found == motions_.end()) return false; - if (lock_motions_.find(key) != lock_motions_.end()) - return toggle_lock_motion(key, found->second); constexpr int priority = 2; - if (!_motionManager->ReserveMotion(priority)) return false; - _motionManager->StartMotionPriority(found->second, false, priority); - return true; + bool started = _motionManager->StartMotionPriority(found->second, false, + priority) != Csm::InvalidMotionQueueEntryHandleValue; + if (started) select_motion(key, selected); + return started; } void NativeModel::start_idle_motion() { @@ -188,33 +188,99 @@ void NativeModel::start_idle_motion() { _motionManager->StartMotionPriority(found->second, false, priority); } -bool NativeModel::toggle_lock_motion(const std::string &key, - Csm::ACubismMotion *motion) { - LockMotion &lock = lock_motions_.at(key); - if (lock.enabled) { - _motionManager->StopAllMotions(); - _motionManager->UpdateMotion(_model, 0.0f); - for (size_t i = 0; i < lock.parameters.size(); ++i) { - int parameter = lock.parameters[i]; - float value = lock.initial_values[i]; - pending_parameter_values_[(size_t)parameter] = value; - pending_parameters_[(size_t)parameter] = 1; - } - lock.initial_values.clear(); - lock.enabled = false; - external_parameters_dirty_ = true; - return true; +void NativeModel::capture_motion_preview() { + if (!_model || motion_preview_active_) return; + const int parameter_count = _model->GetParameterCount(); + std::vector current_parameters((size_t)parameter_count); + for (int i = 0; i < parameter_count; ++i) + current_parameters[(size_t)i] = _model->GetParameterValue(i); + _model->LoadParameters(); + motion_preview_parameters_.resize((size_t)parameter_count); + for (int i = 0; i < parameter_count; ++i) { + motion_preview_parameters_[(size_t)i] = _model->GetParameterValue(i); + _model->SetParameterValue(i, current_parameters[(size_t)i]); } + const int part_count = _model->GetPartCount(); + motion_preview_parts_.resize((size_t)part_count); + for (int i = 0; i < part_count; ++i) + motion_preview_parts_[(size_t)i] = _model->GetPartOpacity(i); + motion_preview_opacity_ = _model->GetModelOpacity(); + motion_preview_active_ = true; +} + +void NativeModel::restore_motion_preview_state() { + if (!_model || !motion_preview_active_) return; + _motionManager->StopAllMotions(); + const int parameter_count = _model->GetParameterCount(); + for (int i = 0; i < parameter_count && + (size_t)i < motion_preview_parameters_.size(); ++i) { + _model->SetParameterValue(i, motion_preview_parameters_[(size_t)i]); + pending_parameters_[(size_t)i] = 0; + } + _model->SaveParameters(); + const int part_count = _model->GetPartCount(); + for (int i = 0; i < part_count && + (size_t)i < motion_preview_parts_.size(); ++i) + _model->SetPartOpacity(i, motion_preview_parts_[(size_t)i]); + _model->SetModelOpacity(motion_preview_opacity_); + _opacity = motion_preview_opacity_; + external_parameters_dirty_ = true; +} + +bool NativeModel::preview_motion(const char *group, int index) { + if (!_model || !group || index < 0) return false; + if (motion_preview_active_) restore_motion_preview_state(); + else capture_motion_preview(); + std::string key = std::string(group) + "_" + std::to_string(index); + std::string playback = motion_to_play(key, &motion_preview_selected_); + auto found = motions_.find(playback); + if (found == motions_.end()) return false; + auto owner = motion_toggle_owners_.find(key); + motion_preview_key_ = owner == motion_toggle_owners_.end() ? key : owner->second; constexpr int priority = 2; - if (!_motionManager->ReserveMotion(priority)) return false; - lock.initial_values.reserve(lock.parameters.size()); - for (int parameter : lock.parameters) - lock.initial_values.push_back(_model->GetParameterValue(parameter)); - lock.enabled = true; - _motionManager->StartMotionPriority(motion, false, priority); + return _motionManager->StartMotionPriority(found->second, false, priority) != + Csm::InvalidMotionQueueEntryHandleValue; +} + +bool NativeModel::commit_motion_preview(const char *group, int index) { + if (!motion_preview_active_ || !group || index < 0) return false; + std::string key = std::string(group) + "_" + std::to_string(index); + auto owner = motion_toggle_owners_.find(key); + const std::string &canonical = owner == motion_toggle_owners_.end() ? + key : owner->second; + if (canonical != motion_preview_key_) return false; + select_motion(canonical, motion_preview_selected_); + motion_preview_parameters_.clear(); + motion_preview_parts_.clear(); + motion_preview_key_.clear(); + motion_preview_active_ = false; return true; } +bool NativeModel::restore_motion_preview() { + if (!motion_preview_active_) return false; + restore_motion_preview_state(); + motion_preview_parameters_.clear(); + motion_preview_parts_.clear(); + motion_preview_key_.clear(); + motion_preview_active_ = false; + return true; +} + +bool NativeModel::motion_selected(const char *group, int index) const { + if (!group || index < 0) return false; + std::string key = std::string(group) + "_" + std::to_string(index); + auto owner = motion_toggle_owners_.find(key); + if (owner != motion_toggle_owners_.end()) key = owner->second; + return selected_motion_keys_.find(key) != selected_motion_keys_.end(); +} + +bool NativeModel::motion_visible(const char *group, int index) const { + if (!group || index < 0) return false; + std::string key = std::string(group) + "_" + std::to_string(index); + auto owner = motion_toggle_owners_.find(key); + return owner == motion_toggle_owners_.end() || owner->second == key; +} bool NativeModel::set_expression(int index) { if (index == -1) { _expressionManager->StopAllMotions(); @@ -224,11 +290,11 @@ bool NativeModel::set_expression(int index) { if (index < 0 || (size_t)index >= expression_names_.size()) return false; auto found = expressions_.find(expression_names_[(size_t)index]); if (found == expressions_.end()) return false; - Csm::CubismMotionQueueEntryHandle handle = - _expressionManager->StartMotion(found->second, false); + _expressionManager->StopAllMotions(); + Csm::CubismMotionQueueEntryHandle handle = _expressionManager->StartMotion( + found->second, false); if (handle == Csm::InvalidMotionQueueEntryHandleValue) return false; expression_index_ = index; return true; } - } // namespace bongo_cat diff --git a/src/live2d/cubism_motion_state.cpp b/src/live2d/cubism_motion_state.cpp new file mode 100644 index 00000000..183f1846 --- /dev/null +++ b/src/live2d/cubism_motion_state.cpp @@ -0,0 +1,148 @@ +#include "cubism_model.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace bongo_cat { + +static bool curve_endpoints(yyjson_val *segments, float *start, float *end) { + if (!yyjson_is_arr(segments) || yyjson_arr_size(segments) < 2) return false; + yyjson_val *first = yyjson_arr_get(segments, 1); + yyjson_val *last = yyjson_arr_get(segments, yyjson_arr_size(segments) - 1); + if (!yyjson_is_num(first) || !yyjson_is_num(last)) return false; + *start = (float)yyjson_get_num(first); + *end = (float)yyjson_get_num(last); + return true; +} + +void NativeModel::load_motion_state(const std::string &key, const char *group, + int motion_index, const std::vector &bytes) { + yyjson_doc *document = yyjson_read( + reinterpret_cast(bytes.data()), bytes.size(), 0); + yyjson_val *root = document ? yyjson_doc_get_root(document) : nullptr; + yyjson_val *curves = yyjson_is_obj(root) ? yyjson_obj_get(root, "Curves") : nullptr; + MotionState state; state.group = group ? group : ""; + state.index = motion_index; + std::vector targets; + size_t index, count; yyjson_val *curve; + if (yyjson_is_arr(curves)) yyjson_arr_foreach(curves, index, count, curve) { + const char *target = yyjson_get_str(yyjson_obj_get(curve, "Target")); + const char *id = yyjson_get_str(yyjson_obj_get(curve, "Id")); + if (!target || !id) continue; + targets.push_back(std::string(target) + ":" + id); + MotionStateCurve value{target, id}; + if (curve_endpoints(yyjson_obj_get(curve, "Segments"), + &value.start, &value.end) && std::fabs(value.start - value.end) > .0001f) + state.curves.push_back(value); + } + if (document) yyjson_doc_free(document); + std::sort(targets.begin(), targets.end()); + targets.erase(std::unique(targets.begin(), targets.end()), targets.end()); + for (const std::string &target : targets) motion_signatures_[key] += target + '\n'; + std::sort(state.curves.begin(), state.curves.end(), + [](const MotionStateCurve &a, const MotionStateCurve &b) { + return a.target == b.target ? a.id < b.id : a.target < b.target; + }); + motion_states_[key] = std::move(state); +} + +static bool inverse(const NativeModel::MotionState &a, + const NativeModel::MotionState &b) { + if (a.group != b.group || a.curves.size() != b.curves.size() || + a.curves.empty()) return false; + int first = std::min(a.index, b.index), second = std::max(a.index, b.index); + if (first < 0 || (first & 1) || second != first + 1) return false; + size_t reciprocal = 0; + for (size_t i = 0; i < a.curves.size(); ++i) { + const auto &left = a.curves[i], &right = b.curves[i]; + if (left.target != right.target || left.id != right.id) return false; + bool same = std::fabs(left.start - right.start) <= .0001f && + std::fabs(left.end - right.end) <= .0001f; + bool reversed = std::fabs(left.start - right.end) <= .0001f && + std::fabs(left.end - right.start) <= .0001f; + if (!same && !reversed) return false; + if (reversed && !same) { + if (left.target != "Parameter") return false; + reciprocal++; + } + } + return reciprocal == 1; +} + +static bool enabled_direction(Csm::CubismModel *model, + const NativeModel::MotionState &a, const NativeModel::MotionState &b, + bool *first_enabled) { + for (size_t i = 0; i < a.curves.size(); ++i) { + const auto &left = a.curves[i], &right = b.curves[i]; + bool same = std::fabs(left.start - right.start) <= .0001f && + std::fabs(left.end - right.end) <= .0001f; + if (same) continue; + auto id = Csm::CubismFramework::GetIdManager()->GetId(left.id.c_str()); + int parameter = model->GetParameterIndex(id); + if (parameter < 0) return false; + float normal = model->GetParameterDefaultValue(parameter); + bool left_normal = std::fabs(left.end - normal) <= .0001f; + bool right_normal = std::fabs(right.end - normal) <= .0001f; + if (left_normal == right_normal) return false; + *first_enabled = !left_normal; + return true; + } + return false; +} + +void NativeModel::pair_motion_states() { + if (!render_options_.mver_projection) return; + for (auto first = motion_states_.begin(); first != motion_states_.end(); ++first) { + if (first->second.group != "CAT_motion_lock") continue; + if (motion_toggle_owners_.find(first->first) != motion_toggle_owners_.end()) continue; + auto second = first; ++second; + for (; second != motion_states_.end(); ++second) { + if (motion_toggle_owners_.find(second->first) != motion_toggle_owners_.end() || + motion_signatures_[first->first] != motion_signatures_[second->first] || + !inverse(first->second, second->second)) continue; + bool first_enabled = false; + if (!enabled_direction(_model, first->second, second->second, + &first_enabled)) continue; + const std::string &owner = first_enabled ? first->first : second->first; + const std::string &partner = owner == first->first ? second->first : first->first; + motion_toggle_partners_[owner] = partner; + motion_toggle_owners_[owner] = motion_toggle_owners_[partner] = owner; + break; + } + } +} + +std::string NativeModel::motion_to_play(const std::string &key, + bool *selected) const { + auto owner = motion_toggle_owners_.find(key); + const std::string &canonical = owner == motion_toggle_owners_.end() ? + key : owner->second; + auto partner = motion_toggle_partners_.find(canonical); + bool active = selected_motion_keys_.find(canonical) != selected_motion_keys_.end(); + if (selected) *selected = partner == motion_toggle_partners_.end() || !active; + return active && partner != motion_toggle_partners_.end() ? + partner->second : canonical; +} + +void NativeModel::select_motion(const std::string &key, bool selected) { + auto owner = motion_toggle_owners_.find(key); + const std::string &canonical = owner == motion_toggle_owners_.end() ? + key : owner->second; + if (!selected) { selected_motion_keys_.erase(canonical); return; } + const std::string &signature = motion_signatures_[canonical]; + for (auto item = selected_motion_keys_.begin(); + item != selected_motion_keys_.end();) { + auto existing = motion_signatures_.find(*item); + if (!signature.empty() && existing != motion_signatures_.end() && + existing->second == signature) item = selected_motion_keys_.erase(item); + else ++item; + } + selected_motion_keys_.insert(canonical); +} + +} // namespace bongo_cat diff --git a/src/live2d/live2d_stub.c b/src/live2d/live2d_stub.c index 1cca2d8b..47b33433 100644 --- a/src/live2d/live2d_stub.c +++ b/src/live2d/live2d_stub.c @@ -62,6 +62,25 @@ bool bongo_cat_live2d_parameter(BongoCatLive2D *value, const char *id, BongoCatP bool bongo_cat_live2d_start_motion(BongoCatLive2D *value, const char *group, int index) { (void)value; (void)group; (void)index; return false; } +bool bongo_cat_live2d_preview_motion(BongoCatLive2D *value, + const char *group, int index) { + (void)value; (void)group; (void)index; return false; +} +bool bongo_cat_live2d_restore_motion_preview(BongoCatLive2D *value) { + (void)value; return false; +} +bool bongo_cat_live2d_commit_motion_preview(BongoCatLive2D *value, + const char *group, int index) { + (void)value; (void)group; (void)index; return false; +} +bool bongo_cat_live2d_motion_selected(const BongoCatLive2D *value, + const char *group, int index) { + (void)value; (void)group; (void)index; return false; +} +bool bongo_cat_live2d_motion_visible(const BongoCatLive2D *value, + const char *group, int index) { + (void)value; (void)group; (void)index; return true; +} bool bongo_cat_live2d_set_expression(BongoCatLive2D *value, int index) { (void)value; (void)index; return false; } diff --git a/src/platform/linux_menu.c b/src/platform/linux_menu.c index 268dd67a..a9fc5ae7 100644 --- a/src/platform/linux_menu.c +++ b/src/platform/linux_menu.c @@ -197,9 +197,14 @@ BongoCatMenuAction bongo_cat_linux_context_menu(BongoCatPlatform *platform, (int)labels->model_count + 1, labels); } else if (action == LINUX_MENU_MOTIONS) { LinuxMenuRow rows[BONGO_CAT_BEHAVIOR_CAP]; - for (size_t i = 0; i < labels->motion_count; ++i) - rows[i] = (LinuxMenuRow){labels->motion_names[i], + char text[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; + for (size_t i = 0; i < labels->motion_count; ++i) { + snprintf(text[i], sizeof(text[i]), "%s%s", + labels->motion_checked && labels->motion_checked[i] ? "[x] " : "", + labels->motion_names[i]); + rows[i] = (LinuxMenuRow){text[i], BONGO_CAT_MENU_MOTION_FIRST + (int)i}; + } action = popup_rows(display, owner, rows, (int)labels->motion_count, labels); } else if (action == LINUX_MENU_EXPRESSIONS) { LinuxMenuRow rows[BONGO_CAT_BEHAVIOR_CAP]; diff --git a/src/platform/macos_menu.m b/src/platform/macos_menu.m index 9dd31f60..9275c04e 100644 --- a/src/platform/macos_menu.m +++ b/src/platform/macos_menu.m @@ -65,15 +65,17 @@ static void add_scale_menu(NSMenu *menu, BongoCatMenuTarget *target, } static void add_named_menu(NSMenu *menu, BongoCatMenuTarget *target, - const char *label, const char *const *names, size_t count, NSInteger first, - size_t selected) { + const char *label, const char names[][BONGO_CAT_MENU_LABEL_CAP], + size_t count, NSInteger first, + const bool *checked, size_t selected) { if (!count) return; NSMenuItem *root = [[NSMenuItem alloc] initWithTitle:text(label) action:nil keyEquivalent:@""]; NSMenu *submenu = [[NSMenu alloc] initWithTitle:text(label)]; [submenu setDelegate:target]; for (size_t i = 0; i < count; ++i) - add_item(submenu, target, names[i], first + (NSInteger)i, i == selected); + add_item(submenu, target, names[i], first + (NSInteger)i, + checked ? checked[i] : i == selected); [root setSubmenu:submenu]; [menu addItem:root]; [submenu release]; [root release]; } @@ -95,10 +97,11 @@ BongoCatMenuAction bongo_cat_macos_context_menu(BongoCatPlatform *platform, add_scale_menu(menu, target, labels->window_size, labels->wheel_size_hint, false); add_scale_menu(menu, target, labels->opacity, labels->wheel_opacity_hint, true); add_named_menu(menu, target, labels->motion, labels->motion_names, - labels->motion_count, BONGO_CAT_MENU_MOTION_FIRST, (size_t)-1); + labels->motion_count, BONGO_CAT_MENU_MOTION_FIRST, + labels->motion_checked, labels->motion_count); add_named_menu(menu, target, labels->expression, labels->expression_names, labels->expression_count, BONGO_CAT_MENU_EXPRESSION_FIRST, - labels->current_expression); + NULL, labels->current_expression); NSMenuItem *modelRoot = [[NSMenuItem alloc] initWithTitle:text(labels->model) action:nil keyEquivalent:@""]; NSMenu *models = [[NSMenu alloc] initWithTitle:text(labels->model)]; diff --git a/src/platform/windows_borderless.c b/src/platform/windows_borderless.c index ae320664..5e8b329f 100644 --- a/src/platform/windows_borderless.c +++ b/src/platform/windows_borderless.c @@ -56,10 +56,12 @@ static LRESULT CALLBACK borderless_window_proc(HWND window, UINT message, UINT id = LOWORD(wparam), flags = HIWORD(wparam); if (flags == 0xffff && !lparam) return CallWindowProcW( original ? original : DefWindowProcW, window, message, wparam, lparam); - if ((flags & (MF_POPUP | MF_SEPARATOR)) || !id) + if ((flags & (MF_POPUP | MF_SEPARATOR)) || !id) { menu->preview(menu->userdata, BONGO_CAT_MENU_NONE); - else - menu->preview(menu->userdata, (BongoCatMenuAction)id); + } else { + BongoCatMenuAction action = (BongoCatMenuAction)id; + menu->preview(menu->userdata, action); + } } else if (message == WM_TIMER && wparam == BONGO_CAT_MENU_PREVIEW_TIMER && menu && menu->tick) { menu->tick(menu->userdata); diff --git a/src/platform/windows_menu.c b/src/platform/windows_menu.c index de5aa622..fda90611 100644 --- a/src/platform/windows_menu.c +++ b/src/platform/windows_menu.c @@ -75,11 +75,17 @@ BongoCatMenuAction bongo_cat_platform_context_menu(BongoCatPlatform *platform, menu_text(opacity, MF_STRING | MF_DISABLED | MF_GRAYED, 0, labels->wheel_opacity_hint); for (size_t i = 0; i < labels->motion_count; ++i) - menu_text(motions, MF_STRING, BONGO_CAT_MENU_MOTION_FIRST + i, + menu_text(motions, MF_STRING | + (labels->motion_checked && labels->motion_checked[i] ? MF_CHECKED : 0), + BONGO_CAT_MENU_MOTION_FIRST + i, labels->motion_names[i]); for (size_t i = 0; i < labels->expression_count; ++i) - menu_text(expressions, MF_STRING | (i == labels->current_expression ? MF_CHECKED : 0), - BONGO_CAT_MENU_EXPRESSION_FIRST + i, labels->expression_names[i]); + menu_text(expressions, MF_STRING, BONGO_CAT_MENU_EXPRESSION_FIRST + i, + labels->expression_names[i]); + if (labels->current_expression < labels->expression_count) + CheckMenuItem(expressions, + (UINT)(BONGO_CAT_MENU_EXPRESSION_FIRST + labels->current_expression), + MF_BYCOMMAND | MF_CHECKED); for (size_t i = 0; i < labels->model_count; ++i) menu_text(models, MF_STRING | (i == labels->current_model ? MF_CHECKED : 0), BONGO_CAT_MENU_MODEL_FIRST + i, labels->model_names[i]); diff --git a/src/runtime/config_store.c b/src/runtime/config_store.c index 38e228e4..dc62b5df 100644 --- a/src/runtime/config_store.c +++ b/src/runtime/config_store.c @@ -29,6 +29,9 @@ static uint64_t preferences_hash(const BongoCatConfig *config) { HASH_FIELD(config->behavior_shortcut_count); hash = hash_bytes(hash, config->behavior_shortcuts, config->behavior_shortcut_count * sizeof(config->behavior_shortcuts[0])); + HASH_FIELD(config->model_label_count); + hash = hash_bytes(hash, config->model_labels, + config->model_label_count * sizeof(config->model_labels[0])); #undef HASH_FIELD return hash; } diff --git a/src/runtime/live2d_visual_audit.c b/src/runtime/live2d_visual_audit.c index ad451bd1..70f99785 100644 --- a/src/runtime/live2d_visual_audit.c +++ b/src/runtime/live2d_visual_audit.c @@ -135,9 +135,9 @@ static size_t behavior_indexes(const BongoCatApp *app, BongoCatBehaviorKind kind return count; } -static bool lock_motion_matrix(FILE *file, BongoCatApp *app, +static bool motion_replay_matrix(FILE *file, BongoCatApp *app, const BongoCatBehaviorEntry *entry, bool mver) { - BongoCatLive2DVisualState active = {0}, stable = {0}, reset = {0}; + BongoCatLive2DVisualState active = {0}, stable = {0}, replayed = {0}; if (!bongo_cat_live2d_start_motion(app->live2d, entry->group, entry->index)) return false; advance(app, 1); @@ -150,12 +150,12 @@ static bool lock_motion_matrix(FILE *file, BongoCatApp *app, if (!bongo_cat_live2d_start_motion(app->live2d, entry->group, entry->index)) return false; advance(app, 1); - snprintf(label, sizeof(label), "motion-%d-reset", entry->index); - passed = record(file, app, label, !mver, &reset) && passed; - if (mver) passed = !active.fitted && !stable.fitted && !reset.fitted && + snprintf(label, sizeof(label), "motion-%d-replayed", entry->index); + passed = record(file, app, label, !mver, &replayed) && passed; + if (mver) passed = !active.fitted && !stable.fitted && !replayed.fitted && close_scale(active.fit_scale, 1.0f) && close_scale(stable.fit_scale, 1.0f) && - close_scale(reset.fit_scale, 1.0f) && passed; + close_scale(replayed.fit_scale, 1.0f) && passed; return passed; } @@ -210,7 +210,7 @@ bool bongo_cat_live2d_visual_audit_run(BongoCatApp *app) { const BongoCatBehaviorEntry *entry = &app->behaviors.entries[i]; if (entry->kind != BONGO_CAT_BEHAVIOR_MOTION || strstr(entry->group, "_lock") == NULL) continue; - passed = lock_motion_matrix(file, app, entry, true) && passed; + passed = motion_replay_matrix(file, app, entry, true) && passed; } } bongo_cat_live2d_set_dragging(app->live2d, 0.0f, 0.0f); diff --git a/src/runtime/model_files.c b/src/runtime/model_files.c index 104f48b7..a2224427 100644 --- a/src/runtime/model_files.c +++ b/src/runtime/model_files.c @@ -322,6 +322,7 @@ BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, } return BONGO_CAT_ERROR_IO; } + bongo_cat_config_set_model_label(&app->config, id, ""); bongo_cat_app_rescan_models(app); return BONGO_CAT_OK; } diff --git a/src/runtime/model_import_metadata.c b/src/runtime/model_import_metadata.c index 5b4b0c9b..62523c90 100644 --- a/src/runtime/model_import_metadata.c +++ b/src/runtime/model_import_metadata.c @@ -84,7 +84,7 @@ void bongo_cat_import_apply_metadata(BongoCatApp *app, const char *model_id, snprintf(existing->label, sizeof(existing->label), "%s", label); continue; } - if (app->config.behavior_shortcut_count >= BONGO_CAT_BEHAVIOR_CAP) break; + if (app->config.behavior_shortcut_count >= BONGO_CAT_BEHAVIOR_BINDING_CAP) break; BongoCatBehaviorShortcut *value = &app->config.behavior_shortcuts[app->config.behavior_shortcut_count++]; snprintf(value->id, sizeof(value->id), "%s", id); diff --git a/src/runtime/model_import_portable.c b/src/runtime/model_import_portable.c index 37dc61a1..a8e8cd43 100644 --- a/src/runtime/model_import_portable.c +++ b/src/runtime/model_import_portable.c @@ -1,4 +1,5 @@ #include "model_import.h" +#include "model_import_portable_internal.h" #include "model_storage.h" #include "runtime.h" #include "bongo_cat/json.h" @@ -13,8 +14,6 @@ #include #define PORTABLE_DIRECTORY "portable-mver" -#define PORTABLE_MARKER ".bongo-cat-portable.json" -#define PORTABLE_SCHEMA_VERSION 9 /* Rebuild cached pointer metadata. */ typedef struct PortableStamp { uint64_t sum, exclusive, bytes, latest, files, entries; @@ -119,35 +118,61 @@ static bool candidate_signature(const BongoCatImportCandidate *candidate, } static bool marker_matches(const char *target, const char *source, - const char *signature) { + const char *signature, const char *identity) { char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), target, PORTABLE_MARKER)) return false; + if (!bongo_cat_path_join(path, sizeof(path), target, + BONGO_CAT_PORTABLE_MARKER)) return false; yyjson_doc *document = bongo_cat_json_read_file(path, 0, NULL); yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; const char *stored_source = yyjson_get_str(yyjson_obj_get(root, "source")); const char *stored_signature = yyjson_get_str(yyjson_obj_get(root, "signature")); + const char *stored_identity = yyjson_get_str(yyjson_obj_get(root, "identity")); bool matches = yyjson_is_obj(root) && yyjson_get_int(yyjson_obj_get(root, "schemaVersion")) == - PORTABLE_SCHEMA_VERSION && - stored_source && stored_signature && strcmp(source, stored_source) == 0 && - strcmp(signature, stored_signature) == 0; + BONGO_CAT_PORTABLE_SCHEMA_VERSION && stored_source && + stored_signature && stored_identity && strcmp(source, stored_source) == 0 && + strcmp(signature, stored_signature) == 0 && + strcmp(identity, stored_identity) == 0; yyjson_doc_free(document); return matches; } +static bool cached_identity(const char *target, const char *source, + const char *signature, char identity[65]) { + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), target, + BONGO_CAT_PORTABLE_MARKER)) return false; + yyjson_doc *document = bongo_cat_json_read_file(path, 0, NULL); + yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; + const char *stored_source = yyjson_get_str(yyjson_obj_get(root, "source")); + const char *stored_signature = yyjson_get_str(yyjson_obj_get(root, "signature")); + const char *stored_identity = yyjson_get_str(yyjson_obj_get(root, "identity")); + bool valid = yyjson_is_obj(root) && + yyjson_get_int(yyjson_obj_get(root, "schemaVersion")) == + BONGO_CAT_PORTABLE_SCHEMA_VERSION && stored_source && + stored_signature && stored_identity && strlen(stored_identity) == 64 && + strcmp(source, stored_source) == 0 && + strcmp(signature, stored_signature) == 0; + if (valid) snprintf(identity, 65, "%s", stored_identity); + yyjson_doc_free(document); + return valid; +} + static bool write_marker(const char *target, const char *source, - const char *signature, BongoCatModelMode mode) { + const char *signature, const char *identity, BongoCatModelMode mode) { yyjson_mut_doc *document = yyjson_mut_doc_new(NULL); yyjson_mut_val *root = document ? yyjson_mut_obj(document) : NULL; if (document) yyjson_mut_doc_set_root(document, root); char path[BONGO_CAT_PATH_CAP]; bool ok = root && yyjson_mut_obj_add_int(document, root, "schemaVersion", - PORTABLE_SCHEMA_VERSION) && + BONGO_CAT_PORTABLE_SCHEMA_VERSION) && yyjson_mut_obj_add_str(document, root, "kind", "portable-mver") && yyjson_mut_obj_add_strcpy(document, root, "source", source) && yyjson_mut_obj_add_strcpy(document, root, "signature", signature) && + yyjson_mut_obj_add_strcpy(document, root, "identity", identity) && yyjson_mut_obj_add_strcpy(document, root, "mode", bongo_cat_mode_name(mode)) && - bongo_cat_path_join(path, sizeof(path), target, PORTABLE_MARKER) && + bongo_cat_path_join(path, sizeof(path), target, + BONGO_CAT_PORTABLE_MARKER) && bongo_cat_json_write_file(path, document, YYJSON_WRITE_PRETTY, NULL); yyjson_mut_doc_free(document); return ok; @@ -157,7 +182,8 @@ static bool previous_cache_usable(const char *target, const char *source) { char marker[BONGO_CAT_PATH_CAP], mode[BONGO_CAT_PATH_CAP]; char metadata[BONGO_CAT_PATH_CAP], resources[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_is_dir(target) || - !bongo_cat_path_join(marker, sizeof(marker), target, PORTABLE_MARKER) || + !bongo_cat_path_join(marker, sizeof(marker), target, + BONGO_CAT_PORTABLE_MARKER) || !bongo_cat_path_join(mode, sizeof(mode), target, ".bongo-cat-mode") || !bongo_cat_path_join(metadata, sizeof(metadata), target, ".bongo-cat-mver.json") || !bongo_cat_path_join(resources, sizeof(resources), target, "resources") || @@ -185,7 +211,8 @@ static bool use_previous_cache(const char *target, const char *source, static bool refresh_cache(const BongoCatImportCandidate *candidate, const char *cache_root, const char *id, const char *source, - const char *signature, bool *created, BongoCatError *error) { + const char *signature, const char *identity, bool *created, + BongoCatError *error) { char target[BONGO_CAT_PATH_CAP], temporary[BONGO_CAT_PATH_CAP]; char backup[BONGO_CAT_PATH_CAP], name[BONGO_CAT_ID_CAP + 8]; snprintf(name, sizeof(name), ".%s.tmp", id); @@ -198,10 +225,10 @@ static bool refresh_cache(const BongoCatImportCandidate *candidate, bongo_cat_path_rename(backup, target); if (bongo_cat_path_is_dir(target)) bongo_cat_model_remove_tree(backup, NULL); *created = !bongo_cat_path_is_dir(target); - if (!*created && marker_matches(target, source, signature)) return true; + if (!*created && marker_matches(target, source, signature, identity)) return true; if (!bongo_cat_path_create_directory(temporary) || !bongo_cat_import_prepare_adapter(candidate, temporary, error) || - !write_marker(temporary, source, signature, candidate->mode)) { + !write_marker(temporary, source, signature, identity, candidate->mode)) { bongo_cat_model_remove_tree(temporary, NULL); if (error && !error->message[0]) bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, "Cannot build portable Mver adapter: %s", source); @@ -228,14 +255,19 @@ static bool refresh_cache(const BongoCatImportCandidate *candidate, static bool add_candidate(BongoCatApp *app, const char *cache_root, const char *source, const BongoCatImportCandidate *candidate, char *first_created, BongoCatError *error) { - char source_hash[65], signature[65], id[BONGO_CAT_ID_CAP]; + char source_hash[65], signature[65], identity[65], id[BONGO_CAT_ID_CAP]; bongo_cat_sha256_bytes(source, strlen(source), source_hash); snprintf(id, sizeof(id), "mver-%.16s-%s", source_hash, bongo_cat_mode_name(candidate->mode)); if (bongo_cat_models_find(&app->models, id)) return true; if (!candidate_signature(candidate, signature, error)) return false; + char target[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(target, sizeof(target), cache_root, id) || + (!cached_identity(target, source, signature, identity) && + !bongo_cat_portable_identity(candidate, identity, error))) return false; bool created = false; - if (!refresh_cache(candidate, cache_root, id, source, signature, &created, error)) + if (!refresh_cache(candidate, cache_root, id, source, signature, identity, + &created, error)) return false; if (app->models.count >= BONGO_CAT_MODEL_CAP) { bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, @@ -309,6 +341,8 @@ static BongoCatResult import_portable_mver(BongoCatApp *app, PortableAdd add = {app, cache_root, first_created}; result = bongo_cat_import_portable_scan(root, add_scanned, &add, error); } + if (result == BONGO_CAT_OK) + bongo_cat_portable_migrate_config(app, cache_root); const char *selected = app->config.current_model; if (first_created[0] && (!selected[0] || strcmp(selected, "standard") == 0 || strcmp(selected, "keyboard") == 0 || strcmp(selected, "gamepad") == 0)) diff --git a/src/runtime/model_import_portable_identity.c b/src/runtime/model_import_portable_identity.c new file mode 100644 index 00000000..4c6990f8 --- /dev/null +++ b/src/runtime/model_import_portable_identity.c @@ -0,0 +1,115 @@ +#include "model_import_portable_internal.h" +#include "bongo_cat/path.h" +#include "bongo_cat/sha256.h" + +#include +#include +#include + +typedef struct IdentityStamp { + uint64_t sum, exclusive, bytes, files, entries; +} IdentityStamp; + +typedef struct IdentityWalk { + const char *root; + const char *group; + IdentityStamp *stamp; + int depth; +} IdentityWalk; + +static uint64_t hash_text(const char *value) { + uint64_t hash = 1469598103934665603ull; + for (const unsigned char *cursor = (const unsigned char *)value; + cursor && *cursor; ++cursor) { + unsigned char byte = *cursor == '\\' ? '/' : *cursor; + hash ^= byte; hash *= 1099511628211ull; + } + return hash; +} + +static uint64_t mix(uint64_t value) { + value ^= value >> 30; value *= 0xbf58476d1ce4e5b9ull; + value ^= value >> 27; value *= 0x94d049bb133111ebull; + return value ^ (value >> 31); +} + +static bool stamp_path(const char *path, IdentityWalk *walk); + +static BongoCatPathVisit stamp_item(void *userdata, + const char *dirname, const char *name) { + IdentityWalk *walk = userdata; + char path[BONGO_CAT_PATH_CAP]; + return ++walk->stamp->entries <= 16384 && + bongo_cat_path_join(path, sizeof(path), dirname, name) && + stamp_path(path, walk) ? BONGO_CAT_PATH_CONTINUE : + BONGO_CAT_PATH_FAILURE; +} + +static const char *relative_path(const IdentityWalk *walk, const char *path) { + size_t root_length = strlen(walk->root); + if (strncmp(path, walk->root, root_length) != 0) return path; + const char *relative = path + root_length; + while (*relative == '/' || *relative == '\\') relative++; + return relative; +} + +static bool stamp_path(const char *path, IdentityWalk *walk) { + if (bongo_cat_path_is_dir(path)) { + if (walk->depth >= 24) return false; + walk->depth++; + bool ok = bongo_cat_path_enumerate(path, stamp_item, walk); + walk->depth--; + return ok; + } + uint64_t size = 0; + if (!bongo_cat_path_file_size(path, &size) || walk->stamp->files >= 8192 || + size > 1073741824ull - walk->stamp->bytes) return false; + char file_hash[65]; + if (bongo_cat_sha256_file(path, file_hash, NULL) != BONGO_CAT_OK) + return false; + uint64_t value = mix(hash_text(walk->group) ^ + hash_text(relative_path(walk, path)) ^ hash_text(file_hash) ^ mix(size)); + walk->stamp->sum += value; + walk->stamp->exclusive ^= value; + walk->stamp->bytes += size; + walk->stamp->files++; + return true; +} + +static bool stamp_root(const char *root, const char *group, + IdentityStamp *stamp) { + if (!root || !root[0]) return true; + IdentityWalk walk = {root, group, stamp, 0}; + return stamp_path(root, &walk); +} + +bool bongo_cat_portable_identity(const BongoCatImportCandidate *candidate, + char output[65], BongoCatError *error) { + if (!candidate || !output) return false; + char config_hash[65], setting[BONGO_CAT_PATH_CAP], setting_hash[65]; + if (bongo_cat_sha256_file(candidate->config, config_hash, error) != + BONGO_CAT_OK || !bongo_cat_path_join(setting, sizeof(setting), + candidate->directory, candidate->setting) || + bongo_cat_sha256_file(setting, setting_hash, error) != BONGO_CAT_OK) + return false; + IdentityStamp stamp = {0}; + if (!stamp_root(candidate->directory, "model", &stamp) || + !stamp_root(candidate->assets, "assets", &stamp) || + !stamp_root(candidate->overrides, "overrides", &stamp)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot fingerprint portable Mver model assets: %s", + candidate->package_root); + return false; + } + char summary[384]; + int length = snprintf(summary, sizeof(summary), + "%s|%s|%s|%d|%016llx|%016llx|%llu|%llu", config_hash, setting_hash, + bongo_cat_mode_name(candidate->mode), (int)candidate->format, + (unsigned long long)stamp.sum, + (unsigned long long)stamp.exclusive, + (unsigned long long)stamp.bytes, + (unsigned long long)stamp.files); + if (length < 0 || (size_t)length >= sizeof(summary)) return false; + bongo_cat_sha256_bytes(summary, (size_t)length, output); + return true; +} diff --git a/src/runtime/model_import_portable_internal.h b/src/runtime/model_import_portable_internal.h new file mode 100644 index 00000000..482818ad --- /dev/null +++ b/src/runtime/model_import_portable_internal.h @@ -0,0 +1,14 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_PORTABLE_INTERNAL_H +#define BONGO_CAT_MODEL_IMPORT_PORTABLE_INTERNAL_H + +#include "model_import.h" + +#define BONGO_CAT_PORTABLE_MARKER ".bongo-cat-portable.json" +#define BONGO_CAT_PORTABLE_SCHEMA_VERSION 10 + +bool bongo_cat_portable_identity(const BongoCatImportCandidate *candidate, + char output[65], BongoCatError *error); +void bongo_cat_portable_migrate_config(BongoCatApp *app, + const char *cache_root); + +#endif diff --git a/src/runtime/model_import_portable_migration.c b/src/runtime/model_import_portable_migration.c new file mode 100644 index 00000000..f4c6a93a --- /dev/null +++ b/src/runtime/model_import_portable_migration.c @@ -0,0 +1,165 @@ +#include "model_import_portable_internal.h" +#include "runtime.h" +#include "bongo_cat/json.h" +#include "bongo_cat/path.h" + +#include +#include +#include +#include + +#define PORTABLE_CACHE_CAP 256 + +typedef struct PortableCacheEntry { + char id[BONGO_CAT_ID_CAP]; + char source[BONGO_CAT_PATH_CAP]; + char identity[65]; + BongoCatModelMode model_mode; + bool active; +} PortableCacheEntry; + +typedef struct PortableCacheList { + BongoCatApp *app; + PortableCacheEntry entries[PORTABLE_CACHE_CAP]; + size_t count; + bool overflow; +} PortableCacheList; + +static bool parse_mode(const char *value, BongoCatModelMode *model_mode) { + for (int i = 0; i <= BONGO_CAT_MODE_GAMEPAD; ++i) + if (value && strcmp(value, bongo_cat_mode_name((BongoCatModelMode)i)) == 0) { + *model_mode = (BongoCatModelMode)i; return true; + } + return false; +} + +static BongoCatPathVisit collect_cache(void *userdata, + const char *dirname, const char *name) { + PortableCacheList *list = userdata; + char directory[BONGO_CAT_PATH_CAP], marker[BONGO_CAT_PATH_CAP]; + if (name[0] == '.' || !bongo_cat_path_join(directory, sizeof(directory), + dirname, name) || !bongo_cat_path_is_dir(directory) || + !bongo_cat_path_join(marker, sizeof(marker), directory, + BONGO_CAT_PORTABLE_MARKER)) return BONGO_CAT_PATH_CONTINUE; + yyjson_doc *document = bongo_cat_json_read_file(marker, 0, NULL); + yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; + const char *kind = yyjson_get_str(yyjson_obj_get(root, "kind")); + const char *source = yyjson_get_str(yyjson_obj_get(root, "source")); + const char *identity = yyjson_get_str(yyjson_obj_get(root, "identity")); + const char *mode_name = yyjson_get_str(yyjson_obj_get(root, "mode")); + BongoCatModelMode model_mode = BONGO_CAT_MODE_STANDARD; + bool valid = yyjson_is_obj(root) && kind && source && identity && + strlen(identity) == 64 && strcmp(kind, "portable-mver") == 0 && + parse_mode(mode_name, &model_mode); + if (valid && list->count >= PORTABLE_CACHE_CAP) list->overflow = true; + if (valid && !list->overflow) { + PortableCacheEntry *entry = &list->entries[list->count++]; + snprintf(entry->id, sizeof(entry->id), "%s", name); + snprintf(entry->source, sizeof(entry->source), "%s", source); + snprintf(entry->identity, sizeof(entry->identity), "%s", identity); + entry->model_mode = model_mode; + entry->active = bongo_cat_models_find(&list->app->models, entry->id) != NULL; + } + yyjson_doc_free(document); + return list->overflow ? BONGO_CAT_PATH_SUCCESS : BONGO_CAT_PATH_CONTINUE; +} + +static bool shortcut_prefix(const char *id, const char *model_id) { + size_t length = strlen(model_id); + return strncmp(id, model_id, length) == 0 && id[length] == ':'; +} + +static bool config_references(const BongoCatConfig *config, const char *id) { + if (strcmp(config->current_model, id) == 0 || + bongo_cat_config_model_label(config, id)) return true; + for (size_t i = 0; i < config->behavior_shortcut_count; ++i) + if (shortcut_prefix(config->behavior_shortcuts[i].id, id)) return true; + return false; +} + +static void migrate_label(BongoCatConfig *config, const char *old_id, + const char *new_id) { + const char *new_label = bongo_cat_config_model_label(config, new_id); + for (size_t i = 0; i < config->model_label_count; ++i) { + BongoCatModelLabel *label = &config->model_labels[i]; + if (strcmp(label->id, old_id) != 0) continue; + if (!new_label) snprintf(label->id, sizeof(label->id), "%s", new_id); + else bongo_cat_config_set_model_label(config, old_id, ""); + return; + } +} + +static size_t shortcut_at(const BongoCatConfig *config, const char *id) { + for (size_t i = 0; i < config->behavior_shortcut_count; ++i) + if (strcmp(config->behavior_shortcuts[i].id, id) == 0) return i; + return config->behavior_shortcut_count; +} + +static void remove_shortcut(BongoCatConfig *config, size_t index) { + config->behavior_shortcut_count--; + if (index < config->behavior_shortcut_count) + memmove(&config->behavior_shortcuts[index], + &config->behavior_shortcuts[index + 1], + (config->behavior_shortcut_count - index) * + sizeof(config->behavior_shortcuts[0])); + memset(&config->behavior_shortcuts[config->behavior_shortcut_count], 0, + sizeof(config->behavior_shortcuts[0])); +} + +static void migrate_shortcuts(BongoCatConfig *config, const char *old_id, + const char *new_id) { + size_t old_length = strlen(old_id); + for (size_t i = 0; i < config->behavior_shortcut_count;) { + BongoCatBehaviorShortcut *old = &config->behavior_shortcuts[i]; + if (!shortcut_prefix(old->id, old_id)) { i++; continue; } + char id[BONGO_CAT_BEHAVIOR_ID_CAP]; + snprintf(id, sizeof(id), "%s%s", new_id, old->id + old_length); + size_t target = shortcut_at(config, id); + if (target == config->behavior_shortcut_count) { + snprintf(old->id, sizeof(old->id), "%s", id); i++; continue; + } + BongoCatBehaviorShortcut *existing = &config->behavior_shortcuts[target]; + if (!existing->shortcut[0]) snprintf(existing->shortcut, + sizeof(existing->shortcut), "%s", old->shortcut); + if (!existing->label[0]) snprintf(existing->label, + sizeof(existing->label), "%s", old->label); + remove_shortcut(config, i); + } +} + +static void migrate(BongoCatApp *app, const char *old_id, + const char *new_id) { + migrate_label(&app->config, old_id, new_id); + migrate_shortcuts(&app->config, old_id, new_id); + if (strcmp(app->config.current_model, old_id) == 0) + snprintf(app->config.current_model, sizeof(app->config.current_model), + "%s", new_id); + SDL_Log("Migrated portable model preferences from %s to %s", old_id, new_id); +} + +void bongo_cat_portable_migrate_config(BongoCatApp *app, + const char *cache_root) { + PortableCacheList *list = calloc(1, sizeof(*list)); + if (!app || !cache_root || !list) { free(list); return; } + list->app = app; + if (!bongo_cat_path_enumerate(cache_root, collect_cache, list) || + list->overflow) { free(list); return; } + for (size_t i = 0; i < list->count; ++i) { + PortableCacheEntry *old = &list->entries[i]; + if (old->active || bongo_cat_path_is_dir(old->source) || + !config_references(&app->config, old->id)) continue; + PortableCacheEntry *replacement = NULL; + size_t candidates = 0, active = 0; + for (size_t j = 0; j < list->count; ++j) { + PortableCacheEntry *item = &list->entries[j]; + if (item == old || item->model_mode != old->model_mode || + strcmp(item->identity, old->identity) != 0 || + (!item->active && !bongo_cat_path_is_dir(item->source))) continue; + candidates++; + if (item->active) { replacement = item; active++; } + } + if (candidates == 1 && active == 1) + migrate(app, old->id, replacement->id); + } + free(list); +} diff --git a/src/runtime/window.c b/src/runtime/window.c index b911b096..ced6d9c5 100644 --- a/src/runtime/window.c +++ b/src/runtime/window.c @@ -122,15 +122,17 @@ static void context_menu(BongoCatApp *app) { const char *model_names[BONGO_CAT_MODEL_CAP]; size_t current_model = app->models.count; for (size_t i = 0; i < app->models.count; ++i) { - model_names[i] = app->models.entries[i].id; + model_names[i] = bongo_cat_model_name(&app->config, + &app->models.entries[i]); if (strcmp(app->models.entries[i].id, app->config.current_model) == 0) current_model = i; } - const char *motion_names[BONGO_CAT_BEHAVIOR_CAP]; - const char *expression_names[BONGO_CAT_BEHAVIOR_CAP]; + char motion_names[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; + char expression_names[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; + bool motion_checked[BONGO_CAT_BEHAVIOR_CAP] = {false}; size_t motion_count, expression_count, current_expression; - bongo_cat_window_behavior_labels(app, motion_names, &motion_count, - expression_names, &expression_count, ¤t_expression); + bongo_cat_window_behavior_labels(app, motion_names, motion_checked, + &motion_count, expression_names, &expression_count, ¤t_expression); bool dark_theme = app->config.app.theme == BONGO_CAT_THEME_DARK || (app->config.app.theme == BONGO_CAT_THEME_AUTO && SDL_GetSystemTheme() == SDL_SYSTEM_THEME_DARK); @@ -149,15 +151,18 @@ static void context_menu(BongoCatApp *app) { tr(app, "composables.useAppMenu.labels.motion", "Motions"), tr(app, "composables.useAppMenu.labels.expression", "Expressions"), model_names, motion_names, expression_names, - app->models.count, current_model, motion_count, expression_count, - current_expression, + motion_checked, + app->models.count, current_model, motion_count, + expression_count, current_expression, app->config.window.scale_percent, app->config.window.opacity_percent, app->config.window.pass_through, app->config.window.always_on_top, dark_theme, bongo_cat_window_menu_preview, bongo_cat_window_menu_preview_tick, bongo_cat_window_menu_restore, &preview}; BongoCatMenuAction action = bongo_cat_platform_context_menu(&app->platform, &labels); - bongo_cat_window_menu_action(app, action); + if (bongo_cat_window_menu_preview_applied(&preview, action)) + bongo_cat_preferences_invalidate(app->preferences); + else bongo_cat_window_menu_action(app, action); } void bongo_cat_window_show_context_menu(BongoCatApp *app) { context_menu(app); } diff --git a/src/runtime/window_menu.h b/src/runtime/window_menu.h index 49168713..c02ab594 100644 --- a/src/runtime/window_menu.h +++ b/src/runtime/window_menu.h @@ -8,7 +8,7 @@ typedef struct BongoCatWindowMenuPreview { char model[BONGO_CAT_ID_CAP]; float scale, opacity; int expression; - BongoCatMenuAction last; + BongoCatMenuAction last, applied; BongoCatModalFrame modal_frame; } BongoCatWindowMenuPreview; @@ -17,13 +17,20 @@ void bongo_cat_window_menu_preview_init(BongoCatWindowMenuPreview *state, void bongo_cat_window_menu_preview(void *userdata, BongoCatMenuAction action); void bongo_cat_window_menu_preview_tick(void *userdata); void bongo_cat_window_menu_restore(void *userdata, BongoCatMenuAction selected); +bool bongo_cat_window_menu_preview_applied( + const BongoCatWindowMenuPreview *state, BongoCatMenuAction selected); void bongo_cat_window_behavior_labels(BongoCatApp *app, - const char **motions, size_t *motion_count, - const char **expressions, size_t *expression_count, + char motions[][BONGO_CAT_MENU_LABEL_CAP], bool *motion_checked, + size_t *motion_count, char expressions[][BONGO_CAT_MENU_LABEL_CAP], + size_t *expression_count, size_t *current_expression); bool bongo_cat_window_behavior_menu_action(BongoCatMenuAction action); bool bongo_cat_window_behavior_action(BongoCatApp *app, BongoCatMenuAction action); +bool bongo_cat_window_behavior_preview(BongoCatApp *app, + BongoCatMenuAction action); +bool bongo_cat_window_behavior_commit_preview(BongoCatApp *app, + BongoCatMenuAction action); bool bongo_cat_window_behavior_self_test(BongoCatApp *app); #endif diff --git a/src/runtime/window_menu_behavior.c b/src/runtime/window_menu_behavior.c index 33c2cf9e..a0e343de 100644 --- a/src/runtime/window_menu_behavior.c +++ b/src/runtime/window_menu_behavior.c @@ -1,5 +1,7 @@ #include "window_menu.h" +#include "bongo_cat/audio.h" +#include #include static const BongoCatBehaviorEntry *nth_behavior(BongoCatApp *app, @@ -8,6 +10,9 @@ static const BongoCatBehaviorEntry *nth_behavior(BongoCatApp *app, for (size_t i = 0; i < app->behaviors.count; ++i) { const BongoCatBehaviorEntry *entry = &app->behaviors.entries[i]; if (entry->kind != kind) continue; + if (kind == BONGO_CAT_BEHAVIOR_MOTION && + !bongo_cat_live2d_motion_visible(app->live2d, + entry->group, entry->index)) continue; if (!position) return entry; position--; } @@ -34,6 +39,15 @@ static const char *behavior_label(const BongoCatApp *app, return entry->label; } +static void menu_label(char output[BONGO_CAT_MENU_LABEL_CAP], + const BongoCatApp *app, const BongoCatBehaviorEntry *entry) { + const char *label = behavior_label(app, entry); + const char *shortcut = behavior_shortcut(app, entry->id); + if (shortcut && shortcut[0]) snprintf(output, BONGO_CAT_MENU_LABEL_CAP, + "%s - %s", label, shortcut); + else snprintf(output, BONGO_CAT_MENU_LABEL_CAP, "%s", label); +} + static bool run_binding(BongoCatApp *app, const BongoCatBehaviorEntry *selected) { const char *shortcut = behavior_shortcut(app, selected->id); bool handled = bongo_cat_app_run_behavior(app, selected); @@ -48,8 +62,9 @@ static bool run_binding(BongoCatApp *app, const BongoCatBehaviorEntry *selected) } void bongo_cat_window_behavior_labels(BongoCatApp *app, - const char **motions, size_t *motion_count, - const char **expressions, size_t *expression_count, + char motions[][BONGO_CAT_MENU_LABEL_CAP], bool *motion_checked, + size_t *motion_count, char expressions[][BONGO_CAT_MENU_LABEL_CAP], + size_t *expression_count, size_t *current_expression) { if (!motion_count || !expression_count) return; *motion_count = 0; *expression_count = 0; @@ -58,14 +73,23 @@ void bongo_cat_window_behavior_labels(BongoCatApp *app, int active_expression = bongo_cat_live2d_expression(app->live2d); for (size_t i = 0; i < app->behaviors.count; ++i) { const BongoCatBehaviorEntry *entry = &app->behaviors.entries[i]; - if (entry->kind == BONGO_CAT_BEHAVIOR_MOTION && motions) - motions[(*motion_count)++] = behavior_label(app, entry); - else if (entry->kind == BONGO_CAT_BEHAVIOR_EXPRESSION && expressions) { + if (entry->kind == BONGO_CAT_BEHAVIOR_MOTION && motions) { + if (!bongo_cat_live2d_motion_visible(app->live2d, + entry->group, entry->index)) continue; + if (motion_checked) motion_checked[*motion_count] = + bongo_cat_live2d_motion_selected(app->live2d, + entry->group, entry->index); + menu_label(motions[*motion_count], app, entry); + (*motion_count)++; + } else if (entry->kind == BONGO_CAT_BEHAVIOR_EXPRESSION && expressions) { if (current_expression && entry->index == active_expression) *current_expression = *expression_count; - expressions[(*expression_count)++] = behavior_label(app, entry); + menu_label(expressions[*expression_count], app, entry); + (*expression_count)++; } } + if (current_expression && active_expression < 0 && *expression_count) + *current_expression = 0; } bool bongo_cat_window_behavior_action(BongoCatApp *app, @@ -85,6 +109,53 @@ bool bongo_cat_window_behavior_action(BongoCatApp *app, return entry && run_binding(app, entry); } +bool bongo_cat_window_behavior_preview(BongoCatApp *app, + BongoCatMenuAction action) { + if (!app) return false; + if (action >= BONGO_CAT_MENU_MOTION_FIRST && + action < BONGO_CAT_MENU_MOTION_FIRST + BONGO_CAT_BEHAVIOR_CAP) { + size_t position = (size_t)(action - BONGO_CAT_MENU_MOTION_FIRST); + const BongoCatBehaviorEntry *entry = nth_behavior(app, + BONGO_CAT_BEHAVIOR_MOTION, position); + return entry && bongo_cat_live2d_preview_motion(app->live2d, + entry->group, entry->index); + } + if (action >= BONGO_CAT_MENU_EXPRESSION_FIRST && + action < BONGO_CAT_MENU_EXPRESSION_FIRST + BONGO_CAT_BEHAVIOR_CAP) { + size_t position = (size_t)(action - BONGO_CAT_MENU_EXPRESSION_FIRST); + const BongoCatBehaviorEntry *entry = nth_behavior(app, + BONGO_CAT_BEHAVIOR_EXPRESSION, position); + return entry && bongo_cat_live2d_set_expression(app->live2d, + entry->index); + } + return false; +} + +bool bongo_cat_window_behavior_commit_preview(BongoCatApp *app, + BongoCatMenuAction action) { + if (!app || action < BONGO_CAT_MENU_MOTION_FIRST || + action >= BONGO_CAT_MENU_MOTION_FIRST + BONGO_CAT_BEHAVIOR_CAP) + return false; + const BongoCatBehaviorEntry *entry = nth_behavior(app, + BONGO_CAT_BEHAVIOR_MOTION, + (size_t)(action - BONGO_CAT_MENU_MOTION_FIRST)); + if (!entry || !bongo_cat_live2d_commit_motion_preview(app->live2d, + entry->group, entry->index)) return false; + if (entry->sound[0]) { + BongoCatError error = {0}; + bongo_cat_audio_play(app->audio, entry->sound, &error); + } + const char *shortcut = behavior_shortcut(app, entry->id); + if (shortcut && shortcut[0]) for (size_t i = 0; i < app->behaviors.count; ++i) { + const BongoCatBehaviorEntry *companion = &app->behaviors.entries[i]; + const char *bound = behavior_shortcut(app, companion->id); + if (companion != entry && bound && strcmp(bound, shortcut) == 0) + bongo_cat_app_run_behavior(app, companion); + } + app->dirty = true; + return true; +} + bool bongo_cat_window_behavior_menu_action(BongoCatMenuAction action) { return (action >= BONGO_CAT_MENU_MOTION_FIRST && action < BONGO_CAT_MENU_MOTION_FIRST + BONGO_CAT_BEHAVIOR_CAP) || @@ -97,17 +168,21 @@ static bool expression_self_test(BongoCatApp *app) { while (nth_behavior(app, BONGO_CAT_BEHAVIOR_EXPRESSION, count)) count++; if (!count) return true; int original = bongo_cat_live2d_expression(app->live2d); + char motions[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; + bool motion_checked[BONGO_CAT_BEHAVIOR_CAP] = {false}; + char expressions[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; + size_t motion_count, expression_count, current_expression; + bool passed = bongo_cat_live2d_set_expression(app->live2d, -1); + bongo_cat_window_behavior_labels(app, motions, motion_checked, &motion_count, + expressions, &expression_count, ¤t_expression); + passed = passed && expression_count == count && current_expression == 0; size_t selected_position = count > 2 ? 2 : count - 1; const BongoCatBehaviorEntry *selected = nth_behavior(app, BONGO_CAT_BEHAVIOR_EXPRESSION, selected_position); - bool passed = selected && bongo_cat_window_behavior_action(app, + passed = selected && bongo_cat_window_behavior_action(app, BONGO_CAT_MENU_EXPRESSION_FIRST + selected_position) && - bongo_cat_live2d_expression(app->live2d) == selected->index; - - const char *motions[BONGO_CAT_BEHAVIOR_CAP]; - const char *expressions[BONGO_CAT_BEHAVIOR_CAP]; - size_t motion_count, expression_count, current_expression; - bongo_cat_window_behavior_labels(app, motions, &motion_count, + bongo_cat_live2d_expression(app->live2d) == selected->index && passed; + bongo_cat_window_behavior_labels(app, motions, motion_checked, &motion_count, expressions, &expression_count, ¤t_expression); passed = passed && expression_count == count && current_expression == selected_position; @@ -132,17 +207,93 @@ static bool expression_self_test(BongoCatApp *app) { bongo_cat_window_menu_restore(&preview, BONGO_CAT_MENU_EXPRESSION_FIRST + alternate_position); passed = passed && bongo_cat_live2d_expression(app->live2d) == - alternate->index; + alternate->index && bongo_cat_window_menu_preview_applied(&preview, + BONGO_CAT_MENU_EXPRESSION_FIRST + alternate_position); } - return bongo_cat_live2d_set_expression(app->live2d, original) && passed; + bool restored = bongo_cat_live2d_set_expression(app->live2d, original); + if (!passed || !restored) SDL_Log("Expression menu self-test failed: " + "count=%llu mapped=%llu current=%llu original=%d restored=%d", + (unsigned long long)count, (unsigned long long)expression_count, + (unsigned long long)current_expression, original, restored); + return restored && passed; } bool bongo_cat_window_behavior_self_test(BongoCatApp *app) { if (!app) return false; bool passed = true; - if (nth_behavior(app, BONGO_CAT_BEHAVIOR_MOTION, 0)) + if (nth_behavior(app, BONGO_CAT_BEHAVIOR_MOTION, 0)) { + const BongoCatBehaviorEntry *first = nth_behavior(app, + BONGO_CAT_BEHAVIOR_MOTION, 0); passed = bongo_cat_window_behavior_action(app, BONGO_CAT_MENU_MOTION_FIRST) && passed; + char motions[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; + bool checked[BONGO_CAT_BEHAVIOR_CAP] = {false}; + bool before[BONGO_CAT_BEHAVIOR_CAP] = {false}; + char expressions[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; + size_t motion_count, expression_count, current_expression; + bongo_cat_window_behavior_labels(app, motions, checked, &motion_count, + expressions, &expression_count, ¤t_expression); + passed = motion_count > 0 && checked[0] && passed; + bool initial_passed = passed; + const char *first_shortcut = behavior_shortcut(app, first->id); + if (first_shortcut && first_shortcut[0]) + passed = strstr(motions[0], first_shortcut) != NULL && passed; + memcpy(before, checked, motion_count * sizeof(before[0])); + size_t preview_position = nth_behavior(app, + BONGO_CAT_BEHAVIOR_MOTION, 1) ? 1 : 0; + BongoCatWindowMenuPreview preview; + bongo_cat_window_menu_preview_init(&preview, app); + bongo_cat_window_menu_preview(&preview, + BONGO_CAT_MENU_MOTION_FIRST + preview_position); + bongo_cat_window_behavior_labels(app, motions, checked, &motion_count, + expressions, &expression_count, ¤t_expression); + bool preview_stable = memcmp(before, checked, + motion_count * sizeof(before[0])) == 0; + passed = preview_stable && passed; + bongo_cat_window_menu_restore(&preview, BONGO_CAT_MENU_NONE); + passed = bongo_cat_live2d_motion_selected(app->live2d, + first->group, first->index) && passed; + passed = bongo_cat_window_behavior_action(app, + BONGO_CAT_MENU_MOTION_FIRST + preview_position) && passed; + bongo_cat_window_behavior_labels(app, motions, checked, &motion_count, + expressions, &expression_count, ¤t_expression); + passed = checked[preview_position] && passed; + bool independent = false; + for (size_t i = 1; i < motion_count && !independent; ++i) { + passed = bongo_cat_window_behavior_action(app, + BONGO_CAT_MENU_MOTION_FIRST) && passed; + passed = bongo_cat_window_behavior_action(app, + BONGO_CAT_MENU_MOTION_FIRST + i) && passed; + bool first_selected = bongo_cat_live2d_motion_selected(app->live2d, + first->group, first->index); + bool candidate_selected = bongo_cat_live2d_motion_selected(app->live2d, + nth_behavior(app, BONGO_CAT_BEHAVIOR_MOTION, i)->group, + nth_behavior(app, BONGO_CAT_BEHAVIOR_MOTION, i)->index); + independent = independent || (first_selected && candidate_selected); + } + if (motion_count > 2 && strcmp(nth_behavior(app, + BONGO_CAT_BEHAVIOR_MOTION, 0)->group, "CAT_motion_lock") == 0) + passed = independent && passed; + passed = bongo_cat_window_behavior_action(app, + BONGO_CAT_MENU_MOTION_FIRST) && passed; + bool paired = false; + for (size_t i = 0; i < app->behaviors.count; ++i) { + const BongoCatBehaviorEntry *item = &app->behaviors.entries[i]; + if (item->kind == BONGO_CAT_BEHAVIOR_MOTION && + strcmp(item->group, first->group) == 0 && + (item->index == first->index - 1 || + item->index == first->index + 1) && + !bongo_cat_live2d_motion_visible(app->live2d, + item->group, item->index)) paired = true; + } + bool toggled_off = !bongo_cat_live2d_motion_selected(app->live2d, + first->group, first->index); + passed = (!paired || toggled_off) && passed; + if (!passed) SDL_Log("Motion menu self-test failed: initial=%d " + "preview=%d count=%llu independent=%d off=%d", + initial_passed, preview_stable, (unsigned long long)motion_count, + independent, toggled_off); + } passed = expression_self_test(app) && passed; return passed; } diff --git a/src/runtime/window_menu_preview.c b/src/runtime/window_menu_preview.c index 96733037..858fd9da 100644 --- a/src/runtime/window_menu_preview.c +++ b/src/runtime/window_menu_preview.c @@ -24,7 +24,10 @@ static int preview_group(const BongoCatApp *app, BongoCatMenuAction action) { if (action >= BONGO_CAT_MENU_OPACITY_10 && action <= BONGO_CAT_MENU_OPACITY_100) return 2; if (menu_model(app, action)) return 3; - if (bongo_cat_window_behavior_menu_action(action)) return 4; + if (action >= BONGO_CAT_MENU_MOTION_FIRST && + action < BONGO_CAT_MENU_MOTION_FIRST + BONGO_CAT_BEHAVIOR_CAP) return 4; + if (action >= BONGO_CAT_MENU_EXPRESSION_FIRST && + action < BONGO_CAT_MENU_EXPRESSION_FIRST + BONGO_CAT_BEHAVIOR_CAP) return 5; return 0; } @@ -35,7 +38,7 @@ void bongo_cat_window_menu_preview_init(BongoCatWindowMenuPreview *state, .scale = app ? app->config.window.scale_percent : 100.0f, .opacity = app ? app->config.window.opacity_percent : 100.0f, .expression = app ? bongo_cat_live2d_expression(app->live2d) : -1, - .last = BONGO_CAT_MENU_NONE}; + .last = BONGO_CAT_MENU_NONE, .applied = BONGO_CAT_MENU_NONE}; bongo_cat_modal_frame_init(&state->modal_frame, app); if (app) snprintf(state->model, sizeof(state->model), "%.*s", (int)sizeof(state->model) - 1, app->config.current_model); @@ -83,7 +86,12 @@ void bongo_cat_window_menu_preview(void *userdata, BongoCatMenuAction action) { (float)(10 * (action - BONGO_CAT_MENU_OPACITY_10 + 1)); bongo_cat_platform_set_opacity(&app->platform, app->config.window.opacity_percent / 100.0f); - } else if (bongo_cat_window_behavior_action(app, action)) { + } else if (action == state->applied && + bongo_cat_window_behavior_menu_action(action)) { + bongo_cat_modal_frame_tick(&state->modal_frame); + return; + } else if (bongo_cat_window_behavior_preview(app, action)) { + state->applied = action; bongo_cat_modal_frame_tick(&state->modal_frame); return; } @@ -100,6 +108,17 @@ void bongo_cat_window_menu_restore(void *userdata, BongoCatMenuAction selected) if (!state || !state->app) return; BongoCatApp *app = state->app; bool changed = false; + bool keep_motion = selected >= BONGO_CAT_MENU_MOTION_FIRST && + selected < BONGO_CAT_MENU_MOTION_FIRST + BONGO_CAT_BEHAVIOR_CAP; + bool committed_motion = keep_motion && + bongo_cat_window_behavior_commit_preview(app, selected); + if (!committed_motion && bongo_cat_live2d_restore_motion_preview(app->live2d)) { + state->applied = BONGO_CAT_MENU_NONE; + changed = true; + } + if (committed_motion) { state->applied = selected; changed = true; } + if (selected == BONGO_CAT_MENU_NONE) + state->applied = BONGO_CAT_MENU_NONE; bool keep_model = menu_model(app, selected) != NULL; bool keep_scale = selected >= BONGO_CAT_MENU_SCALE_50 && selected <= BONGO_CAT_MENU_SCALE_200; @@ -137,3 +156,9 @@ void bongo_cat_window_menu_restore(void *userdata, BongoCatMenuAction selected) } if (changed) bongo_cat_app_render_now(app); } + +bool bongo_cat_window_menu_preview_applied( + const BongoCatWindowMenuPreview *state, BongoCatMenuAction selected) { + return state && bongo_cat_window_behavior_menu_action(selected) && + state->applied == selected; +} diff --git a/src/ui/preferences.c b/src/ui/preferences.c index 1164e1da..90d18262 100644 --- a/src/ui/preferences.c +++ b/src/ui/preferences.c @@ -54,6 +54,7 @@ void bongo_cat_preferences_close(BongoCatPreferences *value) { bongo_cat_preferences_live_resize_uninstall(value); if (bongo_cat_preferences_behavior_dialog_active(value)) bongo_cat_preferences_behavior_dialog_close(value); + bongo_cat_preferences_model_rename_finish(value, true); bongo_cat_preferences_shortcut_cancel(value); if (value->gl_context) SDL_GL_MakeCurrent(value->window, value->gl_context); if (value->ui_initialized && value->input_active) @@ -135,6 +136,13 @@ static Uint32 event_window(const SDL_Event *event) { } } +bool bongo_cat_preferences_chrome_drag_allowed( + const BongoCatPreferences *value) { + return value && value->app && !value->native_drag && + !bongo_cat_preferences_behavior_dialog_active(value) && + !bongo_cat_preferences_remove_dialog_active(value->app); +} + void bongo_cat_preferences_drag_tick(BongoCatPreferences *value) { if (!value || !value->window || !value->chrome_dragging) return; float pointer_x = 0.0f, pointer_y = 0.0f; @@ -170,7 +178,15 @@ static bool chrome_event(BongoCatPreferences *value, const SDL_Event *event) { bongo_cat_preferences_close(value); return true; } - if (value->native_drag) return false; + if (!bongo_cat_preferences_chrome_drag_allowed(value)) { + if (event->type == SDL_EVENT_MOUSE_BUTTON_UP && + event->button.button == SDL_BUTTON_LEFT && value->chrome_dragging) { + SDL_CaptureMouse(false); + value->chrome_dragging = false; + return true; + } + return false; + } if (event->type == SDL_EVENT_MOUSE_BUTTON_DOWN && event->button.button == SDL_BUTTON_LEFT) { int width = 0; @@ -219,6 +235,7 @@ bool bongo_cat_preferences_event(BongoCatPreferences *value, const SDL_Event *ev if (value->app->smoke_input_audit && (event->type == SDL_EVENT_MOUSE_BUTTON_DOWN || event->type == SDL_EVENT_MOUSE_BUTTON_UP)) SDL_Log("Preferences mouse %s at %.1f,%.1f", event->button.down ? "down" : "up", event->button.x, event->button.y); + if (bongo_cat_preferences_model_rename_event(value, event)) return true; if (bongo_cat_preferences_behavior_rename_event(value, event)) return true; if (bongo_cat_preferences_shortcut_event(value, event)) return true; if (event->type == SDL_EVENT_WINDOW_CLOSE_REQUESTED) { diff --git a/src/ui/preferences_behavior_dialog.c b/src/ui/preferences_behavior_dialog.c index 26e41052..00ed6aca 100644 --- a/src/ui/preferences_behavior_dialog.c +++ b/src/ui/preferences_behavior_dialog.c @@ -34,15 +34,19 @@ static bool hit(struct nk_context *context, struct nk_rect bounds, bool enabled) return enabled && nk_input_is_mouse_hovering_rect(&context->input, bounds) && nk_input_is_mouse_click_in_rect(&context->input, NK_BUTTON_LEFT, bounds); } -static bool matches(const BongoCatBehaviorEntry *entry, int tab) { - return tab == 0 ? entry->kind == BONGO_CAT_BEHAVIOR_MOTION : - entry->kind == BONGO_CAT_BEHAVIOR_EXPRESSION; +static bool matches(const BongoCatPreferences *value, + const BongoCatBehaviorEntry *entry, int tab) { + if (tab == 0) return entry->kind == BONGO_CAT_BEHAVIOR_MOTION && + bongo_cat_live2d_motion_visible(value->app->live2d, + entry->group, entry->index); + return entry->kind == BONGO_CAT_BEHAVIOR_EXPRESSION; } static size_t row_count(const BongoCatPreferences *value) { size_t count = 0; for (size_t i = 0; i < value->app->behaviors.count; ++i) - if (matches(&value->app->behaviors.entries[i], value->behavior_tab)) + if (matches(value, &value->app->behaviors.entries[i], + value->behavior_tab)) count++; return count; } @@ -69,6 +73,7 @@ void bongo_cat_preferences_behavior_dialog_close( if (!value || !value->behavior_dialog || value->behavior_dialog_closing_ns) return; value->behavior_dialog_closing_ns = SDL_GetTicksNS(); + bongo_cat_preferences_scrollbar_reset(&value->behavior_scrollbar); bongo_cat_preferences_behavior_rename_finish(value, true); bongo_cat_preferences_shortcut_cancel(value); value->render_dirty = true; @@ -117,6 +122,8 @@ static void draw_segments(BongoCatPreferences *value, BONGO_CAT_UI_CURSOR_POINTER); if (hit(context, button, enabled) && value->behavior_tab != i) { value->behavior_tab = i; + bongo_cat_preferences_scrollbar_reset( + &value->behavior_scrollbar); value->behavior_tab_transition_ns = SDL_GetTicksNS(); bongo_cat_preferences_shortcut_cancel(value); } @@ -128,15 +135,21 @@ static void draw_rows(BongoCatPreferences *value, struct nk_context *context, BongoCatUIPalette p, float opacity, bool enabled, size_t count) { struct nk_rect viewport = nk_rect(panel.x + 20, panel.y + 144, panel.w - 40, panel.h - 164); - float maximum = NK_MAX(0.0f, count * 56.0f - viewport.h); - if (enabled && nk_input_is_mouse_hovering_rect(&context->input, viewport) && - context->input.mouse.scroll_delta.y != 0) { - value->behavior_scroll[value->behavior_tab] = NK_CLAMP(0.0f, - value->behavior_scroll[value->behavior_tab] - - context->input.mouse.scroll_delta.y * 48.0f, maximum); + float content_height = count * 56.0f; + float maximum = NK_MAX(0.0f, content_height - viewport.h); + float saved_offset = value->behavior_scroll[value->behavior_tab]; + BongoCatPreferencesScrollbarResult scroll = + bongo_cat_preferences_scrollbar_draw(context, canvas, viewport, + content_height, saved_offset, &value->behavior_scrollbar, p.accent, + opacity, enabled); + if (scroll.changed) { + value->behavior_scroll[value->behavior_tab] = scroll.offset; value->render_dirty = true; } - float offset = NK_CLAMP(0.0f, value->behavior_scroll[value->behavior_tab], maximum); + float offset = NK_CLAMP(0.0f, scroll.offset, maximum); + float render_offset = offset; + float row_width = bongo_cat_preferences_scrollbar_content_width( + viewport, content_height); float content_opacity = opacity; if (value->behavior_tab_transition_ns) { float progress = (float)(SDL_GetTicksNS() - @@ -145,7 +158,8 @@ static void draw_rows(BongoCatPreferences *value, struct nk_context *context, else { float eased = bongo_cat_ui_ease(BONGO_CAT_UI_EASE_SWIFT, NK_CLAMP(0.0f, progress, 1.0f)); - content_opacity *= eased; offset += 5.0f * (1.0f - eased); + content_opacity *= eased; + render_offset += 5.0f * (1.0f - eased); value->render_dirty = true; } } @@ -153,9 +167,9 @@ static void draw_rows(BongoCatPreferences *value, struct nk_context *context, size_t shown = 0; for (size_t i = 0; i < value->app->behaviors.count; ++i) { BongoCatBehaviorEntry *entry = &value->app->behaviors.entries[i]; - if (!matches(entry, value->behavior_tab)) continue; + if (!matches(value, entry, value->behavior_tab)) continue; struct nk_rect row = nk_rect(viewport.x, - viewport.y + shown++ * 56.0f - offset, viewport.w, 56); + viewport.y + shown++ * 56.0f - render_offset, row_width, 56); if (row.y + row.h >= viewport.y && row.y <= viewport.y + viewport.h) bongo_cat_preferences_behavior_row_draw(value, context, canvas, row, entry, p, diff --git a/src/ui/preferences_behavior_rename.c b/src/ui/preferences_behavior_rename.c index cd2893af..baaaab64 100644 --- a/src/ui/preferences_behavior_rename.c +++ b/src/ui/preferences_behavior_rename.c @@ -1,4 +1,5 @@ #include "preferences_state.h" +#include "preferences_text_edit.h" #include #include @@ -9,7 +10,7 @@ static BongoCatBehaviorShortcut *binding_for(BongoCatConfig *config, for (size_t i = 0; i < config->behavior_shortcut_count; ++i) if (!strcmp(config->behavior_shortcuts[i].id, id)) return &config->behavior_shortcuts[i]; - if (config->behavior_shortcut_count >= BONGO_CAT_BEHAVIOR_CAP) return NULL; + if (config->behavior_shortcut_count >= BONGO_CAT_BEHAVIOR_BINDING_CAP) return NULL; BongoCatBehaviorShortcut *binding = &config->behavior_shortcuts[config->behavior_shortcut_count++]; memset(binding, 0, sizeof(*binding)); @@ -54,6 +55,7 @@ void bongo_cat_preferences_behavior_rename_finish( } value->behavior_rename_id[0] = '\0'; value->behavior_rename_text[0] = '\0'; + value->behavior_rename_cursor = 0; value->behavior_rename_select_all = false; SDL_StopTextInput(value->window); if (label_changed) bongo_cat_preferences_reload_fonts(value); @@ -69,60 +71,43 @@ void bongo_cat_preferences_behavior_rename_begin(BongoCatPreferences *value, snprintf(value->behavior_rename_text, sizeof(value->behavior_rename_text), "%s", display_label(entry, binding)); value->behavior_rename_bounds = bounds; - value->behavior_rename_select_all = true; + bongo_cat_text_edit_begin(value->behavior_rename_text, + &value->behavior_rename_cursor, &value->behavior_rename_select_all); SDL_StartTextInput(value->window); value->render_dirty = true; } -static void append_text(BongoCatPreferences *value, const char *text) { - if (!text) return; - if (value->behavior_rename_select_all) { - value->behavior_rename_text[0] = '\0'; - value->behavior_rename_select_all = false; - } - size_t used = strlen(value->behavior_rename_text); - for (const char *source = text; *source;) { - nk_rune rune = 0; - int decoded = nk_utf_decode(source, &rune, (int)strlen(source)); - if (decoded <= 0) { source++; continue; } - size_t bytes = (size_t)decoded; - if (used + bytes >= sizeof(value->behavior_rename_text)) break; - if (rune < 32) { source += bytes; continue; } - memcpy(value->behavior_rename_text + used, source, bytes); - used += bytes; source += bytes; - } - value->behavior_rename_text[used] = '\0'; -} - -static void erase_text(BongoCatPreferences *value, SDL_Keycode key) { - size_t length = strlen(value->behavior_rename_text); - if (value->behavior_rename_select_all) length = 0; - else if (length && key == SDLK_BACKSPACE) { - do length--; while (length && - ((unsigned char)value->behavior_rename_text[length] & 0xc0) == 0x80); - } - value->behavior_rename_text[length] = '\0'; - value->behavior_rename_select_all = false; -} - static void clipboard_event(BongoCatPreferences *value, SDL_Keycode key) { if (key == SDLK_V) { char *clipboard = SDL_GetClipboardText(); - append_text(value, clipboard); + bongo_cat_text_edit_insert(value->behavior_rename_text, + sizeof(value->behavior_rename_text), + &value->behavior_rename_cursor, + &value->behavior_rename_select_all, clipboard); SDL_free(clipboard); return; } if (value->behavior_rename_select_all) SDL_SetClipboardText(value->behavior_rename_text); if (key == SDLK_X && value->behavior_rename_select_all) - value->behavior_rename_text[0] = '\0'; + bongo_cat_text_edit_clear(value->behavior_rename_text, + &value->behavior_rename_cursor, + &value->behavior_rename_select_all); +} + +static float measure(const void *userdata, const char *text, size_t length) { + const struct nk_user_font *font = userdata; + return font->width(font->userdata, font->height, text, (int)length); } bool bongo_cat_preferences_behavior_rename_event( BongoCatPreferences *value, const SDL_Event *event) { if (!value || !event || !value->behavior_rename_id[0]) return false; if (event->type == SDL_EVENT_TEXT_INPUT) { - append_text(value, event->text.text); + bongo_cat_text_edit_insert(value->behavior_rename_text, + sizeof(value->behavior_rename_text), + &value->behavior_rename_cursor, + &value->behavior_rename_select_all, event->text.text); value->render_dirty = true; return true; } if (event->type == SDL_EVENT_WINDOW_FOCUS_LOST) { @@ -137,6 +122,15 @@ bool bongo_cat_preferences_behavior_rename_event( value->behavior_rename_bounds.y, value->behavior_rename_bounds.w, value->behavior_rename_bounds.h)) bongo_cat_preferences_behavior_rename_finish(value, true); + else { + value->behavior_rename_cursor = bongo_cat_text_edit_nearest( + value->behavior_rename_text, + x - value->behavior_rename_bounds.x - 8.0f, measure, + value->ui.caption_font); + value->behavior_rename_select_all = false; + value->render_dirty = true; + return true; + } return false; } if (event->type != SDL_EVENT_KEY_DOWN && @@ -148,12 +142,24 @@ bool bongo_cat_preferences_behavior_rename_event( bongo_cat_preferences_behavior_rename_finish(value, false); else if (key == SDLK_RETURN || key == SDLK_KP_ENTER) bongo_cat_preferences_behavior_rename_finish(value, true); - else if (control && key == SDLK_A) value->behavior_rename_select_all = true; + else if (control && key == SDLK_A) + bongo_cat_text_edit_select_all(value->behavior_rename_text, + &value->behavior_rename_cursor, + &value->behavior_rename_select_all); else if (control && (key == SDLK_C || key == SDLK_X || key == SDLK_V)) clipboard_event(value, key); - else if (key == SDLK_BACKSPACE || key == SDLK_DELETE) erase_text(value, key); + else if (key == SDLK_BACKSPACE || key == SDLK_DELETE) + bongo_cat_text_edit_erase(value->behavior_rename_text, + &value->behavior_rename_cursor, + &value->behavior_rename_select_all, key == SDLK_DELETE); else if (key == SDLK_LEFT || key == SDLK_RIGHT || key == SDLK_HOME || - key == SDLK_END) value->behavior_rename_select_all = false; + key == SDLK_END) + bongo_cat_text_edit_move(value->behavior_rename_text, + &value->behavior_rename_cursor, + &value->behavior_rename_select_all, + key == SDLK_LEFT ? BONGO_CAT_TEXT_EDIT_LEFT : + key == SDLK_RIGHT ? BONGO_CAT_TEXT_EDIT_RIGHT : + key == SDLK_HOME ? BONGO_CAT_TEXT_EDIT_HOME : BONGO_CAT_TEXT_EDIT_END); value->render_dirty = true; return true; } diff --git a/src/ui/preferences_behavior_row.c b/src/ui/preferences_behavior_row.c index a125eb26..75923bd1 100644 --- a/src/ui/preferences_behavior_row.c +++ b/src/ui/preferences_behavior_row.c @@ -1,5 +1,4 @@ #include "preferences_state.h" -#include "preferences_notice.h" #include "preferences_overlay.h" #include "preferences_shortcut_clear.h" #include "ui_animation.h" @@ -32,7 +31,7 @@ static BongoCatBehaviorShortcut *binding_for(BongoCatConfig *config, for (size_t i = 0; i < config->behavior_shortcut_count; ++i) if (!strcmp(config->behavior_shortcuts[i].id, id)) return &config->behavior_shortcuts[i]; - if (config->behavior_shortcut_count >= BONGO_CAT_BEHAVIOR_CAP) return NULL; + if (config->behavior_shortcut_count >= BONGO_CAT_BEHAVIOR_BINDING_CAP) return NULL; BongoCatBehaviorShortcut *binding = &config->behavior_shortcuts[config->behavior_shortcut_count++]; memset(binding, 0, sizeof(*binding)); @@ -105,17 +104,23 @@ static void draw_name(BongoCatPreferences *value, struct nk_context *context, alpha(renaming ? p.pink : p.accent, opacity)); } const char *shown = renaming ? value->behavior_rename_text : label; - text(canvas, nk_rect(bounds.x + 8, bounds.y + 9, bounds.w - 16, 21), - shown, value->ui.caption_font, alpha(p.text, opacity)); + struct nk_rect text_bounds = nk_rect(bounds.x + 8, + bounds.y + 9, bounds.w - 16, 21); + if (renaming && value->behavior_rename_select_all) + nk_fill_rect(canvas, text_bounds, 4, + alpha(p.selection, opacity)); + text(canvas, text_bounds, shown, value->ui.caption_font, + alpha(p.text, opacity)); if (renaming) { value->behavior_rename_bounds = bounds; float caret = value->ui.caption_font->width( value->ui.caption_font->userdata, value->ui.caption_font->height, - shown, nk_strlen(shown)); + shown, (int)value->behavior_rename_cursor); caret = NK_MIN(caret, bounds.w - 18); - nk_stroke_line(canvas, bounds.x + 8 + caret, bounds.y + 8, - bounds.x + 8 + caret, bounds.y + bounds.h - 8, 1, - alpha(p.pink, opacity)); + if (!value->behavior_rename_select_all) + nk_stroke_line(canvas, bounds.x + 8 + caret, bounds.y + 8, + bounds.x + 8 + caret, bounds.y + bounds.h - 8, 1, + alpha(p.pink, opacity)); } else if (hover) { bongo_cat_ui_cursor_hover_rect(context, bounds, BONGO_CAT_UI_CURSOR_TEXT); if (hit(context, bounds, enabled) && binding) @@ -145,13 +150,8 @@ void bongo_cat_preferences_behavior_row_draw(BongoCatPreferences *value, alpha(play_hover ? p.accent : p.text, opacity)); if (play_hover) bongo_cat_ui_cursor_hover_rect(context, play, BONGO_CAT_UI_CURSOR_POINTER); - if (hit(context, play, enabled)) { - const char *label = display_label(entry, binding); + if (hit(context, play, enabled)) bongo_cat_app_run_behavior(value->app, entry); - char message[BONGO_CAT_ID_CAP + 5]; - snprintf(message, sizeof(message), "%s \xE2\x9C\x93", label); - bongo_cat_preferences_notice_show(value->app, message, false); - } if (!binding) return; char id[BONGO_CAT_ID_CAP + 16]; snprintf(id, sizeof(id), "behavior-%.*s", (int)sizeof(id) - 10, entry->id); diff --git a/src/ui/preferences_dialog.c b/src/ui/preferences_dialog.c index 8606cb23..2e8b5185 100644 --- a/src/ui/preferences_dialog.c +++ b/src/ui/preferences_dialog.c @@ -30,11 +30,6 @@ static struct nk_color alpha(struct nk_color color, float amount) { return bongo_cat_preferences_overlay_alpha(color, amount); } -static const char *model_name(const BongoCatModelEntry *entry) { - return entry && entry->display_name[0] ? entry->display_name : - (entry ? entry->id : "model"); -} - static void text(struct nk_command_buffer *canvas, struct nk_rect bounds, const char *value, const struct nk_user_font *font, struct nk_color color) { nk_draw_text(canvas, bounds, value, nk_strlen(value), font, @@ -66,7 +61,7 @@ void bongo_cat_preferences_remove_dialog_open(BongoCatApp *app, snprintf(remove_dialog.model_id, sizeof(remove_dialog.model_id), "%s", id); const BongoCatModelEntry *entry = bongo_cat_models_find(&app->models, id); snprintf(remove_dialog.model_name, sizeof(remove_dialog.model_name), "%s", - model_name(entry)); + bongo_cat_model_name(&app->config, entry)); remove_dialog.opened_ns = SDL_GetTicksNS(); remove_dialog.closing_ns = 0; remove_dialog.input_armed = false; diff --git a/src/ui/preferences_internal.h b/src/ui/preferences_internal.h index 5ea1adce..1958d909 100644 --- a/src/ui/preferences_internal.h +++ b/src/ui/preferences_internal.h @@ -25,6 +25,16 @@ void bongo_cat_preferences_behavior_rename_begin(BongoCatPreferences *value, struct nk_rect bounds); void bongo_cat_preferences_behavior_rename_finish( BongoCatPreferences *value, bool save); +bool bongo_cat_preferences_model_rename_event( + BongoCatPreferences *value, const SDL_Event *event); +void bongo_cat_preferences_model_rename_begin(BongoCatPreferences *value, + const BongoCatModelEntry *entry, struct nk_rect bounds); +void bongo_cat_preferences_model_rename_finish( + BongoCatPreferences *value, bool save); +bool bongo_cat_preferences_model_name_draw(BongoCatPreferences *value, + struct nk_context *context, struct nk_command_buffer *canvas, + const BongoCatModelEntry *entry, struct nk_rect bounds, + BongoCatUIPalette palette); void bongo_cat_preferences_behavior_row_draw(BongoCatPreferences *value, struct nk_context *context, struct nk_command_buffer *canvas, struct nk_rect row, BongoCatBehaviorEntry *entry, BongoCatUIPalette palette, @@ -52,6 +62,8 @@ void bongo_cat_preferences_remove_dialog_draw(BongoCatApp *app, struct nk_context *context); void bongo_cat_preferences_remove_dialog_clear(const BongoCatApp *app); void bongo_cat_preferences_remove_dialog_close(BongoCatApp *app); +bool bongo_cat_preferences_chrome_drag_allowed( + const BongoCatPreferences *value); typedef struct BongoCatImportDialog BongoCatImportDialog; BongoCatImportDialog *bongo_cat_preferences_import_create(void); diff --git a/src/ui/preferences_model.c b/src/ui/preferences_model.c index 6c63369a..86444e71 100644 --- a/src/ui/preferences_model.c +++ b/src/ui/preferences_model.c @@ -46,14 +46,6 @@ static const char *mode_label(BongoCatApp *app, BongoCatModelMode mode) { return tr(app, "native.modeStandard", "Standard"); } -static const char *model_name(const BongoCatModelEntry *entry) { - if (!strcmp(entry->id, "standard")) return "Standard"; - if (!strcmp(entry->id, "keyboard")) return "Keyboard"; - if (!strcmp(entry->id, "gamepad")) return "Gamepad"; - if (entry->display_name[0]) return entry->display_name; - return entry->id; -} - static void text(struct nk_context *context, struct nk_command_buffer *canvas, struct nk_rect bounds, const char *value, struct nk_color color, const struct nk_user_font *font) { @@ -172,8 +164,10 @@ static void model_card(BongoCatPreferences *value, struct nk_context *context, nk_draw_image(canvas, image, &texture, nk_rgb(255, 255, 255)); } float info_y = preview.y + preview.h + 8; - text(context, canvas, nk_rect(bounds.x + 13, info_y, bounds.w - 26, 22), - model_name(entry), p.text, value->ui.label_font); + struct nk_rect name_bounds = nk_rect(bounds.x + 9, info_y - 3, + bounds.w - 18, 28); + bool name_consumes_click = bongo_cat_preferences_model_name_draw(value, + context, canvas, entry, name_bounds, p); text(context, canvas, nk_rect(bounds.x + 13, info_y + 24, bounds.w - 26, 20), mode_label(app, entry->mode), p.muted, value->ui.caption_font); struct nk_rect actions = nk_rect(bounds.x + 1, bounds.y + bounds.h - 39, @@ -211,7 +205,8 @@ static void model_card(BongoCatPreferences *value, struct nk_context *context, bongo_cat_ui_color_mix(p.border_subtle, p.accent, lift); nk_stroke_rect(canvas, outline, 14.0f - outline_width * .5f, outline_width, outline_color); - if (first_hover || second_hover || third_hover || hover) + if (!name_consumes_click && + (first_hover || second_hover || third_hover || hover)) bongo_cat_ui_cursor_hover_rect(context, bounds, BONGO_CAT_UI_CURSOR_POINTER); if (first_hover && nk_input_is_mouse_click_in_rect(&context->input, @@ -224,8 +219,9 @@ static void model_card(BongoCatPreferences *value, struct nk_context *context, else if (third_hover && nk_input_is_mouse_click_in_rect(&context->input, NK_BUTTON_LEFT, third)) bongo_cat_preferences_remove_dialog_open(app, entry->id); - else if (hover && nk_input_is_mouse_click_in_rect(&context->input, - NK_BUTTON_LEFT, bounds)) select_model(value, entry); + else if (!name_consumes_click && hover && + nk_input_is_mouse_click_in_rect( + &context->input, NK_BUTTON_LEFT, bounds)) select_model(value, entry); } static bool is_builtin(const char *id) { @@ -244,11 +240,11 @@ static void smoke_model_behavior(BongoCatPreferences *value) { if (app->smoke_preference_model_select) { for (size_t i = 0; i < app->models.count; ++i) { BongoCatModelEntry *entry = &app->models.entries[i]; - if (!entry->managed || !strcmp(entry->id, + if (is_builtin(entry->id) || !strcmp(entry->id, app->config.current_model)) continue; app->smoke_preference_model_select = false; value->smoke_behavior_open_pending = true; - SDL_Log("Preferences smoke selecting nearby model %s", entry->id); + SDL_Log("Preferences smoke selecting model %s", entry->id); select_model(value, entry); return; } diff --git a/src/ui/preferences_model_glyphs.c b/src/ui/preferences_model_glyphs.c index d96fdf32..89ef1b6f 100644 --- a/src/ui/preferences_model_glyphs.c +++ b/src/ui/preferences_model_glyphs.c @@ -34,7 +34,7 @@ void bongo_cat_preferences_model_glyphs(const BongoCatApp *app, for (size_t i = 0; i < app->models.count; ++i) { const BongoCatModelEntry *entry = &app->models.entries[i]; add_text(ranges, capacity, &used, - entry->display_name[0] ? entry->display_name : entry->id); + bongo_cat_model_name(&app->config, entry)); } for (size_t i = 0; i < app->behaviors.count; ++i) add_text(ranges, capacity, &used, app->behaviors.entries[i].label); diff --git a/src/ui/preferences_model_rename.c b/src/ui/preferences_model_rename.c new file mode 100644 index 00000000..12e1a41d --- /dev/null +++ b/src/ui/preferences_model_rename.c @@ -0,0 +1,170 @@ +#include "preferences_state.h" +#include "preferences_text_edit.h" + +#include +#include +#include + +bool bongo_cat_preferences_model_name_draw(BongoCatPreferences *value, + struct nk_context *context, struct nk_command_buffer *canvas, + const BongoCatModelEntry *entry, struct nk_rect bounds, + BongoCatUIPalette p) { + bool renaming = !strcmp(value->model_rename_id, entry->id); + bool hover = !renaming && nk_input_is_mouse_hovering_rect( + &context->input, bounds); + bool clicked = hover && nk_input_is_mouse_click_in_rect( + &context->input, NK_BUTTON_LEFT, bounds); + if (renaming || hover) { + nk_fill_rect(canvas, bounds, 7, renaming ? p.hover_pink : p.hover); + nk_stroke_rect(canvas, bounds, 7, 1, renaming ? p.pink : p.accent); + } + const char *shown = renaming ? value->model_rename_text : + bongo_cat_model_name(&value->app->config, entry); + struct nk_rect text_bounds = nk_rect(bounds.x + 5, + bounds.y + 3, bounds.w - 10, 22); + if (renaming && value->model_rename_select_all) + nk_fill_rect(canvas, text_bounds, 4, p.selection); + nk_draw_text(canvas, text_bounds, shown, nk_strlen(shown), value->ui.label_font, + nk_rgba(0, 0, 0, 0), renaming ? p.pink : p.text); + if (renaming) { + value->model_rename_bounds = bounds; + float caret = value->ui.label_font->width( + value->ui.label_font->userdata, value->ui.label_font->height, + shown, (int)value->model_rename_cursor); + caret = NK_MIN(caret, bounds.w - 12); + if (!value->model_rename_select_all) + nk_stroke_line(canvas, bounds.x + 5 + caret, bounds.y + 3, + bounds.x + 5 + caret, bounds.y + bounds.h - 3, 1, p.pink); + } else if (clicked) + bongo_cat_preferences_model_rename_begin(value, entry, bounds); + if (hover || renaming) bongo_cat_ui_cursor_hover_rect(context, bounds, + BONGO_CAT_UI_CURSOR_TEXT); + return renaming || hover; +} + +static void clipboard_event(BongoCatPreferences *value, SDL_Keycode key) { + if (key == SDLK_V) { + char *clipboard = SDL_GetClipboardText(); + bongo_cat_text_edit_insert(value->model_rename_text, + sizeof(value->model_rename_text), &value->model_rename_cursor, + &value->model_rename_select_all, clipboard); + SDL_free(clipboard); + return; + } + if (value->model_rename_select_all) + SDL_SetClipboardText(value->model_rename_text); + if (key == SDLK_X && value->model_rename_select_all) + bongo_cat_text_edit_clear(value->model_rename_text, + &value->model_rename_cursor, &value->model_rename_select_all); +} + +static float measure(const void *userdata, const char *text, size_t length) { + const struct nk_user_font *font = userdata; + return font->width(font->userdata, font->height, text, (int)length); +} + +void bongo_cat_preferences_model_rename_finish( + BongoCatPreferences *value, bool save) { + if (!value || !value->model_rename_id[0]) return; + bool label_changed = false; + if (save) { + size_t start = 0, end = strlen(value->model_rename_text); + while (start < end && (value->model_rename_text[start] == ' ' || + value->model_rename_text[start] == '\t')) start++; + while (end > start && (value->model_rename_text[end - 1] == ' ' || + value->model_rename_text[end - 1] == '\t')) end--; + if (start) memmove(value->model_rename_text, + value->model_rename_text + start, end - start); + value->model_rename_text[end - start] = '\0'; + const BongoCatModelEntry *entry = bongo_cat_models_find( + &value->app->models, value->model_rename_id); + const char *label = entry && !strcmp(value->model_rename_text, + bongo_cat_model_default_name(entry)) ? "" : + value->model_rename_text; + label_changed = bongo_cat_config_set_model_label(&value->app->config, + value->model_rename_id, label); + } + value->model_rename_id[0] = '\0'; + value->model_rename_text[0] = '\0'; + value->model_rename_cursor = 0; + value->model_rename_select_all = false; + SDL_StopTextInput(value->window); + if (label_changed) bongo_cat_preferences_reload_fonts(value); + value->render_dirty = true; +} + +void bongo_cat_preferences_model_rename_begin(BongoCatPreferences *value, + const BongoCatModelEntry *entry, struct nk_rect bounds) { + if (!value || !entry) return; + bongo_cat_preferences_shortcut_cancel(value); + snprintf(value->model_rename_id, sizeof(value->model_rename_id), + "%s", entry->id); + snprintf(value->model_rename_text, sizeof(value->model_rename_text), + "%s", bongo_cat_model_name(&value->app->config, entry)); + value->model_rename_bounds = bounds; + bongo_cat_text_edit_begin(value->model_rename_text, + &value->model_rename_cursor, &value->model_rename_select_all); + SDL_StartTextInput(value->window); + value->render_dirty = true; +} + +bool bongo_cat_preferences_model_rename_event( + BongoCatPreferences *value, const SDL_Event *event) { + if (!value || !event || !value->model_rename_id[0]) return false; + if (event->type == SDL_EVENT_TEXT_INPUT) { + bongo_cat_text_edit_insert(value->model_rename_text, + sizeof(value->model_rename_text), &value->model_rename_cursor, + &value->model_rename_select_all, event->text.text); + value->render_dirty = true; return true; + } + if (event->type == SDL_EVENT_WINDOW_FOCUS_LOST) { + bongo_cat_preferences_model_rename_finish(value, true); return false; + } + if (event->type == SDL_EVENT_MOUSE_BUTTON_DOWN && + event->button.button == SDL_BUTTON_LEFT) { + float scale = value->ui.layout_scale > 0.0f ? + value->ui.layout_scale : 1.0f; + float x = event->button.x / scale, y = event->button.y / scale; + if (!NK_INBOX(x, y, value->model_rename_bounds.x, + value->model_rename_bounds.y, value->model_rename_bounds.w, + value->model_rename_bounds.h)) + bongo_cat_preferences_model_rename_finish(value, true); + else { + value->model_rename_cursor = bongo_cat_text_edit_nearest( + value->model_rename_text, + x - value->model_rename_bounds.x - 5.0f, measure, + value->ui.label_font); + value->model_rename_select_all = false; + value->render_dirty = true; + return true; + } + return false; + } + if (event->type != SDL_EVENT_KEY_DOWN && + event->type != SDL_EVENT_KEY_UP) return false; + if (event->type == SDL_EVENT_KEY_UP) return true; + SDL_Keycode key = event->key.key; + bool control = (event->key.mod & SDL_KMOD_CTRL) != 0; + if (key == SDLK_ESCAPE) + bongo_cat_preferences_model_rename_finish(value, false); + else if (key == SDLK_RETURN || key == SDLK_KP_ENTER) + bongo_cat_preferences_model_rename_finish(value, true); + else if (control && key == SDLK_A) + bongo_cat_text_edit_select_all(value->model_rename_text, + &value->model_rename_cursor, &value->model_rename_select_all); + else if (control && (key == SDLK_C || key == SDLK_X || key == SDLK_V)) + clipboard_event(value, key); + else if (key == SDLK_BACKSPACE || key == SDLK_DELETE) + bongo_cat_text_edit_erase(value->model_rename_text, + &value->model_rename_cursor, &value->model_rename_select_all, + key == SDLK_DELETE); + else if (key == SDLK_LEFT || key == SDLK_RIGHT || key == SDLK_HOME || + key == SDLK_END) + bongo_cat_text_edit_move(value->model_rename_text, + &value->model_rename_cursor, &value->model_rename_select_all, + key == SDLK_LEFT ? BONGO_CAT_TEXT_EDIT_LEFT : + key == SDLK_RIGHT ? BONGO_CAT_TEXT_EDIT_RIGHT : + key == SDLK_HOME ? BONGO_CAT_TEXT_EDIT_HOME : BONGO_CAT_TEXT_EDIT_END); + value->render_dirty = true; + return true; +} diff --git a/src/ui/preferences_overlay.c b/src/ui/preferences_overlay.c index 9c94a07a..c7adbf5b 100644 --- a/src/ui/preferences_overlay.c +++ b/src/ui/preferences_overlay.c @@ -12,7 +12,7 @@ bool bongo_cat_preferences_overlay_input_ready(struct nk_context *context, if (*armed) return true; const struct nk_mouse_button *left = &context->input.mouse.buttons[NK_BUTTON_LEFT]; - if (!left->down && !left->clicked) *armed = true; + if (!left->down) *armed = true; return false; } diff --git a/src/ui/preferences_scrollbar.c b/src/ui/preferences_scrollbar.c new file mode 100644 index 00000000..be1c9933 --- /dev/null +++ b/src/ui/preferences_scrollbar.c @@ -0,0 +1,130 @@ +#include "preferences_scrollbar.h" + +#include "ui_backend.h" + +enum { SCROLLBAR_WIDTH = 10, SCROLLBAR_RESERVE = 14 }; +static const float SCROLLBAR_WHEEL_STEP = 48.0f; + +static struct nk_color with_opacity(struct nk_color color, float opacity) { + color.a = (nk_byte)(color.a * NK_CLAMP(0.0f, opacity, 1.0f) + .5f); + return color; +} + +static void draw_track(struct nk_command_buffer *canvas, struct nk_rect bounds, + const struct nk_style_item *item, const struct nk_style_scrollbar *style, + float opacity) { + struct nk_color tint = with_opacity(nk_rgba(255, 255, 255, 255), opacity); + switch (item->type) { + case NK_STYLE_ITEM_IMAGE: + nk_draw_image(canvas, bounds, &item->data.image, tint); + break; + case NK_STYLE_ITEM_NINE_SLICE: + nk_draw_nine_slice(canvas, bounds, &item->data.slice, tint); + break; + case NK_STYLE_ITEM_COLOR: + nk_fill_rect(canvas, bounds, style->rounding, + with_opacity(item->data.color, opacity)); + if (style->border > 0.0f) + nk_stroke_rect(canvas, bounds, style->rounding, style->border, + with_opacity(style->border_color, opacity)); + break; + } +} + +bool bongo_cat_preferences_scrollbar_needed( + struct nk_rect viewport, float content_height) { + return content_height > viewport.h; +} + +float bongo_cat_preferences_scrollbar_content_width( + struct nk_rect viewport, float content_height) { + return viewport.w - (bongo_cat_preferences_scrollbar_needed( + viewport, content_height) ? SCROLLBAR_RESERVE : 0.0f); +} + +void bongo_cat_preferences_scrollbar_reset( + BongoCatPreferencesScrollbar *scrollbar) { + if (!scrollbar) return; + scrollbar->grab_y = 0.0f; + scrollbar->dragging = false; +} + +BongoCatPreferencesScrollbarResult bongo_cat_preferences_scrollbar_draw( + struct nk_context *context, struct nk_command_buffer *canvas, + struct nk_rect viewport, float content_height, float offset, + BongoCatPreferencesScrollbar *scrollbar, struct nk_color cursor_color, + float opacity, bool enabled) { + BongoCatPreferencesScrollbarResult result = { + .offset = offset, + .scrollable = bongo_cat_preferences_scrollbar_needed( + viewport, content_height), + .changed = false}; + if (!context || !canvas || !scrollbar) return result; + if (!result.scrollable) { + bongo_cat_preferences_scrollbar_reset(scrollbar); + result.changed = offset != 0.0f; + result.offset = 0.0f; + return result; + } + + const struct nk_style_scrollbar *style = &context->style.scrollv; + struct nk_rect track = nk_rect(viewport.x + viewport.w - SCROLLBAR_WIDTH, + viewport.y, SCROLLBAR_WIDTH, viewport.h); + float initial = offset; + float maximum = content_height - viewport.h; + offset = NK_CLAMP(0.0f, offset, maximum); + bool hover = enabled && nk_input_is_mouse_hovering_rect( + &context->input, track); + if (!enabled) scrollbar->dragging = false; + float padding_x = hover || scrollbar->dragging ? 0.0f : style->padding.x; + float inset_y = style->border + style->padding.y; + float cursor_height = viewport.h / content_height * viewport.h - + inset_y * 2.0f; + struct nk_rect cursor = nk_rect(track.x + style->border + padding_x, + track.y + offset / content_height * track.h + inset_y, + track.w - (style->border + padding_x) * 2.0f, + NK_MAX(0.0f, cursor_height)); + + struct nk_mouse_button *left = &context->input.mouse.buttons[NK_BUTTON_LEFT]; + if (!left->down) scrollbar->dragging = false; + if (enabled && left->clicked && NK_INBOX(left->clicked_pos.x, + left->clicked_pos.y, cursor.x, cursor.y, cursor.w, cursor.h)) { + scrollbar->dragging = true; + scrollbar->grab_y = left->clicked_pos.y - cursor.y; + } else if (enabled && left->clicked && hover) { + offset += left->clicked_pos.y < cursor.y ? -viewport.h : viewport.h; + offset = NK_CLAMP(0.0f, offset, maximum); + } + if (enabled && scrollbar->dragging && left->down) { + float travel = NK_MAX(1.0f, track.h - inset_y * 2.0f - cursor.h); + float cursor_y = context->input.mouse.pos.y - track.y - inset_y - + scrollbar->grab_y; + offset = NK_CLAMP(0.0f, cursor_y / travel * maximum, maximum); + } + bool viewport_hover = enabled && nk_input_is_mouse_hovering_rect( + &context->input, viewport); + if (viewport_hover && context->input.mouse.scroll_delta.y != 0.0f) + offset = NK_CLAMP(0.0f, offset - context->input.mouse.scroll_delta.y * + SCROLLBAR_WHEEL_STEP, maximum); + + bool active = hover || scrollbar->dragging; + const struct nk_style_item *track_style = scrollbar->dragging ? + &style->active : hover ? &style->hover : &style->normal; + draw_track(canvas, track, track_style, style, opacity); + cursor.x = track.x + style->border + + (active ? 0.0f : style->padding.x); + cursor.w = track.w - (cursor.x - track.x) * 2.0f; + cursor.y = track.y + offset / content_height * track.h + inset_y; + nk_fill_rect(canvas, cursor, style->rounding_cursor, + with_opacity(cursor_color, opacity)); + if (style->border_cursor > 0.0f) + nk_stroke_rect(canvas, cursor, style->rounding_cursor, + style->border_cursor, + with_opacity(style->cursor_border_color, opacity)); + if (hover) bongo_cat_ui_cursor_hover_rect(context, track, + BONGO_CAT_UI_CURSOR_POINTER); + + result.offset = offset; + result.changed = offset != initial; + return result; +} diff --git a/src/ui/preferences_scrollbar.h b/src/ui/preferences_scrollbar.h new file mode 100644 index 00000000..c42fe63b --- /dev/null +++ b/src/ui/preferences_scrollbar.h @@ -0,0 +1,31 @@ +#ifndef BONGO_CAT_PREFERENCES_SCROLLBAR_H +#define BONGO_CAT_PREFERENCES_SCROLLBAR_H + +#include "nuklear_config.h" + +#include + +typedef struct BongoCatPreferencesScrollbar { + float grab_y; + bool dragging; +} BongoCatPreferencesScrollbar; + +typedef struct BongoCatPreferencesScrollbarResult { + float offset; + bool scrollable; + bool changed; +} BongoCatPreferencesScrollbarResult; + +bool bongo_cat_preferences_scrollbar_needed( + struct nk_rect viewport, float content_height); +float bongo_cat_preferences_scrollbar_content_width( + struct nk_rect viewport, float content_height); +void bongo_cat_preferences_scrollbar_reset( + BongoCatPreferencesScrollbar *scrollbar); +BongoCatPreferencesScrollbarResult bongo_cat_preferences_scrollbar_draw( + struct nk_context *context, struct nk_command_buffer *canvas, + struct nk_rect viewport, float content_height, float offset, + BongoCatPreferencesScrollbar *scrollbar, struct nk_color cursor_color, + float opacity, bool enabled); + +#endif diff --git a/src/ui/preferences_state.h b/src/ui/preferences_state.h index 143a0ccc..e5b35ab3 100644 --- a/src/ui/preferences_state.h +++ b/src/ui/preferences_state.h @@ -2,6 +2,7 @@ #define BONGO_CAT_PREFERENCES_STATE_H #include "preferences_internal.h" +#include "preferences_scrollbar.h" #include "ui_backend.h" #include "bongo_cat/i18n.h" #include "bongo_cat/preferences.h" @@ -61,10 +62,17 @@ struct BongoCatPreferences { uint64_t behavior_dialog_closing_ns; uint64_t behavior_tab_transition_ns; float behavior_scroll[2]; + BongoCatPreferencesScrollbar behavior_scrollbar; char behavior_rename_id[BONGO_CAT_PATH_CAP]; char behavior_rename_text[BONGO_CAT_ID_CAP]; struct nk_rect behavior_rename_bounds; + size_t behavior_rename_cursor; bool behavior_rename_select_all; + char model_rename_id[BONGO_CAT_ID_CAP]; + char model_rename_text[BONGO_CAT_ID_CAP]; + struct nk_rect model_rename_bounds; + size_t model_rename_cursor; + bool model_rename_select_all; bool native_drag; bool chrome_dragging; bool live_resize_active; diff --git a/src/ui/preferences_text_edit.c b/src/ui/preferences_text_edit.c new file mode 100644 index 00000000..dff2df9b --- /dev/null +++ b/src/ui/preferences_text_edit.c @@ -0,0 +1,143 @@ +#include "preferences_text_edit.h" + +#include + +static bool continuation(unsigned char value) { + return (value & 0xc0) == 0x80; +} + +static size_t boundary(const char *text, size_t cursor) { + size_t length = text ? strlen(text) : 0; + if (cursor > length) cursor = length; + while (cursor && cursor < length && continuation((unsigned char)text[cursor])) + cursor--; + return cursor; +} + +static size_t previous(const char *text, size_t cursor) { + cursor = boundary(text, cursor); + if (!cursor) return 0; + do cursor--; while (cursor && continuation((unsigned char)text[cursor])); + return cursor; +} + +static size_t next(const char *text, size_t cursor) { + size_t length = text ? strlen(text) : 0; + cursor = boundary(text, cursor); + if (cursor >= length) return length; + do cursor++; while (cursor < length && + continuation((unsigned char)text[cursor])); + return cursor; +} + +static size_t rune_bytes(const unsigned char *value, size_t remaining) { + if (!remaining || !value[0]) return 0; + if (value[0] < 0x80) return 1; + size_t count = value[0] >= 0xc2 && value[0] <= 0xdf ? 2 : + value[0] >= 0xe0 && value[0] <= 0xef ? 3 : + value[0] >= 0xf0 && value[0] <= 0xf4 ? 4 : 0; + if (!count || count > remaining) return 0; + for (size_t i = 1; i < count; ++i) + if (!continuation(value[i])) return 0; + if ((count == 3 && value[0] == 0xe0 && value[1] < 0xa0) || + (count == 3 && value[0] == 0xed && value[1] >= 0xa0) || + (count == 4 && value[0] == 0xf0 && value[1] < 0x90) || + (count == 4 && value[0] == 0xf4 && value[1] >= 0x90)) return 0; + return count; +} + +void bongo_cat_text_edit_begin(const char *text, size_t *cursor, + bool *select_all) { + if (cursor) *cursor = text ? strlen(text) : 0; + if (select_all) *select_all = false; +} + +bool bongo_cat_text_edit_insert(char *text, size_t capacity, size_t *cursor, + bool *select_all, const char *inserted) { + if (!text || !capacity || !cursor || !select_all || !inserted) return false; + bool changed = false; + if (*select_all) { + changed = text[0] != '\0'; + text[0] = '\0'; *cursor = 0; *select_all = false; + } + size_t length = strlen(text); + *cursor = boundary(text, *cursor); + size_t remaining = strlen(inserted); + for (const unsigned char *source = (const unsigned char *)inserted; + remaining && *source;) { + size_t bytes = rune_bytes(source, remaining); + if (!bytes) { source++; remaining--; continue; } + if (bytes == 1 && source[0] < 32) { + source++; remaining--; continue; + } + if (length + bytes >= capacity) break; + memmove(text + *cursor + bytes, text + *cursor, + length - *cursor + 1); + memcpy(text + *cursor, source, bytes); + *cursor += bytes; length += bytes; + source += bytes; remaining -= bytes; changed = true; + } + return changed; +} + +bool bongo_cat_text_edit_erase(char *text, size_t *cursor, + bool *select_all, bool forward) { + if (!text || !cursor || !select_all) return false; + size_t length = strlen(text); + if (*select_all) { + bool changed = length != 0; + text[0] = '\0'; *cursor = 0; *select_all = false; + return changed; + } + *cursor = boundary(text, *cursor); + size_t start = forward ? *cursor : previous(text, *cursor); + size_t end = forward ? next(text, *cursor) : *cursor; + if (start == end) return false; + memmove(text + start, text + end, length - end + 1); + *cursor = start; + return true; +} + +bool bongo_cat_text_edit_move(const char *text, size_t *cursor, + bool *select_all, BongoCatTextEditMove move) { + if (!text || !cursor || !select_all) return false; + size_t length = strlen(text), old = *cursor; + bool selected = *select_all; + *cursor = boundary(text, *cursor); + if (move == BONGO_CAT_TEXT_EDIT_HOME || (selected && + move == BONGO_CAT_TEXT_EDIT_LEFT)) *cursor = 0; + else if (move == BONGO_CAT_TEXT_EDIT_END || (selected && + move == BONGO_CAT_TEXT_EDIT_RIGHT)) *cursor = length; + else if (move == BONGO_CAT_TEXT_EDIT_LEFT) *cursor = previous(text, *cursor); + else if (move == BONGO_CAT_TEXT_EDIT_RIGHT) *cursor = next(text, *cursor); + *select_all = false; + return selected || old != *cursor; +} + +void bongo_cat_text_edit_select_all(const char *text, size_t *cursor, + bool *select_all) { + if (cursor) *cursor = text ? strlen(text) : 0; + if (select_all) *select_all = true; +} + +void bongo_cat_text_edit_clear(char *text, size_t *cursor, bool *select_all) { + if (text) text[0] = '\0'; + if (cursor) *cursor = 0; + if (select_all) *select_all = false; +} + +size_t bongo_cat_text_edit_nearest(const char *text, float target, + float (*measure)(const void *userdata, const char *text, size_t length), + const void *userdata) { + if (!text || !measure || target <= 0.0f) return 0; + size_t length = strlen(text), cursor = 0; + float previous_width = 0.0f; + while (cursor < length) { + size_t after = next(text, cursor); + float width = measure(userdata, text, after); + if (target < previous_width + (width - previous_width) * 0.5f) + return cursor; + previous_width = width; cursor = after; + } + return length; +} diff --git a/src/ui/preferences_text_edit.h b/src/ui/preferences_text_edit.h new file mode 100644 index 00000000..f46efd8f --- /dev/null +++ b/src/ui/preferences_text_edit.h @@ -0,0 +1,29 @@ +#ifndef BONGO_CAT_PREFERENCES_TEXT_EDIT_H +#define BONGO_CAT_PREFERENCES_TEXT_EDIT_H + +#include +#include + +typedef enum BongoCatTextEditMove { + BONGO_CAT_TEXT_EDIT_LEFT, + BONGO_CAT_TEXT_EDIT_RIGHT, + BONGO_CAT_TEXT_EDIT_HOME, + BONGO_CAT_TEXT_EDIT_END +} BongoCatTextEditMove; + +void bongo_cat_text_edit_begin(const char *text, size_t *cursor, + bool *select_all); +bool bongo_cat_text_edit_insert(char *text, size_t capacity, size_t *cursor, + bool *select_all, const char *inserted); +bool bongo_cat_text_edit_erase(char *text, size_t *cursor, + bool *select_all, bool forward); +bool bongo_cat_text_edit_move(const char *text, size_t *cursor, + bool *select_all, BongoCatTextEditMove move); +void bongo_cat_text_edit_select_all(const char *text, size_t *cursor, + bool *select_all); +void bongo_cat_text_edit_clear(char *text, size_t *cursor, bool *select_all); +size_t bongo_cat_text_edit_nearest(const char *text, float target, + float (*measure)(const void *userdata, const char *text, size_t length), + const void *userdata); + +#endif diff --git a/tests/test_config.c b/tests/test_config.c index 317fc98d..2b30836c 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -1,5 +1,6 @@ #include "test.h" #include "bongo_cat/config.h" +#include "bongo_cat/model.h" #include "bongo_cat/file.h" #include "bongo_cat/path.h" #include "bongo_cat/utf8.h" @@ -76,6 +77,10 @@ void test_config(void) { sizeof("keyboard:motion:Tap:0")); memcpy(value.behavior_shortcuts[0].shortcut, "Control+1", sizeof("Control+1")); memcpy(value.behavior_shortcuts[0].label, "Happy tap", sizeof("Happy tap")); + memcpy(value.model_labels[0].id, "mver-demo-standard", + sizeof("mver-demo-standard")); + memcpy(value.model_labels[0].label, "Lucia", sizeof("Lucia")); + value.model_label_count = 1; const char *preferences = "bongo-cat-\xE5\x81\x8F\xE5\xA5\xBD.json"; const char *session = "bongo-cat-\xE4\xBC\x9A\xE8\xAF\x9D.json"; @@ -101,6 +106,20 @@ void test_config(void) { CHECK(loaded.behavior_shortcut_count == 1); CHECK(strcmp(loaded.behavior_shortcuts[0].shortcut, "Control+1") == 0); CHECK(strcmp(loaded.behavior_shortcuts[0].label, "Happy tap") == 0); + CHECK(loaded.model_label_count == 1); + CHECK(strcmp(bongo_cat_config_model_label(&loaded, + "mver-demo-standard"), "Lucia") == 0); + CHECK(strcmp(bongo_cat_model_name(&loaded, + &(BongoCatModelEntry){.id = "mver-demo-standard", + .display_name = "Nearby"}), "Lucia") == 0); + CHECK(bongo_cat_config_set_model_label(&loaded, + "mver-demo-standard", "New name")); + CHECK(strcmp(bongo_cat_config_model_label(&loaded, + "mver-demo-standard"), "New name") == 0); + CHECK(bongo_cat_config_set_model_label(&loaded, + "mver-demo-standard", "")); + CHECK(bongo_cat_config_model_label(&loaded, + "mver-demo-standard") == NULL); for (int language = 0; language < BONGO_CAT_LANG_COUNT; ++language) { value.app.language = (BongoCatLanguage)language; diff --git a/tests/test_mver_import.c b/tests/test_mver_import.c index 4d9dc95f..1b1f1ceb 100644 --- a/tests/test_mver_import.c +++ b/tests/test_mver_import.c @@ -2,6 +2,7 @@ #include "model_storage.h" #include "preferences_model_glyphs.h" #include "preferences_state.h" +#include "preferences_text_edit.h" #include "ui_font_atlas.h" #include "runtime.h" #include "bongo_cat/file.h" @@ -23,6 +24,45 @@ const char *test_mver_pointer_config(bool live2d); bool test_mver_pointer_fixture_assets(const char *standard, const char *source); bool test_mver_pointer_adapter(const char *adapter, bool expected_enabled); +static float fixed_width(const void *userdata, const char *text, size_t length) { + (void)userdata; (void)text; return (float)length; +} + +static void rename_text_edit(void) { + char text[32] = "A\xE4\xB8\xAD" "B"; + size_t cursor = 0; + bool selected = false; + bongo_cat_text_edit_begin(text, &cursor, &selected); + CHECK(cursor == 5 && !selected); + CHECK(bongo_cat_text_edit_move(text, &cursor, &selected, + BONGO_CAT_TEXT_EDIT_LEFT)); + CHECK(cursor == 4); + CHECK(bongo_cat_text_edit_insert(text, sizeof(text), &cursor, &selected, + "\xE6\x96\xB0")); + CHECK(strcmp(text, "A\xE4\xB8\xAD\xE6\x96\xB0" "B") == 0 && + cursor == 7); + CHECK(bongo_cat_text_edit_erase(text, &cursor, &selected, false)); + CHECK(strcmp(text, "A\xE4\xB8\xAD" "B") == 0 && cursor == 4); + CHECK(bongo_cat_text_edit_move(text, &cursor, &selected, + BONGO_CAT_TEXT_EDIT_HOME)); + CHECK(bongo_cat_text_edit_erase(text, &cursor, &selected, true)); + CHECK(strcmp(text, "\xE4\xB8\xAD" "B") == 0 && cursor == 0); + CHECK(bongo_cat_text_edit_move(text, &cursor, &selected, + BONGO_CAT_TEXT_EDIT_RIGHT)); + CHECK(cursor == 3); + CHECK(bongo_cat_text_edit_nearest(text, 1.4f, fixed_width, NULL) == 0); + CHECK(bongo_cat_text_edit_nearest(text, 1.6f, fixed_width, NULL) == 3); + bongo_cat_text_edit_select_all(text, &cursor, &selected); + CHECK(selected && cursor == strlen(text)); + CHECK(bongo_cat_text_edit_insert(text, sizeof(text), &cursor, &selected, + "\xE6\x9B\xBF\xE6\x8D\xA2")); + CHECK(strcmp(text, "\xE6\x9B\xBF\xE6\x8D\xA2") == 0 && + cursor == 6 && !selected); + bongo_cat_text_edit_select_all(text, &cursor, &selected); + CHECK(bongo_cat_text_edit_erase(text, &cursor, &selected, false)); + CHECK(text[0] == '\0' && cursor == 0 && !selected); +} + static bool chord(const char *json, bool gamepad, const char *expected) { yyjson_doc *document = yyjson_read(json, strlen(json), 0); BongoCatImportCandidate candidate = {0}; @@ -199,6 +239,78 @@ static bool portable_fixture(const char *root) { return test_mver_pointer_fixture_assets(standard, source); } +static void portable_identity_migration(void) { + char *temporary = SDL_GetCurrentDirectory(); + CHECK(temporary != NULL); + if (!temporary) return; + char root[BONGO_CAT_PATH_CAP], data[BONGO_CAT_PATH_CAP]; + char source[BONGO_CAT_PATH_CAP], moved[BONGO_CAT_PATH_CAP]; + char again[BONGO_CAT_PATH_CAP], duplicate[BONGO_CAT_PATH_CAP]; + unsigned long long nonce = (unsigned long long)SDL_GetTicksNS(); + snprintf(root, sizeof(root), "%s/bongo-cat-portable-move-%llu", + temporary, nonce); + snprintf(data, sizeof(data), "%s/bongo-cat-portable-data-%llu", + temporary, nonce); + CHECK(SDL_CreateDirectory(root)); + CHECK(SDL_CreateDirectory(data)); + CHECK(child(source, sizeof(source), root, "source", false)); + CHECK(child(moved, sizeof(moved), root, "moved", false)); + CHECK(child(again, sizeof(again), root, "again", false)); + CHECK(child(duplicate, sizeof(duplicate), root, "duplicate", false)); + CHECK(portable_fixture(source)); + BongoCatApp *app = calloc(1, sizeof(*app)); + CHECK(app != NULL); + if (!app) goto cleanup; + bongo_cat_config_defaults(&app->config); + bongo_cat_models_init(&app->models); + snprintf(app->data_root, sizeof(app->data_root), "%s", data); + BongoCatError error = {0}; + CHECK(bongo_cat_import_portable_mver(app, source, &error) == BONGO_CAT_OK); + CHECK(app->models.count == 1); + char old_id[BONGO_CAT_ID_CAP]; + snprintf(old_id, sizeof(old_id), "%s", app->models.entries[0].id); + CHECK(bongo_cat_config_set_model_label(&app->config, old_id, + "Portable custom name")); + BongoCatBehaviorShortcut *binding = + &app->config.behavior_shortcuts[app->config.behavior_shortcut_count++]; + snprintf(binding->id, sizeof(binding->id), "%s:expression:2", old_id); + snprintf(binding->shortcut, sizeof(binding->shortcut), "Alt+3"); + snprintf(binding->label, sizeof(binding->label), "Custom expression"); + CHECK(bongo_cat_path_rename(source, moved)); + bongo_cat_models_init(&app->models); + CHECK(bongo_cat_import_portable_mver(app, moved, &error) == BONGO_CAT_OK); + CHECK(app->models.count == 1); + const char *new_id = app->models.entries[0].id; + CHECK(strcmp(old_id, new_id) != 0); + CHECK(strcmp(app->config.current_model, new_id) == 0); + CHECK(strcmp(bongo_cat_config_model_label(&app->config, new_id), + "Portable custom name") == 0); + CHECK(bongo_cat_config_model_label(&app->config, old_id) == NULL); + CHECK(app->config.behavior_shortcut_count == 1); + CHECK(strncmp(app->config.behavior_shortcuts[0].id, new_id, + strlen(new_id)) == 0); + CHECK(strcmp(app->config.behavior_shortcuts[0].shortcut, "Alt+3") == 0); + CHECK(strcmp(app->config.behavior_shortcuts[0].label, + "Custom expression") == 0); + char ambiguous_id[BONGO_CAT_ID_CAP]; + snprintf(ambiguous_id, sizeof(ambiguous_id), "%s", new_id); + CHECK(bongo_cat_config_set_model_label(&app->config, ambiguous_id, + "Must remain here")); + CHECK(bongo_cat_path_rename(moved, again)); + CHECK(portable_fixture(duplicate)); + bongo_cat_models_init(&app->models); + CHECK(bongo_cat_import_portable_mver(app, root, &error) == BONGO_CAT_OK); + CHECK(app->models.count == 2); + CHECK(strcmp(app->config.current_model, ambiguous_id) == 0); + CHECK(strcmp(bongo_cat_config_model_label(&app->config, ambiguous_id), + "Must remain here") == 0); + free(app); +cleanup: + CHECK(bongo_cat_model_remove_tree(root, NULL)); + CHECK(bongo_cat_model_remove_tree(data, NULL)); + SDL_free(temporary); +} + static void portable_model(void) { char root[BONGO_CAT_PATH_CAP], package[BONGO_CAT_PATH_CAP]; char data[BONGO_CAT_PATH_CAP], mode[BONGO_CAT_PATH_CAP]; @@ -312,6 +424,7 @@ static void portable_model(void) { } int main(void) { + rename_text_edit(); CHECK(chord("[17,65]", true, "Control+A")); CHECK(chord("[0]", true, "Gamepad:South")); CHECK(chord("[15]", true, "Gamepad:Select")); @@ -329,6 +442,7 @@ int main(void) { metadata_backfills_labels(); behavior_labels_add_font_glyphs(); font_reload_defers_during_frame(); + portable_identity_migration(); portable_model(); return failures ? 1 : 0; } diff --git a/tests/test_mver_pointer_import.c b/tests/test_mver_pointer_import.c index 696931f5..3913f046 100644 --- a/tests/test_mver_pointer_import.c +++ b/tests/test_mver_pointer_import.c @@ -1,5 +1,7 @@ #include "model_import.h" +#include "preferences_internal.h" #include "preferences_overlay.h" +#include "preferences_state.h" #include "bongo_cat/json.h" #include "bongo_cat/path.h" @@ -7,6 +9,7 @@ #include static bool overlay_input_self_test(void); +static bool overlay_chrome_self_test(void); const char *test_mver_pointer_config(bool live2d) { const char *prefix = "{\"decoration\":{\"l2d_correct\":1.987," @@ -40,7 +43,7 @@ bool test_mver_pointer_fixture_assets(const char *standard, } bool test_mver_pointer_adapter(const char *adapter, bool expected_enabled) { - if (!overlay_input_self_test()) return false; + if (!overlay_input_self_test() || !overlay_chrome_self_test()) return false; char path[BONGO_CAT_PATH_CAP]; bool files = bongo_cat_path_join(path, sizeof(path), adapter, "resources/mver-pointer/arm.png") && bongo_cat_path_is_file(path) && @@ -74,9 +77,25 @@ static bool overlay_input_self_test(void) { left->clicked = 0; if (bongo_cat_preferences_overlay_input_ready(&context, &armed) || armed) return false; - left->down = nk_false; + left->down = nk_false; left->clicked = 1; if (bongo_cat_preferences_overlay_input_ready(&context, &armed) || !armed) return false; - left->down = nk_true; left->clicked = 1; + left->clicked = 2; return bongo_cat_preferences_overlay_input_ready(&context, &armed); } + +static bool overlay_chrome_self_test(void) { + BongoCatApp *app = SDL_calloc(1, sizeof(*app)); + BongoCatPreferences *preferences = SDL_calloc(1, sizeof(*preferences)); + bool passed = app && preferences; + if (passed) { + preferences->app = app; + passed = bongo_cat_preferences_chrome_drag_allowed(preferences); + preferences->behavior_dialog = true; + passed = passed && + !bongo_cat_preferences_chrome_drag_allowed(preferences); + } + SDL_free(preferences); + SDL_free(app); + return passed; +} From 7d8b1419a7b99fb496a217d321a049779f95fffe Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 13 Aug 2026 22:16:29 +0800 Subject: [PATCH 007/334] Refactor reusable UI and runtime components --- CMakeLists.txt | 69 ++----- cmake/CheckLines.cmake | 16 +- cmake/RuntimeSources.cmake | 59 ++++++ src/render/overlay.c | 103 +--------- src/render/overlay_draw.c | 75 +++++++ src/render/overlay_internal.h | 45 +++++ src/runtime/model_catalog_runtime.c | 190 ++++++++++++++++++ src/runtime/model_files.c | 171 ---------------- src/runtime/model_import_portable.c | 82 +------- src/runtime/model_import_portable_internal.h | 2 + src/runtime/model_import_portable_signature.c | 91 +++++++++ src/runtime/window.c | 131 +----------- src/runtime/window_menu_actions.c | 139 +++++++++++++ src/ui/preferences_behavior_rename.c | 136 +++---------- src/ui/preferences_behavior_row.c | 13 +- src/ui/preferences_fonts.c | 85 ++++++++ src/ui/preferences_fonts.h | 25 +++ src/ui/preferences_model_rename.c | 141 +++---------- src/ui/preferences_render.c | 13 -- src/ui/preferences_scale.c | 96 +-------- src/ui/preferences_smoke.c | 11 + src/ui/preferences_state.h | 13 +- src/ui/preferences_text_edit.c | 10 + src/ui/preferences_text_edit.h | 1 + src/ui/preferences_text_session.c | 112 +++++++++++ src/ui/preferences_text_session.h | 35 ++++ tests/test_mver_import.c | 158 +-------------- tests/test_mver_portable_identity.c | 91 +++++++++ tests/test_mver_support.c | 54 +++++ tests/test_mver_support.h | 12 ++ tests/test_preferences_text.c | 90 +++++++++ 31 files changed, 1217 insertions(+), 1052 deletions(-) create mode 100644 cmake/RuntimeSources.cmake create mode 100644 src/render/overlay_draw.c create mode 100644 src/render/overlay_internal.h create mode 100644 src/runtime/model_catalog_runtime.c create mode 100644 src/runtime/model_import_portable_signature.c create mode 100644 src/runtime/window_menu_actions.c create mode 100644 src/ui/preferences_fonts.c create mode 100644 src/ui/preferences_fonts.h create mode 100644 src/ui/preferences_text_session.c create mode 100644 src/ui/preferences_text_session.h create mode 100644 tests/test_mver_portable_identity.c create mode 100644 tests/test_mver_support.c create mode 100644 tests/test_mver_support.h create mode 100644 tests/test_preferences_text.c diff --git a/CMakeLists.txt b/CMakeLists.txt index 0fc5e1bd..2be76723 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -73,60 +73,9 @@ if(WIN32) target_sources(bongo_cat_core PRIVATE src/core/windows_utf8.c) endif() +include(cmake/RuntimeSources.cmake) add_library(bongo_cat_runtime STATIC - src/core/app_state.c - src/media/audio.c - src/media/image.c src/media/image_resize.c src/media/image_alpha.c - src/media/stb_image_impl.c - src/platform/memory.c src/render/gl_api.c - src/render/overlay.c src/render/mver_pointer_overlay.c src/render/mver_pointer_overlay_draw.c - src/runtime/app.c src/runtime/config_store.c - src/runtime/assets.c src/runtime/startup.c - src/runtime/frame_audit.c src/runtime/frame_clock.c src/runtime/modal_frame.c - src/runtime/gamepad.c - src/runtime/live2d_audit.c src/runtime/live2d_visual_audit.c - src/runtime/live2d_viewer_audit.c - src/runtime/memory_policy.c src/runtime/mouse.c src/runtime/mouse_mapping.c - src/runtime/model_files.c src/runtime/model_update.c - src/runtime/model_storage.c - src/runtime/model_import.c - src/runtime/model_import_package.c - src/runtime/model_import_discovery.c - src/runtime/model_import_format.c - src/runtime/model_import_portable.c src/runtime/model_import_portable_scan.c - src/runtime/model_import_portable_identity.c - src/runtime/model_import_portable_migration.c - src/runtime/model_import_mver_patch.c - src/runtime/model_import_mver.c src/runtime/model_import_mver_image.c - src/runtime/model_import_mver_shortcut.c src/runtime/model_import_mver_effect.c - src/runtime/model_import_mver_motion.c src/runtime/model_import_mver_labels.c src/runtime/model_import_report.c - src/runtime/model_import_mver_metadata.c src/runtime/model_import_metadata.c - src/runtime/runtime_flow.c - src/runtime/shortcuts.c - src/runtime/tray.c - src/runtime/window.c src/runtime/window_display.c src/runtime/window_drag.c - src/runtime/window_geometry.c - src/runtime/window_menu_behavior.c src/runtime/window_menu_preview.c - src/runtime/window_hit.c src/runtime/window_wheel.c - src/ui/nuklear_impl.c - src/ui/ui_backend.c src/ui/ui_resize_cache.c src/ui/ui_scale.c src/ui/ui_cursor.c - src/ui/ui_animation.c src/ui/ui_paint.c src/ui/ui_paint_cache.c src/ui/ui_paint_border.c src/ui/ui_paint_shell.c - src/ui/ui_font.c src/ui/ui_font_atlas.c src/ui/ui_font_atlas_sources.c src/ui/ui_font_atlas_ranges.c src/ui/ui_font_atlas_upload.c src/ui/ui_font_reload.c - src/ui/ui_input.c - src/ui/ui_catime.c src/ui/ui_catime_tabs.c src/ui/ui_catime_theme.c src/ui/ui_catime_icons.c - src/ui/ui_native_theme.c - src/ui/preferences.c src/ui/preferences_scale.c src/ui/preferences_render.c src/ui/preferences_language.c - src/ui/preferences_assets.c src/ui/preferences_about.c src/ui/preferences_about_community.c - src/ui/preferences_live_resize.c - src/ui/preferences_dialog.c src/ui/preferences_import.c src/ui/preferences_overlay.c - src/ui/preferences_behavior_dialog.c src/ui/preferences_behavior_rename.c src/ui/preferences_behavior_row.c src/ui/preferences_scrollbar.c - src/ui/preferences_text_edit.c - src/ui/preferences_notice.c - src/ui/preferences_model.c src/ui/preferences_model_rename.c src/ui/preferences_model_cover.c src/ui/preferences_model_glyphs.c - src/ui/preferences_pages.c src/ui/preferences_theme.c - src/ui/preferences_shortcuts.c src/ui/preferences_shortcut_clear.c src/ui/preferences_smoke.c - src/ui/preferences_widgets.c src/ui/preferences_controls.c src/ui/preferences_combo.c src/ui/preferences_toggle.c - ${BONGO_CAT_LIVE2D_SOURCES}) + ${BONGO_CAT_RUNTIME_SOURCES} ${BONGO_CAT_LIVE2D_SOURCES}) target_include_directories(bongo_cat_runtime PUBLIC include) target_include_directories(bongo_cat_runtime SYSTEM PRIVATE ${BONGO_CAT_STB_INCLUDE_DIR} ${BONGO_CAT_NUKLEAR_INCLUDE_DIR}) @@ -316,13 +265,23 @@ if(BUILD_TESTING) target_link_libraries(bongo_cat_app_state_tests PRIVATE bongo_cat_warnings) target_include_directories(bongo_cat_app_state_tests PRIVATE include) add_test(NAME app-state COMMAND bongo_cat_app_state_tests) - add_executable(bongo_cat_mver_import_tests tests/test_mver_import.c tests/test_mver_pointer_import.c) + set(BONGO_CAT_MVER_IMPORT_TEST_SOURCES + tests/test_mver_import.c + tests/test_mver_pointer_import.c + tests/test_preferences_text.c + tests/test_mver_portable_identity.c + tests/test_mver_support.c) + add_executable(bongo_cat_mver_import_tests + ${BONGO_CAT_MVER_IMPORT_TEST_SOURCES}) target_include_directories(bongo_cat_mver_import_tests PRIVATE src/runtime src/ui) target_include_directories(bongo_cat_mver_import_tests SYSTEM PRIVATE ${BONGO_CAT_NUKLEAR_INCLUDE_DIR}) target_link_libraries(bongo_cat_mver_import_tests PRIVATE bongo_cat_runtime) target_compile_definitions(bongo_cat_mver_import_tests PRIVATE BONGO_CAT_NATIVE_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") if(MSVC) - set_property(SOURCE tests/test_mver_import.c tests/test_mver_pointer_import.c tests/test_windows_capture.c APPEND PROPERTY COMPILE_OPTIONS "/experimental:c11atomics") + set_property(SOURCE ${BONGO_CAT_MVER_IMPORT_TEST_SOURCES} + APPEND PROPERTY COMPILE_OPTIONS "/experimental:c11atomics") + set_property(SOURCE tests/test_windows_capture.c APPEND PROPERTY + COMPILE_OPTIONS "/experimental:c11atomics") endif() add_test(NAME model-import-unit COMMAND bongo_cat_mver_import_tests) if(WIN32) diff --git a/cmake/CheckLines.cmake b/cmake/CheckLines.cmake index e3349445..d1f43188 100644 --- a/cmake/CheckLines.cmake +++ b/cmake/CheckLines.cmake @@ -23,28 +23,14 @@ foreach(FILE IN LISTS FILES) string(REPLACE "\\" "/" RELATIVE_FILE "${RELATIVE_FILE}") set(LIMIT 300) - if(RELATIVE_FILE STREQUAL "src/live2d/cubism_model_load.cpp") - set(LIMIT 358) - elseif(RELATIVE_FILE STREQUAL "src/media/image.c") + if(RELATIVE_FILE STREQUAL "src/media/image.c") set(LIMIT 332) - elseif(RELATIVE_FILE STREQUAL "src/runtime/model_import_portable.c") - set(LIMIT 341) - elseif(RELATIVE_FILE STREQUAL "src/ui/preferences_render.c") - set(LIMIT 308) - elseif(RELATIVE_FILE STREQUAL "src/ui/preferences_scale.c") - set(LIMIT 307) - elseif(RELATIVE_FILE STREQUAL "src/ui/preferences_model.c") - set(LIMIT 304) - elseif(RELATIVE_FILE STREQUAL "tests/test_mver_import.c") - set(LIMIT 327) elseif(RELATIVE_FILE STREQUAL "src/tools/cubism_viewer_blind_test.c") set(LIMIT 376) elseif(RELATIVE_FILE STREQUAL "src/tools/cubism_viewer_desktop_capture.c") set(LIMIT 396) elseif(RELATIVE_FILE STREQUAL "src/tools/mver_phase_metrics.c") set(LIMIT 321) - elseif(RELATIVE_FILE STREQUAL "CMakeLists.txt") - set(LIMIT 336) endif() if(COUNT GREATER LIMIT) diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake new file mode 100644 index 00000000..05b9f0fe --- /dev/null +++ b/cmake/RuntimeSources.cmake @@ -0,0 +1,59 @@ +set(BONGO_CAT_RUNTIME_SOURCES + src/core/app_state.c + src/media/audio.c + src/media/image.c src/media/image_resize.c src/media/image_alpha.c + src/media/stb_image_impl.c + src/platform/memory.c src/render/gl_api.c + src/render/overlay.c src/render/overlay_draw.c + src/render/mver_pointer_overlay.c src/render/mver_pointer_overlay_draw.c + src/runtime/app.c src/runtime/config_store.c + src/runtime/assets.c src/runtime/startup.c + src/runtime/frame_audit.c src/runtime/frame_clock.c src/runtime/modal_frame.c + src/runtime/gamepad.c + src/runtime/live2d_audit.c src/runtime/live2d_visual_audit.c + src/runtime/live2d_viewer_audit.c + src/runtime/memory_policy.c src/runtime/mouse.c src/runtime/mouse_mapping.c + src/runtime/model_files.c src/runtime/model_catalog_runtime.c + src/runtime/model_update.c src/runtime/model_storage.c + src/runtime/model_import.c src/runtime/model_import_package.c + src/runtime/model_import_discovery.c src/runtime/model_import_format.c + src/runtime/model_import_portable.c src/runtime/model_import_portable_scan.c + src/runtime/model_import_portable_identity.c + src/runtime/model_import_portable_migration.c + src/runtime/model_import_portable_signature.c + src/runtime/model_import_mver_patch.c + src/runtime/model_import_mver.c src/runtime/model_import_mver_image.c + src/runtime/model_import_mver_shortcut.c src/runtime/model_import_mver_effect.c + src/runtime/model_import_mver_motion.c src/runtime/model_import_mver_labels.c + src/runtime/model_import_report.c src/runtime/model_import_mver_metadata.c + src/runtime/model_import_metadata.c src/runtime/runtime_flow.c + src/runtime/shortcuts.c src/runtime/tray.c + src/runtime/window.c src/runtime/window_display.c src/runtime/window_drag.c + src/runtime/window_geometry.c src/runtime/window_menu_actions.c + src/runtime/window_menu_behavior.c src/runtime/window_menu_preview.c + src/runtime/window_hit.c src/runtime/window_wheel.c + src/ui/nuklear_impl.c + src/ui/ui_backend.c src/ui/ui_resize_cache.c src/ui/ui_scale.c + src/ui/ui_cursor.c src/ui/ui_animation.c src/ui/ui_paint.c + src/ui/ui_paint_cache.c src/ui/ui_paint_border.c src/ui/ui_paint_shell.c + src/ui/ui_font.c src/ui/ui_font_atlas.c src/ui/ui_font_atlas_sources.c + src/ui/ui_font_atlas_ranges.c src/ui/ui_font_atlas_upload.c + src/ui/ui_font_reload.c src/ui/ui_input.c + src/ui/ui_catime.c src/ui/ui_catime_tabs.c src/ui/ui_catime_theme.c + src/ui/ui_catime_icons.c src/ui/ui_native_theme.c + src/ui/preferences.c src/ui/preferences_scale.c src/ui/preferences_fonts.c + src/ui/preferences_render.c src/ui/preferences_language.c + src/ui/preferences_assets.c src/ui/preferences_about.c + src/ui/preferences_about_community.c src/ui/preferences_live_resize.c + src/ui/preferences_dialog.c src/ui/preferences_import.c + src/ui/preferences_overlay.c src/ui/preferences_behavior_dialog.c + src/ui/preferences_behavior_rename.c src/ui/preferences_behavior_row.c + src/ui/preferences_scrollbar.c src/ui/preferences_text_edit.c + src/ui/preferences_text_session.c src/ui/preferences_notice.c + src/ui/preferences_model.c src/ui/preferences_model_rename.c + src/ui/preferences_model_cover.c src/ui/preferences_model_glyphs.c + src/ui/preferences_pages.c src/ui/preferences_theme.c + src/ui/preferences_shortcuts.c src/ui/preferences_shortcut_clear.c + src/ui/preferences_smoke.c src/ui/preferences_widgets.c + src/ui/preferences_controls.c src/ui/preferences_combo.c + src/ui/preferences_toggle.c) diff --git a/src/render/overlay.c b/src/render/overlay.c index 6693203b..4148379c 100644 --- a/src/render/overlay.c +++ b/src/render/overlay.c @@ -1,48 +1,12 @@ -#include "bongo_cat/overlay.h" -#include "bongo_cat/gl_api.h" +#include "overlay_internal.h" #include "bongo_cat/image.h" #include "bongo_cat/path.h" -#include "mver_pointer_overlay.h" #include #include #include #include #include -typedef struct TextureSlot { - char path[BONGO_CAT_PATH_CAP]; - GLuint texture; - uint64_t used; -} TextureSlot; -struct BongoCatOverlay { - BongoCatGL gl; - BongoCatMverPointerOverlay *mver_pointer; - GLuint program; - GLint mirror_location; - GLint image_location; - GLint erase_left_location; - GLint erase_right_location; - GLuint vao; - GLuint vbo; - GLuint background; - GLuint composite; - bool composed_cover; - bool clean_paws; - bool composite_dirty; - TextureSlot cache[4]; - GLuint left; - GLuint right; - GLuint effect; - char left_name[BONGO_CAT_ID_CAP]; - char right_name[BONGO_CAT_ID_CAP]; - char background_path[BONGO_CAT_PATH_CAP]; - char left_path[BONGO_CAT_PATH_CAP]; - char right_path[BONGO_CAT_PATH_CAP]; - char effect_path[BONGO_CAT_PATH_CAP]; - char directory[BONGO_CAT_PATH_CAP]; - uint64_t clock; -}; - static const char *vertex_source = "#version 330 core\n" "layout(location=0) in vec2 pos; layout(location=1) in vec2 uv;\n" @@ -286,68 +250,3 @@ bool bongo_cat_overlay_effect(BongoCatOverlay *value, const char *path) { snprintf(value->effect_path, sizeof(value->effect_path), "%s", path); return value->effect != 0; } - -static void draw(BongoCatOverlay *value, GLuint texture, bool mirror, bool blend) { - if (!value || !texture) return; - if (blend) { - glEnable(GL_BLEND); - value->gl.blend_func_separate(GL_ONE, GL_ONE_MINUS_SRC_ALPHA, - GL_ONE, GL_ONE_MINUS_SRC_ALPHA); - } else glDisable(GL_BLEND); - value->gl.use_program(value->program); - value->gl.uniform_1i(value->mirror_location, mirror); - value->gl.uniform_1i(value->image_location, 0); - value->gl.uniform_1i(value->erase_left_location, - !blend && value->composed_cover && !value->composite && value->left != 0); - value->gl.uniform_1i(value->erase_right_location, - !blend && value->composed_cover && !value->composite && value->right != 0); - value->gl.active_texture(GL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, texture); - value->gl.bind_vertex_array(value->vao); - glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); - value->gl.bind_vertex_array(0); -} - -void bongo_cat_overlay_draw_background(BongoCatOverlay *value, bool mirror) { - if (value) { -#ifndef BONGO_CAT_HAS_CUBISM - if (value->composite_dirty) { - value->composite_dirty = false; - if (value->left || value->right) { - BongoCatError ignored = {0}; - value->composite = bongo_cat_image_composite_texture(value->background_path, - value->left_path, value->right_path, value->composite, - value->clean_paws && value->left, - value->clean_paws && value->right, &ignored); - } - } -#endif - bool active = value->left || value->right; - draw(value, active && value->composite ? value->composite : value->background, - mirror, false); - } -} - -void bongo_cat_overlay_draw_keys(BongoCatOverlay *value, bool mirror) { - if (!value) return; -#ifdef BONGO_CAT_HAS_CUBISM - draw(value, value->left, mirror, true); - draw(value, value->right, mirror, true); -#else - (void)mirror; -#endif -} -void bongo_cat_overlay_draw_pointer_before_keys(BongoCatOverlay *value) { - if (value) bongo_cat_mver_pointer_overlay_draw_before_keys(value->mver_pointer); -} -void bongo_cat_overlay_draw_effect(BongoCatOverlay *value, bool mirror) { - if (!value) return; -#ifdef BONGO_CAT_HAS_CUBISM - draw(value, value->effect, mirror, true); -#else - (void)mirror; -#endif -} -void bongo_cat_overlay_draw_pointer_after_keys(BongoCatOverlay *value) { - if (value) bongo_cat_mver_pointer_overlay_draw_after_keys(value->mver_pointer); -} diff --git a/src/render/overlay_draw.c b/src/render/overlay_draw.c new file mode 100644 index 00000000..4c7f96e6 --- /dev/null +++ b/src/render/overlay_draw.c @@ -0,0 +1,75 @@ +#include "overlay_internal.h" + +#include "bongo_cat/image.h" + +static void draw(BongoCatOverlay *value, GLuint texture, + bool mirror, bool blend) { + if (!value || !texture) return; + if (blend) { + glEnable(GL_BLEND); + value->gl.blend_func_separate(GL_ONE, GL_ONE_MINUS_SRC_ALPHA, + GL_ONE, GL_ONE_MINUS_SRC_ALPHA); + } else glDisable(GL_BLEND); + value->gl.use_program(value->program); + value->gl.uniform_1i(value->mirror_location, mirror); + value->gl.uniform_1i(value->image_location, 0); + value->gl.uniform_1i(value->erase_left_location, + !blend && value->composed_cover && !value->composite && + value->left != 0); + value->gl.uniform_1i(value->erase_right_location, + !blend && value->composed_cover && !value->composite && + value->right != 0); + value->gl.active_texture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, texture); + value->gl.bind_vertex_array(value->vao); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + value->gl.bind_vertex_array(0); +} + +void bongo_cat_overlay_draw_background(BongoCatOverlay *value, bool mirror) { + if (!value) return; +#ifndef BONGO_CAT_HAS_CUBISM + if (value->composite_dirty) { + value->composite_dirty = false; + if (value->left || value->right) { + BongoCatError ignored = {0}; + value->composite = bongo_cat_image_composite_texture( + value->background_path, value->left_path, value->right_path, + value->composite, value->clean_paws && value->left, + value->clean_paws && value->right, &ignored); + } + } +#endif + bool active = value->left || value->right; + draw(value, active && value->composite ? value->composite : + value->background, mirror, false); +} + +void bongo_cat_overlay_draw_keys(BongoCatOverlay *value, bool mirror) { + if (!value) return; +#ifdef BONGO_CAT_HAS_CUBISM + draw(value, value->left, mirror, true); + draw(value, value->right, mirror, true); +#else + (void)mirror; +#endif +} + +void bongo_cat_overlay_draw_pointer_before_keys(BongoCatOverlay *value) { + if (value) + bongo_cat_mver_pointer_overlay_draw_before_keys(value->mver_pointer); +} + +void bongo_cat_overlay_draw_effect(BongoCatOverlay *value, bool mirror) { + if (!value) return; +#ifdef BONGO_CAT_HAS_CUBISM + draw(value, value->effect, mirror, true); +#else + (void)mirror; +#endif +} + +void bongo_cat_overlay_draw_pointer_after_keys(BongoCatOverlay *value) { + if (value) + bongo_cat_mver_pointer_overlay_draw_after_keys(value->mver_pointer); +} diff --git a/src/render/overlay_internal.h b/src/render/overlay_internal.h new file mode 100644 index 00000000..f6b5ce97 --- /dev/null +++ b/src/render/overlay_internal.h @@ -0,0 +1,45 @@ +#ifndef BONGO_CAT_OVERLAY_INTERNAL_H +#define BONGO_CAT_OVERLAY_INTERNAL_H + +#include "bongo_cat/overlay.h" +#include "bongo_cat/gl_api.h" +#include "mver_pointer_overlay.h" + +#include + +typedef struct TextureSlot { + char path[BONGO_CAT_PATH_CAP]; + GLuint texture; + uint64_t used; +} TextureSlot; + +struct BongoCatOverlay { + BongoCatGL gl; + BongoCatMverPointerOverlay *mver_pointer; + GLuint program; + GLint mirror_location; + GLint image_location; + GLint erase_left_location; + GLint erase_right_location; + GLuint vao; + GLuint vbo; + GLuint background; + GLuint composite; + bool composed_cover; + bool clean_paws; + bool composite_dirty; + TextureSlot cache[4]; + GLuint left; + GLuint right; + GLuint effect; + char left_name[BONGO_CAT_ID_CAP]; + char right_name[BONGO_CAT_ID_CAP]; + char background_path[BONGO_CAT_PATH_CAP]; + char left_path[BONGO_CAT_PATH_CAP]; + char right_path[BONGO_CAT_PATH_CAP]; + char effect_path[BONGO_CAT_PATH_CAP]; + char directory[BONGO_CAT_PATH_CAP]; + uint64_t clock; +}; + +#endif diff --git a/src/runtime/model_catalog_runtime.c b/src/runtime/model_catalog_runtime.c new file mode 100644 index 00000000..21475705 --- /dev/null +++ b/src/runtime/model_catalog_runtime.c @@ -0,0 +1,190 @@ +#include "runtime.h" +#include "model_import.h" +#include "model_storage.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +typedef struct TreeContext { + const char *source; + const char *target; + BongoCatError *error; + unsigned depth; +} TreeContext; + +enum { MODEL_TREE_DEPTH_CAP = 32 }; + +static bool copy_tree(const char *source, const char *target, unsigned depth, + BongoCatError *error); + +static BongoCatPathVisit copy_item(void *userdata, + const char *dirname, const char *name) { + (void)dirname; + TreeContext *context = userdata; + char source[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(source, sizeof(source), context->source, name) || + !bongo_cat_path_join(target, sizeof(target), context->target, name)) { + bongo_cat_error_set(context->error, BONGO_CAT_ERROR_IO, + "Model path is too long"); + return BONGO_CAT_PATH_FAILURE; + } + bool directory = bongo_cat_path_is_dir(source); + bool file = !directory && bongo_cat_path_is_file(source); + bool ok = directory ? copy_tree(source, target, context->depth + 1, + context->error) : file && bongo_cat_path_copy_file(source, target); + if (!ok && context->error && !context->error->message[0]) + bongo_cat_error_set(context->error, BONGO_CAT_ERROR_IO, + "Cannot copy %s: %s", source, SDL_GetError()); + return ok ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; +} + +static bool copy_tree(const char *source, const char *target, unsigned depth, + BongoCatError *error) { + if (depth > MODEL_TREE_DEPTH_CAP) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Model directory nesting exceeds %u levels", + MODEL_TREE_DEPTH_CAP); + return false; + } + if (!bongo_cat_path_create_directory(target)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot create %s: %s", target, SDL_GetError()); + return false; + } + TreeContext context = {source, target, error, depth}; + return bongo_cat_path_enumerate(source, copy_item, &context); +} + +BongoCatResult bongo_cat_copy_directory(const char *source, + const char *target, BongoCatError *error) { + if (!source || !target || !bongo_cat_path_is_dir(source)) + return BONGO_CAT_ERROR_ARGUMENT; + return copy_tree(source, target, 0, error) ? BONGO_CAT_OK : + error && error->code == BONGO_CAT_ERROR_FORMAT ? + BONGO_CAT_ERROR_FORMAT : BONGO_CAT_ERROR_IO; +} + +static bool custom_root(BongoCatApp *app, char *path, size_t capacity) { + return app->data_root[0] && bongo_cat_path_join(path, capacity, + app->data_root, "custom-models") && + bongo_cat_path_create_directory(path); +} + +static bool shortcut_model_exists(const BongoCatModelCatalog *models, + const char *shortcut_id) { + const char *separator = shortcut_id ? strchr(shortcut_id, ':') : NULL; + if (!separator) return false; + size_t length = (size_t)(separator - shortcut_id); + for (size_t i = 0; i < models->count; ++i) { + const char *id = models->entries[i].id; + if (strlen(id) == length && !strncmp(id, shortcut_id, length)) + return true; + } + return false; +} + +static void prune_behavior_shortcuts(BongoCatApp *app) { + size_t output = 0; + for (size_t i = 0; i < app->config.behavior_shortcut_count; ++i) { + BongoCatBehaviorShortcut *value = &app->config.behavior_shortcuts[i]; + if (!shortcut_model_exists(&app->models, value->id)) continue; + if (output != i) app->config.behavior_shortcuts[output] = *value; + output++; + } + app->config.behavior_shortcut_count = output; +} + +static void scan_portable_root(BongoCatApp *app, const char *root) { + if (!root || !root[0] || !bongo_cat_path_is_dir(root)) return; + size_t before = app->models.count; + BongoCatError error = {0}; + BongoCatResult result = bongo_cat_import_portable_mver_scan(app, root, + &error); + size_t added = app->models.count - before; + if (added) SDL_Log("Nearby model scan added %llu model modes from %s", + (unsigned long long)added, root); + if (result != BONGO_CAT_OK && error.message[0]) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", error.message); +} + +void bongo_cat_app_rescan_models(BongoCatApp *app) { + if (!app) return; + BongoCatError error = {0}; + char root[BONGO_CAT_PATH_CAP]; + bongo_cat_models_init(&app->models); + bongo_cat_path_join(root, sizeof(root), app->asset_root, "models"); + bongo_cat_models_scan(&app->models, root, true, &error); + if (custom_root(app, root, sizeof(root))) { + bongo_cat_model_cleanup_imports(root, &error); + bongo_cat_models_scan(&app->models, root, false, &error); + } + const char *base = SDL_GetBasePath(); + if (!SDL_getenv("BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN")) + scan_portable_root(app, base); + prune_behavior_shortcuts(app); + for (size_t i = 0; i < app->models.count; ++i) + if (!app->models.entries[i].preset) + bongo_cat_import_apply_metadata(app, app->models.entries[i].id, + app->models.entries[i].adapter_directory); +} + +BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, + BongoCatError *error) { + if (!app || !id) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, + "Missing model id"); + return BONGO_CAT_ERROR_ARGUMENT; + } + const BongoCatModelEntry *entry = bongo_cat_models_find(&app->models, id); + if (!entry) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, + "Model is not installed: %s", id); + return BONGO_CAT_ERROR_ARGUMENT; + } + if (entry->preset || entry->managed) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, entry->managed ? + "Portable Mver models are managed by their source directory: %s" : + "Built-in models cannot be removed: %s", id); + return BONGO_CAT_ERROR_ARGUMENT; + } + bool selected = !strcmp(id, app->config.current_model) || + !strcmp(id, app->loaded_model); + char directory[BONGO_CAT_PATH_CAP]; + snprintf(directory, sizeof(directory), "%s", entry->storage_directory); + if (selected) { + BongoCatError load_error = {0}; + bool replacement = false; + for (size_t i = 0; i < app->models.count; ++i) + if (strcmp(app->models.entries[i].id, id) && + bongo_cat_app_select_model_with_error(app, + app->models.entries[i].id, &load_error)) { + replacement = true; + break; + } + if (!replacement) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, + "Cannot delete the active model because no replacement could be displayed: %s", + load_error.message[0] ? load_error.message : + "no installed models"); + return BONGO_CAT_ERROR_CUBISM; + } + } + if (!bongo_cat_model_remove_tree(directory, error)) { + bongo_cat_app_rescan_models(app); + if (selected && bongo_cat_models_find(&app->models, id)) { + BongoCatError restore_error = {0}; + if (!bongo_cat_app_select_model_with_error(app, id, + &restore_error)) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Unable to restore model after deletion failed: %s", + restore_error.message[0] ? restore_error.message : + "unknown error"); + } + return BONGO_CAT_ERROR_IO; + } + bongo_cat_config_set_model_label(&app->config, id, ""); + bongo_cat_app_rescan_models(app); + return BONGO_CAT_OK; +} diff --git a/src/runtime/model_files.c b/src/runtime/model_files.c index a2224427..db2c66ec 100644 --- a/src/runtime/model_files.c +++ b/src/runtime/model_files.c @@ -1,22 +1,12 @@ #include "runtime.h" #include "model_import.h" -#include "model_storage.h" -#include "bongo_cat/file.h" #include "bongo_cat/audio.h" #include "bongo_cat/overlay.h" -#include "bongo_cat/path.h" #include #include #include #include -typedef struct TreeContext { - const char *source; - const char *target; - BongoCatError *error; - unsigned depth; -} TreeContext; -#define MODEL_TREE_DEPTH_CAP 32 static void select_model_state(BongoCatApp *app, const BongoCatModelEntry *entry) { snprintf(app->config.current_model, sizeof(app->config.current_model), "%s", entry->id); @@ -165,164 +155,3 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, bool bongo_cat_app_select_model(BongoCatApp *app, const char *id) { return bongo_cat_app_select_model_with_error(app, id, NULL); } -static bool copy_tree(const char *source, const char *target, unsigned depth, - BongoCatError *error); -static BongoCatPathVisit copy_item(void *userdata, - const char *dirname, const char *name) { - (void)dirname; - TreeContext *context = userdata; - char source[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(source, sizeof(source), context->source, name) || - !bongo_cat_path_join(target, sizeof(target), context->target, name)) { - bongo_cat_error_set(context->error, BONGO_CAT_ERROR_IO, "Model path is too long"); - return BONGO_CAT_PATH_FAILURE; - } - bool directory = bongo_cat_path_is_dir(source); - bool file = !directory && bongo_cat_path_is_file(source); - bool ok = directory - ? copy_tree(source, target, context->depth + 1, context->error) - : file && bongo_cat_path_copy_file(source, target); - if (!ok && context->error && !context->error->message[0]) - bongo_cat_error_set(context->error, BONGO_CAT_ERROR_IO, "Cannot copy %s: %s", - source, SDL_GetError()); - return ok ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; -} - -static bool copy_tree(const char *source, const char *target, unsigned depth, - BongoCatError *error) { - if (depth > MODEL_TREE_DEPTH_CAP) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Model directory nesting exceeds %u levels", MODEL_TREE_DEPTH_CAP); - return false; - } - if (!bongo_cat_path_create_directory(target)) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, "Cannot create %s: %s", target, SDL_GetError()); - return false; - } - TreeContext context = {source, target, error, depth}; - return bongo_cat_path_enumerate(source, copy_item, &context); -} - -BongoCatResult bongo_cat_copy_directory(const char *source, const char *target, - BongoCatError *error) { - if (!source || !target || !bongo_cat_path_is_dir(source)) return BONGO_CAT_ERROR_ARGUMENT; - return copy_tree(source, target, 0, error) ? BONGO_CAT_OK : - error && error->code == BONGO_CAT_ERROR_FORMAT ? - BONGO_CAT_ERROR_FORMAT : BONGO_CAT_ERROR_IO; -} - -static bool custom_root(BongoCatApp *app, char *path, size_t capacity) { - return app->data_root[0] && - bongo_cat_path_join(path, capacity, app->data_root, "custom-models") && - bongo_cat_path_create_directory(path); -} - -static bool shortcut_model_exists(const BongoCatModelCatalog *models, - const char *shortcut_id) { - const char *separator = shortcut_id ? strchr(shortcut_id, ':') : NULL; - if (!separator) return false; - size_t length = (size_t)(separator - shortcut_id); - for (size_t i = 0; i < models->count; ++i) { - const char *id = models->entries[i].id; - if (strlen(id) == length && strncmp(id, shortcut_id, length) == 0) return true; - } - return false; -} - -static void prune_behavior_shortcuts(BongoCatApp *app) { - size_t output = 0; - for (size_t i = 0; i < app->config.behavior_shortcut_count; ++i) { - BongoCatBehaviorShortcut *value = &app->config.behavior_shortcuts[i]; - if (!shortcut_model_exists(&app->models, value->id)) continue; - if (output != i) app->config.behavior_shortcuts[output] = *value; - output++; - } - app->config.behavior_shortcut_count = output; -} - -static void scan_portable_root(BongoCatApp *app, const char *root) { - if (!root || !root[0] || !bongo_cat_path_is_dir(root)) return; - size_t before = app->models.count; - BongoCatError error = {0}; - BongoCatResult result = bongo_cat_import_portable_mver_scan(app, root, &error); - size_t added = app->models.count - before; - if (added) SDL_Log("Nearby model scan added %llu model modes from %s", - (unsigned long long)added, root); - if (result != BONGO_CAT_OK && error.message[0]) - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", error.message); -} - -void bongo_cat_app_rescan_models(BongoCatApp *app) { - if (!app) return; - BongoCatError error = {0}; - char root[BONGO_CAT_PATH_CAP]; - bongo_cat_models_init(&app->models); - bongo_cat_path_join(root, sizeof(root), app->asset_root, "models"); - bongo_cat_models_scan(&app->models, root, true, &error); - if (custom_root(app, root, sizeof(root))) { - bongo_cat_model_cleanup_imports(root, &error); - bongo_cat_models_scan(&app->models, root, false, &error); - } - const char *base = SDL_GetBasePath(); - if (!SDL_getenv("BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN")) - scan_portable_root(app, base); - prune_behavior_shortcuts(app); - for (size_t i = 0; i < app->models.count; ++i) - if (!app->models.entries[i].preset) - bongo_cat_import_apply_metadata(app, app->models.entries[i].id, - app->models.entries[i].adapter_directory); -} - -BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, - BongoCatError *error) { - if (!app || !id) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, "Missing model id"); - return BONGO_CAT_ERROR_ARGUMENT; - } - const BongoCatModelEntry *entry = bongo_cat_models_find(&app->models, id); - if (!entry) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, "Model is not installed: %s", id); - return BONGO_CAT_ERROR_ARGUMENT; - } - if (entry->preset || entry->managed) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, - entry->managed ? "Portable Mver models are managed by their source directory: %s" : - "Built-in models cannot be removed: %s", id); - return BONGO_CAT_ERROR_ARGUMENT; - } - bool selected = strcmp(id, app->config.current_model) == 0 || - strcmp(id, app->loaded_model) == 0; - char directory[BONGO_CAT_PATH_CAP]; - snprintf(directory, sizeof(directory), "%s", entry->storage_directory); - if (selected) { - BongoCatError load_error = {0}; - bool replacement = false; - for (size_t i = 0; i < app->models.count; ++i) - if (strcmp(app->models.entries[i].id, id) != 0 && - bongo_cat_app_select_model_with_error(app, - app->models.entries[i].id, &load_error)) { - replacement = true; - break; - } - if (!replacement) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, - "Cannot delete the active model because no replacement could be displayed: %s", - load_error.message[0] ? load_error.message : "no installed models"); - return BONGO_CAT_ERROR_CUBISM; - } - } - if (!bongo_cat_model_remove_tree(directory, error)) { - bongo_cat_app_rescan_models(app); - if (selected && bongo_cat_models_find(&app->models, id)) { - BongoCatError restore_error = {0}; - if (!bongo_cat_app_select_model_with_error(app, id, &restore_error)) - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "Unable to restore model after deletion failed: %s", - restore_error.message[0] ? restore_error.message : "unknown error"); - } - return BONGO_CAT_ERROR_IO; - } - bongo_cat_config_set_model_label(&app->config, id, ""); - bongo_cat_app_rescan_models(app); - return BONGO_CAT_OK; -} diff --git a/src/runtime/model_import_portable.c b/src/runtime/model_import_portable.c index a8e8cd43..acb2073b 100644 --- a/src/runtime/model_import_portable.c +++ b/src/runtime/model_import_portable.c @@ -7,7 +7,6 @@ #include "bongo_cat/sha256.h" #include -#include #include #include #include @@ -15,15 +14,6 @@ #define PORTABLE_DIRECTORY "portable-mver" -typedef struct PortableStamp { - uint64_t sum, exclusive, bytes, latest, files, entries; -} PortableStamp; - -typedef struct StampWalk { - PortableStamp *stamp; - int depth; -} StampWalk; - static bool parent_path(const char *path, char *parent, size_t capacity) { size_t length = path ? strlen(path) : 0; while (length && (path[length - 1] == '/' || path[length - 1] == '\\')) length--; @@ -48,75 +38,6 @@ static void candidate_name(const char *source, snprintf(name, capacity, "%s", value[0] ? value : "Nearby model"); } -static uint64_t text_hash(const char *value) { - uint64_t hash = 1469598103934665603ull; - for (const unsigned char *cursor = (const unsigned char *)value; *cursor; ++cursor) { - hash ^= *cursor; - hash *= 1099511628211ull; - } - return hash; -} - -static uint64_t mix(uint64_t value) { - value ^= value >> 30; value *= 0xbf58476d1ce4e5b9ull; - value ^= value >> 27; value *= 0x94d049bb133111ebull; - return value ^ (value >> 31); -} - -static bool stamp_path(const char *path, PortableStamp *stamp, int depth); - -static BongoCatPathVisit stamp_item(void *userdata, - const char *dirname, const char *name) { - StampWalk *walk = userdata; - char path[BONGO_CAT_PATH_CAP]; - return ++walk->stamp->entries <= 16384 && - bongo_cat_path_join(path, sizeof(path), dirname, name) && - stamp_path(path, walk->stamp, walk->depth) - ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; -} - -static bool stamp_path(const char *path, PortableStamp *stamp, int depth) { - if (bongo_cat_path_is_dir(path)) { - if (depth >= 24) return false; - StampWalk walk = {stamp, depth + 1}; - return bongo_cat_path_enumerate(path, stamp_item, &walk); - } - uint64_t size, modified; - if (!bongo_cat_path_file_info(path, &size, &modified)) return false; - if (stamp->files >= 8192 || size > 1073741824ull - stamp->bytes) - return false; - uint64_t value = text_hash(path) ^ mix(size) ^ mix(modified); - value = mix(value); - stamp->sum += value; - stamp->exclusive ^= value; - stamp->bytes += size; - if (modified > stamp->latest) stamp->latest = modified; - stamp->files++; - return true; -} - -static bool candidate_signature(const BongoCatImportCandidate *candidate, - char output[65], BongoCatError *error) { - char config_hash[65], summary[256]; - if (bongo_cat_sha256_file(candidate->config, config_hash, error) != - BONGO_CAT_OK) return false; - PortableStamp stamp = {0}; - if (!stamp_path(candidate->directory, &stamp, 0) || - !stamp_path(candidate->assets, &stamp, 0) || - (candidate->overrides[0] && !stamp_path(candidate->overrides, &stamp, 0))) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot inspect portable Mver model assets: %s", candidate->package_root); - return false; - } - int length = snprintf(summary, sizeof(summary), "%s|%016llx|%016llx|%llu|%llu|%llu", - config_hash, (unsigned long long)stamp.sum, - (unsigned long long)stamp.exclusive, (unsigned long long)stamp.bytes, - (unsigned long long)stamp.latest, (unsigned long long)stamp.files); - if (length < 0 || (size_t)length >= sizeof(summary)) return false; - bongo_cat_sha256_bytes(summary, (size_t)length, output); - return true; -} - static bool marker_matches(const char *target, const char *source, const char *signature, const char *identity) { char path[BONGO_CAT_PATH_CAP]; @@ -260,7 +181,8 @@ static bool add_candidate(BongoCatApp *app, const char *cache_root, snprintf(id, sizeof(id), "mver-%.16s-%s", source_hash, bongo_cat_mode_name(candidate->mode)); if (bongo_cat_models_find(&app->models, id)) return true; - if (!candidate_signature(candidate, signature, error)) return false; + if (!bongo_cat_portable_signature(candidate, signature, error)) + return false; char target[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(target, sizeof(target), cache_root, id) || (!cached_identity(target, source, signature, identity) && diff --git a/src/runtime/model_import_portable_internal.h b/src/runtime/model_import_portable_internal.h index 482818ad..ffe4f020 100644 --- a/src/runtime/model_import_portable_internal.h +++ b/src/runtime/model_import_portable_internal.h @@ -8,6 +8,8 @@ bool bongo_cat_portable_identity(const BongoCatImportCandidate *candidate, char output[65], BongoCatError *error); +bool bongo_cat_portable_signature(const BongoCatImportCandidate *candidate, + char output[65], BongoCatError *error); void bongo_cat_portable_migrate_config(BongoCatApp *app, const char *cache_root); diff --git a/src/runtime/model_import_portable_signature.c b/src/runtime/model_import_portable_signature.c new file mode 100644 index 00000000..0a56b9fc --- /dev/null +++ b/src/runtime/model_import_portable_signature.c @@ -0,0 +1,91 @@ +#include "model_import_portable_internal.h" +#include "bongo_cat/path.h" +#include "bongo_cat/sha256.h" + +#include +#include + +typedef struct PortableStamp { + uint64_t sum, exclusive, bytes, latest, files, entries; +} PortableStamp; + +typedef struct StampWalk { + PortableStamp *stamp; + int depth; +} StampWalk; + +static uint64_t text_hash(const char *value) { + uint64_t hash = 1469598103934665603ull; + for (const unsigned char *cursor = (const unsigned char *)value; + *cursor; ++cursor) { + hash ^= *cursor; + hash *= 1099511628211ull; + } + return hash; +} + +static uint64_t mix(uint64_t value) { + value ^= value >> 30; + value *= 0xbf58476d1ce4e5b9ull; + value ^= value >> 27; + value *= 0x94d049bb133111ebull; + return value ^ (value >> 31); +} + +static bool stamp_path(const char *path, PortableStamp *stamp, int depth); + +static BongoCatPathVisit stamp_item(void *userdata, + const char *dirname, const char *name) { + StampWalk *walk = userdata; + char path[BONGO_CAT_PATH_CAP]; + return ++walk->stamp->entries <= 16384 && + bongo_cat_path_join(path, sizeof(path), dirname, name) && + stamp_path(path, walk->stamp, walk->depth) + ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; +} + +static bool stamp_path(const char *path, PortableStamp *stamp, int depth) { + if (bongo_cat_path_is_dir(path)) { + if (depth >= 24) return false; + StampWalk walk = {stamp, depth + 1}; + return bongo_cat_path_enumerate(path, stamp_item, &walk); + } + uint64_t size, modified; + if (!bongo_cat_path_file_info(path, &size, &modified)) return false; + if (stamp->files >= 8192 || size > 1073741824ull - stamp->bytes) + return false; + uint64_t value = mix(text_hash(path) ^ mix(size) ^ mix(modified)); + stamp->sum += value; + stamp->exclusive ^= value; + stamp->bytes += size; + if (modified > stamp->latest) stamp->latest = modified; + stamp->files++; + return true; +} + +bool bongo_cat_portable_signature(const BongoCatImportCandidate *candidate, + char output[65], BongoCatError *error) { + char config_hash[65], summary[256]; + if (bongo_cat_sha256_file(candidate->config, config_hash, error) != + BONGO_CAT_OK) return false; + PortableStamp stamp = {0}; + if (!stamp_path(candidate->directory, &stamp, 0) || + !stamp_path(candidate->assets, &stamp, 0) || + (candidate->overrides[0] && + !stamp_path(candidate->overrides, &stamp, 0))) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot inspect portable Mver model assets: %s", + candidate->package_root); + return false; + } + int length = snprintf(summary, sizeof(summary), + "%s|%016llx|%016llx|%llu|%llu|%llu", config_hash, + (unsigned long long)stamp.sum, + (unsigned long long)stamp.exclusive, + (unsigned long long)stamp.bytes, + (unsigned long long)stamp.latest, + (unsigned long long)stamp.files); + if (length < 0 || (size_t)length >= sizeof(summary)) return false; + bongo_cat_sha256_bytes(summary, (size_t)length, output); + return true; +} diff --git a/src/runtime/window.c b/src/runtime/window.c index ced6d9c5..640904d6 100644 --- a/src/runtime/window.c +++ b/src/runtime/window.c @@ -1,9 +1,4 @@ #include "runtime.h" -#include "window_menu.h" -#include "bongo_cat/i18n.h" -#include "bongo_cat/preferences.h" -#include "../ui/preferences_notice.h" - #include #include @@ -98,130 +93,6 @@ void bongo_cat_window_apply(BongoCatApp *app) { bongo_cat_platform_set_always_on_top(&app->platform, value->always_on_top); } -static const char *tr(BongoCatApp *app, const char *key, const char *fallback) { - return bongo_cat_i18n_get(app->i18n, key, fallback); -} - -static bool select_model_action(BongoCatApp *app, const char *id) { - BongoCatError error = {0}; - if (bongo_cat_app_select_model_with_error(app, id, &error)) return true; - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Model switch failed: %s", - error.message[0] ? error.message : "unknown error"); - const char *message = tr(app, "native.modelLoadFailed", - "Unable to display this model"); - if (app->preferences && bongo_cat_preferences_visible(app->preferences)) - bongo_cat_preferences_notice_show(app, message, true); - else SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, BONGO_CAT_NAME, - message, app->window); - return false; -} - -static void context_menu(BongoCatApp *app) { - BongoCatWindowMenuPreview preview; - bongo_cat_window_menu_preview_init(&preview, app); - const char *model_names[BONGO_CAT_MODEL_CAP]; - size_t current_model = app->models.count; - for (size_t i = 0; i < app->models.count; ++i) { - model_names[i] = bongo_cat_model_name(&app->config, - &app->models.entries[i]); - if (strcmp(app->models.entries[i].id, app->config.current_model) == 0) - current_model = i; - } - char motion_names[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; - char expression_names[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; - bool motion_checked[BONGO_CAT_BEHAVIOR_CAP] = {false}; - size_t motion_count, expression_count, current_expression; - bongo_cat_window_behavior_labels(app, motion_names, motion_checked, - &motion_count, expression_names, &expression_count, ¤t_expression); - bool dark_theme = app->config.app.theme == BONGO_CAT_THEME_DARK || - (app->config.app.theme == BONGO_CAT_THEME_AUTO && - SDL_GetSystemTheme() == SDL_SYSTEM_THEME_DARK); - BongoCatMenuLabels labels = { - tr(app, "composables.useAppMenu.labels.preference", "Preferences"), - tr(app, "composables.useAppMenu.labels.hideCat", "Hide Cat"), - tr(app, "composables.useAppMenu.labels.passThrough", "Pass Through"), - tr(app, "composables.useAppMenu.labels.alwaysOnTop", "Always on top"), - tr(app, "composables.useAppMenu.labels.windowSize", "Window Size"), - tr(app, "composables.useAppMenu.labels.opacity", "Opacity"), - tr(app, "composables.useAppMenu.labels.model", "Model"), - tr(app, "composables.useAppMenu.labels.addModel", "Add Model"), - tr(app, "composables.useAppMenu.labels.quitApp", "Exit"), - tr(app, "composables.useAppMenu.labels.wheelSizeHint", "Wheel: resize"), - tr(app, "composables.useAppMenu.labels.wheelOpacityHint", "Ctrl+Wheel: opacity"), - tr(app, "composables.useAppMenu.labels.motion", "Motions"), - tr(app, "composables.useAppMenu.labels.expression", "Expressions"), - model_names, motion_names, expression_names, - motion_checked, - app->models.count, current_model, motion_count, - expression_count, current_expression, - app->config.window.scale_percent, app->config.window.opacity_percent, - app->config.window.pass_through, app->config.window.always_on_top, - dark_theme, - bongo_cat_window_menu_preview, bongo_cat_window_menu_preview_tick, - bongo_cat_window_menu_restore, &preview}; - BongoCatMenuAction action = bongo_cat_platform_context_menu(&app->platform, &labels); - if (bongo_cat_window_menu_preview_applied(&preview, action)) - bongo_cat_preferences_invalidate(app->preferences); - else bongo_cat_window_menu_action(app, action); -} - -void bongo_cat_window_show_context_menu(BongoCatApp *app) { context_menu(app); } - -void bongo_cat_window_menu_action(BongoCatApp *app, BongoCatMenuAction action) { - if (action == BONGO_CAT_MENU_PREFERENCES) bongo_cat_preferences_show(app->preferences); - else if (action == BONGO_CAT_MENU_MODEL_ADD) - bongo_cat_preferences_open_model_import(app->preferences, app->window); - else if (action == BONGO_CAT_MENU_HIDE) - bongo_cat_window_set_visible(app, false); - else if (action == BONGO_CAT_MENU_PASS_THROUGH) { - app->config.window.pass_through = !app->config.window.pass_through; - bongo_cat_window_mark_hit_dirty(app); - bongo_cat_window_sync_click_through(app); - } else if (action == BONGO_CAT_MENU_ALWAYS_ON_TOP) { - app->config.window.always_on_top = !app->config.window.always_on_top; - bongo_cat_platform_set_always_on_top(&app->platform, app->config.window.always_on_top); - bongo_cat_window_mark_hit_dirty(app); - bongo_cat_window_sync_click_through(app); - } else if (action >= BONGO_CAT_MENU_SCALE_50 && action <= BONGO_CAT_MENU_SCALE_200) { - bongo_cat_window_cancel_wheel_animation(app); - bongo_cat_window_set_scale(app, - (float)(50 + 10 * (action - BONGO_CAT_MENU_SCALE_50))); - } else if (action >= BONGO_CAT_MENU_OPACITY_10 && action <= BONGO_CAT_MENU_OPACITY_100) { - bongo_cat_window_cancel_wheel_animation(app); - app->config.window.opacity_percent = (float)(10 * (action-BONGO_CAT_MENU_OPACITY_10+1)); - bongo_cat_platform_set_opacity(&app->platform, - app->config.window.opacity_percent / 100.0f); - } else if (bongo_cat_window_behavior_action(app, action)) { - bongo_cat_app_render_now(app); - } else if (action >= BONGO_CAT_MENU_MODEL_FIRST && - action < BONGO_CAT_MENU_MODEL_FIRST + BONGO_CAT_MODEL_CAP) { - size_t index = (size_t)(action - BONGO_CAT_MENU_MODEL_FIRST); - if (index < app->models.count) - select_model_action(app, app->models.entries[index].id); - } else if (action == BONGO_CAT_MENU_EXIT) app->running = false; - bongo_cat_preferences_invalidate(app->preferences); -} - -bool bongo_cat_window_menu_self_test(BongoCatApp *app) { - if (!app || !app->preferences) return false; - app->config.window.pass_through = false; - app->config.window.always_on_top = false; - app->config.window.scale_percent = 100.0f; - app->config.window.opacity_percent = 100.0f; - bongo_cat_window_menu_action(app, BONGO_CAT_MENU_PASS_THROUGH); - bongo_cat_window_menu_action(app, BONGO_CAT_MENU_ALWAYS_ON_TOP); - bongo_cat_window_menu_action(app, BONGO_CAT_MENU_SCALE_120); - bongo_cat_window_menu_action(app, BONGO_CAT_MENU_OPACITY_50); - bongo_cat_window_menu_action(app, BONGO_CAT_MENU_PREFERENCES); - bool behavior = bongo_cat_window_behavior_self_test(app); - bool result = app->config.window.pass_through && app->config.window.always_on_top && - app->config.window.scale_percent == 120.0f && - app->config.window.opacity_percent == 50.0f && - bongo_cat_preferences_visible(app->preferences) && behavior; - bongo_cat_preferences_close(app->preferences); - return result; -} - bool bongo_cat_window_event(BongoCatApp *app, const SDL_Event *event) { bongo_cat_window_display_event(app, event); if (event->type >= SDL_EVENT_WINDOW_FIRST && event->type <= SDL_EVENT_WINDOW_LAST && @@ -286,7 +157,7 @@ bool bongo_cat_window_event(BongoCatApp *app, const SDL_Event *event) { event->button.button == SDL_BUTTON_RIGHT) { bongo_cat_window_mark_hit_dirty(app); if (app->resize_gesture) app->resize_gesture = false; - else context_menu(app); + else bongo_cat_window_show_context_menu(app); } return true; } diff --git a/src/runtime/window_menu_actions.c b/src/runtime/window_menu_actions.c new file mode 100644 index 00000000..8bbe21fc --- /dev/null +++ b/src/runtime/window_menu_actions.c @@ -0,0 +1,139 @@ +#include "runtime.h" +#include "window_menu.h" +#include "bongo_cat/i18n.h" +#include "bongo_cat/preferences.h" +#include "../ui/preferences_notice.h" + +#include +#include + +static const char *tr(BongoCatApp *app, const char *key, + const char *fallback) { + return bongo_cat_i18n_get(app->i18n, key, fallback); +} + +static bool select_model(BongoCatApp *app, const char *id) { + BongoCatError error = {0}; + if (bongo_cat_app_select_model_with_error(app, id, &error)) return true; + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Model switch failed: %s", + error.message[0] ? error.message : "unknown error"); + const char *message = tr(app, "native.modelLoadFailed", + "Unable to display this model"); + if (app->preferences && bongo_cat_preferences_visible(app->preferences)) + bongo_cat_preferences_notice_show(app, message, true); + else SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, BONGO_CAT_NAME, + message, app->window); + return false; +} + +void bongo_cat_window_show_context_menu(BongoCatApp *app) { + BongoCatWindowMenuPreview preview; + bongo_cat_window_menu_preview_init(&preview, app); + const char *model_names[BONGO_CAT_MODEL_CAP]; + size_t current_model = app->models.count; + for (size_t i = 0; i < app->models.count; ++i) { + model_names[i] = bongo_cat_model_name(&app->config, + &app->models.entries[i]); + if (!strcmp(app->models.entries[i].id, app->config.current_model)) + current_model = i; + } + char motion_names[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; + char expression_names[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; + bool motion_checked[BONGO_CAT_BEHAVIOR_CAP] = {false}; + size_t motion_count, expression_count, current_expression; + bongo_cat_window_behavior_labels(app, motion_names, motion_checked, + &motion_count, expression_names, &expression_count, + ¤t_expression); + bool dark_theme = app->config.app.theme == BONGO_CAT_THEME_DARK || + (app->config.app.theme == BONGO_CAT_THEME_AUTO && + SDL_GetSystemTheme() == SDL_SYSTEM_THEME_DARK); + BongoCatMenuLabels labels = { + tr(app, "composables.useAppMenu.labels.preference", "Preferences"), + tr(app, "composables.useAppMenu.labels.hideCat", "Hide Cat"), + tr(app, "composables.useAppMenu.labels.passThrough", "Pass Through"), + tr(app, "composables.useAppMenu.labels.alwaysOnTop", "Always on top"), + tr(app, "composables.useAppMenu.labels.windowSize", "Window Size"), + tr(app, "composables.useAppMenu.labels.opacity", "Opacity"), + tr(app, "composables.useAppMenu.labels.model", "Model"), + tr(app, "composables.useAppMenu.labels.addModel", "Add Model"), + tr(app, "composables.useAppMenu.labels.quitApp", "Exit"), + tr(app, "composables.useAppMenu.labels.wheelSizeHint", "Wheel: resize"), + tr(app, "composables.useAppMenu.labels.wheelOpacityHint", + "Ctrl+Wheel: opacity"), + tr(app, "composables.useAppMenu.labels.motion", "Motions"), + tr(app, "composables.useAppMenu.labels.expression", "Expressions"), + model_names, motion_names, expression_names, motion_checked, + app->models.count, current_model, motion_count, expression_count, + current_expression, app->config.window.scale_percent, + app->config.window.opacity_percent, app->config.window.pass_through, + app->config.window.always_on_top, dark_theme, + bongo_cat_window_menu_preview, bongo_cat_window_menu_preview_tick, + bongo_cat_window_menu_restore, &preview}; + BongoCatMenuAction action = bongo_cat_platform_context_menu( + &app->platform, &labels); + if (bongo_cat_window_menu_preview_applied(&preview, action)) + bongo_cat_preferences_invalidate(app->preferences); + else bongo_cat_window_menu_action(app, action); +} + +void bongo_cat_window_menu_action(BongoCatApp *app, + BongoCatMenuAction action) { + if (action == BONGO_CAT_MENU_PREFERENCES) + bongo_cat_preferences_show(app->preferences); + else if (action == BONGO_CAT_MENU_MODEL_ADD) + bongo_cat_preferences_open_model_import(app->preferences, app->window); + else if (action == BONGO_CAT_MENU_HIDE) + bongo_cat_window_set_visible(app, false); + else if (action == BONGO_CAT_MENU_PASS_THROUGH) { + app->config.window.pass_through = !app->config.window.pass_through; + bongo_cat_window_mark_hit_dirty(app); + bongo_cat_window_sync_click_through(app); + } else if (action == BONGO_CAT_MENU_ALWAYS_ON_TOP) { + app->config.window.always_on_top = !app->config.window.always_on_top; + bongo_cat_platform_set_always_on_top(&app->platform, + app->config.window.always_on_top); + bongo_cat_window_mark_hit_dirty(app); + bongo_cat_window_sync_click_through(app); + } else if (action >= BONGO_CAT_MENU_SCALE_50 && + action <= BONGO_CAT_MENU_SCALE_200) { + bongo_cat_window_cancel_wheel_animation(app); + bongo_cat_window_set_scale(app, + (float)(50 + 10 * (action - BONGO_CAT_MENU_SCALE_50))); + } else if (action >= BONGO_CAT_MENU_OPACITY_10 && + action <= BONGO_CAT_MENU_OPACITY_100) { + bongo_cat_window_cancel_wheel_animation(app); + app->config.window.opacity_percent = + (float)(10 * (action - BONGO_CAT_MENU_OPACITY_10 + 1)); + bongo_cat_platform_set_opacity(&app->platform, + app->config.window.opacity_percent / 100.0f); + } else if (bongo_cat_window_behavior_action(app, action)) { + bongo_cat_app_render_now(app); + } else if (action >= BONGO_CAT_MENU_MODEL_FIRST && + action < BONGO_CAT_MENU_MODEL_FIRST + BONGO_CAT_MODEL_CAP) { + size_t index = (size_t)(action - BONGO_CAT_MENU_MODEL_FIRST); + if (index < app->models.count) + select_model(app, app->models.entries[index].id); + } else if (action == BONGO_CAT_MENU_EXIT) app->running = false; + bongo_cat_preferences_invalidate(app->preferences); +} + +bool bongo_cat_window_menu_self_test(BongoCatApp *app) { + if (!app || !app->preferences) return false; + app->config.window.pass_through = false; + app->config.window.always_on_top = false; + app->config.window.scale_percent = 100.0f; + app->config.window.opacity_percent = 100.0f; + bongo_cat_window_menu_action(app, BONGO_CAT_MENU_PASS_THROUGH); + bongo_cat_window_menu_action(app, BONGO_CAT_MENU_ALWAYS_ON_TOP); + bongo_cat_window_menu_action(app, BONGO_CAT_MENU_SCALE_120); + bongo_cat_window_menu_action(app, BONGO_CAT_MENU_OPACITY_50); + bongo_cat_window_menu_action(app, BONGO_CAT_MENU_PREFERENCES); + bool behavior = bongo_cat_window_behavior_self_test(app); + bool result = app->config.window.pass_through && + app->config.window.always_on_top && + app->config.window.scale_percent == 120.0f && + app->config.window.opacity_percent == 50.0f && + bongo_cat_preferences_visible(app->preferences) && behavior; + bongo_cat_preferences_close(app->preferences); + return result; +} diff --git a/src/ui/preferences_behavior_rename.c b/src/ui/preferences_behavior_rename.c index baaaab64..d38c5cda 100644 --- a/src/ui/preferences_behavior_rename.c +++ b/src/ui/preferences_behavior_rename.c @@ -10,7 +10,8 @@ static BongoCatBehaviorShortcut *binding_for(BongoCatConfig *config, for (size_t i = 0; i < config->behavior_shortcut_count; ++i) if (!strcmp(config->behavior_shortcuts[i].id, id)) return &config->behavior_shortcuts[i]; - if (config->behavior_shortcut_count >= BONGO_CAT_BEHAVIOR_BINDING_CAP) return NULL; + if (config->behavior_shortcut_count >= BONGO_CAT_BEHAVIOR_BINDING_CAP) + return NULL; BongoCatBehaviorShortcut *binding = &config->behavior_shortcuts[config->behavior_shortcut_count++]; memset(binding, 0, sizeof(*binding)); @@ -25,38 +26,28 @@ static const char *display_label(const BongoCatBehaviorEntry *entry, void bongo_cat_preferences_behavior_rename_finish( BongoCatPreferences *value, bool save) { - if (!value || !value->behavior_rename_id[0]) return; + if (!value || !bongo_cat_preferences_text_session_active( + &value->behavior_rename)) return; + BongoCatPreferencesTextSession *session = &value->behavior_rename; bool label_changed = false; if (save) { - size_t start = 0, end = strlen(value->behavior_rename_text); - while (start < end && (value->behavior_rename_text[start] == ' ' || - value->behavior_rename_text[start] == '\t')) start++; - while (end > start && (value->behavior_rename_text[end - 1] == ' ' || - value->behavior_rename_text[end - 1] == '\t')) end--; - if (start) memmove(value->behavior_rename_text, - value->behavior_rename_text + start, end - start); - value->behavior_rename_text[end - start] = '\0'; + bongo_cat_text_edit_trim(session->text); BongoCatBehaviorEntry *entry = NULL; for (size_t i = 0; i < value->app->behaviors.count; ++i) - if (!strcmp(value->app->behaviors.entries[i].id, - value->behavior_rename_id)) { - entry = &value->app->behaviors.entries[i]; break; + if (!strcmp(value->app->behaviors.entries[i].id, session->id)) { + entry = &value->app->behaviors.entries[i]; + break; } BongoCatBehaviorShortcut *binding = binding_for(&value->app->config, - value->behavior_rename_id); + session->id); if (binding) { - char label[BONGO_CAT_ID_CAP]; - snprintf(label, sizeof(label), "%s", - entry && !strcmp(value->behavior_rename_text, entry->label) ? "" : - value->behavior_rename_text); + const char *label = entry && !strcmp(session->text, entry->label) ? + "" : session->text; label_changed = strcmp(binding->label, label) != 0; snprintf(binding->label, sizeof(binding->label), "%s", label); } } - value->behavior_rename_id[0] = '\0'; - value->behavior_rename_text[0] = '\0'; - value->behavior_rename_cursor = 0; - value->behavior_rename_select_all = false; + bongo_cat_preferences_text_session_reset(session); SDL_StopTextInput(value->window); if (label_changed) bongo_cat_preferences_reload_fonts(value); value->render_dirty = true; @@ -65,101 +56,22 @@ void bongo_cat_preferences_behavior_rename_finish( void bongo_cat_preferences_behavior_rename_begin(BongoCatPreferences *value, const BongoCatBehaviorEntry *entry, BongoCatBehaviorShortcut *binding, struct nk_rect bounds) { + if (!value || !entry) return; bongo_cat_preferences_shortcut_cancel(value); - snprintf(value->behavior_rename_id, sizeof(value->behavior_rename_id), - "%s", entry->id); - snprintf(value->behavior_rename_text, sizeof(value->behavior_rename_text), - "%s", display_label(entry, binding)); - value->behavior_rename_bounds = bounds; - bongo_cat_text_edit_begin(value->behavior_rename_text, - &value->behavior_rename_cursor, &value->behavior_rename_select_all); + bongo_cat_preferences_text_session_begin(&value->behavior_rename, + entry->id, display_label(entry, binding), bounds); SDL_StartTextInput(value->window); value->render_dirty = true; } -static void clipboard_event(BongoCatPreferences *value, SDL_Keycode key) { - if (key == SDLK_V) { - char *clipboard = SDL_GetClipboardText(); - bongo_cat_text_edit_insert(value->behavior_rename_text, - sizeof(value->behavior_rename_text), - &value->behavior_rename_cursor, - &value->behavior_rename_select_all, clipboard); - SDL_free(clipboard); - return; - } - if (value->behavior_rename_select_all) - SDL_SetClipboardText(value->behavior_rename_text); - if (key == SDLK_X && value->behavior_rename_select_all) - bongo_cat_text_edit_clear(value->behavior_rename_text, - &value->behavior_rename_cursor, - &value->behavior_rename_select_all); -} - -static float measure(const void *userdata, const char *text, size_t length) { - const struct nk_user_font *font = userdata; - return font->width(font->userdata, font->height, text, (int)length); -} - bool bongo_cat_preferences_behavior_rename_event( BongoCatPreferences *value, const SDL_Event *event) { - if (!value || !event || !value->behavior_rename_id[0]) return false; - if (event->type == SDL_EVENT_TEXT_INPUT) { - bongo_cat_text_edit_insert(value->behavior_rename_text, - sizeof(value->behavior_rename_text), - &value->behavior_rename_cursor, - &value->behavior_rename_select_all, event->text.text); - value->render_dirty = true; return true; - } - if (event->type == SDL_EVENT_WINDOW_FOCUS_LOST) { - bongo_cat_preferences_behavior_rename_finish(value, true); return false; - } - if (event->type == SDL_EVENT_MOUSE_BUTTON_DOWN && - event->button.button == SDL_BUTTON_LEFT) { - float scale = value->ui.layout_scale > 0.0f ? - value->ui.layout_scale : 1.0f; - float x = event->button.x / scale, y = event->button.y / scale; - if (!NK_INBOX(x, y, value->behavior_rename_bounds.x, - value->behavior_rename_bounds.y, value->behavior_rename_bounds.w, - value->behavior_rename_bounds.h)) - bongo_cat_preferences_behavior_rename_finish(value, true); - else { - value->behavior_rename_cursor = bongo_cat_text_edit_nearest( - value->behavior_rename_text, - x - value->behavior_rename_bounds.x - 8.0f, measure, - value->ui.caption_font); - value->behavior_rename_select_all = false; - value->render_dirty = true; - return true; - } - return false; - } - if (event->type != SDL_EVENT_KEY_DOWN && - event->type != SDL_EVENT_KEY_UP) return false; - if (event->type == SDL_EVENT_KEY_UP) return true; - SDL_Keycode key = event->key.key; - bool control = (event->key.mod & SDL_KMOD_CTRL) != 0; - if (key == SDLK_ESCAPE) - bongo_cat_preferences_behavior_rename_finish(value, false); - else if (key == SDLK_RETURN || key == SDLK_KP_ENTER) - bongo_cat_preferences_behavior_rename_finish(value, true); - else if (control && key == SDLK_A) - bongo_cat_text_edit_select_all(value->behavior_rename_text, - &value->behavior_rename_cursor, - &value->behavior_rename_select_all); - else if (control && (key == SDLK_C || key == SDLK_X || key == SDLK_V)) - clipboard_event(value, key); - else if (key == SDLK_BACKSPACE || key == SDLK_DELETE) - bongo_cat_text_edit_erase(value->behavior_rename_text, - &value->behavior_rename_cursor, - &value->behavior_rename_select_all, key == SDLK_DELETE); - else if (key == SDLK_LEFT || key == SDLK_RIGHT || key == SDLK_HOME || - key == SDLK_END) - bongo_cat_text_edit_move(value->behavior_rename_text, - &value->behavior_rename_cursor, - &value->behavior_rename_select_all, - key == SDLK_LEFT ? BONGO_CAT_TEXT_EDIT_LEFT : - key == SDLK_RIGHT ? BONGO_CAT_TEXT_EDIT_RIGHT : - key == SDLK_HOME ? BONGO_CAT_TEXT_EDIT_HOME : BONGO_CAT_TEXT_EDIT_END); - value->render_dirty = true; - return true; + if (!value) return false; + BongoCatPreferencesTextSessionEvent result = + bongo_cat_preferences_text_session_event(&value->behavior_rename, + event, value->ui.layout_scale, value->ui.caption_font, 8.0f); + if (result.finish) + bongo_cat_preferences_behavior_rename_finish(value, result.save); + if (result.handled) value->render_dirty = true; + return result.handled; } diff --git a/src/ui/preferences_behavior_row.c b/src/ui/preferences_behavior_row.c index 75923bd1..c4e5e1e9 100644 --- a/src/ui/preferences_behavior_row.c +++ b/src/ui/preferences_behavior_row.c @@ -94,7 +94,8 @@ static void draw_name(BongoCatPreferences *value, struct nk_context *context, BongoCatBehaviorEntry *entry, BongoCatBehaviorShortcut *binding, BongoCatUIPalette p, float opacity, bool enabled) { const char *label = display_label(entry, binding); - bool renaming = !strcmp(value->behavior_rename_id, entry->id); + BongoCatPreferencesTextSession *session = &value->behavior_rename; + bool renaming = !strcmp(session->id, entry->id); bool hover = enabled && nk_input_is_mouse_hovering_rect( &context->input, bounds); if (renaming || hover) { @@ -103,21 +104,21 @@ static void draw_name(BongoCatPreferences *value, struct nk_context *context, nk_stroke_rect(canvas, bounds, 8, 1, alpha(renaming ? p.pink : p.accent, opacity)); } - const char *shown = renaming ? value->behavior_rename_text : label; + const char *shown = renaming ? session->text : label; struct nk_rect text_bounds = nk_rect(bounds.x + 8, bounds.y + 9, bounds.w - 16, 21); - if (renaming && value->behavior_rename_select_all) + if (renaming && session->select_all) nk_fill_rect(canvas, text_bounds, 4, alpha(p.selection, opacity)); text(canvas, text_bounds, shown, value->ui.caption_font, alpha(p.text, opacity)); if (renaming) { - value->behavior_rename_bounds = bounds; + session->bounds = bounds; float caret = value->ui.caption_font->width( value->ui.caption_font->userdata, value->ui.caption_font->height, - shown, (int)value->behavior_rename_cursor); + shown, (int)session->cursor); caret = NK_MIN(caret, bounds.w - 18); - if (!value->behavior_rename_select_all) + if (!session->select_all) nk_stroke_line(canvas, bounds.x + 8 + caret, bounds.y + 8, bounds.x + 8 + caret, bounds.y + bounds.h - 8, 1, alpha(p.pink, opacity)); diff --git a/src/ui/preferences_fonts.c b/src/ui/preferences_fonts.c new file mode 100644 index 00000000..6a5a2023 --- /dev/null +++ b/src/ui/preferences_fonts.c @@ -0,0 +1,85 @@ +#include "preferences_fonts.h" + +#include "preferences_model_cover.h" +#include "preferences_model_glyphs.h" +#include "ui_font.h" +#include "ui_font_atlas.h" +#include "ui_paint.h" +#include "bongo_cat/i18n.h" + +#include +#include + +void bongo_cat_preferences_fonts_resolve(BongoCatPreferences *value, + BongoCatPreferenceFonts *fonts) { + memset(fonts, 0, sizeof(*fonts)); + fonts->body = bongo_cat_ui_system_font(fonts->body_path, + sizeof(fonts->body_path), false); + fonts->body_fallback = bongo_cat_ui_system_font(fonts->body_fallback_path, + sizeof(fonts->body_fallback_path), true); + fonts->body_korean_fallback = bongo_cat_ui_system_korean_font( + fonts->body_korean_fallback_path, + sizeof(fonts->body_korean_fallback_path)); + fonts->heading = bongo_cat_ui_system_heading_font(fonts->heading_path, + sizeof(fonts->heading_path), false); + fonts->heading_fallback = bongo_cat_ui_system_heading_font( + fonts->heading_fallback_path, + sizeof(fonts->heading_fallback_path), true); + fonts->heading_korean_fallback = bongo_cat_ui_system_korean_heading_font( + fonts->heading_korean_fallback_path, + sizeof(fonts->heading_korean_fallback_path)); + if (!fonts->heading) fonts->heading = fonts->body; + if (!fonts->heading_fallback) fonts->heading_fallback = + fonts->body_fallback; + if (!value->app->i18n) return; + bongo_cat_i18n_glyph_ranges(value->app->i18n, value->glyph_ranges, + sizeof(value->glyph_ranges) / sizeof(value->glyph_ranges[0])); + bongo_cat_preferences_model_glyphs(value->app, value->glyph_ranges, + sizeof(value->glyph_ranges) / sizeof(value->glyph_ranges[0])); + fonts->ranges = value->glyph_ranges; +} + +static bool reload(BongoCatPreferences *value, + const BongoCatPreferenceFonts *fonts, float raster_scale) { + return bongo_cat_ui_font_atlas_reload(&value->ui, fonts->body, + fonts->body_fallback, fonts->body_korean_fallback, fonts->heading, + fonts->heading_fallback, fonts->heading_korean_fallback, fonts->ranges, + raster_scale); +} + +bool bongo_cat_preferences_reload_fonts(BongoCatPreferences *value) { + if (!value || !value->ui_initialized) return false; + if (value->ui.frame_building) { + value->font_reload_pending = true; + value->render_dirty = true; + return true; + } + BongoCatPreferenceFonts fonts; + bongo_cat_preferences_fonts_resolve(value, &fonts); + bool reloaded = reload(value, &fonts, value->ui.raster_scale); + if (reloaded) value->font_reload_pending = false; + return reloaded; +} + +bool bongo_cat_preferences_refresh_raster(BongoCatPreferences *value) { + if (!value || value->pending_raster_scale <= 0.0f) return true; + uint64_t now = SDL_GetTicksNS(); + if (value->raster_retry_ns > now) return false; + float raster_scale = value->pending_raster_scale; + BongoCatPreferenceFonts fonts; + bongo_cat_preferences_fonts_resolve(value, &fonts); + if (!reload(value, &fonts, raster_scale)) { + value->raster_retry_ns = now + 1000000000ull; + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Preferences font atlas could not be rebuilt for %.2fx", + raster_scale); + return false; + } + value->pending_raster_scale = 0.0f; + value->raster_retry_ns = 0; + bongo_cat_ui_paint_destroy(&value->ui); + bongo_cat_preferences_model_cover_cache_clear(value->app); + bongo_cat_preferences_assets_clear(value); + bongo_cat_preferences_assets_load(value); + return true; +} diff --git a/src/ui/preferences_fonts.h b/src/ui/preferences_fonts.h new file mode 100644 index 00000000..d5233550 --- /dev/null +++ b/src/ui/preferences_fonts.h @@ -0,0 +1,25 @@ +#ifndef BONGO_CAT_PREFERENCES_FONTS_H +#define BONGO_CAT_PREFERENCES_FONTS_H + +#include "preferences_state.h" + +typedef struct BongoCatPreferenceFonts { + char body_path[BONGO_CAT_PATH_CAP]; + char body_fallback_path[BONGO_CAT_PATH_CAP]; + char body_korean_fallback_path[BONGO_CAT_PATH_CAP]; + char heading_path[BONGO_CAT_PATH_CAP]; + char heading_fallback_path[BONGO_CAT_PATH_CAP]; + char heading_korean_fallback_path[BONGO_CAT_PATH_CAP]; + const char *body; + const char *body_fallback; + const char *body_korean_fallback; + const char *heading; + const char *heading_fallback; + const char *heading_korean_fallback; + const nk_rune *ranges; +} BongoCatPreferenceFonts; + +void bongo_cat_preferences_fonts_resolve(BongoCatPreferences *preferences, + BongoCatPreferenceFonts *fonts); + +#endif diff --git a/src/ui/preferences_model_rename.c b/src/ui/preferences_model_rename.c index 12e1a41d..a4e6a596 100644 --- a/src/ui/preferences_model_rename.c +++ b/src/ui/preferences_model_rename.c @@ -2,14 +2,14 @@ #include "preferences_text_edit.h" #include -#include #include bool bongo_cat_preferences_model_name_draw(BongoCatPreferences *value, struct nk_context *context, struct nk_command_buffer *canvas, const BongoCatModelEntry *entry, struct nk_rect bounds, BongoCatUIPalette p) { - bool renaming = !strcmp(value->model_rename_id, entry->id); + BongoCatPreferencesTextSession *session = &value->model_rename; + bool renaming = !strcmp(session->id, entry->id); bool hover = !renaming && nk_input_is_mouse_hovering_rect( &context->input, bounds); bool clicked = hover && nk_input_is_mouse_click_in_rect( @@ -18,21 +18,22 @@ bool bongo_cat_preferences_model_name_draw(BongoCatPreferences *value, nk_fill_rect(canvas, bounds, 7, renaming ? p.hover_pink : p.hover); nk_stroke_rect(canvas, bounds, 7, 1, renaming ? p.pink : p.accent); } - const char *shown = renaming ? value->model_rename_text : + const char *shown = renaming ? session->text : bongo_cat_model_name(&value->app->config, entry); struct nk_rect text_bounds = nk_rect(bounds.x + 5, bounds.y + 3, bounds.w - 10, 22); - if (renaming && value->model_rename_select_all) + if (renaming && session->select_all) nk_fill_rect(canvas, text_bounds, 4, p.selection); - nk_draw_text(canvas, text_bounds, shown, nk_strlen(shown), value->ui.label_font, - nk_rgba(0, 0, 0, 0), renaming ? p.pink : p.text); + nk_draw_text(canvas, text_bounds, shown, nk_strlen(shown), + value->ui.label_font, nk_rgba(0, 0, 0, 0), + renaming ? p.pink : p.text); if (renaming) { - value->model_rename_bounds = bounds; + session->bounds = bounds; float caret = value->ui.label_font->width( value->ui.label_font->userdata, value->ui.label_font->height, - shown, (int)value->model_rename_cursor); + shown, (int)session->cursor); caret = NK_MIN(caret, bounds.w - 12); - if (!value->model_rename_select_all) + if (!session->select_all) nk_stroke_line(canvas, bounds.x + 5 + caret, bounds.y + 3, bounds.x + 5 + caret, bounds.y + bounds.h - 3, 1, p.pink); } else if (clicked) @@ -42,52 +43,22 @@ bool bongo_cat_preferences_model_name_draw(BongoCatPreferences *value, return renaming || hover; } -static void clipboard_event(BongoCatPreferences *value, SDL_Keycode key) { - if (key == SDLK_V) { - char *clipboard = SDL_GetClipboardText(); - bongo_cat_text_edit_insert(value->model_rename_text, - sizeof(value->model_rename_text), &value->model_rename_cursor, - &value->model_rename_select_all, clipboard); - SDL_free(clipboard); - return; - } - if (value->model_rename_select_all) - SDL_SetClipboardText(value->model_rename_text); - if (key == SDLK_X && value->model_rename_select_all) - bongo_cat_text_edit_clear(value->model_rename_text, - &value->model_rename_cursor, &value->model_rename_select_all); -} - -static float measure(const void *userdata, const char *text, size_t length) { - const struct nk_user_font *font = userdata; - return font->width(font->userdata, font->height, text, (int)length); -} - void bongo_cat_preferences_model_rename_finish( BongoCatPreferences *value, bool save) { - if (!value || !value->model_rename_id[0]) return; + if (!value || !bongo_cat_preferences_text_session_active( + &value->model_rename)) return; + BongoCatPreferencesTextSession *session = &value->model_rename; bool label_changed = false; if (save) { - size_t start = 0, end = strlen(value->model_rename_text); - while (start < end && (value->model_rename_text[start] == ' ' || - value->model_rename_text[start] == '\t')) start++; - while (end > start && (value->model_rename_text[end - 1] == ' ' || - value->model_rename_text[end - 1] == '\t')) end--; - if (start) memmove(value->model_rename_text, - value->model_rename_text + start, end - start); - value->model_rename_text[end - start] = '\0'; + bongo_cat_text_edit_trim(session->text); const BongoCatModelEntry *entry = bongo_cat_models_find( - &value->app->models, value->model_rename_id); - const char *label = entry && !strcmp(value->model_rename_text, - bongo_cat_model_default_name(entry)) ? "" : - value->model_rename_text; + &value->app->models, session->id); + const char *label = entry && !strcmp(session->text, + bongo_cat_model_default_name(entry)) ? "" : session->text; label_changed = bongo_cat_config_set_model_label(&value->app->config, - value->model_rename_id, label); + session->id, label); } - value->model_rename_id[0] = '\0'; - value->model_rename_text[0] = '\0'; - value->model_rename_cursor = 0; - value->model_rename_select_all = false; + bongo_cat_preferences_text_session_reset(session); SDL_StopTextInput(value->window); if (label_changed) bongo_cat_preferences_reload_fonts(value); value->render_dirty = true; @@ -97,74 +68,20 @@ void bongo_cat_preferences_model_rename_begin(BongoCatPreferences *value, const BongoCatModelEntry *entry, struct nk_rect bounds) { if (!value || !entry) return; bongo_cat_preferences_shortcut_cancel(value); - snprintf(value->model_rename_id, sizeof(value->model_rename_id), - "%s", entry->id); - snprintf(value->model_rename_text, sizeof(value->model_rename_text), - "%s", bongo_cat_model_name(&value->app->config, entry)); - value->model_rename_bounds = bounds; - bongo_cat_text_edit_begin(value->model_rename_text, - &value->model_rename_cursor, &value->model_rename_select_all); + bongo_cat_preferences_text_session_begin(&value->model_rename, entry->id, + bongo_cat_model_name(&value->app->config, entry), bounds); SDL_StartTextInput(value->window); value->render_dirty = true; } bool bongo_cat_preferences_model_rename_event( BongoCatPreferences *value, const SDL_Event *event) { - if (!value || !event || !value->model_rename_id[0]) return false; - if (event->type == SDL_EVENT_TEXT_INPUT) { - bongo_cat_text_edit_insert(value->model_rename_text, - sizeof(value->model_rename_text), &value->model_rename_cursor, - &value->model_rename_select_all, event->text.text); - value->render_dirty = true; return true; - } - if (event->type == SDL_EVENT_WINDOW_FOCUS_LOST) { - bongo_cat_preferences_model_rename_finish(value, true); return false; - } - if (event->type == SDL_EVENT_MOUSE_BUTTON_DOWN && - event->button.button == SDL_BUTTON_LEFT) { - float scale = value->ui.layout_scale > 0.0f ? - value->ui.layout_scale : 1.0f; - float x = event->button.x / scale, y = event->button.y / scale; - if (!NK_INBOX(x, y, value->model_rename_bounds.x, - value->model_rename_bounds.y, value->model_rename_bounds.w, - value->model_rename_bounds.h)) - bongo_cat_preferences_model_rename_finish(value, true); - else { - value->model_rename_cursor = bongo_cat_text_edit_nearest( - value->model_rename_text, - x - value->model_rename_bounds.x - 5.0f, measure, - value->ui.label_font); - value->model_rename_select_all = false; - value->render_dirty = true; - return true; - } - return false; - } - if (event->type != SDL_EVENT_KEY_DOWN && - event->type != SDL_EVENT_KEY_UP) return false; - if (event->type == SDL_EVENT_KEY_UP) return true; - SDL_Keycode key = event->key.key; - bool control = (event->key.mod & SDL_KMOD_CTRL) != 0; - if (key == SDLK_ESCAPE) - bongo_cat_preferences_model_rename_finish(value, false); - else if (key == SDLK_RETURN || key == SDLK_KP_ENTER) - bongo_cat_preferences_model_rename_finish(value, true); - else if (control && key == SDLK_A) - bongo_cat_text_edit_select_all(value->model_rename_text, - &value->model_rename_cursor, &value->model_rename_select_all); - else if (control && (key == SDLK_C || key == SDLK_X || key == SDLK_V)) - clipboard_event(value, key); - else if (key == SDLK_BACKSPACE || key == SDLK_DELETE) - bongo_cat_text_edit_erase(value->model_rename_text, - &value->model_rename_cursor, &value->model_rename_select_all, - key == SDLK_DELETE); - else if (key == SDLK_LEFT || key == SDLK_RIGHT || key == SDLK_HOME || - key == SDLK_END) - bongo_cat_text_edit_move(value->model_rename_text, - &value->model_rename_cursor, &value->model_rename_select_all, - key == SDLK_LEFT ? BONGO_CAT_TEXT_EDIT_LEFT : - key == SDLK_RIGHT ? BONGO_CAT_TEXT_EDIT_RIGHT : - key == SDLK_HOME ? BONGO_CAT_TEXT_EDIT_HOME : BONGO_CAT_TEXT_EDIT_END); - value->render_dirty = true; - return true; + if (!value) return false; + BongoCatPreferencesTextSessionEvent result = + bongo_cat_preferences_text_session_event(&value->model_rename, + event, value->ui.layout_scale, value->ui.label_font, 5.0f); + if (result.finish) + bongo_cat_preferences_model_rename_finish(value, result.save); + if (result.handled) value->render_dirty = true; + return result.handled; } diff --git a/src/ui/preferences_render.c b/src/ui/preferences_render.c index 6759b789..957a75e2 100644 --- a/src/ui/preferences_render.c +++ b/src/ui/preferences_render.c @@ -4,9 +4,7 @@ #include "ui_catime.h" #include "ui_animation.h" #include "ui_paint.h" -#include "bongo_cat/file.h" #include "bongo_cat/memory_policy.h" -#include "bongo_cat/path.h" #include "bongo_cat/tray.h" #include #include @@ -225,17 +223,6 @@ static bool draw_frame(BongoCatPreferences *value, float width, float height, return close_requested; } -void bongo_cat_preferences_record_frame(BongoCatPreferences *value) { - if (!value->app->smoke_frame_series) return; - char path[BONGO_CAT_PATH_CAP]; - bongo_cat_path_join(path, sizeof(path), value->app->data_root, - "preferences-frames.csv"); - FILE *file = bongo_cat_file_open(path, "ab"); - if (!file) return; - fprintf(file, "%llu\n", (unsigned long long)SDL_GetTicksNS()); - fclose(file); -} - void bongo_cat_preferences_render(BongoCatPreferences *value) { if (!value || !value->window) return; bongo_cat_preferences_drag_tick(value); diff --git a/src/ui/preferences_scale.c b/src/ui/preferences_scale.c index a3e85814..894ab607 100644 --- a/src/ui/preferences_scale.c +++ b/src/ui/preferences_scale.c @@ -1,14 +1,9 @@ #include "preferences_state.h" +#include "preferences_fonts.h" #include "ui_catime.h" -#include "ui_font.h" -#include "ui_font_atlas.h" -#include "preferences_model_glyphs.h" -#include "ui_paint.h" -#include "bongo_cat/i18n.h" #include "bongo_cat/memory_policy.h" #include #include -#include #define PREF_WIDTH 900.0f #define PREF_HEIGHT 680.0f @@ -16,22 +11,6 @@ #define PREF_MIN_HEIGHT 560.0f #define PREF_SCREEN_MARGIN 48.0f -typedef struct PreferenceFonts { - char body_path[BONGO_CAT_PATH_CAP]; - char body_fallback_path[BONGO_CAT_PATH_CAP]; - char body_korean_fallback_path[BONGO_CAT_PATH_CAP]; - char heading_path[BONGO_CAT_PATH_CAP]; - char heading_fallback_path[BONGO_CAT_PATH_CAP]; - char heading_korean_fallback_path[BONGO_CAT_PATH_CAP]; - const char *body; - const char *body_fallback; - const char *body_korean_fallback; - const char *heading; - const char *heading_fallback; - const char *heading_korean_fallback; - const nk_rune *ranges; -} PreferenceFonts; - static SDL_DisplayID window_display(SDL_Window *window) { SDL_DisplayID display = window ? SDL_GetDisplayForWindow(window) : 0; return display ? display : SDL_GetPrimaryDisplay(); @@ -73,33 +52,6 @@ static void fit_position(SDL_Window *window, SDL_DisplayID display, SDL_SetWindowPosition(window, next_x, next_y); } -static void font_paths(BongoCatPreferences *value, PreferenceFonts *fonts) { - memset(fonts, 0, sizeof(*fonts)); - fonts->body = bongo_cat_ui_system_font(fonts->body_path, - sizeof(fonts->body_path), false); - fonts->body_fallback = bongo_cat_ui_system_font(fonts->body_fallback_path, - sizeof(fonts->body_fallback_path), true); - fonts->body_korean_fallback = bongo_cat_ui_system_korean_font( - fonts->body_korean_fallback_path, - sizeof(fonts->body_korean_fallback_path)); - fonts->heading = bongo_cat_ui_system_heading_font(fonts->heading_path, - sizeof(fonts->heading_path), false); - fonts->heading_fallback = bongo_cat_ui_system_heading_font( - fonts->heading_fallback_path, - sizeof(fonts->heading_fallback_path), true); - fonts->heading_korean_fallback = bongo_cat_ui_system_korean_heading_font( - fonts->heading_korean_fallback_path, - sizeof(fonts->heading_korean_fallback_path)); - if (!fonts->heading) fonts->heading = fonts->body; - if (!fonts->heading_fallback) fonts->heading_fallback = fonts->body_fallback; - if (!value->app->i18n) return; - bongo_cat_i18n_glyph_ranges(value->app->i18n, value->glyph_ranges, - sizeof(value->glyph_ranges) / sizeof(value->glyph_ranges[0])); - bongo_cat_preferences_model_glyphs(value->app, value->glyph_ranges, - sizeof(value->glyph_ranges) / sizeof(value->glyph_ranges[0])); - fonts->ranges = value->glyph_ranges; -} - static SDL_HitTestResult SDLCALL preference_hit_test(SDL_Window *window, const SDL_Point *point, void *data) { BongoCatPreferences *value = data; @@ -201,8 +153,8 @@ bool bongo_cat_preferences_open_window(BongoCatPreferences *value) { context_ready = attach_gl_context(value); } if (!context_ready) return false; - PreferenceFonts fonts; - font_paths(value, &fonts); + BongoCatPreferenceFonts fonts; + bongo_cat_preferences_fonts_resolve(value, &fonts); BongoCatError error = {0}; if (!bongo_cat_ui_init(&value->ui, value->window, fonts.body, fonts.body_fallback, fonts.body_korean_fallback, fonts.heading, @@ -233,48 +185,6 @@ bool bongo_cat_preferences_open_window(BongoCatPreferences *value) { return true; } -bool bongo_cat_preferences_reload_fonts(BongoCatPreferences *value) { - if (!value || !value->ui_initialized) return false; - if (value->ui.frame_building) { - value->font_reload_pending = true; - value->render_dirty = true; - return true; - } - PreferenceFonts fonts; - font_paths(value, &fonts); - bool reloaded = bongo_cat_ui_font_atlas_reload(&value->ui, fonts.body, - fonts.body_fallback, fonts.body_korean_fallback, fonts.heading, - fonts.heading_fallback, fonts.heading_korean_fallback, fonts.ranges, - value->ui.raster_scale); - if (reloaded) value->font_reload_pending = false; - return reloaded; -} - -bool bongo_cat_preferences_refresh_raster(BongoCatPreferences *value) { - if (!value || value->pending_raster_scale <= 0.0f) return true; - uint64_t now = SDL_GetTicksNS(); - if (value->raster_retry_ns > now) return false; - float raster_scale = value->pending_raster_scale; - PreferenceFonts fonts; - font_paths(value, &fonts); - if (!bongo_cat_ui_font_atlas_reload(&value->ui, fonts.body, - fonts.body_fallback, fonts.body_korean_fallback, fonts.heading, - fonts.heading_fallback, fonts.heading_korean_fallback, fonts.ranges, - raster_scale)) { - value->raster_retry_ns = now + 1000000000ull; - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "Preferences font atlas could not be rebuilt for %.2fx", raster_scale); - return false; - } - value->pending_raster_scale = 0.0f; - value->raster_retry_ns = 0; - bongo_cat_ui_paint_destroy(&value->ui); - bongo_cat_preferences_model_cover_cache_clear(value->app); - bongo_cat_preferences_assets_clear(value); - bongo_cat_preferences_assets_load(value); - return true; -} - bool bongo_cat_preferences_scale_event(BongoCatPreferences *value, const SDL_Event *event) { if (!value || !value->window || !event || diff --git a/src/ui/preferences_smoke.c b/src/ui/preferences_smoke.c index 7271cf24..250105d9 100644 --- a/src/ui/preferences_smoke.c +++ b/src/ui/preferences_smoke.c @@ -52,6 +52,17 @@ static void write_window_handle(BongoCatPreferences *value) { fclose(file); } +void bongo_cat_preferences_record_frame(BongoCatPreferences *value) { + if (!value->app->smoke_frame_series) return; + char path[BONGO_CAT_PATH_CAP]; + bongo_cat_path_join(path, sizeof(path), value->app->data_root, + "preferences-frames.csv"); + FILE *file = bongo_cat_file_open(path, "ab"); + if (!file) return; + fprintf(file, "%llu\n", (unsigned long long)SDL_GetTicksNS()); + fclose(file); +} + void bongo_cat_preferences_smoke_frame(BongoCatPreferences *value) { if (!value) return; write_window_handle(value); diff --git a/src/ui/preferences_state.h b/src/ui/preferences_state.h index e5b35ab3..872b465d 100644 --- a/src/ui/preferences_state.h +++ b/src/ui/preferences_state.h @@ -3,6 +3,7 @@ #include "preferences_internal.h" #include "preferences_scrollbar.h" +#include "preferences_text_session.h" #include "ui_backend.h" #include "bongo_cat/i18n.h" #include "bongo_cat/preferences.h" @@ -63,16 +64,8 @@ struct BongoCatPreferences { uint64_t behavior_tab_transition_ns; float behavior_scroll[2]; BongoCatPreferencesScrollbar behavior_scrollbar; - char behavior_rename_id[BONGO_CAT_PATH_CAP]; - char behavior_rename_text[BONGO_CAT_ID_CAP]; - struct nk_rect behavior_rename_bounds; - size_t behavior_rename_cursor; - bool behavior_rename_select_all; - char model_rename_id[BONGO_CAT_ID_CAP]; - char model_rename_text[BONGO_CAT_ID_CAP]; - struct nk_rect model_rename_bounds; - size_t model_rename_cursor; - bool model_rename_select_all; + BongoCatPreferencesTextSession behavior_rename; + BongoCatPreferencesTextSession model_rename; bool native_drag; bool chrome_dragging; bool live_resize_active; diff --git a/src/ui/preferences_text_edit.c b/src/ui/preferences_text_edit.c index dff2df9b..f482c417 100644 --- a/src/ui/preferences_text_edit.c +++ b/src/ui/preferences_text_edit.c @@ -126,6 +126,16 @@ void bongo_cat_text_edit_clear(char *text, size_t *cursor, bool *select_all) { if (select_all) *select_all = false; } +void bongo_cat_text_edit_trim(char *text) { + if (!text) return; + size_t start = 0, end = strlen(text); + while (start < end && (text[start] == ' ' || text[start] == '\t')) start++; + while (end > start && (text[end - 1] == ' ' || text[end - 1] == '\t')) + end--; + if (start) memmove(text, text + start, end - start); + text[end - start] = '\0'; +} + size_t bongo_cat_text_edit_nearest(const char *text, float target, float (*measure)(const void *userdata, const char *text, size_t length), const void *userdata) { diff --git a/src/ui/preferences_text_edit.h b/src/ui/preferences_text_edit.h index f46efd8f..eeb35cf2 100644 --- a/src/ui/preferences_text_edit.h +++ b/src/ui/preferences_text_edit.h @@ -22,6 +22,7 @@ bool bongo_cat_text_edit_move(const char *text, size_t *cursor, void bongo_cat_text_edit_select_all(const char *text, size_t *cursor, bool *select_all); void bongo_cat_text_edit_clear(char *text, size_t *cursor, bool *select_all); +void bongo_cat_text_edit_trim(char *text); size_t bongo_cat_text_edit_nearest(const char *text, float target, float (*measure)(const void *userdata, const char *text, size_t length), const void *userdata); diff --git a/src/ui/preferences_text_session.c b/src/ui/preferences_text_session.c new file mode 100644 index 00000000..a4f4bcd0 --- /dev/null +++ b/src/ui/preferences_text_session.c @@ -0,0 +1,112 @@ +#include "preferences_text_session.h" + +#include "preferences_text_edit.h" + +#include +#include +#include + +static float measure(const void *userdata, const char *text, size_t length) { + const struct nk_user_font *font = userdata; + return font->width(font->userdata, font->height, text, (int)length); +} + +static void clipboard_event(BongoCatPreferencesTextSession *session, + SDL_Keycode key) { + if (key == SDLK_V) { + char *clipboard = SDL_GetClipboardText(); + bongo_cat_text_edit_insert(session->text, sizeof(session->text), + &session->cursor, &session->select_all, clipboard); + SDL_free(clipboard); + return; + } + if (session->select_all) SDL_SetClipboardText(session->text); + if (key == SDLK_X && session->select_all) + bongo_cat_text_edit_clear(session->text, &session->cursor, + &session->select_all); +} + +bool bongo_cat_preferences_text_session_active( + const BongoCatPreferencesTextSession *session) { + return session && session->id[0]; +} + +void bongo_cat_preferences_text_session_begin( + BongoCatPreferencesTextSession *session, const char *id, + const char *text, struct nk_rect bounds) { + if (!session) return; + snprintf(session->id, sizeof(session->id), "%s", id ? id : ""); + snprintf(session->text, sizeof(session->text), "%s", text ? text : ""); + session->bounds = bounds; + bongo_cat_text_edit_begin(session->text, &session->cursor, + &session->select_all); +} + +void bongo_cat_preferences_text_session_reset( + BongoCatPreferencesTextSession *session) { + if (session) memset(session, 0, sizeof(*session)); +} + +BongoCatPreferencesTextSessionEvent bongo_cat_preferences_text_session_event( + BongoCatPreferencesTextSession *session, const SDL_Event *event, + float layout_scale, const struct nk_user_font *font, float text_inset) { + BongoCatPreferencesTextSessionEvent result = {0}; + if (!bongo_cat_preferences_text_session_active(session) || !event) + return result; + if (event->type == SDL_EVENT_TEXT_INPUT) { + bongo_cat_text_edit_insert(session->text, sizeof(session->text), + &session->cursor, &session->select_all, event->text.text); + result.handled = true; + return result; + } + if (event->type == SDL_EVENT_WINDOW_FOCUS_LOST) { + result.finish = result.save = true; + return result; + } + if (event->type == SDL_EVENT_MOUSE_BUTTON_DOWN && + event->button.button == SDL_BUTTON_LEFT) { + float scale = layout_scale > 0.0f ? layout_scale : 1.0f; + float x = event->button.x / scale, y = event->button.y / scale; + if (!NK_INBOX(x, y, session->bounds.x, session->bounds.y, + session->bounds.w, session->bounds.h)) { + result.finish = result.save = true; + return result; + } + if (font) session->cursor = bongo_cat_text_edit_nearest(session->text, + x - session->bounds.x - text_inset, measure, font); + session->select_all = false; + result.handled = true; + return result; + } + if (event->type != SDL_EVENT_KEY_DOWN && + event->type != SDL_EVENT_KEY_UP) return result; + result.handled = true; + if (event->type == SDL_EVENT_KEY_UP) return result; + SDL_Keycode key = event->key.key; + bool control = (event->key.mod & SDL_KMOD_CTRL) != 0; + if (key == SDLK_ESCAPE) { + result.finish = true; + return result; + } + if (key == SDLK_RETURN || key == SDLK_KP_ENTER) { + result.finish = result.save = true; + return result; + } + if (control && key == SDLK_A) + bongo_cat_text_edit_select_all(session->text, &session->cursor, + &session->select_all); + else if (control && (key == SDLK_C || key == SDLK_X || key == SDLK_V)) + clipboard_event(session, key); + else if (key == SDLK_BACKSPACE || key == SDLK_DELETE) + bongo_cat_text_edit_erase(session->text, &session->cursor, + &session->select_all, key == SDLK_DELETE); + else if (key == SDLK_LEFT || key == SDLK_RIGHT || key == SDLK_HOME || + key == SDLK_END) + bongo_cat_text_edit_move(session->text, &session->cursor, + &session->select_all, + key == SDLK_LEFT ? BONGO_CAT_TEXT_EDIT_LEFT : + key == SDLK_RIGHT ? BONGO_CAT_TEXT_EDIT_RIGHT : + key == SDLK_HOME ? BONGO_CAT_TEXT_EDIT_HOME : + BONGO_CAT_TEXT_EDIT_END); + return result; +} diff --git a/src/ui/preferences_text_session.h b/src/ui/preferences_text_session.h new file mode 100644 index 00000000..f8a2fa8c --- /dev/null +++ b/src/ui/preferences_text_session.h @@ -0,0 +1,35 @@ +#ifndef BONGO_CAT_PREFERENCES_TEXT_SESSION_H +#define BONGO_CAT_PREFERENCES_TEXT_SESSION_H + +#include "nuklear_config.h" +#include "bongo_cat/common.h" + +#include +#include + +typedef struct BongoCatPreferencesTextSession { + char id[BONGO_CAT_BEHAVIOR_ID_CAP]; + char text[BONGO_CAT_ID_CAP]; + struct nk_rect bounds; + size_t cursor; + bool select_all; +} BongoCatPreferencesTextSession; + +typedef struct BongoCatPreferencesTextSessionEvent { + bool handled; + bool finish; + bool save; +} BongoCatPreferencesTextSessionEvent; + +bool bongo_cat_preferences_text_session_active( + const BongoCatPreferencesTextSession *session); +void bongo_cat_preferences_text_session_begin( + BongoCatPreferencesTextSession *session, const char *id, + const char *text, struct nk_rect bounds); +void bongo_cat_preferences_text_session_reset( + BongoCatPreferencesTextSession *session); +BongoCatPreferencesTextSessionEvent bongo_cat_preferences_text_session_event( + BongoCatPreferencesTextSession *session, const SDL_Event *event, + float layout_scale, const struct nk_user_font *font, float text_inset); + +#endif diff --git a/tests/test_mver_import.c b/tests/test_mver_import.c index 1b1f1ceb..b958ffbf 100644 --- a/tests/test_mver_import.c +++ b/tests/test_mver_import.c @@ -2,10 +2,9 @@ #include "model_storage.h" #include "preferences_model_glyphs.h" #include "preferences_state.h" -#include "preferences_text_edit.h" #include "ui_font_atlas.h" #include "runtime.h" -#include "bongo_cat/file.h" +#include "test_mver_support.h" #include "bongo_cat/path.h" #include @@ -23,45 +22,8 @@ static int failures; const char *test_mver_pointer_config(bool live2d); bool test_mver_pointer_fixture_assets(const char *standard, const char *source); bool test_mver_pointer_adapter(const char *adapter, bool expected_enabled); - -static float fixed_width(const void *userdata, const char *text, size_t length) { - (void)userdata; (void)text; return (float)length; -} - -static void rename_text_edit(void) { - char text[32] = "A\xE4\xB8\xAD" "B"; - size_t cursor = 0; - bool selected = false; - bongo_cat_text_edit_begin(text, &cursor, &selected); - CHECK(cursor == 5 && !selected); - CHECK(bongo_cat_text_edit_move(text, &cursor, &selected, - BONGO_CAT_TEXT_EDIT_LEFT)); - CHECK(cursor == 4); - CHECK(bongo_cat_text_edit_insert(text, sizeof(text), &cursor, &selected, - "\xE6\x96\xB0")); - CHECK(strcmp(text, "A\xE4\xB8\xAD\xE6\x96\xB0" "B") == 0 && - cursor == 7); - CHECK(bongo_cat_text_edit_erase(text, &cursor, &selected, false)); - CHECK(strcmp(text, "A\xE4\xB8\xAD" "B") == 0 && cursor == 4); - CHECK(bongo_cat_text_edit_move(text, &cursor, &selected, - BONGO_CAT_TEXT_EDIT_HOME)); - CHECK(bongo_cat_text_edit_erase(text, &cursor, &selected, true)); - CHECK(strcmp(text, "\xE4\xB8\xAD" "B") == 0 && cursor == 0); - CHECK(bongo_cat_text_edit_move(text, &cursor, &selected, - BONGO_CAT_TEXT_EDIT_RIGHT)); - CHECK(cursor == 3); - CHECK(bongo_cat_text_edit_nearest(text, 1.4f, fixed_width, NULL) == 0); - CHECK(bongo_cat_text_edit_nearest(text, 1.6f, fixed_width, NULL) == 3); - bongo_cat_text_edit_select_all(text, &cursor, &selected); - CHECK(selected && cursor == strlen(text)); - CHECK(bongo_cat_text_edit_insert(text, sizeof(text), &cursor, &selected, - "\xE6\x9B\xBF\xE6\x8D\xA2")); - CHECK(strcmp(text, "\xE6\x9B\xBF\xE6\x8D\xA2") == 0 && - cursor == 6 && !selected); - bongo_cat_text_edit_select_all(text, &cursor, &selected); - CHECK(bongo_cat_text_edit_erase(text, &cursor, &selected, false)); - CHECK(text[0] == '\0' && cursor == 0 && !selected); -} +int test_preferences_text(void); +int test_mver_portable_identity(void); static bool chord(const char *json, bool gamepad, const char *expected) { yyjson_doc *document = yyjson_read(json, strlen(json), 0); @@ -75,19 +37,6 @@ static bool chord(const char *json, bool gamepad, const char *expected) { return expected ? matches : !ok; } -static bool write_text(const char *path, const char *text) { - FILE *file = bongo_cat_file_open(path, "wb"); - bool ok = file && fwrite(text, 1, strlen(text), file) == strlen(text); - if (file && fclose(file) != 0) ok = false; - return ok; -} - -static bool child(char *output, size_t capacity, const char *root, - const char *name, bool directory) { - return bongo_cat_path_join(output, capacity, root, name) && - (!directory || SDL_CreateDirectory(output)); -} - static void labels_from_shortcut_rows(void) { static const char default_expression[] = "\xE9\xBB\x98\xE8\xAE\xA4\xE8\xA1\xA8\xE6\x83\x85"; @@ -214,103 +163,6 @@ static void font_reload_defers_during_frame(void) { NULL, NULL, NULL, NULL, NULL, 1.0f)); } -static bool portable_fixture(const char *root) { - char image[BONGO_CAT_PATH_CAP], standard[BONGO_CAT_PATH_CAP]; - char hand[BONGO_CAT_PATH_CAP], model[BONGO_CAT_PATH_CAP]; - char path[BONGO_CAT_PATH_CAP], source[BONGO_CAT_PATH_CAP]; - if (!SDL_CreateDirectory(root) || !child(image, sizeof(image), root, "img", true) || - !child(standard, sizeof(standard), image, "standard", true) || - !child(hand, sizeof(hand), standard, "hand", true) || - !child(model, sizeof(model), standard, "cat_model", true)) return false; - if (!child(path, sizeof(path), root, "config.json", false) || - !write_text(path, test_mver_pointer_config(true))) - return false; - if (!child(path, sizeof(path), model, "cat.model3.json", false) || - !write_text(path, "{\"Version\":3,\"FileReferences\":{\"Moc\":\"cat.moc3\"," - "\"Textures\":[\"texture.png\"]}}") || - !child(path, sizeof(path), model, "cat.moc3", false) || - !write_text(path, "MOC3")) return false; - snprintf(source, sizeof(source), "%s/resources/assets/tray.png", - BONGO_CAT_NATIVE_SOURCE_DIR); - if (!child(path, sizeof(path), model, "texture.png", false) || - !SDL_CopyFile(source, path) || !child(path, sizeof(path), hand, "0.png", false) || - !SDL_CopyFile(source, path) || !child(path, sizeof(path), standard, "bg.png", false) || - !SDL_CopyFile(source, path)) return false; - return test_mver_pointer_fixture_assets(standard, source); -} - -static void portable_identity_migration(void) { - char *temporary = SDL_GetCurrentDirectory(); - CHECK(temporary != NULL); - if (!temporary) return; - char root[BONGO_CAT_PATH_CAP], data[BONGO_CAT_PATH_CAP]; - char source[BONGO_CAT_PATH_CAP], moved[BONGO_CAT_PATH_CAP]; - char again[BONGO_CAT_PATH_CAP], duplicate[BONGO_CAT_PATH_CAP]; - unsigned long long nonce = (unsigned long long)SDL_GetTicksNS(); - snprintf(root, sizeof(root), "%s/bongo-cat-portable-move-%llu", - temporary, nonce); - snprintf(data, sizeof(data), "%s/bongo-cat-portable-data-%llu", - temporary, nonce); - CHECK(SDL_CreateDirectory(root)); - CHECK(SDL_CreateDirectory(data)); - CHECK(child(source, sizeof(source), root, "source", false)); - CHECK(child(moved, sizeof(moved), root, "moved", false)); - CHECK(child(again, sizeof(again), root, "again", false)); - CHECK(child(duplicate, sizeof(duplicate), root, "duplicate", false)); - CHECK(portable_fixture(source)); - BongoCatApp *app = calloc(1, sizeof(*app)); - CHECK(app != NULL); - if (!app) goto cleanup; - bongo_cat_config_defaults(&app->config); - bongo_cat_models_init(&app->models); - snprintf(app->data_root, sizeof(app->data_root), "%s", data); - BongoCatError error = {0}; - CHECK(bongo_cat_import_portable_mver(app, source, &error) == BONGO_CAT_OK); - CHECK(app->models.count == 1); - char old_id[BONGO_CAT_ID_CAP]; - snprintf(old_id, sizeof(old_id), "%s", app->models.entries[0].id); - CHECK(bongo_cat_config_set_model_label(&app->config, old_id, - "Portable custom name")); - BongoCatBehaviorShortcut *binding = - &app->config.behavior_shortcuts[app->config.behavior_shortcut_count++]; - snprintf(binding->id, sizeof(binding->id), "%s:expression:2", old_id); - snprintf(binding->shortcut, sizeof(binding->shortcut), "Alt+3"); - snprintf(binding->label, sizeof(binding->label), "Custom expression"); - CHECK(bongo_cat_path_rename(source, moved)); - bongo_cat_models_init(&app->models); - CHECK(bongo_cat_import_portable_mver(app, moved, &error) == BONGO_CAT_OK); - CHECK(app->models.count == 1); - const char *new_id = app->models.entries[0].id; - CHECK(strcmp(old_id, new_id) != 0); - CHECK(strcmp(app->config.current_model, new_id) == 0); - CHECK(strcmp(bongo_cat_config_model_label(&app->config, new_id), - "Portable custom name") == 0); - CHECK(bongo_cat_config_model_label(&app->config, old_id) == NULL); - CHECK(app->config.behavior_shortcut_count == 1); - CHECK(strncmp(app->config.behavior_shortcuts[0].id, new_id, - strlen(new_id)) == 0); - CHECK(strcmp(app->config.behavior_shortcuts[0].shortcut, "Alt+3") == 0); - CHECK(strcmp(app->config.behavior_shortcuts[0].label, - "Custom expression") == 0); - char ambiguous_id[BONGO_CAT_ID_CAP]; - snprintf(ambiguous_id, sizeof(ambiguous_id), "%s", new_id); - CHECK(bongo_cat_config_set_model_label(&app->config, ambiguous_id, - "Must remain here")); - CHECK(bongo_cat_path_rename(moved, again)); - CHECK(portable_fixture(duplicate)); - bongo_cat_models_init(&app->models); - CHECK(bongo_cat_import_portable_mver(app, root, &error) == BONGO_CAT_OK); - CHECK(app->models.count == 2); - CHECK(strcmp(app->config.current_model, ambiguous_id) == 0); - CHECK(strcmp(bongo_cat_config_model_label(&app->config, ambiguous_id), - "Must remain here") == 0); - free(app); -cleanup: - CHECK(bongo_cat_model_remove_tree(root, NULL)); - CHECK(bongo_cat_model_remove_tree(data, NULL)); - SDL_free(temporary); -} - static void portable_model(void) { char root[BONGO_CAT_PATH_CAP], package[BONGO_CAT_PATH_CAP]; char data[BONGO_CAT_PATH_CAP], mode[BONGO_CAT_PATH_CAP]; @@ -424,7 +276,7 @@ static void portable_model(void) { } int main(void) { - rename_text_edit(); + failures += test_preferences_text(); CHECK(chord("[17,65]", true, "Control+A")); CHECK(chord("[0]", true, "Gamepad:South")); CHECK(chord("[15]", true, "Gamepad:Select")); @@ -442,7 +294,7 @@ int main(void) { metadata_backfills_labels(); behavior_labels_add_font_glyphs(); font_reload_defers_during_frame(); - portable_identity_migration(); + failures += test_mver_portable_identity(); portable_model(); return failures ? 1 : 0; } diff --git a/tests/test_mver_portable_identity.c b/tests/test_mver_portable_identity.c new file mode 100644 index 00000000..b364d6f2 --- /dev/null +++ b/tests/test_mver_portable_identity.c @@ -0,0 +1,91 @@ +#include "test_mver_support.h" + +#include "model_import.h" +#include "model_storage.h" +#include "runtime.h" +#include "bongo_cat/path.h" + +#include +#include +#include +#include + +static int failures; +#define CHECK(value) do { if (!(value)) { \ + fprintf(stderr, "%s:%d: check failed: %s\n", __FILE__, __LINE__, #value); \ + failures++; \ +} } while (0) + +int test_mver_portable_identity(void) { + failures = 0; + char *temporary = SDL_GetCurrentDirectory(); + CHECK(temporary != NULL); + if (!temporary) return failures; + char root[BONGO_CAT_PATH_CAP], data[BONGO_CAT_PATH_CAP]; + char source[BONGO_CAT_PATH_CAP], moved[BONGO_CAT_PATH_CAP]; + char again[BONGO_CAT_PATH_CAP], duplicate[BONGO_CAT_PATH_CAP]; + unsigned long long nonce = (unsigned long long)SDL_GetTicksNS(); + snprintf(root, sizeof(root), "%s/bongo-cat-portable-move-%llu", + temporary, nonce); + snprintf(data, sizeof(data), "%s/bongo-cat-portable-data-%llu", + temporary, nonce); + CHECK(SDL_CreateDirectory(root)); + CHECK(SDL_CreateDirectory(data)); + CHECK(child(source, sizeof(source), root, "source", false)); + CHECK(child(moved, sizeof(moved), root, "moved", false)); + CHECK(child(again, sizeof(again), root, "again", false)); + CHECK(child(duplicate, sizeof(duplicate), root, "duplicate", false)); + CHECK(portable_fixture(source)); + BongoCatApp *app = calloc(1, sizeof(*app)); + CHECK(app != NULL); + if (!app) goto cleanup; + bongo_cat_config_defaults(&app->config); + bongo_cat_models_init(&app->models); + snprintf(app->data_root, sizeof(app->data_root), "%s", data); + BongoCatError error = {0}; + CHECK(bongo_cat_import_portable_mver(app, source, &error) == BONGO_CAT_OK); + CHECK(app->models.count == 1); + char old_id[BONGO_CAT_ID_CAP]; + snprintf(old_id, sizeof(old_id), "%s", app->models.entries[0].id); + CHECK(bongo_cat_config_set_model_label(&app->config, old_id, + "Portable custom name")); + BongoCatBehaviorShortcut *binding = + &app->config.behavior_shortcuts[app->config.behavior_shortcut_count++]; + snprintf(binding->id, sizeof(binding->id), "%s:expression:2", old_id); + snprintf(binding->shortcut, sizeof(binding->shortcut), "Alt+3"); + snprintf(binding->label, sizeof(binding->label), "Custom expression"); + CHECK(bongo_cat_path_rename(source, moved)); + bongo_cat_models_init(&app->models); + CHECK(bongo_cat_import_portable_mver(app, moved, &error) == BONGO_CAT_OK); + CHECK(app->models.count == 1); + const char *new_id = app->models.entries[0].id; + CHECK(strcmp(old_id, new_id) != 0); + CHECK(strcmp(app->config.current_model, new_id) == 0); + CHECK(strcmp(bongo_cat_config_model_label(&app->config, new_id), + "Portable custom name") == 0); + CHECK(bongo_cat_config_model_label(&app->config, old_id) == NULL); + CHECK(app->config.behavior_shortcut_count == 1); + CHECK(strncmp(app->config.behavior_shortcuts[0].id, new_id, + strlen(new_id)) == 0); + CHECK(strcmp(app->config.behavior_shortcuts[0].shortcut, "Alt+3") == 0); + CHECK(strcmp(app->config.behavior_shortcuts[0].label, + "Custom expression") == 0); + char ambiguous_id[BONGO_CAT_ID_CAP]; + snprintf(ambiguous_id, sizeof(ambiguous_id), "%s", new_id); + CHECK(bongo_cat_config_set_model_label(&app->config, ambiguous_id, + "Must remain here")); + CHECK(bongo_cat_path_rename(moved, again)); + CHECK(portable_fixture(duplicate)); + bongo_cat_models_init(&app->models); + CHECK(bongo_cat_import_portable_mver(app, root, &error) == BONGO_CAT_OK); + CHECK(app->models.count == 2); + CHECK(strcmp(app->config.current_model, ambiguous_id) == 0); + CHECK(strcmp(bongo_cat_config_model_label(&app->config, ambiguous_id), + "Must remain here") == 0); + free(app); +cleanup: + CHECK(bongo_cat_model_remove_tree(root, NULL)); + CHECK(bongo_cat_model_remove_tree(data, NULL)); + SDL_free(temporary); + return failures; +} diff --git a/tests/test_mver_support.c b/tests/test_mver_support.c new file mode 100644 index 00000000..7073bf89 --- /dev/null +++ b/tests/test_mver_support.c @@ -0,0 +1,54 @@ +#include "test_mver_support.h" + +#include "bongo_cat/common.h" +#include "bongo_cat/file.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +const char *test_mver_pointer_config(bool live2d); +bool test_mver_pointer_fixture_assets(const char *standard, + const char *source); + +bool write_text(const char *path, const char *text) { + FILE *file = bongo_cat_file_open(path, "wb"); + bool ok = file && fwrite(text, 1, strlen(text), file) == strlen(text); + if (file && fclose(file) != 0) ok = false; + return ok; +} + +bool child(char *output, size_t capacity, const char *root, + const char *name, bool directory) { + return bongo_cat_path_join(output, capacity, root, name) && + (!directory || SDL_CreateDirectory(output)); +} + +bool portable_fixture(const char *root) { + char image[BONGO_CAT_PATH_CAP], standard[BONGO_CAT_PATH_CAP]; + char hand[BONGO_CAT_PATH_CAP], model[BONGO_CAT_PATH_CAP]; + char path[BONGO_CAT_PATH_CAP], source[BONGO_CAT_PATH_CAP]; + if (!SDL_CreateDirectory(root) || + !child(image, sizeof(image), root, "img", true) || + !child(standard, sizeof(standard), image, "standard", true) || + !child(hand, sizeof(hand), standard, "hand", true) || + !child(model, sizeof(model), standard, "cat_model", true)) + return false; + if (!child(path, sizeof(path), root, "config.json", false) || + !write_text(path, test_mver_pointer_config(true))) return false; + if (!child(path, sizeof(path), model, "cat.model3.json", false) || + !write_text(path, "{\"Version\":3,\"FileReferences\":" + "{\"Moc\":\"cat.moc3\",\"Textures\":[\"texture.png\"]}}") || + !child(path, sizeof(path), model, "cat.moc3", false) || + !write_text(path, "MOC3")) return false; + snprintf(source, sizeof(source), "%s/resources/assets/tray.png", + BONGO_CAT_NATIVE_SOURCE_DIR); + if (!child(path, sizeof(path), model, "texture.png", false) || + !SDL_CopyFile(source, path) || + !child(path, sizeof(path), hand, "0.png", false) || + !SDL_CopyFile(source, path) || + !child(path, sizeof(path), standard, "bg.png", false) || + !SDL_CopyFile(source, path)) return false; + return test_mver_pointer_fixture_assets(standard, source); +} diff --git a/tests/test_mver_support.h b/tests/test_mver_support.h new file mode 100644 index 00000000..899d47e2 --- /dev/null +++ b/tests/test_mver_support.h @@ -0,0 +1,12 @@ +#ifndef BONGO_CAT_TEST_MVER_SUPPORT_H +#define BONGO_CAT_TEST_MVER_SUPPORT_H + +#include +#include + +bool write_text(const char *path, const char *text); +bool child(char *output, size_t capacity, const char *root, + const char *name, bool directory); +bool portable_fixture(const char *root); + +#endif diff --git a/tests/test_preferences_text.c b/tests/test_preferences_text.c new file mode 100644 index 00000000..7528e968 --- /dev/null +++ b/tests/test_preferences_text.c @@ -0,0 +1,90 @@ +#include "preferences_text_edit.h" +#include "preferences_text_session.h" + +#include +#include +#include + +static int failures; +#define CHECK(value) do { if (!(value)) { \ + fprintf(stderr, "%s:%d: check failed: %s\n", __FILE__, __LINE__, #value); \ + failures++; \ +} } while (0) + +static float fixed_width(const void *userdata, const char *text, size_t length) { + (void)userdata; + (void)text; + return (float)length; +} + +static void text_edit(void) { + char text[32] = "A\xE4\xB8\xAD" "B"; + size_t cursor = 0; + bool selected = false; + bongo_cat_text_edit_begin(text, &cursor, &selected); + CHECK(cursor == 5 && !selected); + CHECK(bongo_cat_text_edit_move(text, &cursor, &selected, + BONGO_CAT_TEXT_EDIT_LEFT)); + CHECK(cursor == 4); + CHECK(bongo_cat_text_edit_insert(text, sizeof(text), &cursor, &selected, + "\xE6\x96\xB0")); + CHECK(strcmp(text, "A\xE4\xB8\xAD\xE6\x96\xB0" "B") == 0 && + cursor == 7); + CHECK(bongo_cat_text_edit_erase(text, &cursor, &selected, false)); + CHECK(strcmp(text, "A\xE4\xB8\xAD" "B") == 0 && cursor == 4); + CHECK(bongo_cat_text_edit_move(text, &cursor, &selected, + BONGO_CAT_TEXT_EDIT_HOME)); + CHECK(bongo_cat_text_edit_erase(text, &cursor, &selected, true)); + CHECK(strcmp(text, "\xE4\xB8\xAD" "B") == 0 && cursor == 0); + CHECK(bongo_cat_text_edit_move(text, &cursor, &selected, + BONGO_CAT_TEXT_EDIT_RIGHT)); + CHECK(cursor == 3); + CHECK(bongo_cat_text_edit_nearest(text, 1.4f, fixed_width, NULL) == 0); + CHECK(bongo_cat_text_edit_nearest(text, 1.6f, fixed_width, NULL) == 3); + bongo_cat_text_edit_select_all(text, &cursor, &selected); + CHECK(bongo_cat_text_edit_insert(text, sizeof(text), &cursor, &selected, + "\xE6\x9B\xBF\xE6\x8D\xA2")); + CHECK(strcmp(text, "\xE6\x9B\xBF\xE6\x8D\xA2") == 0 && + cursor == 6 && !selected); + bongo_cat_text_edit_select_all(text, &cursor, &selected); + CHECK(bongo_cat_text_edit_erase(text, &cursor, &selected, false)); + CHECK(text[0] == '\0' && cursor == 0 && !selected); + snprintf(text, sizeof(text), " label\t "); + bongo_cat_text_edit_trim(text); + CHECK(strcmp(text, "label") == 0); +} + +static void text_session(void) { + BongoCatPreferencesTextSession session = {0}; + bongo_cat_preferences_text_session_begin(&session, "model", "Name", + nk_rect(10, 20, 100, 30)); + CHECK(bongo_cat_preferences_text_session_active(&session)); + SDL_Event event = {.type = SDL_EVENT_KEY_DOWN}; + event.key.key = SDLK_A; + event.key.mod = SDL_KMOD_CTRL; + BongoCatPreferencesTextSessionEvent result = + bongo_cat_preferences_text_session_event(&session, &event, + 1.0f, NULL, 5.0f); + CHECK(result.handled && !result.finish && session.select_all); + event.key.key = SDLK_DELETE; + event.key.mod = SDL_KMOD_NONE; + result = bongo_cat_preferences_text_session_event(&session, &event, + 1.0f, NULL, 5.0f); + CHECK(result.handled && session.text[0] == '\0' && !session.select_all); + event.type = SDL_EVENT_MOUSE_BUTTON_DOWN; + event.button.button = SDL_BUTTON_LEFT; + event.button.x = 200; + event.button.y = 200; + result = bongo_cat_preferences_text_session_event(&session, &event, + 1.0f, NULL, 5.0f); + CHECK(!result.handled && result.finish && result.save); + bongo_cat_preferences_text_session_reset(&session); + CHECK(!bongo_cat_preferences_text_session_active(&session)); +} + +int test_preferences_text(void) { + failures = 0; + text_edit(); + text_session(); + return failures; +} From 2d0bb9646b91a19104053c716acc21daa2e6acf8 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 14 Aug 2026 17:11:49 +0800 Subject: [PATCH 008/334] Improve capture diagnostics and OBS background fallback --- CMakeLists.txt | 5 +- cmake/RuntimeSources.cmake | 3 +- include/bongo_cat/config.h | 13 +++ include/bongo_cat/platform.h | 3 +- resources/assets/locales/de-DE.json | 2 + resources/assets/locales/en-US.json | 2 + resources/assets/locales/es-ES.json | 2 + resources/assets/locales/fr-FR.json | 2 + resources/assets/locales/ja-JP.json | 2 + resources/assets/locales/ko-KR.json | 2 + resources/assets/locales/pt-BR.json | 2 + resources/assets/locales/ru-RU.json | 2 + resources/assets/locales/zh-CN.json | 2 + resources/assets/locales/zh-TW.json | 2 + src/core/config_defaults.c | 20 +++++ src/core/config_io.c | 14 +++ src/platform/linux.c | 5 +- src/platform/macos.m | 4 +- src/platform/windows.c | 7 +- src/platform/windows_borderless.c | 2 + src/platform/windows_capture.c | 98 +++++++++++++++++--- src/platform/windows_capture.h | 2 + src/platform/windows_diagnostics.c | 134 ++++++++++++++++++++++++++++ src/platform/windows_diagnostics.h | 10 +++ src/platform/windows_layered.c | 31 ++++--- src/platform/windows_tray.c | 26 +++++- src/platform/windows_tray.h | 10 +++ src/runtime/app.c | 3 +- src/runtime/config_store.c | 2 + src/runtime/frame_audit.c | 105 +++++++++++++++++----- src/runtime/runtime.h | 1 + src/runtime/startup.c | 41 +++++++-- src/runtime/tray.c | 124 +++++++++++++++++++------ src/runtime/window.c | 7 ++ src/runtime/window_background.c | 23 +++++ src/ui/preferences_controls.h | 3 + src/ui/preferences_pages.c | 7 ++ src/ui/preferences_toggle.c | 62 ++++++++++++- src/ui/preferences_widgets.c | 93 +++++++++++++------ src/ui/preferences_widgets.h | 4 + tests/test_config.c | 18 ++++ tests/test_windows_capture.c | 63 ++++++++++++- 42 files changed, 829 insertions(+), 134 deletions(-) create mode 100644 src/platform/windows_diagnostics.c create mode 100644 src/platform/windows_diagnostics.h create mode 100644 src/platform/windows_tray.h create mode 100644 src/runtime/window_background.c diff --git a/CMakeLists.txt b/CMakeLists.txt index 2be76723..9b9d6c68 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -102,8 +102,9 @@ if(WIN32) src/platform/windows_startup.c src/platform/windows_borderless.c src/platform/windows_capture.c src/platform/windows_pointer.c src/platform/windows_direct_input.c src/platform/windows_input.c src/platform/windows_popup.c src/platform/windows_layered.c src/platform/windows_tray.c - src/platform/windows_assets.c) - target_link_libraries(bongo_cat_runtime PRIVATE dinput8 dxguid dwmapi ole32 shell32 user32 uuid windowscodecs) + src/platform/windows_assets.c src/platform/windows_diagnostics.c) + target_link_libraries(bongo_cat_runtime PRIVATE dinput8 dxguid dwmapi ole32 shell32 + user32 uuid windowscodecs advapi32) elseif(APPLE) enable_language(OBJC) target_sources(bongo_cat_runtime PRIVATE src/platform/macos.m src/platform/macos_input.m diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 05b9f0fe..c430ad33 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -28,7 +28,8 @@ set(BONGO_CAT_RUNTIME_SOURCES src/runtime/model_import_report.c src/runtime/model_import_mver_metadata.c src/runtime/model_import_metadata.c src/runtime/runtime_flow.c src/runtime/shortcuts.c src/runtime/tray.c - src/runtime/window.c src/runtime/window_display.c src/runtime/window_drag.c + src/runtime/window.c src/runtime/window_background.c + src/runtime/window_display.c src/runtime/window_drag.c src/runtime/window_geometry.c src/runtime/window_menu_actions.c src/runtime/window_menu_behavior.c src/runtime/window_menu_preview.c src/runtime/window_hit.c src/runtime/window_wheel.c diff --git a/include/bongo_cat/config.h b/include/bongo_cat/config.h index 47737139..aaa8e2ed 100644 --- a/include/bongo_cat/config.h +++ b/include/bongo_cat/config.h @@ -27,6 +27,13 @@ typedef enum BongoCatModelMode { BONGO_CAT_MODE_KEYBOARD, BONGO_CAT_MODE_GAMEPAD } BongoCatModelMode; +typedef enum BongoCatObsBackgroundColor { + BONGO_CAT_OBS_BACKGROUND_GREEN, + BONGO_CAT_OBS_BACKGROUND_BLUE, + BONGO_CAT_OBS_BACKGROUND_RED, + BONGO_CAT_OBS_BACKGROUND_MAGENTA, + BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT +} BongoCatObsBackgroundColor; typedef struct BongoCatModelOptions { bool mirror; @@ -43,6 +50,8 @@ typedef struct BongoCatWindowOptions { bool always_on_top; bool hide_on_hover; bool keep_in_screen; + bool obs_background; + BongoCatObsBackgroundColor obs_background_color; float scale_percent; float opacity_percent; float hide_delay_seconds; @@ -114,6 +123,10 @@ BongoCatResult bongo_cat_session_save(const char *path, const char *bongo_cat_theme_name(BongoCatTheme value); const char *bongo_cat_language_name(BongoCatLanguage value); const char *bongo_cat_mode_name(BongoCatModelMode value); +const char *bongo_cat_obs_background_color_name( + BongoCatObsBackgroundColor value); +uint32_t bongo_cat_obs_background_color_rgb( + BongoCatObsBackgroundColor value); #ifdef __cplusplus } diff --git a/include/bongo_cat/platform.h b/include/bongo_cat/platform.h index d7dca16b..998c7fc1 100644 --- a/include/bongo_cat/platform.h +++ b/include/bongo_cat/platform.h @@ -78,6 +78,7 @@ typedef struct BongoCatMenuLabels { typedef void (*BongoCatTrayClick)(void *userdata); typedef void (*BongoCatModalTick)(void *userdata); +typedef void (*BongoCatTrayRestore)(void *userdata); BongoCatResult bongo_cat_platform_init(BongoCatPlatform *platform, SDL_Window *window, BongoCatInputState *input, BongoCatError *error); @@ -105,7 +106,7 @@ void bongo_cat_platform_begin_drag(BongoCatPlatform *platform, bool bongo_cat_platform_dynamic_hit_supported(void); void bongo_cat_platform_set_tray_callbacks(void *tray, BongoCatTrayClick left_click, BongoCatModalTick modal_tick, - void *userdata); + BongoCatTrayRestore restore, void *userdata); bool bongo_cat_platform_single_instance_begin(void); bool bongo_cat_platform_single_instance_take_wake(void); void bongo_cat_platform_single_instance_end(void); diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index 0c4e7c5f..e468f116 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -22,6 +22,7 @@ "keepInScreen": "Auf dem Bildschirm halten", "windowSize": "Fenstergröße", "opacity": "Deckkraft", + "obsBackground": "OBS-Hintergrund", "autoReleaseDelay": "Verzögerung der automatischen Freigabe", "hideOnHover": "Beim Darüberfahren ausblenden", "maxFPS": "Maximale Bildrate" @@ -30,6 +31,7 @@ "passThrough": "Wenn aktiviert, gehen Klicks durch das Fenster hindurch, ohne es zu beeinflussen.", "alwaysOnTop": "Wenn aktiviert, bleibt das Fenster über allen anderen Fenstern.", "keepInScreen": "Wenn aktiviert, bleibt das Fenster automatisch innerhalb der Bildschirmgrenzen.", + "obsBackground": "Bei schwarzer OBS-Fensteraufnahme aktivieren.\nMit der Windows-7-Kompatibilitätsmethode aufnehmen und den Hintergrund per Farbschlüssel-Filter entfernen.", "windowSize": "Bewegen Sie die Maus an den Fensterrand oder halten Sie Umschalt gedrückt und ziehen Sie mit der rechten Maustaste.", "hideOnHover": "Wenn aktiviert, wird das Fenster beim Darüberfahren mit der Maus automatisch ausgeblendet. Die Verzögerung 0 blendet es sofort aus." } diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index 982f5bb9..663315f6 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -22,6 +22,7 @@ "keepInScreen": "Keep on Screen", "windowSize": "Window Size", "opacity": "Opacity", + "obsBackground": "OBS Background", "autoReleaseDelay": "Auto Release Delay", "hideOnHover": "Hide on Hover", "maxFPS": "Max Frame Rate" @@ -30,6 +31,7 @@ "passThrough": "When enabled, clicks pass through the window without affecting it.", "alwaysOnTop": "When enabled, the window stays above all other windows.", "keepInScreen": "When enabled, the window automatically stays within the screen boundaries.", + "obsBackground": "Enable when OBS Window Capture is black.\nUse the Windows 7 compatibility method and remove the background with a color key filter.", "windowSize": "Move mouse to window edge, or hold Shift and right-drag to resize.", "hideOnHover": "When enabled, the window hides automatically on mouse hover. You can set a delay; 0 means hide immediately." } diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index 21adb30b..cf0b21fe 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -22,6 +22,7 @@ "keepInScreen": "Mantener en pantalla", "windowSize": "Tamaño de la ventana", "opacity": "Opacidad", + "obsBackground": "Fondo de OBS", "autoReleaseDelay": "Retardo de liberación automática", "hideOnHover": "Ocultar al pasar el ratón", "maxFPS": "Frecuencia máxima de fotogramas" @@ -30,6 +31,7 @@ "passThrough": "Al activarlo, los clics atraviesan la ventana sin afectarla.", "alwaysOnTop": "Al activarlo, la ventana permanece por encima de las demás.", "keepInScreen": "Al activarlo, la ventana se mantiene automáticamente dentro de los límites de la pantalla.", + "obsBackground": "Actívalo si la captura de ventana de OBS aparece en negro.\nUsa el método compatible con Windows 7 y elimina el fondo con un filtro de clave de color.", "windowSize": "Mueva el ratón al borde de la ventana o mantenga Mayús y arrastre con el botón derecho para cambiar el tamaño.", "hideOnHover": "Al activarlo, la ventana se oculta automáticamente al pasar el ratón. Puede definir un retardo; 0 significa ocultar inmediatamente." } diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index ac17f90b..2d43d52c 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -22,6 +22,7 @@ "keepInScreen": "Maintenir à l'écran", "windowSize": "Taille de la fenêtre", "opacity": "Opacité", + "obsBackground": "Arrière-plan OBS", "autoReleaseDelay": "Délai de relâchement automatique", "hideOnHover": "Masquer au survol", "maxFPS": "Fréquence d'images maximale" @@ -30,6 +31,7 @@ "passThrough": "Lorsque cette option est activée, les clics traversent la fenêtre sans l'affecter.", "alwaysOnTop": "Lorsque cette option est activée, la fenêtre reste au-dessus des autres fenêtres.", "keepInScreen": "Lorsque cette option est activée, la fenêtre reste automatiquement dans les limites de l'écran.", + "obsBackground": "Activez cette option si la capture de fenêtre OBS est noire.\nUtilisez la méthode compatible Windows 7 et supprimez le fond avec un filtre d'incrustation couleur.", "windowSize": "Placez la souris sur le bord de la fenêtre, ou maintenez Maj et faites glisser avec le bouton droit pour redimensionner.", "hideOnHover": "Lorsque cette option est activée, la fenêtre se masque au survol de la souris. Vous pouvez définir un délai ; 0 signifie immédiatement." } diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index c5fd28fb..591c5733 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -22,6 +22,7 @@ "keepInScreen": "画面内に保持", "windowSize": "ウィンドウサイズ", "opacity": "不透明度", + "obsBackground": "OBS 単色背景", "autoReleaseDelay": "自動リリースまでの時間", "hideOnHover": "ホバー時に非表示", "maxFPS": "最大フレームレート" @@ -30,6 +31,7 @@ "passThrough": "有効にすると、クリックはウィンドウに影響せず背後へ通過します。", "alwaysOnTop": "有効にすると、ウィンドウがほかのすべてのウィンドウより手前に表示されます。", "keepInScreen": "有効にすると、ウィンドウが自動的に画面の範囲内に収まります。", + "obsBackground": "OBSのウィンドウキャプチャが黒画面になる場合に有効にします。\n「Windows 7互換」方式でキャプチャし、カラーキーフィルターで背景を除去してください。", "windowSize": "マウスをウィンドウ端へ移動するか、Shiftキーを押しながら右ドラッグしてサイズを変更します。", "hideOnHover": "有効にすると、マウスを重ねたときにウィンドウが自動的に隠れます。遅延を設定でき、0ではすぐに隠れます。" } diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index f1f3b63b..bfd2d634 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -22,6 +22,7 @@ "keepInScreen": "화면 안에 유지", "windowSize": "창 크기", "opacity": "불투명도", + "obsBackground": "OBS 단색 배경", "autoReleaseDelay": "자동 해제 지연", "hideOnHover": "마우스를 올리면 숨기기", "maxFPS": "최대 프레임 속도" @@ -30,6 +31,7 @@ "passThrough": "활성화하면 클릭이 창에 영향을 주지 않고 뒤로 통과합니다.", "alwaysOnTop": "활성화하면 창이 다른 모든 창 위에 유지됩니다.", "keepInScreen": "활성화하면 창이 자동으로 화면 경계 안에 유지됩니다.", + "obsBackground": "OBS 창 캡처가 검은 화면일 때 켜세요.\nWindows 7 호환 방식으로 캡처하고 색상 키 필터로 배경을 제거하세요.", "windowSize": "마우스를 창 가장자리로 이동하거나 Shift를 누른 채 오른쪽 버튼으로 끌어 크기를 조절합니다.", "hideOnHover": "활성화하면 마우스를 올렸을 때 창이 자동으로 숨겨집니다. 지연 시간을 설정할 수 있으며 0은 즉시 숨김입니다." } diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index 99908713..d0f30e8a 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -22,6 +22,7 @@ "keepInScreen": "Manter na Tela", "windowSize": "Tamanho da Janela", "opacity": "Opacidade", + "obsBackground": "Fundo do OBS", "autoReleaseDelay": "Atraso de Liberação Automática", "hideOnHover": "Ocultar ao Passar o Mouse", "maxFPS": "Taxa Máxima de Quadros" @@ -30,6 +31,7 @@ "passThrough": "Quando ativado, a janela não afetará operações em outros aplicativos.", "alwaysOnTop": "Quando ativado, a janela sempre ficará acima de outros aplicativos.", "keepInScreen": "Quando ativado, a janela permanece automaticamente dentro dos limites da tela.", + "obsBackground": "Ative se a captura de janela do OBS ficar preta.\nUse o método de compatibilidade com o Windows 7 e remova o fundo com um filtro de chave de cor.", "windowSize": "Mova o mouse para a borda da janela ou segure Shift e arraste com o botão direito para redimensionar.", "hideOnHover": "Quando ativado, a janela se oculta automaticamente ao passar o mouse. Você pode definir um atraso; 0 significa ocultar imediatamente." } diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index f3d3fc94..dedf3670 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -22,6 +22,7 @@ "keepInScreen": "Удерживать на экране", "windowSize": "Размер окна", "opacity": "Непрозрачность", + "obsBackground": "Фон OBS", "autoReleaseDelay": "Задержка автоотпускания", "hideOnHover": "Скрывать при наведении", "maxFPS": "Максимальная частота кадров" @@ -30,6 +31,7 @@ "passThrough": "Если включено, щелчки проходят сквозь окно, не воздействуя на него.", "alwaysOnTop": "Если включено, окно остаётся поверх всех остальных окон.", "keepInScreen": "Если включено, окно автоматически остаётся в границах экрана.", + "obsBackground": "Включите, если захват окна OBS показывает чёрный экран.\nИспользуйте метод совместимости с Windows 7 и удалите фон фильтром цветового ключа.", "windowSize": "Подведите мышь к краю окна или удерживайте Shift и перетаскивайте правой кнопкой для изменения размера.", "hideOnHover": "Если включено, окно автоматически скрывается при наведении мыши. Можно задать задержку; 0 означает скрытие сразу." } diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index 2948a6d0..430d16e2 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -22,6 +22,7 @@ "keepInScreen": "保持在屏幕内", "windowSize": "窗口尺寸", "opacity": "不透明度", + "obsBackground": "OBS 纯色背景", "autoReleaseDelay": "按键自动释放延迟", "hideOnHover": "鼠标移入隐藏", "maxFPS": "最大帧率" @@ -30,6 +31,7 @@ "passThrough": "启用后,窗口不影响对其他应用程序的操作。", "alwaysOnTop": "启用后,窗口始终显示在其他应用程序上方。", "keepInScreen": "启用后,窗口会自动调整位置,防止超出屏幕边界。", + "obsBackground": "OBS 窗口捕获黑屏时开启;\n请使用“Windows 7 兼容”捕获,并用色键滤镜移除背景。", "windowSize": "将鼠标移至窗口边缘,或按住 Shift 并右键拖动,也可以调整窗口大小。", "hideOnHover": "启用后,鼠标悬停窗口时自动隐藏。可设置延迟时间,0 表示立即隐藏。" } diff --git a/resources/assets/locales/zh-TW.json b/resources/assets/locales/zh-TW.json index f3d1862e..aae8258a 100644 --- a/resources/assets/locales/zh-TW.json +++ b/resources/assets/locales/zh-TW.json @@ -22,6 +22,7 @@ "keepInScreen": "保持在螢幕內", "windowSize": "視窗尺寸", "opacity": "不透明度", + "obsBackground": "OBS 純色背景", "autoReleaseDelay": "按鍵自動釋放延遲", "hideOnHover": "滑鼠游標移入隱藏", "maxFPS": "最大幀率" @@ -30,6 +31,7 @@ "passThrough": "啟用後,視窗不影響對其他應用程式的操作。", "alwaysOnTop": "啟用後,視窗始終顯示在其他應用程式上方。", "keepInScreen": "啟用後,視窗會自動調整位置,防止超出螢幕邊界。", + "obsBackground": "OBS 視窗擷取黑屏時開啟;\n請使用「Windows 7 相容」擷取,並用色鍵濾鏡移除背景。", "windowSize": "將滑鼠游標移至視窗邊緣,或按住 Shift 並右鍵拖曳,也可以調整視窗大小。", "hideOnHover": "啟用後,滑鼠游標懸停視窗時自動隱藏。可設定延遲時間,0 表示立即隱藏。" } diff --git a/src/core/config_defaults.c b/src/core/config_defaults.c index 66405afe..6e947cd9 100644 --- a/src/core/config_defaults.c +++ b/src/core/config_defaults.c @@ -109,6 +109,7 @@ void bongo_cat_config_defaults(BongoCatConfig *config) { config->window.visible = true; config->window.always_on_top = true; config->window.keep_in_screen = false; + config->window.obs_background_color = BONGO_CAT_OBS_BACKGROUND_GREEN; config->window.scale_percent = BONGO_CAT_DEFAULT_WINDOW_SCALE_PERCENT; config->window.opacity_percent = BONGO_CAT_DEFAULT_WINDOW_OPACITY_PERCENT; @@ -129,6 +130,9 @@ void bongo_cat_config_validate(BongoCatConfig *config) { config->window.scale_percent = clampf(config->window.scale_percent, 10.0f, 500.0f); config->window.opacity_percent = clampf(config->window.opacity_percent, 10.0f, 100.0f); config->window.hide_delay_seconds = clampf(config->window.hide_delay_seconds, 0.0f, 60.0f); + if ((unsigned)config->window.obs_background_color >= + BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT) + config->window.obs_background_color = BONGO_CAT_OBS_BACKGROUND_GREEN; if (config->window.width < 64) config->window.width = 64; if (config->window.height < 64) config->window.height = 64; if (config->window.width > 8192) config->window.width = 8192; @@ -183,3 +187,19 @@ const char *bongo_cat_mode_name(BongoCatModelMode value) { const char *names[] = {"standard", "keyboard", "gamepad"}; return value <= BONGO_CAT_MODE_GAMEPAD ? names[value] : names[0]; } + +const char *bongo_cat_obs_background_color_name( + BongoCatObsBackgroundColor value) { + static const char *names[] = { + "#00ff00", "#0000ff", "#ff0000", "#ff00ff"}; + return (unsigned)value < BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT ? + names[value] : names[BONGO_CAT_OBS_BACKGROUND_GREEN]; +} + +uint32_t bongo_cat_obs_background_color_rgb( + BongoCatObsBackgroundColor value) { + static const uint32_t colors[] = { + 0x00ff00, 0x0000ff, 0xff0000, 0xff00ff}; + return (unsigned)value < BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT ? + colors[value] : colors[BONGO_CAT_OBS_BACKGROUND_GREEN]; +} diff --git a/src/core/config_io.c b/src/core/config_io.c index a4e8b8c6..59d69f2e 100644 --- a/src/core/config_io.c +++ b/src/core/config_io.c @@ -36,6 +36,14 @@ static BongoCatLanguage parse_language(const char *value) { return (BongoCatLanguage)i; return BONGO_CAT_LANG_EN_US; } +static BongoCatObsBackgroundColor parse_obs_background_color( + const char *value, BongoCatObsBackgroundColor fallback) { + if (value) for (int i = 0; i < BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT; ++i) + if (strcmp(value, bongo_cat_obs_background_color_name( + (BongoCatObsBackgroundColor)i)) == 0) + return (BongoCatObsBackgroundColor)i; + return fallback; +} static void read_model(yyjson_val *obj, BongoCatModelOptions *value) { if (!yyjson_is_obj(obj)) return; @@ -52,6 +60,9 @@ static void read_window(yyjson_val *obj, BongoCatWindowOptions *value) { value->always_on_top = get_bool(obj, "alwaysOnTop", value->always_on_top); value->hide_on_hover = get_bool(obj, "hideOnHover", value->hide_on_hover); value->keep_in_screen = get_bool(obj, "keepInScreen", value->keep_in_screen); + value->obs_background = get_bool(obj, "obsBackground", value->obs_background); + value->obs_background_color = parse_obs_background_color( + get_string(obj, "obsBackgroundColor"), value->obs_background_color); value->hide_delay_seconds = get_float(obj, "hideOnHoverDelay", value->hide_delay_seconds); } static void read_app(yyjson_val *obj, BongoCatAppOptions *value) { @@ -140,6 +151,9 @@ static void write_window(yyjson_mut_doc *doc, yyjson_mut_val *obj, yyjson_mut_obj_add_bool(doc, obj, "alwaysOnTop", v->always_on_top); yyjson_mut_obj_add_bool(doc, obj, "hideOnHover", v->hide_on_hover); yyjson_mut_obj_add_bool(doc, obj, "keepInScreen", v->keep_in_screen); + yyjson_mut_obj_add_bool(doc, obj, "obsBackground", v->obs_background); + yyjson_mut_obj_add_strcpy(doc, obj, "obsBackgroundColor", + bongo_cat_obs_background_color_name(v->obs_background_color)); yyjson_mut_obj_add_real(doc, obj, "hideOnHoverDelay", v->hide_delay_seconds); } static void write_app(yyjson_mut_doc *doc, yyjson_mut_val *obj, diff --git a/src/platform/linux.c b/src/platform/linux.c index 66caa9f0..9c214ecd 100644 --- a/src/platform/linux.c +++ b/src/platform/linux.c @@ -184,8 +184,9 @@ bool bongo_cat_platform_open_directory(const char *path) { void bongo_cat_platform_set_tray_callbacks(void *tray, BongoCatTrayClick left_click, BongoCatModalTick modal_tick, - void *userdata) { - (void)tray; (void)left_click; (void)modal_tick; (void)userdata; + BongoCatTrayRestore restore, void *userdata) { + (void)tray; (void)left_click; (void)modal_tick; + (void)restore; (void)userdata; } bool bongo_cat_platform_single_instance_begin(void) { char path[96]; snprintf(path, sizeof(path), "/tmp/bongo-cat-%lu.lock", diff --git a/src/platform/macos.m b/src/platform/macos.m index a478b656..fbd5ca62 100644 --- a/src/platform/macos.m +++ b/src/platform/macos.m @@ -245,8 +245,8 @@ bool bongo_cat_platform_open_directory(const char *path) { void bongo_cat_platform_set_tray_callbacks(void *tray, BongoCatTrayClick left_click, BongoCatModalTick modal_tick, - void *userdata) { - BongoCatSDLTray *native = tray; + BongoCatTrayRestore restore, void *userdata) { + (void)restore; BongoCatSDLTray *native = tray; if (tray_target) { [tray_target unbind]; [tray_target release]; tray_target = nil; } diff --git a/src/platform/windows.c b/src/platform/windows.c index 39ed70d6..efc8baed 100644 --- a/src/platform/windows.c +++ b/src/platform/windows.c @@ -9,7 +9,6 @@ #include #include #include -#include #include #include static HWND native_window(BongoCatPlatform *platform) { @@ -50,12 +49,9 @@ BongoCatResult bongo_cat_platform_init(BongoCatPlatform *platform, SDL_Window *w SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, "Borderless resize is unavailable: %s", SDL_GetError()); } - if (SDL_GetWindowFlags(window) & SDL_WINDOW_TRANSPARENT) { - MARGINS margins = {-1, -1, -1, -1}; - DwmExtendFrameIntoClientArea(hwnd, &margins); - } SetWindowPos(hwnd, NULL, 0, 0, 0, 0, SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE); + bongo_cat_windows_capture_log(hwnd, "initialized"); return BONGO_CAT_OK; } void bongo_cat_platform_shutdown(BongoCatPlatform *platform) { @@ -108,6 +104,7 @@ void bongo_cat_platform_set_always_on_top(BongoCatPlatform *platform, bool enabl apply_window_level(window, enabled); bongo_cat_windows_layered_set_always_on_top(platform, enabled); bongo_cat_windows_capture_configure(window); + bongo_cat_windows_capture_log(window, "window-level"); } void bongo_cat_platform_begin_drag(BongoCatPlatform *platform, BongoCatModalTick modal_tick, void *userdata) { diff --git a/src/platform/windows_borderless.c b/src/platform/windows_borderless.c index 5e8b329f..8272a975 100644 --- a/src/platform/windows_borderless.c +++ b/src/platform/windows_borderless.c @@ -1,5 +1,6 @@ #include "windows_borderless.h" #include "windows_capture.h" +#include "windows_tray.h" #ifdef _WIN32 #include @@ -29,6 +30,7 @@ static LONG_PTR borderless_style(LONG_PTR style) { static LRESULT CALLBACK borderless_window_proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) { + bongo_cat_windows_tray_handle_message(message); WNDPROC original = (WNDPROC)GetPropW(window, original_proc_property); WindowsMenuBinding *menu = GetPropW(window, menu_binding_property); WindowsDragBinding *drag = GetPropW(window, drag_binding_property); diff --git a/src/platform/windows_capture.c b/src/platform/windows_capture.c index 851818ef..466990e9 100644 --- a/src/platform/windows_capture.c +++ b/src/platform/windows_capture.c @@ -1,4 +1,5 @@ #include "windows_capture.h" +#include "windows_diagnostics.h" #ifdef _WIN32 #include @@ -10,6 +11,8 @@ static UINT taskbar_created_message; static UINT capture_refresh_message; static bool removal_warning_emitted; static bool style_warning_emitted; +static bool transparency_warning_emitted; +static bool environment_logged; #define BONGO_CAT_CAPTURE_REFRESH_TIMER ((UINT_PTR)0xBC51) static bool read_extended_style(HWND window, LONG_PTR *style) { @@ -20,10 +23,12 @@ static bool read_extended_style(HWND window, LONG_PTR *style) { return true; } -static bool write_extended_style(HWND window, LONG_PTR style) { +static bool write_extended_style(HWND window, LONG_PTR style, DWORD *error) { SetLastError(ERROR_SUCCESS); LONG_PTR previous = SetWindowLongPtrW(window, GWL_EXSTYLE, style); - return previous || GetLastError() == ERROR_SUCCESS; + DWORD result = GetLastError(); + if (error) *error = result; + return previous || result == ERROR_SUCCESS; } static void register_messages(void) { @@ -48,17 +53,69 @@ static HRESULT remove_taskbar_tab(HWND window) { return result; } -static void refresh_transparency(HWND window) { - MARGINS margins = {-1, -1, -1, -1}; - DwmExtendFrameIntoClientArea(window, &margins); +bool bongo_cat_windows_capture_restore_transparency(HWND window) { + if (!window) return false; HRGN region = CreateRectRgn(-1, -1, 0, 0); - if (!region) return; + if (!region) return false; DWM_BLURBEHIND blur = {0}; blur.dwFlags = DWM_BB_ENABLE | DWM_BB_BLURREGION; blur.fEnable = TRUE; blur.hRgnBlur = region; - DwmEnableBlurBehindWindow(window, &blur); + HRESULT result = DwmEnableBlurBehindWindow(window, &blur); DeleteObject(region); + if (FAILED(result) && !transparency_warning_emitted) { + transparency_warning_emitted = true; + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Cannot restore Windows transparent composition (0x%08lx)", + (unsigned long)result); + } + return SUCCEEDED(result); +} + +static void log_environment(HWND window) { + if (environment_logged) return; + environment_logged = true; + OSVERSIONINFOW version = {.dwOSVersionInfoSize = sizeof(version)}; + typedef LONG (WINAPI *RtlGetVersionFn)(OSVERSIONINFOW *); + HMODULE module = GetModuleHandleW(L"ntdll.dll"); + RtlGetVersionFn get_version = module ? + (RtlGetVersionFn)(void *)GetProcAddress(module, "RtlGetVersion") : NULL; + bool version_known = get_version && get_version(&version) == 0; + BOOL composition = FALSE; + HRESULT composition_result = DwmIsCompositionEnabled(&composition); + SDL_Log("Windows capture environment: version=%lu.%lu.%lu known=%d " + "composition=%d composition_result=0x%08lx remote_session=%d", + (unsigned long)version.dwMajorVersion, + (unsigned long)version.dwMinorVersion, + (unsigned long)version.dwBuildNumber, version_known, + SUCCEEDED(composition_result) && composition, + (unsigned long)composition_result, GetSystemMetrics(SM_REMOTESESSION) != 0); + bongo_cat_windows_diagnostics_log(window); +} + +void bongo_cat_windows_capture_log(HWND window, const char *stage) { + log_environment(window); + if (!window || !IsWindow(window)) return; + RECT bounds = {0}; + DWORD cloaked = 0, affinity = 0; + HRESULT cloak_result = DwmGetWindowAttribute(window, DWMWA_CLOAKED, + &cloaked, sizeof(cloaked)); + bool affinity_known = GetWindowDisplayAffinity(window, &affinity) != FALSE; + LONG_PTR style = GetWindowLongPtrW(window, GWL_STYLE); + LONG_PTR extended = GetWindowLongPtrW(window, GWL_EXSTYLE); + GetWindowRect(window, &bounds); + SDL_Log("Windows capture state (%s): hwnd=%p visible=%d iconic=%d " + "cloaked=%lu cloak_known=%d rect=%ld,%ld %ldx%ld style=0x%llx " + "exstyle=0x%llx owner=%p affinity=0x%lx affinity_known=%d", + stage ? stage : "unknown", (void *)window, + IsWindowVisible(window) != FALSE, IsIconic(window) != FALSE, + (unsigned long)cloaked, SUCCEEDED(cloak_result), + (long)bounds.left, (long)bounds.top, + (long)(bounds.right - bounds.left), + (long)(bounds.bottom - bounds.top), + (unsigned long long)style, (unsigned long long)extended, + (void *)GetWindowLongPtrW(window, GWLP_HWNDPARENT), + (unsigned long)affinity, affinity_known); } static void refresh_taskbar(HWND window) { @@ -92,14 +149,24 @@ bool bongo_cat_windows_capture_configure(HWND window) { } return false; } - LONG_PTR next = (style | WS_EX_APPWINDOW) & ~WS_EX_TOOLWINDOW; + /* OBS does not require APPWINDOW. Avoid a hide/show style transition for + ordinary SDL windows because it can recreate their DWM redirection. */ + LONG_PTR next = style & ~WS_EX_TOOLWINDOW; if (next != style) { bool shown = IsWindowVisible(window) != FALSE; if (shown) ShowWindow(window, SW_HIDE); - if (write_extended_style(window, next)) { + DWORD style_error = ERROR_SUCCESS; + if (write_extended_style(window, next, &style_error)) { SetWindowPos(window, NULL, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED); - refresh_transparency(window); + bongo_cat_windows_capture_restore_transparency(window); + } else if (!style_warning_emitted) { + style_warning_emitted = true; + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Cannot update the window style required for OBS discovery " + "(Win32 error %lu, old=0x%llx, new=0x%llx)", + (unsigned long)style_error, (unsigned long long)style, + (unsigned long long)next); } if (shown) { ShowWindow(window, SW_SHOWNOACTIVATE); @@ -108,11 +175,16 @@ bool bongo_cat_windows_capture_configure(HWND window) { } LONG_PTR applied = 0; bool ready = read_extended_style(window, &applied) && - (applied & WS_EX_APPWINDOW) && !(applied & WS_EX_TOOLWINDOW); + !(applied & (WS_EX_TOOLWINDOW | WS_EX_NOREDIRECTIONBITMAP)); if (!ready && !style_warning_emitted) { style_warning_emitted = true; - SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, - "Cannot configure the window for OBS discovery"); + if (applied & WS_EX_NOREDIRECTIONBITMAP) + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Window capture is incompatible with WS_EX_NOREDIRECTIONBITMAP " + "(exstyle=0x%llx)", (unsigned long long)applied); + else + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Cannot configure the window for OBS discovery"); } refresh_taskbar(window); schedule_refresh(window); diff --git a/src/platform/windows_capture.h b/src/platform/windows_capture.h index 55596d30..cf5654f5 100644 --- a/src/platform/windows_capture.h +++ b/src/platform/windows_capture.h @@ -6,6 +6,8 @@ #include bool bongo_cat_windows_capture_configure(HWND window); +bool bongo_cat_windows_capture_restore_transparency(HWND window); +void bongo_cat_windows_capture_log(HWND window, const char *stage); bool bongo_cat_windows_capture_handle_message( HWND window, UINT message, WPARAM wparam); #endif diff --git a/src/platform/windows_diagnostics.c b/src/platform/windows_diagnostics.c new file mode 100644 index 00000000..21ca1448 --- /dev/null +++ b/src/platform/windows_diagnostics.c @@ -0,0 +1,134 @@ +#include "windows_diagnostics.h" + +#ifdef _WIN32 +#include +#include +#include +#include + +static void utf8(const wchar_t *source, char *target, size_t capacity) { + if (!target || !capacity) return; + target[0] = '\0'; + if (!source || !source[0]) return; + int length = WideCharToMultiByte(CP_UTF8, 0, source, -1, + target, (int)capacity, NULL, NULL); + if (!length) target[0] = '\0'; + target[capacity - 1] = '\0'; +} + +static void log_displays(void) { + for (DWORD index = 0; index < 16; ++index) { + DISPLAY_DEVICEW adapter = {.cb = sizeof(adapter)}; + if (!EnumDisplayDevicesW(NULL, index, &adapter, 0)) break; + char name[128], description[256], id[512]; + utf8(adapter.DeviceName, name, sizeof(name)); + utf8(adapter.DeviceString, description, sizeof(description)); + utf8(adapter.DeviceID, id, sizeof(id)); + SDL_Log("Display adapter %lu: name=%s description=%s id=%s flags=0x%lx", + (unsigned long)index, name, description, id, + (unsigned long)adapter.StateFlags); + for (DWORD monitor_index = 0; monitor_index < 16; ++monitor_index) { + DISPLAY_DEVICEW monitor = {.cb = sizeof(monitor)}; + if (!EnumDisplayDevicesW(adapter.DeviceName, monitor_index, + &monitor, 0)) break; + char monitor_name[128], monitor_description[256]; + utf8(monitor.DeviceName, monitor_name, sizeof(monitor_name)); + utf8(monitor.DeviceString, monitor_description, + sizeof(monitor_description)); + SDL_Log("Display output %lu.%lu: name=%s description=%s " + "flags=0x%lx", (unsigned long)index, + (unsigned long)monitor_index, monitor_name, + monitor_description, (unsigned long)monitor.StateFlags); + } + } +} + +static void log_process(void) { + HANDLE token = NULL; + TOKEN_ELEVATION elevation = {0}; + DWORD length = 0, integrity = 0; + if (OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) { + GetTokenInformation(token, TokenElevation, &elevation, + sizeof(elevation), &length); + DWORD needed = 0; + GetTokenInformation(token, TokenIntegrityLevel, NULL, 0, &needed); + if (needed) { + TOKEN_MANDATORY_LABEL *label = malloc(needed); + if (label && GetTokenInformation(token, TokenIntegrityLevel, + label, needed, &needed)) { + PSID sid = label->Label.Sid; + if (IsValidSid(sid) && *GetSidSubAuthorityCount(sid) > 0) + integrity = *GetSidSubAuthority(sid, + *GetSidSubAuthorityCount(sid) - 1); + } + free(label); + } + CloseHandle(token); + } + wchar_t executable_wide[MAX_PATH] = {0}; + char executable[MAX_PATH * 3]; + GetModuleFileNameW(NULL, executable_wide, MAX_PATH - 1); + utf8(executable_wide, executable, sizeof(executable)); + SYSTEM_INFO system = {0}; + GetNativeSystemInfo(&system); + SDL_Log("Process environment: pid=%lu native_arch=%u pointer_bits=%u " + "elevated=%d integrity_rid=0x%lx executable=%s", + (unsigned long)GetCurrentProcessId(), + (unsigned)system.wProcessorArchitecture, (unsigned)(sizeof(void *) * 8), + elevation.TokenIsElevated != 0, (unsigned long)integrity, executable); +} + +static void log_window(HWND window) { + char class_name[128], title[256]; + wchar_t class_wide[128] = {0}, title_wide[256] = {0}; + GetClassNameW(window, class_wide, (int)(sizeof(class_wide) / + sizeof(class_wide[0]))); + GetWindowTextW(window, title_wide, (int)(sizeof(title_wide) / + sizeof(title_wide[0]))); + utf8(class_wide, class_name, sizeof(class_name)); + utf8(title_wide, title, sizeof(title)); + RECT frame = {0}; + HRESULT frame_result = DwmGetWindowAttribute(window, + DWMWA_EXTENDED_FRAME_BOUNDS, &frame, sizeof(frame)); + LONG_PTR extended = GetWindowLongPtrW(window, GWL_EXSTYLE); + BYTE alpha = 0; COLORREF color = 0; DWORD flags = 0; + BOOL layered_known = (extended & WS_EX_LAYERED) != 0 && + GetLayeredWindowAttributes(window, &color, &alpha, &flags); + typedef UINT (WINAPI *GetDpiForWindowFn)(HWND); + HMODULE user32 = GetModuleHandleW(L"user32.dll"); + GetDpiForWindowFn get_dpi = user32 ? (GetDpiForWindowFn)(void *) + GetProcAddress(user32, "GetDpiForWindow") : NULL; + UINT dpi = get_dpi ? get_dpi(window) : 0; + DWORD pid = 0; DWORD thread = GetWindowThreadProcessId(window, &pid); + SDL_Log("Capture window details: class=%s title=%s pid=%lu thread=%lu " + "dpi=%u frame_known=%d frame=%ld,%ld %ldx%ld " + "layered_known=%d alpha=%u layered_flags=0x%lx", class_name, title, + (unsigned long)pid, (unsigned long)thread, dpi, + SUCCEEDED(frame_result), (long)frame.left, (long)frame.top, + (long)(frame.right - frame.left), (long)(frame.bottom - frame.top), + layered_known, (unsigned)alpha, (unsigned long)flags); +} + +void bongo_cat_windows_diagnostics_log(HWND window) { + OSVERSIONINFOW version = {.dwOSVersionInfoSize = sizeof(version)}; + typedef LONG (WINAPI *RtlGetVersionFn)(OSVERSIONINFOW *); + HMODULE module = GetModuleHandleW(L"ntdll.dll"); + RtlGetVersionFn get_version = module ? + (RtlGetVersionFn)(void *)GetProcAddress(module, "RtlGetVersion") : NULL; + bool known = get_version && get_version(&version) == 0; + BOOL composition = FALSE; + HRESULT composition_result = DwmIsCompositionEnabled(&composition); + SDL_Log("Windows diagnostics: version=%lu.%lu.%lu known=%d " + "wgc_os_supported=%d remote_session=%d composition=%d " + "composition_result=0x%08lx", + (unsigned long)version.dwMajorVersion, + (unsigned long)version.dwMinorVersion, + (unsigned long)version.dwBuildNumber, known, + known && version.dwBuildNumber >= 18362, + GetSystemMetrics(SM_REMOTESESSION) != 0, + composition, (unsigned long)composition_result); + log_process(); + log_displays(); + if (window) log_window(window); +} +#endif diff --git a/src/platform/windows_diagnostics.h b/src/platform/windows_diagnostics.h new file mode 100644 index 00000000..dd5eca00 --- /dev/null +++ b/src/platform/windows_diagnostics.h @@ -0,0 +1,10 @@ +#ifndef BONGO_CAT_WINDOWS_DIAGNOSTICS_H +#define BONGO_CAT_WINDOWS_DIAGNOSTICS_H + +#ifdef _WIN32 +#include + +void bongo_cat_windows_diagnostics_log(HWND window); +#endif + +#endif diff --git a/src/platform/windows_layered.c b/src/platform/windows_layered.c index 058b4e94..6ed07604 100644 --- a/src/platform/windows_layered.c +++ b/src/platform/windows_layered.c @@ -1,17 +1,14 @@ #include "windows_layered.h" #include "windows_capture.h" - #ifdef _WIN32 #include #include #include -#include #include #include #include static const wchar_t proxy_class[] = L"BongoCat.LayeredPresenter"; static const wchar_t proxy_property[] = L"BongoCat.LayeredProxy"; - /* SDL's OpenGL window owns a DC, so a separate layered window presents frames. */ typedef struct BongoCatWindowsLayered { HDC memory_dc; @@ -22,7 +19,7 @@ typedef struct BongoCatWindowsLayered { size_t readback_capacity; HWND proxy; int width, height, source_width, source_height; - bool readback_valid, active, has_frame; + bool readback_valid, active, has_frame, mode_logged; bool source_transparent, visible, topmost; } BongoCatWindowsLayered; static HWND native_window(BongoCatPlatform *platform) { @@ -30,7 +27,6 @@ static HWND native_window(BongoCatPlatform *platform) { SDL_GetWindowProperties(platform->window), SDL_PROP_WINDOW_WIN32_HWND_POINTER, NULL) : NULL; } - void *bongo_cat_windows_layered_create(void) { BongoCatWindowsLayered *value = calloc(1, sizeof(*value)); if (!value) return NULL; @@ -49,7 +45,6 @@ static void release_bitmap(BongoCatWindowsLayered *value) { value->pixels = NULL; value->width = value->height = 0; } - static void restore_source(BongoCatPlatform *platform) { BongoCatWindowsLayered *value = platform ? platform->presenter : NULL; HWND source = native_window(platform); @@ -59,12 +54,11 @@ static void restore_source(BongoCatPlatform *platform) { style & ~(WS_EX_LAYERED | WS_EX_TRANSPARENT)); SetWindowPos(source, NULL, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED); - MARGINS margins = {-1, -1, -1, -1}; - DwmExtendFrameIntoClientArea(source, &margins); value->source_transparent = false; SDL_SetWindowOpacity(platform->window, platform->window_opacity); + bongo_cat_windows_capture_restore_transparency(source); + bongo_cat_windows_capture_log(source, "layered-source-restored"); } - void bongo_cat_windows_layered_destroy(BongoCatPlatform *platform) { BongoCatWindowsLayered *value = platform ? platform->presenter : NULL; if (!value) return; @@ -78,7 +72,6 @@ void bongo_cat_windows_layered_destroy(BongoCatPlatform *platform) { free(value); platform->presenter = NULL; } - static bool register_proxy_class(void) { WNDCLASSEXW existing = {.cbSize = sizeof(existing)}; HINSTANCE instance = GetModuleHandleW(NULL); @@ -91,7 +84,6 @@ static bool register_proxy_class(void) { type.lpszClassName = proxy_class; return RegisterClassExW(&type) != 0; } - static bool ensure_proxy(BongoCatPlatform *platform) { BongoCatWindowsLayered *value = platform ? platform->presenter : NULL; HWND source = native_window(platform); @@ -112,9 +104,9 @@ static bool ensure_proxy(BongoCatPlatform *platform) { if (small_icon) SendMessageW(value->proxy, WM_SETICON, ICON_SMALL, (LPARAM)small_icon); SetPropW(source, proxy_property, value->proxy); + bongo_cat_windows_capture_log(value->proxy, "layered-proxy-created"); return true; } - static bool update_proxy(BongoCatPlatform *platform, BongoCatWindowsLayered *value) { HWND source_window = native_window(platform); @@ -153,21 +145,28 @@ static bool make_source_transparent(BongoCatPlatform *platform) { SetWindowPos(source, NULL, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED); value->source_transparent = true; + bongo_cat_windows_capture_log(source, "layered-source-suppressed"); return true; } void bongo_cat_windows_layered_set_click_through( BongoCatPlatform *platform, bool enabled) { BongoCatWindowsLayered *value = platform ? platform->presenter : NULL; - if (!value || value->active == enabled) return; + if (!value || (value->mode_logged && value->active == enabled)) return; + value->mode_logged = true; value->active = enabled; + SDL_Log("Windows presentation path: forced_click_through=%d", enabled); if (enabled) { value->has_frame = false; ensure_proxy(platform); if (value->proxy) ShowWindow(value->proxy, SW_HIDE); + bongo_cat_windows_capture_log(native_window(platform), + "layered-presenter-ready"); } else { if (value->proxy) ShowWindow(value->proxy, SW_HIDE); restore_source(platform); + bongo_cat_windows_capture_log(native_window(platform), + "direct-opengl-presenter"); } } @@ -216,7 +215,11 @@ void bongo_cat_platform_set_visible(BongoCatPlatform *platform, bool visible) { if (value) value->visible = visible; if (!visible && value && value->proxy) ShowWindow(value->proxy, SW_HIDE); visible ? SDL_ShowWindow(platform->window) : SDL_HideWindow(platform->window); - if (visible) bongo_cat_windows_capture_configure(native_window(platform)); + if (visible) { + HWND source = native_window(platform); + bongo_cat_windows_capture_configure(source); + bongo_cat_windows_capture_log(source, "visible"); + } } static bool resize_bitmap(BongoCatWindowsLayered *value, int width, int height) { diff --git a/src/platform/windows_tray.c b/src/platform/windows_tray.c index 035bd21c..0a02c57d 100644 --- a/src/platform/windows_tray.c +++ b/src/platform/windows_tray.c @@ -1,5 +1,6 @@ #include "bongo_cat/platform.h" #include "windows_popup.h" +#include "windows_tray.h" #ifdef _WIN32 #include @@ -7,6 +8,10 @@ #define BONGO_CAT_TRAY_MODAL_TIMER ((UINT_PTR)0xBC4F) static const wchar_t tray_binding_property[] = L"BongoCat.TrayBinding"; +static UINT taskbar_created_message; +static void *restore_tray; +static BongoCatTrayRestore restore_callback; +static void *restore_userdata; typedef struct WindowsTrayBinding { WNDPROC original; @@ -86,10 +91,29 @@ static void bind_tray_window(HWND window, void *tray, void bongo_cat_platform_set_tray_callbacks(void *tray, BongoCatTrayClick left_click, BongoCatModalTick modal_tick, - void *userdata) { + BongoCatTrayRestore restore, void *userdata) { + if (!taskbar_created_message) + taskbar_created_message = RegisterWindowMessageW(L"TaskbarCreated"); + if (tray && restore) { + restore_tray = tray; + restore_callback = restore; + restore_userdata = userdata; + } else if (!tray || tray == restore_tray) { + restore_tray = NULL; + restore_callback = NULL; + restore_userdata = NULL; + } /* Pinned SDL stores SDL_Tray* in its private message window userdata. */ HWND window = NULL; while ((window = FindWindowExW(HWND_MESSAGE, window, L"Message", NULL)) != NULL) bind_tray_window(window, tray, left_click, modal_tick, userdata); } + +void bongo_cat_windows_tray_handle_message(UINT message) { + if (!taskbar_created_message) + taskbar_created_message = RegisterWindowMessageW(L"TaskbarCreated"); + if (taskbar_created_message && message == taskbar_created_message && + restore_callback) + restore_callback(restore_userdata); +} #endif diff --git a/src/platform/windows_tray.h b/src/platform/windows_tray.h new file mode 100644 index 00000000..22c11fa1 --- /dev/null +++ b/src/platform/windows_tray.h @@ -0,0 +1,10 @@ +#ifndef BONGO_CAT_WINDOWS_TRAY_H +#define BONGO_CAT_WINDOWS_TRAY_H + +#ifdef _WIN32 +#include + +void bongo_cat_windows_tray_handle_message(UINT message); +#endif + +#endif diff --git a/src/runtime/app.c b/src/runtime/app.c index c9b16740..3e046f4b 100644 --- a/src/runtime/app.c +++ b/src/runtime/app.c @@ -198,8 +198,7 @@ static void render(BongoCatApp *app) { glViewport(0, 0, width, height); glEnable(GL_MULTISAMPLE); glDisable(GL_SCISSOR_TEST); glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); - glClearColor(0.0f, 0.0f, 0.0f, 0.0f); - glClear(GL_COLOR_BUFFER_BIT); + bongo_cat_window_clear_background(app); bongo_cat_overlay_draw_background(app->overlay, app->config.model.mirror); bongo_cat_live2d_set_mirror(app->live2d, app->config.model.mirror); bongo_cat_live2d_draw(app->live2d); if (!app->model_mouse_parameters) bongo_cat_overlay_draw_pointer_before_keys(app->overlay); diff --git a/src/runtime/config_store.c b/src/runtime/config_store.c index dc62b5df..833e3ff3 100644 --- a/src/runtime/config_store.c +++ b/src/runtime/config_store.c @@ -23,6 +23,8 @@ static uint64_t preferences_hash(const BongoCatConfig *config) { HASH_FIELD(config->window.always_on_top); HASH_FIELD(config->window.hide_on_hover); HASH_FIELD(config->window.keep_in_screen); + HASH_FIELD(config->window.obs_background); + HASH_FIELD(config->window.obs_background_color); HASH_FIELD(config->window.hide_delay_seconds); HASH_FIELD(config->app); HASH_FIELD(config->shortcuts); diff --git a/src/runtime/frame_audit.c b/src/runtime/frame_audit.c index 03dd619f..9a8695e5 100644 --- a/src/runtime/frame_audit.c +++ b/src/runtime/frame_audit.c @@ -61,6 +61,84 @@ static FrameStats frame_stats(const unsigned char *pixels, return stats; } +static void log_first_frame(BongoCatApp *app, int width, int height) { + if (width <= 0 || height <= 0 || width > 4096 || height > 4096) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "First-frame diagnostic skipped for size %dx%d", width, height); + return; + } + size_t pitch = (size_t)width * 4, bytes = pitch * (size_t)height; + if (pitch / 4 != (size_t)width || bytes / pitch != (size_t)height) return; + unsigned char *pixels = calloc(1, bytes); + if (!pixels) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "First-frame diagnostic allocation failed for %zu bytes", bytes); + return; + } + GLenum before = glGetError(); + glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels); + GLenum after = glGetError(); + unsigned long long alpha = 0, opaque = 0, rgb_colored = 0; + unsigned long long visible_colored = 0, black = 0; + unsigned long long red = 0, green = 0, blue = 0, alpha_sum = 0; + for (int y = 0; y < height; ++y) for (int x = 0; x < width; ++x) { + const unsigned char *pixel = pixels + (size_t)y * pitch + (size_t)x * 4; + unsigned sum = (unsigned)pixel[0] + pixel[1] + pixel[2]; + alpha += pixel[3] > 8; opaque += pixel[3] > 247; + rgb_colored += sum > 30; + visible_colored += pixel[3] > 8 && sum > 30; + black += pixel[3] > 8 && sum <= 30; + red += pixel[0]; green += pixel[1]; blue += pixel[2]; + alpha_sum += pixel[3]; + } + unsigned long long total = (unsigned long long)width * height; + int sample_buffers = 0, sample_count = 0; + SDL_GL_GetAttribute(SDL_GL_MULTISAMPLEBUFFERS, &sample_buffers); + SDL_GL_GetAttribute(SDL_GL_MULTISAMPLESAMPLES, &sample_count); + SDL_Log("First-frame pixels: size=%dx%d total=%llu alpha=%llu opaque=%llu " + "rgb_colored=%llu visible_colored=%llu black_with_alpha=%llu " + "transparent=%llu " + "avg_rgba=%.1f,%.1f,%.1f,%.1f gl_error_before=0x%x gl_error_after=0x%x " + "msaa=%d/%d", width, height, total, alpha, opaque, rgb_colored, + visible_colored, black, + total - alpha, (double)red / total, (double)green / total, + (double)blue / total, (double)alpha_sum / total, before, after, + sample_buffers, sample_count); + SDL_Log("First-frame ratios: alpha=%.2f%% rgb_colored=%.2f%% " + "visible_colored=%.2f%% black_with_alpha=%.2f%%", + 100.0 * alpha / total, 100.0 * rgb_colored / total, + 100.0 * visible_colored / total, 100.0 * black / total); + if (after != GL_NO_ERROR) SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "First-frame diagnosis: OpenGL framebuffer readback failed (0x%x)", after); + else if (!rgb_colored && !alpha) SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "First-frame diagnosis: framebuffer is blank and fully transparent"); + else if (!rgb_colored) SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "First-frame diagnosis: framebuffer contains only black pixels"); + else if (!visible_colored) SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "First-frame diagnosis: framebuffer has RGB content but no visible color alpha"); + else SDL_Log("First-frame diagnosis: OpenGL framebuffer contains visible content"); + if (app->smoke) { + const int points[][2] = {{0, 0}, {width - 1, 0}, {0, height - 1}, + {width - 1, height - 1}, {width / 2, height / 2}}; + unsigned transparent = 0, opaque_samples = 0; + for (size_t i = 0; i < sizeof(points) / sizeof(points[0]); ++i) { + unsigned char value = pixels[(size_t)points[i][1] * pitch + + (size_t)points[i][0] * 4 + 3]; + transparent += value < 16; opaque_samples += value > 239; + } + char path[BONGO_CAT_PATH_CAP]; + bongo_cat_path_join(path, sizeof(path), app->data_root, "frame-alpha.txt"); + FILE *file = bongo_cat_file_open(path, "wb"); + if (file) { + fprintf(file, "samples=5 transparent=%u opaque=%u sample_buffers=%d " + "sample_count=%d gl_error=%u\n", transparent, opaque_samples, + sample_buffers, sample_count, (unsigned)after); + fclose(file); + } + } + free(pixels); +} + static void record_frame(BongoCatApp *app, const unsigned char *pixels, int width, int height, size_t pitch) { if (!app->smoke_frame_series) return; @@ -89,32 +167,13 @@ static void record_frame(BongoCatApp *app, const unsigned char *pixels, } void bongo_cat_frame_audit(BongoCatApp *app, int width, int height) { - if (!app || !app->smoke || width < 2 || height < 2) return; - char path[BONGO_CAT_PATH_CAP]; + if (!app || width < 2 || height < 2) return; if (!app->smoke_frame_audited) { app->smoke_frame_audited = true; - const int points[][2] = {{0, 0}, {width - 1, 0}, {0, height - 1}, - {width - 1, height - 1}, {width / 2, height / 2}}; - unsigned transparent = 0, opaque = 0; - for (size_t i = 0; i < sizeof(points) / sizeof(points[0]); ++i) { - unsigned char pixel[4] = {0}; - glReadPixels(points[i][0], points[i][1], 1, 1, GL_RGBA, - GL_UNSIGNED_BYTE, pixel); - if (pixel[3] < 16) transparent++; - if (pixel[3] > 239) opaque++; - } - bongo_cat_path_join(path, sizeof(path), app->data_root, "frame-alpha.txt"); - FILE *file = bongo_cat_file_open(path, "wb"); - if (file) { - int sample_buffers = 0, sample_count = 0; - SDL_GL_GetAttribute(SDL_GL_MULTISAMPLEBUFFERS, &sample_buffers); - SDL_GL_GetAttribute(SDL_GL_MULTISAMPLESAMPLES, &sample_count); - fprintf(file, "samples=5 transparent=%u opaque=%u sample_buffers=%d " - "sample_count=%d gl_error=%u\n", transparent, opaque, - sample_buffers, sample_count, (unsigned)glGetError()); - fclose(file); - } + log_first_frame(app, width, height); } + if (!app->smoke) return; + char path[BONGO_CAT_PATH_CAP]; size_t pitch = (size_t)width * 4, bytes = pitch * (size_t)height; unsigned char *pixels = malloc(bytes); if (!pixels) return; diff --git a/src/runtime/runtime.h b/src/runtime/runtime.h index 2032ba62..b13fc63c 100644 --- a/src/runtime/runtime.h +++ b/src/runtime/runtime.h @@ -82,6 +82,7 @@ void bongo_cat_live2d_audit_run(BongoCatApp *app); bool bongo_cat_live2d_visual_audit_run(BongoCatApp *app); bool bongo_cat_live2d_viewer_audit_run(BongoCatApp *app); void bongo_cat_frame_audit(BongoCatApp *app, int width, int height); +void bongo_cat_window_clear_background(BongoCatApp *app); void bongo_cat_frame_capture_pending(BongoCatApp *app, int width, int height); void bongo_cat_app_render_now(BongoCatApp *app); void bongo_cat_runtime_flow_update(BongoCatApp *app, uint64_t now); diff --git a/src/runtime/startup.c b/src/runtime/startup.c index 36ac4b9b..46444a38 100644 --- a/src/runtime/startup.c +++ b/src/runtime/startup.c @@ -14,21 +14,33 @@ #endif static char startup_log_path[BONGO_CAT_PATH_CAP]; +static char desktop_log_path[BONGO_CAT_PATH_CAP]; static bool startup_is_ready; +static void append_log(const char *path, int category, + SDL_LogPriority priority, const char *message) { + FILE *file = path && path[0] ? bongo_cat_file_open(path, "ab") : NULL; + if (!file) return; + fprintf(file, "%lld [%d:%d] %s\n", (long long)time(NULL), category, + (int)priority, message ? message : ""); + fclose(file); +} + static void SDLCALL log_output(void *userdata, int category, SDL_LogPriority priority, const char *message) { (void)userdata; - FILE *file = startup_log_path[0] - ? bongo_cat_file_open(startup_log_path, "ab") : NULL; - if (file) { - fprintf(file, "%lld [%d:%d] %s\n", (long long)time(NULL), category, - (int)priority, message ? message : ""); - fclose(file); - } + append_log(startup_log_path, category, priority, message); + append_log(desktop_log_path, category, priority, message); fprintf(stderr, "%s\n", message ? message : ""); } +static bool reset_log(const char *path) { + FILE *file = path && path[0] ? bongo_cat_file_open(path, "wb") : NULL; + if (!file) return false; + fprintf(file, "BongoCat %s startup log\n", BONGO_CAT_VERSION); + return fclose(file) == 0; +} + static bool store_argument(char *target, size_t capacity, const char *value, const char *name, BongoCatError *error) { int length = snprintf(target, capacity, "%s", value ? value : ""); @@ -167,10 +179,21 @@ static void begin_log(BongoCatApp *app) { } if (bongo_cat_path_is_file(startup_log_path)) bongo_cat_file_replace(startup_log_path, previous, true); - FILE *file = bongo_cat_file_open(startup_log_path, "wb"); - if (file) { fprintf(file, "BongoCat %s startup log\n", BONGO_CAT_VERSION); fclose(file); } + reset_log(startup_log_path); +#ifdef _WIN32 + const char *desktop = SDL_GetUserFolder(SDL_FOLDER_DESKTOP); + if (!desktop || !bongo_cat_path_join(desktop_log_path, + sizeof(desktop_log_path), desktop, "BongoCat-OBS-diagnostic.log") || + !reset_log(desktop_log_path)) desktop_log_path[0] = '\0'; +#endif SDL_SetLogOutputFunction(log_output, NULL); SDL_Log("Startup on %s; data=%s", SDL_GetPlatform(), app->data_root); +#ifdef _WIN32 + if (desktop_log_path[0]) + SDL_Log("Desktop diagnostic log: %s", desktop_log_path); + else SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Desktop diagnostic log is unavailable; using the application data log"); +#endif if (interrupted[0]) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Previous startup ended before readiness at stage: %s", interrupted); } diff --git a/src/runtime/tray.c b/src/runtime/tray.c index 6d7d9e76..869a143f 100644 --- a/src/runtime/tray.c +++ b/src/runtime/tray.c @@ -20,7 +20,9 @@ struct BongoCatTray { SDL_TrayEntry *exit; BongoCatImage icon; BongoCatModalFrame modal_frame; - bool state_valid; + bool state_valid, restore_requested; + uint64_t restore_after_ns; + unsigned restore_count; bool last_visible, last_pass_through, last_always_on_top; BongoCatLanguage last_language; }; @@ -78,6 +80,15 @@ static void on_exit(void *userdata, SDL_TrayEntry *entry) { ((BongoCatTray *)userdata)->app->running = false; } +static void on_tray_restore(void *userdata) { + BongoCatTray *tray = userdata; + if (!tray) return; + if (!tray->restore_requested) + SDL_Log("Windows shell restarted; scheduling tray restoration"); + tray->restore_requested = true; + tray->restore_after_ns = 0; +} + static SDL_TrayEntry *add(SDL_TrayMenu *menu, const char *label, SDL_TrayEntryFlags flags, SDL_TrayCallback callback, BongoCatTray *tray) { SDL_TrayEntry *entry = SDL_InsertTrayEntryAt(menu, -1, label, flags); @@ -85,6 +96,59 @@ static SDL_TrayEntry *add(SDL_TrayMenu *menu, const char *label, return entry; } +static void destroy_native_tray(BongoCatTray *tray) { + if (!tray || !tray->handle) return; + bongo_cat_platform_set_tray_callbacks( + tray->handle, NULL, NULL, NULL, NULL); + SDL_DestroyTray(tray->handle); + tray->handle = NULL; + tray->visible = tray->pass_through = tray->always_on_top = NULL; + tray->preferences = tray->exit = NULL; + tray->state_valid = false; +} + +static bool create_native_tray(BongoCatTray *tray, BongoCatError *error) { + tray->handle = SDL_CreateTray(tray->icon.surface, BONGO_CAT_NAME); + if (!tray->handle) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, + "Tray creation failed: %s", SDL_GetError()); + return false; + } + SDL_TrayMenu *menu = SDL_CreateTrayMenu(tray->handle); + SDL_TrayEntry *separator1 = NULL, *separator2 = NULL; + if (menu) { + tray->preferences = add(menu, bongo_cat_i18n_get(tray->app->i18n, + "composables.useAppMenu.labels.preference", "Preferences"), + SDL_TRAYENTRY_BUTTON, on_preferences, tray); + separator1 = add(menu, NULL, 0, NULL, tray); + tray->visible = add(menu, bongo_cat_i18n_get(tray->app->i18n, + "composables.useAppMenu.labels.showCat", "Show BongoCat"), + SDL_TRAYENTRY_CHECKBOX, on_visible, tray); + tray->pass_through = add(menu, bongo_cat_i18n_get(tray->app->i18n, + "composables.useAppMenu.labels.passThrough", "Mouse pass-through"), + SDL_TRAYENTRY_CHECKBOX, on_pass_through, tray); + tray->always_on_top = add(menu, bongo_cat_i18n_get(tray->app->i18n, + "composables.useAppMenu.labels.alwaysOnTop", "Always on top"), + SDL_TRAYENTRY_CHECKBOX, on_always_on_top, tray); + separator2 = add(menu, NULL, 0, NULL, tray); + tray->exit = add(menu, bongo_cat_i18n_get(tray->app->i18n, + "composables.useAppMenu.labels.quitApp", "Exit"), + SDL_TRAYENTRY_BUTTON, on_exit, tray); + } + if (!menu || !tray->preferences || !separator1 || !tray->visible || + !tray->pass_through || !tray->always_on_top || !separator2 || + !tray->exit) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, + "Tray menu creation failed: %s", SDL_GetError()); + destroy_native_tray(tray); + return false; + } + bongo_cat_platform_set_tray_callbacks(tray->handle, + on_tray_left_click, on_tray_modal_frame, on_tray_restore, tray); + tray->state_valid = false; + return true; +} + BongoCatTray *bongo_cat_tray_create(BongoCatApp *app, BongoCatError *error) { if (!app || !app->config.app.tray_visible) return NULL; BongoCatTray *tray = calloc(1, sizeof(*tray)); @@ -102,38 +166,37 @@ BongoCatTray *bongo_cat_tray_create(BongoCatApp *app, BongoCatError *error) { bongo_cat_tray_destroy(tray); return NULL; } - tray->handle = SDL_CreateTray(tray->icon.surface, BONGO_CAT_NAME); - bongo_cat_image_free(&tray->icon); - if (!tray->handle) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, "Tray creation failed: %s", SDL_GetError()); + if (!create_native_tray(tray, error)) { bongo_cat_tray_destroy(tray); return NULL; } - SDL_TrayMenu *menu = SDL_CreateTrayMenu(tray->handle); - tray->preferences = add(menu, bongo_cat_i18n_get(app->i18n, - "composables.useAppMenu.labels.preference", "Preferences"), SDL_TRAYENTRY_BUTTON, - on_preferences, tray); - add(menu, NULL, 0, NULL, tray); - tray->visible = add(menu, bongo_cat_i18n_get(app->i18n, - "composables.useAppMenu.labels.showCat", "Show BongoCat"), SDL_TRAYENTRY_CHECKBOX, - on_visible, tray); - tray->pass_through = add(menu, bongo_cat_i18n_get(app->i18n, - "composables.useAppMenu.labels.passThrough", "Mouse pass-through"), SDL_TRAYENTRY_CHECKBOX, - on_pass_through, tray); - tray->always_on_top = add(menu, bongo_cat_i18n_get(app->i18n, - "composables.useAppMenu.labels.alwaysOnTop", "Always on top"), SDL_TRAYENTRY_CHECKBOX, - on_always_on_top, tray); - add(menu, NULL, 0, NULL, tray); - tray->exit = add(menu, bongo_cat_i18n_get(app->i18n, - "composables.useAppMenu.labels.quitApp", "Exit"), SDL_TRAYENTRY_BUTTON, on_exit, tray); - bongo_cat_platform_set_tray_callbacks(tray->handle, - on_tray_left_click, on_tray_modal_frame, tray); + SDL_Log("Tray created"); bongo_cat_tray_sync(tray); return tray; } +static bool restore_native_tray(BongoCatTray *tray) { + if (!tray->restore_requested) return tray->handle != NULL; + uint64_t now = SDL_GetTicksNS(); + if (now < tray->restore_after_ns) return tray->handle != NULL; + destroy_native_tray(tray); + BongoCatError error = {0}; + if (!create_native_tray(tray, &error)) { + tray->restore_requested = true; + tray->restore_after_ns = now + 1000000000ull; + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Tray restoration failed; retrying: %s", error.message); + return false; + } + tray->restore_requested = false; + tray->restore_count++; + SDL_Log("Tray restored after Windows shell restart (count=%u)", + tray->restore_count); + return true; +} + void bongo_cat_tray_sync(BongoCatTray *tray) { - if (!tray) return; + if (!tray || !restore_native_tray(tray)) return; BongoCatApp *app = tray->app; if (tray->state_valid && tray->last_visible == app->config.window.visible && tray->last_pass_through == app->config.window.pass_through && @@ -170,6 +233,14 @@ bool bongo_cat_tray_self_test(BongoCatTray *tray) { const SDL_TrayEntry **entries = SDL_GetTrayEntries( SDL_GetTrayEntryParent(tray->preferences), &count); if (!entries || count < 1 || entries[0] != tray->preferences) return false; +#ifdef _WIN32 + on_tray_restore(tray); + bongo_cat_tray_sync(tray); + if (tray->restore_requested || !tray->handle || !tray->preferences || + !tray->visible || !tray->pass_through || !tray->always_on_top || + !tray->exit) return false; + SDL_Log("Tray self-test: shell restart restored"); +#endif BongoCatApp *app = tray->app; bool visible = app->config.window.visible; bool pass_through = app->config.window.pass_through; @@ -195,8 +266,7 @@ bool bongo_cat_tray_self_test(BongoCatTray *tray) { void bongo_cat_tray_destroy(BongoCatTray *tray) { if (!tray) return; - bongo_cat_platform_set_tray_callbacks(tray->handle, NULL, NULL, NULL); - if (tray->handle) SDL_DestroyTray(tray->handle); + destroy_native_tray(tray); bongo_cat_image_free(&tray->icon); free(tray); } diff --git a/src/runtime/window.c b/src/runtime/window.c index 640904d6..9e5c79f8 100644 --- a/src/runtime/window.c +++ b/src/runtime/window.c @@ -63,9 +63,16 @@ BongoCatResult bongo_cat_window_create(BongoCatApp *app, BongoCatError *error) { int sample_buffers = 0, sample_count = 0; SDL_GL_GetAttribute(SDL_GL_MULTISAMPLEBUFFERS, &sample_buffers); SDL_GL_GetAttribute(SDL_GL_MULTISAMPLESAMPLES, &sample_count); + const GLubyte *vendor = glGetString(GL_VENDOR); + const GLubyte *renderer = glGetString(GL_RENDERER); + const GLubyte *version = glGetString(GL_VERSION); SDL_Log("OpenGL window ready (transparent=%d, MSAA=%d, " "sample_buffers=%d, sample_count=%d)", options[i][0], options[i][1], sample_buffers, sample_count); + SDL_Log("OpenGL context: vendor=%s renderer=%s version=%s", + vendor ? (const char *)vendor : "unknown", + renderer ? (const char *)renderer : "unknown", + version ? (const char *)version : "unknown"); if (!SDL_GL_SetSwapInterval(1)) SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, "Vertical sync unavailable: %s", SDL_GetError()); return BONGO_CAT_OK; diff --git a/src/runtime/window_background.c b/src/runtime/window_background.c new file mode 100644 index 00000000..a33b1e45 --- /dev/null +++ b/src/runtime/window_background.c @@ -0,0 +1,23 @@ +#include "runtime.h" + +#include + +void bongo_cat_window_clear_background(BongoCatApp *app) { + BongoCatWindowOptions *window = &app->config.window; + uint32_t rgb = bongo_cat_obs_background_color_rgb( + window->obs_background_color); + float alpha = window->obs_background ? 1.0f : 0.0f; + glClearColor(window->obs_background ? ((rgb >> 16) & 255) / 255.0f : 0.0f, + window->obs_background ? ((rgb >> 8) & 255) / 255.0f : 0.0f, + window->obs_background ? (rgb & 255) / 255.0f : 0.0f, alpha); + glClear(GL_COLOR_BUFFER_BIT); + static int last_enabled = -1, last_color = -1; + if (last_enabled != window->obs_background || + last_color != window->obs_background_color) { + last_enabled = window->obs_background; + last_color = window->obs_background_color; + SDL_Log("OBS background mode: enabled=%d color=%s", + window->obs_background, + bongo_cat_obs_background_color_name(window->obs_background_color)); + } +} diff --git a/src/ui/preferences_controls.h b/src/ui/preferences_controls.h index 037f3a6a..b44aa679 100644 --- a/src/ui/preferences_controls.h +++ b/src/ui/preferences_controls.h @@ -2,6 +2,7 @@ #define BONGO_CAT_PREFERENCES_CONTROLS_H #include +#include "bongo_cat/config.h" #include "nuklear_config.h" bool bongo_cat_pref_control_float(struct nk_context *context, const char *id, @@ -14,6 +15,8 @@ bool bongo_cat_pref_control_slider(struct nk_context *context, const char *id, float default_value); bool bongo_cat_pref_control_toggle(struct nk_context *context, const char *id, bool *value); +bool bongo_cat_pref_control_obs_background(struct nk_context *context, + const char *id, bool *enabled, BongoCatObsBackgroundColor *color); int bongo_cat_pref_control_combo(struct nk_context *context, const char *id, const char *const *items, int count, int selected); bool bongo_cat_pref_controls_animating(struct nk_context *context); diff --git a/src/ui/preferences_pages.c b/src/ui/preferences_pages.c index 19f264f3..9a5fa75b 100644 --- a/src/ui/preferences_pages.c +++ b/src/ui/preferences_pages.c @@ -41,6 +41,13 @@ void bongo_cat_preferences_page_cat(BongoCatApp *app, struct nk_context *context "pages.preference.cat.labels.keepInScreen", "Keep on Screen"), "", &window->keep_in_screen) && window->keep_in_screen) bongo_cat_window_clamp_to_display(app); + if (bongo_cat_pref_obs_background(context, "obs-background", tr(app, + "pages.preference.cat.labels.obsBackground", "OBS Background"), tr(app, + "pages.preference.cat.hints.obsBackground", "Enable when OBS Window " + "Capture is black.\nUse the Windows 7 compatibility method and remove " + "the background with a color key filter."), + &window->obs_background, &window->obs_background_color)) + app->dirty = true; float old_scale = window->scale_percent; bongo_cat_pref_float(context, "window-size", tr(app, "pages.preference.cat.labels.windowSize", "Window Size"), tr(app, diff --git a/src/ui/preferences_toggle.c b/src/ui/preferences_toggle.c index 709e4e8d..ea6b45b4 100644 --- a/src/ui/preferences_toggle.c +++ b/src/ui/preferences_toggle.c @@ -3,13 +3,12 @@ #include "ui_backend.h" #include "ui_catime.h" #include "ui_paint.h" +#include "bongo_cat/config.h" #include -bool bongo_cat_pref_control_toggle(struct nk_context *context, - const char *id, bool *value) { - struct nk_rect cell; - if (nk_widget(&cell, context) == NK_WIDGET_INVALID) return false; +static bool toggle_at(struct nk_context *context, const char *id, + bool *value, struct nk_rect cell) { const float effect_margin = 18.0f; struct nk_rect track = nk_rect(cell.x + cell.w - 46 - effect_margin, cell.y + (cell.h - 24) * .5f, 46, 24); @@ -53,3 +52,58 @@ bool bongo_cat_pref_control_toggle(struct nk_context *context, BONGO_CAT_UI_CURSOR_POINTER); return changed; } + +bool bongo_cat_pref_control_toggle(struct nk_context *context, + const char *id, bool *value) { + struct nk_rect cell; + if (nk_widget(&cell, context) == NK_WIDGET_INVALID) return false; + return toggle_at(context, id, value, cell); +} + +static bool draw_swatches(struct nk_context *context, const char *id, + struct nk_rect cell, BongoCatObsBackgroundColor *selected) { + const float slot = 25.0f, switch_width = 64.0f; + float left = cell.x + cell.w - switch_width - + BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT * slot - 7.0f; + BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); + struct nk_command_buffer *canvas = nk_window_get_canvas(context); + bool changed = false; + for (int i = 0; i < BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT; ++i) { + struct nk_rect hit = nk_rect(left + i * slot, + cell.y + (cell.h - 24.0f) * .5f, 24.0f, 24.0f); + bool hover = nk_input_is_mouse_hovering_rect(&context->input, hit); + if (nk_input_is_mouse_click_in_rect(&context->input, + NK_BUTTON_LEFT, hit)) { + *selected = (BongoCatObsBackgroundColor)i; + changed = true; + } + char animation_id[96]; + snprintf(animation_id, sizeof(animation_id), + "obs-background-%s-%d", id, i); + float amount = bongo_cat_ui_animate_eased(context, animation_id, + *selected == i ? 1.0f : 0.0f, 180.0f, + BONGO_CAT_UI_EASE_STANDARD); + float size = 14.0f + 6.0f * amount; + struct nk_rect circle = nk_rect(hit.x + (hit.w - size) * .5f, + hit.y + (hit.h - size) * .5f, size, size); + uint32_t rgb = bongo_cat_obs_background_color_rgb( + (BongoCatObsBackgroundColor)i); + if (amount > 0.0f && p.effects) + bongo_cat_ui_paint_shadow(context, circle, size * .5f, + 0, 1, 4, 0, nk_rgba(0, 0, 0, 51)); + nk_fill_circle(canvas, circle, nk_rgb((rgb >> 16) & 255, + (rgb >> 8) & 255, rgb & 255)); + nk_stroke_circle(canvas, circle, 1.0f, p.border); + if (hover) bongo_cat_ui_cursor_hover_rect(context, hit, + BONGO_CAT_UI_CURSOR_POINTER); + } + return changed; +} + +bool bongo_cat_pref_control_obs_background(struct nk_context *context, + const char *id, bool *enabled, BongoCatObsBackgroundColor *color) { + struct nk_rect cell; + if (nk_widget(&cell, context) == NK_WIDGET_INVALID) return false; + bool changed = toggle_at(context, id, enabled, cell); + return (*enabled && draw_swatches(context, id, cell, color)) || changed; +} diff --git a/src/ui/preferences_widgets.c b/src/ui/preferences_widgets.c index 3b15ee96..6c19009e 100644 --- a/src/ui/preferences_widgets.c +++ b/src/ui/preferences_widgets.c @@ -6,7 +6,6 @@ #include "ui_catime.h" #include "ui_icons.h" #include "ui_paint.h" - #include #include #include @@ -20,14 +19,35 @@ typedef struct FormStyle { } FormStyle; static struct nk_context *section_context; static bool section_first; +static const float description_line_height = 19.0f; +static bool has_line_break(const char *text) { + if (!text) return false; + while (*text) { + if (*text++ == '\n') return true; + } + return false; +} +static int wrapped_line_count(const struct nk_user_font *font, + const char *text, int length, float width) { + if (length <= 0) return 1; + float measured = font->width(font->userdata, font->height, text, length); + return (int)(measured / width) + 1; +} static int detail_lines(const struct nk_context *context, const char *text) { if (!text || !text[0]) return 0; const struct nk_user_font *font = bongo_cat_ui_caption_font(context); - float width = nk_window_get_content_region(context).w - 310.0f; + float width = nk_window_get_content_region(context).w - + (has_line_break(text) ? 30.0f : 310.0f); if (width < 220.0f) width = 220.0f; - float measured = font->width(font->userdata, font->height, - text, nk_strlen(text)); - int lines = (int)(measured / width) + 1; + int lines = 0; + const char *segment = text; + for (;;) { + const char *end = segment; + while (*end && *end != '\n') ++end; + lines += wrapped_line_count(font, segment, (int)(end - segment), width); + if (!*end) break; + segment = end + 1; + } return NK_CLAMP(1, lines, 3); } static void restore_style(struct nk_context *context, const FormStyle *saved) { @@ -53,7 +73,7 @@ static bool form_begin(struct nk_context *context, const char *id, context->style.window.group_padding = nk_vec2(13, 11); context->style.window.spacing = nk_vec2(8, 3); context->style.window.group_border = 0; - float height = lines ? 92.0f : 76.0f; + float height = lines ? 70.0f + description_line_height * lines : 76.0f; if (section_context == context && section_first && !lines) height = 69.0f; if (section_context == context) section_first = false; nk_layout_row_dynamic(context, height, 1); @@ -64,9 +84,7 @@ static bool form_begin(struct nk_context *context, const char *id, return true; } static void form_end(struct nk_context *context, const FormStyle *saved) { - nk_group_end(context); - restore_style(context, saved); -} + nk_group_end(context); restore_style(context, saved); } static float left_width(struct nk_context *context) { return NK_MAX(220.0f, nk_window_get_content_region(context).w - 228.0f); } @@ -84,26 +102,46 @@ static void form_title_sized(struct nk_context *context, const char *title, nk_style_pop_font(context); nk_layout_row_push(context, NK_MAX(control_width, available - left - 8.0f)); } -static void form_title(struct nk_context *context, const char *title) { - form_title_sized(context, title, 220.0f); -} +static void form_title(struct nk_context *context, const char *title) { form_title_sized(context, title, 220.0f); } static void description(struct nk_context *context, const char *text, int lines) { if (!lines) return; BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); - float left = left_width(context); - nk_layout_row_begin(context, NK_STATIC, 22.0f * lines, 2); - nk_layout_row_push(context, left); + float available = nk_window_get_content_region(context).w; + bool multiline = has_line_break(text); + float left = multiline ? available : left_width(context); + float wrap_width = available - (multiline ? 10.0f : 310.0f); + if (wrap_width < 220.0f) wrap_width = 220.0f; + const struct nk_user_font *font = bongo_cat_ui_caption_font(context); nk_style_push_font(context, bongo_cat_ui_caption_font(context)); struct nk_vec2 text_padding = context->style.text.padding; + float row_spacing = context->style.window.spacing.y; context->style.text.padding.x += 5.0f; - nk_label_colored_wrap(context, text, p.muted); + context->style.window.spacing.y = 0; + const char *segment = text; + int remaining = lines; + while (remaining > 0) { + const char *end = segment; + while (*end && *end != '\n') ++end; + int segment_lines = wrapped_line_count(font, segment, + (int)(end - segment), wrap_width); + segment_lines = NK_MIN(segment_lines, remaining); + nk_layout_row_begin(context, NK_STATIC, + description_line_height * segment_lines, multiline ? 1 : 2); + nk_layout_row_push(context, left); + nk_text_wrap_colored(context, segment, (int)(end - segment), p.muted); + if (!multiline) { + nk_layout_row_push(context, NK_MAX(1.0f, available - left - 8.0f)); + nk_spacing(context, 1); + } + nk_layout_row_end(context); + remaining -= segment_lines; + if (!*end) break; + segment = end + 1; + } + context->style.window.spacing.y = row_spacing; context->style.text.padding = text_padding; nk_style_pop_font(context); - nk_layout_row_push(context, NK_MAX(1.0f, - nk_window_get_content_region(context).w - left - 8.0f)); - nk_spacing(context, 1); - nk_layout_row_end(context); } static bool secondary_button(struct nk_context *context, const char *label) { BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); @@ -136,7 +174,6 @@ void bongo_cat_pref_section(struct nk_context *context, const char *title) { nk_draw_text(canvas, text, title, nk_strlen(title), font, nk_rgba(0, 0, 0, 0), p.text); } - bool bongo_cat_pref_toggle(struct nk_context *context, const char *id, const char *title, const char *detail, bool *value) { int lines = detail_lines(context, detail); FormStyle saved; @@ -146,6 +183,16 @@ bool bongo_cat_pref_toggle(struct nk_context *context, const char *id, nk_layout_row_end(context); description(context, detail, lines); form_end(context, &saved); return changed; } +bool bongo_cat_pref_obs_background(struct nk_context *context, const char *id, + const char *title, const char *detail, bool *enabled, + BongoCatObsBackgroundColor *color) { + int lines = detail_lines(context, detail); FormStyle saved; + if (!form_begin(context, id, lines, &saved)) return false; + form_title_sized(context, title, 230.0f); + bool changed = bongo_cat_pref_control_obs_background(context, id, enabled, color); + nk_layout_row_end(context); description(context, detail, lines); + form_end(context, &saved); return changed; +} bool bongo_cat_pref_float(struct nk_context *context, const char *id, const char *title, const char *detail, float minimum, float *value, float maximum, float step, float default_value) { @@ -179,7 +226,6 @@ bool bongo_cat_pref_slider(struct nk_context *context, const char *id, nk_layout_row_end(context); description(context, detail, lines); form_end(context, &saved); return changed; } - int bongo_cat_pref_combo(struct nk_context *context, const char *id, const char *title, const char *detail, const char *const *items, int count, int selected) { @@ -191,7 +237,6 @@ int bongo_cat_pref_combo(struct nk_context *context, const char *id, nk_layout_row_end(context); description(context, detail, lines); form_end(context, &saved); return selected; } - int bongo_cat_pref_edit(struct nk_context *context, const char *id, const char *title, const char *detail, const char *value, bool recording, const char *idle_hint, const char *record_hint) { @@ -272,7 +317,6 @@ int bongo_cat_pref_edit(struct nk_context *context, const char *id, form_end(context, &saved); return clear ? -1 : clicked; } - bool bongo_cat_pref_button(struct nk_context *context, const char *id, const char *title, const char *detail, const char *button) { int lines = detail_lines(context, detail); FormStyle saved; @@ -281,7 +325,6 @@ bool bongo_cat_pref_button(struct nk_context *context, const char *id, nk_layout_row_end(context); description(context, detail, lines); form_end(context, &saved); return result; } - void bongo_cat_pref_status(struct nk_context *context, const char *id, const char *title, const char *detail) { int lines = detail_lines(context, detail); FormStyle saved; diff --git a/src/ui/preferences_widgets.h b/src/ui/preferences_widgets.h index 550da408..09cd35cf 100644 --- a/src/ui/preferences_widgets.h +++ b/src/ui/preferences_widgets.h @@ -2,11 +2,15 @@ #define BONGO_CAT_PREFERENCES_WIDGETS_H #include +#include "bongo_cat/config.h" #include "nuklear_config.h" void bongo_cat_pref_section(struct nk_context *context, const char *title); bool bongo_cat_pref_toggle(struct nk_context *context, const char *id, const char *title, const char *description, bool *value); +bool bongo_cat_pref_obs_background(struct nk_context *context, const char *id, + const char *title, const char *description, bool *enabled, + BongoCatObsBackgroundColor *color); bool bongo_cat_pref_float(struct nk_context *context, const char *id, const char *title, const char *description, float minimum, float *value, float maximum, float step, float default_value); diff --git a/tests/test_config.c b/tests/test_config.c index 2b30836c..b0dfc120 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -31,15 +31,27 @@ void test_config(void) { CHECK(value.window.width == 612 && value.window.height == 354); CHECK(value.window.visible && value.window.always_on_top); CHECK(!value.window.keep_in_screen); + CHECK(!value.window.obs_background); + CHECK(value.window.obs_background_color == BONGO_CAT_OBS_BACKGROUND_GREEN); + const uint32_t obs_colors[] = { + 0x00ff00, 0x0000ff, 0xff0000, 0xff00ff}; + const char *obs_names[] = { + "#00ff00", "#0000ff", "#ff0000", "#ff00ff"}; + for (int i = 0; i < BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT; ++i) { + CHECK(bongo_cat_obs_background_color_rgb(i) == obs_colors[i]); + CHECK(strcmp(bongo_cat_obs_background_color_name(i), obs_names[i]) == 0); + } CHECK(value.model.mouse_centered); value.model.max_fps = 900; value.window.scale_percent = -2.0f; value.window.opacity_percent = 123.0f; + value.window.obs_background_color = BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT; bongo_cat_config_validate(&value); CHECK(value.model.max_fps == 240); CHECK(value.window.scale_percent == 10.0f); CHECK(value.window.opacity_percent == 100.0f); + CHECK(value.window.obs_background_color == BONGO_CAT_OBS_BACKGROUND_GREEN); BongoCatConfig duplicates; bongo_cat_config_defaults(&duplicates); @@ -67,6 +79,8 @@ void test_config(void) { value.model.mirror = true; value.model.mouse_centered = false; value.window.pass_through = true; + value.window.obs_background = true; + value.window.obs_background_color = BONGO_CAT_OBS_BACKGROUND_BLUE; value.window.x = -321; value.window.opacity_percent = 75.0f; value.app.language = BONGO_CAT_LANG_ZH_CN; @@ -89,6 +103,8 @@ void test_config(void) { CHECK(!contains_text(preferences, "\"motionSound\"")); CHECK(!contains_text(preferences, "\"behavior\"")); CHECK(!contains_text(preferences, "\"taskbarVisible\"")); + CHECK(contains_text(preferences, "\"obsBackground\": true")); + CHECK(contains_text(preferences, "\"obsBackgroundColor\": \"#0000ff\"")); CHECK(bongo_cat_session_save(session, &value, &error) == BONGO_CAT_OK); CHECK(bongo_cat_path_is_file(preferences)); CHECK(bongo_cat_path_is_file(session)); @@ -99,6 +115,8 @@ void test_config(void) { CHECK(loaded.model.max_fps == 30 && loaded.model.mirror); CHECK(!loaded.model.mouse_centered); CHECK(loaded.window.pass_through); + CHECK(loaded.window.obs_background); + CHECK(loaded.window.obs_background_color == BONGO_CAT_OBS_BACKGROUND_BLUE); CHECK(loaded.window.x == 0); CHECK(loaded.window.opacity_percent == 100.0f); CHECK(loaded.app.language == BONGO_CAT_LANG_ZH_CN); diff --git a/tests/test_windows_capture.c b/tests/test_windows_capture.c index 4f498d82..4aff8ce1 100644 --- a/tests/test_windows_capture.c +++ b/tests/test_windows_capture.c @@ -2,6 +2,7 @@ #include "windows_direct_input.h" #include "windows_input.h" #include "windows_popup.h" +#include "windows_tray.h" #include #include @@ -63,20 +64,76 @@ static void test_direct_input_rebase(void) { DestroyWindow(window); } -int main(void) { - HWND window = CreateWindowExW(WS_EX_TOOLWINDOW, L"STATIC", +static void count_tray_restore(void *userdata) { + int *count = userdata; + (*count)++; +} + +static void test_tray_restart_notification(void) { + int restores = 0; + void *tray = &restores; + bongo_cat_platform_set_tray_callbacks( + tray, NULL, NULL, count_tray_restore, &restores); + bongo_cat_windows_tray_handle_message(WM_NULL); + CHECK(restores == 0); + UINT taskbar_created = RegisterWindowMessageW(L"TaskbarCreated"); + CHECK(taskbar_created != 0); + bongo_cat_windows_tray_handle_message(taskbar_created); + CHECK(restores == 1); + bongo_cat_platform_set_tray_callbacks(tray, NULL, NULL, NULL, NULL); + bongo_cat_windows_tray_handle_message(taskbar_created); + CHECK(restores == 1); +} + +static void test_capture_styles(void) { + HWND window = CreateWindowExW(WS_EX_TOOLWINDOW | WS_EX_LAYERED | + WS_EX_TRANSPARENT, L"STATIC", L"BongoCat capture test", WS_POPUP, 0, 0, 32, 32, NULL, NULL, GetModuleHandleW(NULL), NULL); CHECK(window != NULL); if (window) { CHECK(bongo_cat_windows_capture_configure(window)); LONG_PTR style = GetWindowLongPtrW(window, GWL_EXSTYLE); - CHECK((style & WS_EX_APPWINDOW) != 0); + CHECK((style & WS_EX_APPWINDOW) == 0); CHECK((style & WS_EX_TOOLWINDOW) == 0); + CHECK((style & WS_EX_NOREDIRECTIONBITMAP) == 0); + CHECK((style & WS_EX_LAYERED) != 0); + CHECK((style & WS_EX_TRANSPARENT) != 0); + CHECK(bongo_cat_windows_capture_configure(window)); + CHECK(GetWindowLongPtrW(window, GWL_EXSTYLE) == style); CHECK(!bongo_cat_windows_capture_handle_message(window, WM_NULL, 0)); CHECK(!bongo_cat_windows_capture_handle_message(window, WM_TIMER, 1)); DestroyWindow(window); } + window = CreateWindowExW(WS_EX_NOREDIRECTIONBITMAP, L"STATIC", + L"BongoCat redirection test", WS_POPUP, + 0, 0, 32, 32, NULL, NULL, GetModuleHandleW(NULL), NULL); + CHECK(window != NULL); + if (window) { + CHECK(!bongo_cat_windows_capture_configure(window)); + LONG_PTR style = GetWindowLongPtrW(window, GWL_EXSTYLE); + CHECK((style & WS_EX_TOOLWINDOW) == 0); + CHECK((style & WS_EX_NOREDIRECTIONBITMAP) != 0); + DestroyWindow(window); + } + window = CreateWindowExW(WS_EX_APPWINDOW | WS_EX_LAYERED, L"STATIC", + L"BongoCat capture app-window test", WS_POPUP, 0, 0, 32, 32, + NULL, NULL, GetModuleHandleW(NULL), NULL); + CHECK(window != NULL); + if (window) { + LONG_PTR before = GetWindowLongPtrW(window, GWL_EXSTYLE); + CHECK(bongo_cat_windows_capture_configure(window)); + LONG_PTR after = GetWindowLongPtrW(window, GWL_EXSTYLE); + CHECK((after & WS_EX_APPWINDOW) != 0); + CHECK((after & WS_EX_LAYERED) != 0); + CHECK(after == before); + DestroyWindow(window); + } +} + +int main(void) { + test_capture_styles(); + test_tray_restart_notification(); test_popup_completion(); test_input_startup(); test_direct_input_rebase(); From 00f883c2d5d760aa430269868c9e967a4de8c05b Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 14 Aug 2026 21:44:21 +0800 Subject: [PATCH 009/334] Extend capture diagnostics and fallback guidance --- CMakeLists.txt | 3 +- cmake/CheckLines.cmake | 2 + cmake/RuntimeSources.cmake | 6 +- resources/assets/locales/de-DE.json | 5 +- resources/assets/locales/en-US.json | 5 +- resources/assets/locales/es-ES.json | 5 +- resources/assets/locales/fr-FR.json | 5 +- resources/assets/locales/ja-JP.json | 5 +- resources/assets/locales/ko-KR.json | 5 +- resources/assets/locales/pt-BR.json | 5 +- resources/assets/locales/ru-RU.json | 5 +- resources/assets/locales/zh-CN.json | 5 +- resources/assets/locales/zh-TW.json | 5 +- src/platform/windows_capture_probe.c | 231 +++++++++++++++++++++++++ src/platform/windows_diagnostics.c | 90 ++++++++++ src/platform/windows_diagnostics.h | 3 + src/runtime/app.c | 1 + src/runtime/frame_audit.c | 2 + src/runtime/frame_presentation_audit.c | 179 +++++++++++++++++++ src/runtime/runtime.h | 3 + src/runtime/startup.c | 37 +++- src/ui/preferences_pages.c | 9 +- src/ui/preferences_widgets.c | 8 +- src/ui/preferences_widgets.h | 2 +- src/ui/ui_tooltip.c | 109 ++++++++++++ src/ui/ui_tooltip.h | 9 + tests/test_windows_capture.c | 2 + 27 files changed, 707 insertions(+), 39 deletions(-) create mode 100644 src/platform/windows_capture_probe.c create mode 100644 src/runtime/frame_presentation_audit.c create mode 100644 src/ui/ui_tooltip.c create mode 100644 src/ui/ui_tooltip.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 9b9d6c68..0e4e2987 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -102,7 +102,8 @@ if(WIN32) src/platform/windows_startup.c src/platform/windows_borderless.c src/platform/windows_capture.c src/platform/windows_pointer.c src/platform/windows_direct_input.c src/platform/windows_input.c src/platform/windows_popup.c src/platform/windows_layered.c src/platform/windows_tray.c - src/platform/windows_assets.c src/platform/windows_diagnostics.c) + src/platform/windows_assets.c src/platform/windows_diagnostics.c + src/platform/windows_capture_probe.c) target_link_libraries(bongo_cat_runtime PRIVATE dinput8 dxguid dwmapi ole32 shell32 user32 uuid windowscodecs advapi32) elseif(APPLE) diff --git a/cmake/CheckLines.cmake b/cmake/CheckLines.cmake index d1f43188..e08e95f6 100644 --- a/cmake/CheckLines.cmake +++ b/cmake/CheckLines.cmake @@ -31,6 +31,8 @@ foreach(FILE IN LISTS FILES) set(LIMIT 396) elseif(RELATIVE_FILE STREQUAL "src/tools/mver_phase_metrics.c") set(LIMIT 321) + elseif(RELATIVE_FILE STREQUAL "src/ui/preferences_widgets.c") + set(LIMIT 350) endif() if(COUNT GREATER LIMIT) diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index c430ad33..91391081 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -8,7 +8,8 @@ set(BONGO_CAT_RUNTIME_SOURCES src/render/mver_pointer_overlay.c src/render/mver_pointer_overlay_draw.c src/runtime/app.c src/runtime/config_store.c src/runtime/assets.c src/runtime/startup.c - src/runtime/frame_audit.c src/runtime/frame_clock.c src/runtime/modal_frame.c + src/runtime/frame_audit.c src/runtime/frame_presentation_audit.c + src/runtime/frame_clock.c src/runtime/modal_frame.c src/runtime/gamepad.c src/runtime/live2d_audit.c src/runtime/live2d_visual_audit.c src/runtime/live2d_viewer_audit.c @@ -34,7 +35,8 @@ set(BONGO_CAT_RUNTIME_SOURCES src/runtime/window_menu_behavior.c src/runtime/window_menu_preview.c src/runtime/window_hit.c src/runtime/window_wheel.c src/ui/nuklear_impl.c - src/ui/ui_backend.c src/ui/ui_resize_cache.c src/ui/ui_scale.c + src/ui/ui_backend.c src/ui/ui_tooltip.c src/ui/ui_resize_cache.c + src/ui/ui_scale.c src/ui/ui_cursor.c src/ui/ui_animation.c src/ui/ui_paint.c src/ui/ui_paint_cache.c src/ui/ui_paint_border.c src/ui/ui_paint_shell.c src/ui/ui_font.c src/ui/ui_font_atlas.c src/ui/ui_font_atlas_sources.c diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index e468f116..7376f613 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -22,7 +22,7 @@ "keepInScreen": "Auf dem Bildschirm halten", "windowSize": "Fenstergröße", "opacity": "Deckkraft", - "obsBackground": "OBS-Hintergrund", + "obsBackground": "Einfarbiger Hintergrund", "autoReleaseDelay": "Verzögerung der automatischen Freigabe", "hideOnHover": "Beim Darüberfahren ausblenden", "maxFPS": "Maximale Bildrate" @@ -31,7 +31,8 @@ "passThrough": "Wenn aktiviert, gehen Klicks durch das Fenster hindurch, ohne es zu beeinflussen.", "alwaysOnTop": "Wenn aktiviert, bleibt das Fenster über allen anderen Fenstern.", "keepInScreen": "Wenn aktiviert, bleibt das Fenster automatisch innerhalb der Bildschirmgrenzen.", - "obsBackground": "Bei schwarzer OBS-Fensteraufnahme aktivieren.\nMit der Windows-7-Kompatibilitätsmethode aufnehmen und den Hintergrund per Farbschlüssel-Filter entfernen.", + "obsBackground": "Fensteraufnahme schwarz?", + "obsBackgroundHelp": "OBS: Diese Option aktivieren, die Windows-7-Kompatibilitätsmethode verwenden und den Hintergrund mit einem Farbschlüssel-Filter entfernen.", "windowSize": "Bewegen Sie die Maus an den Fensterrand oder halten Sie Umschalt gedrückt und ziehen Sie mit der rechten Maustaste.", "hideOnHover": "Wenn aktiviert, wird das Fenster beim Darüberfahren mit der Maus automatisch ausgeblendet. Die Verzögerung 0 blendet es sofort aus." } diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index 663315f6..c762ceed 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -22,7 +22,7 @@ "keepInScreen": "Keep on Screen", "windowSize": "Window Size", "opacity": "Opacity", - "obsBackground": "OBS Background", + "obsBackground": "Solid Background", "autoReleaseDelay": "Auto Release Delay", "hideOnHover": "Hide on Hover", "maxFPS": "Max Frame Rate" @@ -31,7 +31,8 @@ "passThrough": "When enabled, clicks pass through the window without affecting it.", "alwaysOnTop": "When enabled, the window stays above all other windows.", "keepInScreen": "When enabled, the window automatically stays within the screen boundaries.", - "obsBackground": "Enable when OBS Window Capture is black.\nUse the Windows 7 compatibility method and remove the background with a color key filter.", + "obsBackground": "Window capture is black?", + "obsBackgroundHelp": "OBS: enable this option, use the Windows 7 compatibility method, and remove the background with a color key filter.", "windowSize": "Move mouse to window edge, or hold Shift and right-drag to resize.", "hideOnHover": "When enabled, the window hides automatically on mouse hover. You can set a delay; 0 means hide immediately." } diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index cf0b21fe..af10e783 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -22,7 +22,7 @@ "keepInScreen": "Mantener en pantalla", "windowSize": "Tamaño de la ventana", "opacity": "Opacidad", - "obsBackground": "Fondo de OBS", + "obsBackground": "Fondo sólido", "autoReleaseDelay": "Retardo de liberación automática", "hideOnHover": "Ocultar al pasar el ratón", "maxFPS": "Frecuencia máxima de fotogramas" @@ -31,7 +31,8 @@ "passThrough": "Al activarlo, los clics atraviesan la ventana sin afectarla.", "alwaysOnTop": "Al activarlo, la ventana permanece por encima de las demás.", "keepInScreen": "Al activarlo, la ventana se mantiene automáticamente dentro de los límites de la pantalla.", - "obsBackground": "Actívalo si la captura de ventana de OBS aparece en negro.\nUsa el método compatible con Windows 7 y elimina el fondo con un filtro de clave de color.", + "obsBackground": "¿La captura de ventana aparece en negro?", + "obsBackgroundHelp": "OBS: activa esta opción, usa el método compatible con Windows 7 y elimina el fondo con un filtro de clave de color.", "windowSize": "Mueva el ratón al borde de la ventana o mantenga Mayús y arrastre con el botón derecho para cambiar el tamaño.", "hideOnHover": "Al activarlo, la ventana se oculta automáticamente al pasar el ratón. Puede definir un retardo; 0 significa ocultar inmediatamente." } diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index 2d43d52c..572575b2 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -22,7 +22,7 @@ "keepInScreen": "Maintenir à l'écran", "windowSize": "Taille de la fenêtre", "opacity": "Opacité", - "obsBackground": "Arrière-plan OBS", + "obsBackground": "Arrière-plan uni", "autoReleaseDelay": "Délai de relâchement automatique", "hideOnHover": "Masquer au survol", "maxFPS": "Fréquence d'images maximale" @@ -31,7 +31,8 @@ "passThrough": "Lorsque cette option est activée, les clics traversent la fenêtre sans l'affecter.", "alwaysOnTop": "Lorsque cette option est activée, la fenêtre reste au-dessus des autres fenêtres.", "keepInScreen": "Lorsque cette option est activée, la fenêtre reste automatiquement dans les limites de l'écran.", - "obsBackground": "Activez cette option si la capture de fenêtre OBS est noire.\nUtilisez la méthode compatible Windows 7 et supprimez le fond avec un filtre d'incrustation couleur.", + "obsBackground": "La capture de fenêtre est noire ?", + "obsBackgroundHelp": "OBS : activez cette option, utilisez la méthode compatible Windows 7 et supprimez le fond avec un filtre d'incrustation couleur.", "windowSize": "Placez la souris sur le bord de la fenêtre, ou maintenez Maj et faites glisser avec le bouton droit pour redimensionner.", "hideOnHover": "Lorsque cette option est activée, la fenêtre se masque au survol de la souris. Vous pouvez définir un délai ; 0 signifie immédiatement." } diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index 591c5733..200172fa 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -22,7 +22,7 @@ "keepInScreen": "画面内に保持", "windowSize": "ウィンドウサイズ", "opacity": "不透明度", - "obsBackground": "OBS 単色背景", + "obsBackground": "単色背景", "autoReleaseDelay": "自動リリースまでの時間", "hideOnHover": "ホバー時に非表示", "maxFPS": "最大フレームレート" @@ -31,7 +31,8 @@ "passThrough": "有効にすると、クリックはウィンドウに影響せず背後へ通過します。", "alwaysOnTop": "有効にすると、ウィンドウがほかのすべてのウィンドウより手前に表示されます。", "keepInScreen": "有効にすると、ウィンドウが自動的に画面の範囲内に収まります。", - "obsBackground": "OBSのウィンドウキャプチャが黒画面になる場合に有効にします。\n「Windows 7互換」方式でキャプチャし、カラーキーフィルターで背景を除去してください。", + "obsBackground": "ウィンドウキャプチャが黒い?", + "obsBackgroundHelp": "OBS:「Windows 7互換」方式でキャプチャする場合はこの項目を有効にし、カラーキーフィルターで背景を除去してください。", "windowSize": "マウスをウィンドウ端へ移動するか、Shiftキーを押しながら右ドラッグしてサイズを変更します。", "hideOnHover": "有効にすると、マウスを重ねたときにウィンドウが自動的に隠れます。遅延を設定でき、0ではすぐに隠れます。" } diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index bfd2d634..cbf8be7e 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -22,7 +22,7 @@ "keepInScreen": "화면 안에 유지", "windowSize": "창 크기", "opacity": "불투명도", - "obsBackground": "OBS 단색 배경", + "obsBackground": "단색 배경", "autoReleaseDelay": "자동 해제 지연", "hideOnHover": "마우스를 올리면 숨기기", "maxFPS": "최대 프레임 속도" @@ -31,7 +31,8 @@ "passThrough": "활성화하면 클릭이 창에 영향을 주지 않고 뒤로 통과합니다.", "alwaysOnTop": "활성화하면 창이 다른 모든 창 위에 유지됩니다.", "keepInScreen": "활성화하면 창이 자동으로 화면 경계 안에 유지됩니다.", - "obsBackground": "OBS 창 캡처가 검은 화면일 때 켜세요.\nWindows 7 호환 방식으로 캡처하고 색상 키 필터로 배경을 제거하세요.", + "obsBackground": "창 캡처가 검은 화면인가요?", + "obsBackgroundHelp": "OBS: 이 옵션을 켜고 Windows 7 호환 방식으로 캡처한 뒤 색상 키 필터로 배경을 제거하세요.", "windowSize": "마우스를 창 가장자리로 이동하거나 Shift를 누른 채 오른쪽 버튼으로 끌어 크기를 조절합니다.", "hideOnHover": "활성화하면 마우스를 올렸을 때 창이 자동으로 숨겨집니다. 지연 시간을 설정할 수 있으며 0은 즉시 숨김입니다." } diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index d0f30e8a..9312682d 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -22,7 +22,7 @@ "keepInScreen": "Manter na Tela", "windowSize": "Tamanho da Janela", "opacity": "Opacidade", - "obsBackground": "Fundo do OBS", + "obsBackground": "Fundo sólido", "autoReleaseDelay": "Atraso de Liberação Automática", "hideOnHover": "Ocultar ao Passar o Mouse", "maxFPS": "Taxa Máxima de Quadros" @@ -31,7 +31,8 @@ "passThrough": "Quando ativado, a janela não afetará operações em outros aplicativos.", "alwaysOnTop": "Quando ativado, a janela sempre ficará acima de outros aplicativos.", "keepInScreen": "Quando ativado, a janela permanece automaticamente dentro dos limites da tela.", - "obsBackground": "Ative se a captura de janela do OBS ficar preta.\nUse o método de compatibilidade com o Windows 7 e remova o fundo com um filtro de chave de cor.", + "obsBackground": "A captura da janela está preta?", + "obsBackgroundHelp": "OBS: ative esta opção, use o método compatível com o Windows 7 e remova o fundo com um filtro de chave de cor.", "windowSize": "Mova o mouse para a borda da janela ou segure Shift e arraste com o botão direito para redimensionar.", "hideOnHover": "Quando ativado, a janela se oculta automaticamente ao passar o mouse. Você pode definir um atraso; 0 significa ocultar imediatamente." } diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index dedf3670..7097e451 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -22,7 +22,7 @@ "keepInScreen": "Удерживать на экране", "windowSize": "Размер окна", "opacity": "Непрозрачность", - "obsBackground": "Фон OBS", + "obsBackground": "Однотонный фон", "autoReleaseDelay": "Задержка автоотпускания", "hideOnHover": "Скрывать при наведении", "maxFPS": "Максимальная частота кадров" @@ -31,7 +31,8 @@ "passThrough": "Если включено, щелчки проходят сквозь окно, не воздействуя на него.", "alwaysOnTop": "Если включено, окно остаётся поверх всех остальных окон.", "keepInScreen": "Если включено, окно автоматически остаётся в границах экрана.", - "obsBackground": "Включите, если захват окна OBS показывает чёрный экран.\nИспользуйте метод совместимости с Windows 7 и удалите фон фильтром цветового ключа.", + "obsBackground": "Захват окна показывает чёрный экран?", + "obsBackgroundHelp": "OBS: включите этот параметр, используйте метод совместимости с Windows 7 и удалите фон фильтром цветового ключа.", "windowSize": "Подведите мышь к краю окна или удерживайте Shift и перетаскивайте правой кнопкой для изменения размера.", "hideOnHover": "Если включено, окно автоматически скрывается при наведении мыши. Можно задать задержку; 0 означает скрытие сразу." } diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index 430d16e2..e845ec95 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -22,7 +22,7 @@ "keepInScreen": "保持在屏幕内", "windowSize": "窗口尺寸", "opacity": "不透明度", - "obsBackground": "OBS 纯色背景", + "obsBackground": "纯色背景", "autoReleaseDelay": "按键自动释放延迟", "hideOnHover": "鼠标移入隐藏", "maxFPS": "最大帧率" @@ -31,7 +31,8 @@ "passThrough": "启用后,窗口不影响对其他应用程序的操作。", "alwaysOnTop": "启用后,窗口始终显示在其他应用程序上方。", "keepInScreen": "启用后,窗口会自动调整位置,防止超出屏幕边界。", - "obsBackground": "OBS 窗口捕获黑屏时开启;\n请使用“Windows 7 兼容”捕获,并用色键滤镜移除背景。", + "obsBackground": "窗口捕获黑屏?", + "obsBackgroundHelp": "OBS:开启当前选项,使用 Windows 7 兼容捕获,并用色键滤镜移除背景。\n哔哩哔哩直播姬:无需开启当前选项,选择游戏进程捕获,并开启允许窗口透明。", "windowSize": "将鼠标移至窗口边缘,或按住 Shift 并右键拖动,也可以调整窗口大小。", "hideOnHover": "启用后,鼠标悬停窗口时自动隐藏。可设置延迟时间,0 表示立即隐藏。" } diff --git a/resources/assets/locales/zh-TW.json b/resources/assets/locales/zh-TW.json index aae8258a..3950e91c 100644 --- a/resources/assets/locales/zh-TW.json +++ b/resources/assets/locales/zh-TW.json @@ -22,7 +22,7 @@ "keepInScreen": "保持在螢幕內", "windowSize": "視窗尺寸", "opacity": "不透明度", - "obsBackground": "OBS 純色背景", + "obsBackground": "純色背景", "autoReleaseDelay": "按鍵自動釋放延遲", "hideOnHover": "滑鼠游標移入隱藏", "maxFPS": "最大幀率" @@ -31,7 +31,8 @@ "passThrough": "啟用後,視窗不影響對其他應用程式的操作。", "alwaysOnTop": "啟用後,視窗始終顯示在其他應用程式上方。", "keepInScreen": "啟用後,視窗會自動調整位置,防止超出螢幕邊界。", - "obsBackground": "OBS 視窗擷取黑屏時開啟;\n請使用「Windows 7 相容」擷取,並用色鍵濾鏡移除背景。", + "obsBackground": "視窗擷取黑屏?", + "obsBackgroundHelp": "OBS:開啟目前選項,使用 Windows 7 相容擷取,並用色鍵濾鏡移除背景。\n嗶哩嗶哩直播姬:不需要開啟目前選項,選擇遊戲程序擷取,並開啟允許視窗透明。", "windowSize": "將滑鼠游標移至視窗邊緣,或按住 Shift 並右鍵拖曳,也可以調整視窗大小。", "hideOnHover": "啟用後,滑鼠游標懸停視窗時自動隱藏。可設定延遲時間,0 表示立即隱藏。" } diff --git a/src/platform/windows_capture_probe.c b/src/platform/windows_capture_probe.c new file mode 100644 index 00000000..23683538 --- /dev/null +++ b/src/platform/windows_capture_probe.c @@ -0,0 +1,231 @@ +#include "windows_diagnostics.h" + +#ifdef _WIN32 +#include +#include +#include +#include + +#ifndef PW_RENDERFULLCONTENT +#define PW_RENDERFULLCONTENT 0x00000002 +#endif + +typedef struct CaptureSurface { + HDC dc; + HBITMAP bitmap; + HGDIOBJ previous; + unsigned char *pixels; + size_t bytes; + int width; + int height; +} CaptureSurface; + +typedef struct CaptureObservation { + bool called; + bool wrote_pixels; + bool nonblack; + unsigned long long hash; +} CaptureObservation; + +static bool capture_surface_create(CaptureSurface *surface, + int width, int height) { + if (!surface || width < 1 || height < 1 || width > 8192 || height > 8192) + return false; + size_t pixels = (size_t)width * (size_t)height; + if (pixels / (size_t)width != (size_t)height || pixels > SIZE_MAX / 4) + return false; + *surface = (CaptureSurface){.width = width, .height = height, + .bytes = pixels * 4}; + surface->dc = CreateCompatibleDC(NULL); + if (!surface->dc) return false; + BITMAPINFO info = {0}; + info.bmiHeader.biSize = sizeof(info.bmiHeader); + info.bmiHeader.biWidth = width; + info.bmiHeader.biHeight = -height; + info.bmiHeader.biPlanes = 1; + info.bmiHeader.biBitCount = 32; + info.bmiHeader.biCompression = BI_RGB; + void *bits = NULL; + surface->bitmap = CreateDIBSection(surface->dc, &info, + DIB_RGB_COLORS, &bits, NULL, 0); + if (!surface->bitmap || !bits) { + if (surface->bitmap) DeleteObject(surface->bitmap); + DeleteDC(surface->dc); + *surface = (CaptureSurface){0}; + return false; + } + surface->pixels = bits; + surface->previous = SelectObject(surface->dc, surface->bitmap); + if (!surface->previous || surface->previous == HGDI_ERROR) { + DeleteObject(surface->bitmap); + DeleteDC(surface->dc); + *surface = (CaptureSurface){0}; + return false; + } + return true; +} + +static void capture_surface_destroy(CaptureSurface *surface) { + if (!surface) return; + if (surface->dc && surface->previous) + SelectObject(surface->dc, surface->previous); + if (surface->bitmap) DeleteObject(surface->bitmap); + if (surface->dc) DeleteDC(surface->dc); + *surface = (CaptureSurface){0}; +} + +static void capture_surface_prepare(CaptureSurface *surface) { + for (size_t index = 0; index < surface->bytes; index += 4) { + surface->pixels[index] = 0x5a; + surface->pixels[index + 1] = 0xa5; + surface->pixels[index + 2] = 0x3c; + surface->pixels[index + 3] = 0xc7; + } +} + +static CaptureObservation log_capture_pixels(const char *stage, + const char *method, const CaptureSurface *surface, + bool called, DWORD error) { + CaptureObservation observation = {.called = called}; + if (!surface || !surface->pixels || !surface->bytes) return observation; + unsigned long long total = (unsigned long long)surface->width * + (unsigned long long)surface->height; + unsigned long long untouched = 0, black = 0, colored = 0; + unsigned long long key_green = 0, key_blue = 0; + unsigned long long key_red = 0, key_magenta = 0; + unsigned long long red = 0, green = 0, blue = 0; + uint64_t hash = UINT64_C(1469598103934665603); + for (size_t index = 0; index < surface->bytes; index += 4) { + unsigned b = surface->pixels[index]; + unsigned g = surface->pixels[index + 1]; + unsigned r = surface->pixels[index + 2]; + bool unchanged = b == 0x5a && g == 0xa5 && r == 0x3c && + surface->pixels[index + 3] == 0xc7; + untouched += unchanged; + for (int component = 0; component < 4; ++component) { + hash ^= surface->pixels[index + (size_t)component]; + hash *= UINT64_C(1099511628211); + } + if (unchanged) continue; + unsigned maximum = r > g ? (r > b ? r : b) : (g > b ? g : b); + unsigned minimum = r < g ? (r < b ? r : b) : (g < b ? g : b); + unsigned sum = r + g + b; + black += sum <= 24; + colored += sum > 40 && maximum - minimum > 16; + key_green += g > 160 && r < 96 && b < 96; + key_blue += b > 160 && r < 96 && g < 96; + key_red += r > 160 && g < 96 && b < 96; + key_magenta += r > 160 && b > 160 && g < 96; + red += r; green += g; blue += b; + } + unsigned long long written = total - untouched; + unsigned long long nonblack = written - black; + unsigned long long measured = written ? written : 1; + observation.wrote_pixels = written > 0; + observation.nonblack = written > 0 && nonblack * 1000 >= written; + observation.hash = hash; + SDL_Log("Legacy capture probe (%s/%s): call=%d error=%lu wrote_pixels=%d " + "size=%dx%d total=%llu written=%llu (%.2f%%) nonblack=%llu " + "(%.2f%%) colored=%llu (%.2f%%) key_green=%llu key_blue=%llu " + "key_red=%llu key_magenta=%llu avg_rgb=%.1f,%.1f,%.1f hash=0x%016llx", + stage ? stage : "unknown", method, called, (unsigned long)error, + observation.wrote_pixels, surface->width, surface->height, total, + written, 100.0 * written / total, nonblack, + 100.0 * nonblack / measured, colored, 100.0 * colored / measured, + key_green, key_blue, key_red, key_magenta, + (double)red / measured, (double)green / measured, + (double)blue / measured, + (unsigned long long)hash); + return observation; +} + +static CaptureObservation probe_bitblt(HDC source, DWORD source_error, + CaptureSurface *surface, const char *stage, const char *method, + DWORD operation) { + capture_surface_prepare(surface); + if (source) SetLastError(ERROR_SUCCESS); + bool called = source && BitBlt(surface->dc, 0, 0, + surface->width, surface->height, source, 0, 0, operation) != FALSE; + DWORD error = called ? ERROR_SUCCESS : source ? GetLastError() : source_error; + GdiFlush(); + return log_capture_pixels(stage, method, surface, called, error); +} + +static CaptureObservation probe_print_window(HWND window, + CaptureSurface *surface, const char *stage, const char *method, + UINT flags) { + capture_surface_prepare(surface); + SetLastError(ERROR_SUCCESS); + bool called = PrintWindow(window, surface->dc, flags) != FALSE; + DWORD error = called ? ERROR_SUCCESS : GetLastError(); + GdiFlush(); + return log_capture_pixels(stage, method, surface, called, error); +} + +bool bongo_cat_windows_diagnostics_probe_capture( + HWND window, const char *stage) { + if (!window || !IsWindow(window)) return false; + RECT client = {0}; + if (!GetClientRect(window, &client)) return false; + int width = client.right - client.left; + int height = client.bottom - client.top; + CaptureSurface surface; + if (!capture_surface_create(&surface, width, height)) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Legacy capture probe (%s): cannot allocate %dx%d surface " + "(Win32 error %lu)", stage ? stage : "unknown", width, height, + (unsigned long)GetLastError()); + return false; + } + SDL_Log("Legacy capture probe begin: stage=%s hwnd=%p visible=%d " + "iconic=%d client=%dx%d", + stage ? stage : "unknown", (void *)window, + IsWindowVisible(window) != FALSE, IsIconic(window) != FALSE, + width, height); + HRESULT flush = DwmFlush(); + SDL_Log("Legacy capture probe composition sync: stage=%s result=0x%08lx", + stage ? stage : "unknown", (unsigned long)flush); + SetLastError(ERROR_SUCCESS); + HDC source = GetDC(window); + DWORD source_error = source ? ERROR_SUCCESS : GetLastError(); + CaptureObservation bitblt = probe_bitblt(source, source_error, &surface, + stage, "bitblt", SRCCOPY); + CaptureObservation captureblt = probe_bitblt(source, source_error, &surface, + stage, "bitblt-captureblt", SRCCOPY | CAPTUREBLT); + if (source) ReleaseDC(window, source); + CaptureObservation print_client = probe_print_window(window, &surface, + stage, "printwindow-client", PW_CLIENTONLY); + CaptureObservation print_full = probe_print_window(window, &surface, + stage, "printwindow-fullcontent", PW_CLIENTONLY | PW_RENDERFULLCONTENT); + const CaptureObservation observations[] = { + bitblt, captureblt, print_client, print_full}; + unsigned called = 0, wrote = 0, nonblack = 0; + for (size_t index = 0; index < 4; ++index) { + called += observations[index].called; + wrote += observations[index].wrote_pixels; + nonblack += observations[index].nonblack; + } + SDL_Log("Legacy capture probe summary: stage=%s calls=%u/4 writes=%u/4 " + "nonblack=%u/4 bitblt=%d captureblt=%d print_client=%d print_full=%d", + stage ? stage : "unknown", called, wrote, nonblack, + bitblt.nonblack, captureblt.nonblack, + print_client.nonblack, print_full.nonblack); + if (!called) SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Legacy capture diagnosis (%s): every API call failed", + stage ? stage : "unknown"); + else if (!wrote) SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Legacy capture diagnosis (%s): APIs reported success without writing pixels", + stage ? stage : "unknown"); + else if (!nonblack) SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Legacy capture diagnosis (%s): written results contain only black pixels", + stage ? stage : "unknown"); + else if (nonblack < wrote) SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Legacy capture diagnosis (%s): pixel availability depends on the API", + stage ? stage : "unknown"); + else SDL_Log("Legacy capture diagnosis (%s): every written result contains " + "nonblack pixels; this does not prove a third-party capturer uses that path", + stage ? stage : "unknown"); + capture_surface_destroy(&surface); + return true; +} +#endif diff --git a/src/platform/windows_diagnostics.c b/src/platform/windows_diagnostics.c index 21ca1448..e8022137 100644 --- a/src/platform/windows_diagnostics.c +++ b/src/platform/windows_diagnostics.c @@ -4,6 +4,7 @@ #include #include #include +#include #include static void utf8(const wchar_t *source, char *target, size_t capacity) { @@ -78,6 +79,85 @@ static void log_process(void) { elevation.TokenIsElevated != 0, (unsigned long)integrity, executable); } +static void log_monitor(HWND window) { + HMONITOR monitor = MonitorFromWindow(window, MONITOR_DEFAULTTONEAREST); + MONITORINFOEXW info = {.cbSize = sizeof(info)}; + if (!monitor || !GetMonitorInfoW(monitor, (MONITORINFO *)&info)) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Window monitor lookup failed (Win32 error %lu)", + (unsigned long)GetLastError()); + return; + } + char device[128]; + utf8(info.szDevice, device, sizeof(device)); + DEVMODEW mode = {.dmSize = sizeof(mode)}; + bool mode_known = EnumDisplaySettingsExW(info.szDevice, + ENUM_CURRENT_SETTINGS, &mode, 0) != FALSE; + SDL_Log("Window monitor: handle=%p device=%s primary=%d " + "monitor=%ld,%ld %ldx%ld work=%ld,%ld %ldx%ld " + "mode_known=%d mode_position=%ld,%ld mode_size=%lux%lu " + "frequency=%lu orientation=%lu", + (void *)monitor, device, (info.dwFlags & MONITORINFOF_PRIMARY) != 0, + (long)info.rcMonitor.left, (long)info.rcMonitor.top, + (long)(info.rcMonitor.right - info.rcMonitor.left), + (long)(info.rcMonitor.bottom - info.rcMonitor.top), + (long)info.rcWork.left, (long)info.rcWork.top, + (long)(info.rcWork.right - info.rcWork.left), + (long)(info.rcWork.bottom - info.rcWork.top), mode_known, + mode_known ? (long)mode.dmPosition.x : 0, + mode_known ? (long)mode.dmPosition.y : 0, + mode_known ? (unsigned long)mode.dmPelsWidth : 0, + mode_known ? (unsigned long)mode.dmPelsHeight : 0, + mode_known ? (unsigned long)mode.dmDisplayFrequency : 0, + mode_known ? (unsigned long)mode.dmDisplayOrientation : 0); + for (DWORD index = 0; index < 32; ++index) { + DISPLAY_DEVICEW adapter = {.cb = sizeof(adapter)}; + if (!EnumDisplayDevicesW(NULL, index, &adapter, 0)) break; + if (wcscmp(adapter.DeviceName, info.szDevice) != 0) continue; + char description[256], id[512]; + utf8(adapter.DeviceString, description, sizeof(description)); + utf8(adapter.DeviceID, id, sizeof(id)); + SDL_Log("Window monitor adapter: index=%lu description=%s id=%s " + "flags=0x%lx", (unsigned long)index, description, id, + (unsigned long)adapter.StateFlags); + break; + } +} + +static void log_device_context(HWND window) { + HDC dc = GetDC(window); + if (!dc) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Window device context lookup failed (Win32 error %lu)", + (unsigned long)GetLastError()); + return; + } + int format = GetPixelFormat(dc); + PIXELFORMATDESCRIPTOR pixel_format = {0}; + bool described = format > 0 && DescribePixelFormat(dc, format, + sizeof(pixel_format), &pixel_format) != 0; + HDC current_dc = wglGetCurrentDC(); + HGLRC current_context = wglGetCurrentContext(); + HWND current_window = current_dc ? WindowFromDC(current_dc) : NULL; + SDL_Log("Window device context: pixel_format=%d described=%d " + "pfd_flags=0x%lx color_bits=%u alpha_bits=%u depth_bits=%u " + "stencil_bits=%u layer_type=%u technology=%d bits_pixel=%d " + "planes=%d raster_caps=0x%x current_gl_context=%p current_dc=%p " + "current_dc_window=%p matches_window=%d", + format, described, + described ? (unsigned long)pixel_format.dwFlags : 0, + described ? (unsigned)pixel_format.cColorBits : 0, + described ? (unsigned)pixel_format.cAlphaBits : 0, + described ? (unsigned)pixel_format.cDepthBits : 0, + described ? (unsigned)pixel_format.cStencilBits : 0, + described ? (unsigned)pixel_format.iLayerType : 0, + GetDeviceCaps(dc, TECHNOLOGY), GetDeviceCaps(dc, BITSPIXEL), + GetDeviceCaps(dc, PLANES), (unsigned)GetDeviceCaps(dc, RASTERCAPS), + (void *)current_context, (void *)current_dc, (void *)current_window, + current_window == window); + ReleaseDC(window, dc); +} + static void log_window(HWND window) { char class_name[128], title[256]; wchar_t class_wide[128] = {0}, title_wide[256] = {0}; @@ -100,13 +180,22 @@ static void log_window(HWND window) { GetProcAddress(user32, "GetDpiForWindow") : NULL; UINT dpi = get_dpi ? get_dpi(window) : 0; DWORD pid = 0; DWORD thread = GetWindowThreadProcessId(window, &pid); + RECT client = {0}; POINT client_origin = {0}; + bool client_known = GetClientRect(window, &client) != FALSE && + ClientToScreen(window, &client_origin) != FALSE; SDL_Log("Capture window details: class=%s title=%s pid=%lu thread=%lu " "dpi=%u frame_known=%d frame=%ld,%ld %ldx%ld " + "client_known=%d client=%ld,%ld %ldx%ld " "layered_known=%d alpha=%u layered_flags=0x%lx", class_name, title, (unsigned long)pid, (unsigned long)thread, dpi, SUCCEEDED(frame_result), (long)frame.left, (long)frame.top, (long)(frame.right - frame.left), (long)(frame.bottom - frame.top), + client_known, (long)client_origin.x, (long)client_origin.y, + (long)(client.right - client.left), + (long)(client.bottom - client.top), layered_known, (unsigned)alpha, (unsigned long)flags); + log_monitor(window); + log_device_context(window); } void bongo_cat_windows_diagnostics_log(HWND window) { @@ -131,4 +220,5 @@ void bongo_cat_windows_diagnostics_log(HWND window) { log_displays(); if (window) log_window(window); } + #endif diff --git a/src/platform/windows_diagnostics.h b/src/platform/windows_diagnostics.h index dd5eca00..f6c9a040 100644 --- a/src/platform/windows_diagnostics.h +++ b/src/platform/windows_diagnostics.h @@ -2,9 +2,12 @@ #define BONGO_CAT_WINDOWS_DIAGNOSTICS_H #ifdef _WIN32 +#include #include void bongo_cat_windows_diagnostics_log(HWND window); +bool bongo_cat_windows_diagnostics_probe_capture( + HWND window, const char *stage); #endif #endif diff --git a/src/runtime/app.c b/src/runtime/app.c index 3e046f4b..534dd18e 100644 --- a/src/runtime/app.c +++ b/src/runtime/app.c @@ -215,6 +215,7 @@ static void render(BongoCatApp *app) { "Main frame presentation failed: %s", SDL_GetError()); return; } + bongo_cat_frame_presented_audit(app); bongo_cat_startup_ready(app); bongo_cat_memory_policy_frame_presented(); app->dirty = false; bongo_cat_window_sync_click_through(app); bongo_cat_window_schedule_hit_check(app); } diff --git a/src/runtime/frame_audit.c b/src/runtime/frame_audit.c index 9a8695e5..c3965a31 100644 --- a/src/runtime/frame_audit.c +++ b/src/runtime/frame_audit.c @@ -117,6 +117,8 @@ static void log_first_frame(BongoCatApp *app, int width, int height) { else if (!visible_colored) SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, "First-frame diagnosis: framebuffer has RGB content but no visible color alpha"); else SDL_Log("First-frame diagnosis: OpenGL framebuffer contains visible content"); + bongo_cat_frame_presentation_prepare(app, pixels, width, height, + after == GL_NO_ERROR && visible_colored > 0); if (app->smoke) { const int points[][2] = {{0, 0}, {width - 1, 0}, {0, height - 1}, {width - 1, height - 1}, {width / 2, height / 2}}; diff --git a/src/runtime/frame_presentation_audit.c b/src/runtime/frame_presentation_audit.c new file mode 100644 index 00000000..9cd9bdb4 --- /dev/null +++ b/src/runtime/frame_presentation_audit.c @@ -0,0 +1,179 @@ +#include "runtime.h" + +#include +#include +#include + +#ifdef _WIN32 +#include "../platform/windows_capture.h" +#include "../platform/windows_diagnostics.h" +#include "../platform/windows_layered.h" +#include +#endif + +#ifndef GL_DRAW_FRAMEBUFFER_BINDING +#define GL_DRAW_FRAMEBUFFER_BINDING 0x8CA6 +#endif +#ifndef GL_READ_FRAMEBUFFER_BINDING +#define GL_READ_FRAMEBUFFER_BINDING 0x8CAA +#endif +#ifndef GL_SAMPLE_BUFFERS +#define GL_SAMPLE_BUFFERS 0x80A8 +#endif +#ifndef GL_SAMPLES +#define GL_SAMPLES 0x80A9 +#endif + +typedef struct PresentationAudit { + bool prepared; + bool presented; + bool visible; + int width; + int height; + uint64_t rendered_hash; +} PresentationAudit; + +static PresentationAudit audit; + +static uint64_t pixel_hash(const unsigned char *pixels, size_t bytes) { + uint64_t hash = UINT64_C(1469598103934665603); + for (size_t index = 0; index < bytes; ++index) { + hash ^= pixels[index]; + hash *= UINT64_C(1099511628211); + } + return hash; +} + +static void log_gl_presentation_state(BongoCatApp *app) { + GLint draw_buffer = 0, read_buffer = 0; + GLint draw_framebuffer = 0, read_framebuffer = 0; + GLint red_bits = 0, green_bits = 0, blue_bits = 0, alpha_bits = 0; + GLint depth_bits = 0, stencil_bits = 0, sample_buffers = 0, samples = 0; + GLboolean double_buffer = GL_FALSE, stereo = GL_FALSE; + GLenum before = glGetError(); + glGetIntegerv(GL_DRAW_BUFFER, &draw_buffer); + glGetIntegerv(GL_READ_BUFFER, &read_buffer); + glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &draw_framebuffer); + glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &read_framebuffer); + glGetIntegerv(GL_RED_BITS, &red_bits); + glGetIntegerv(GL_GREEN_BITS, &green_bits); + glGetIntegerv(GL_BLUE_BITS, &blue_bits); + glGetIntegerv(GL_ALPHA_BITS, &alpha_bits); + glGetIntegerv(GL_DEPTH_BITS, &depth_bits); + glGetIntegerv(GL_STENCIL_BITS, &stencil_bits); + glGetIntegerv(GL_SAMPLE_BUFFERS, &sample_buffers); + glGetIntegerv(GL_SAMPLES, &samples); + glGetBooleanv(GL_DOUBLEBUFFER, &double_buffer); + glGetBooleanv(GL_STEREO, &stereo); + GLenum after = glGetError(); + int sdl_double_buffer = -1, accelerated = -1, retained = -1; + SDL_GL_GetAttribute(SDL_GL_DOUBLEBUFFER, &sdl_double_buffer); + SDL_GL_GetAttribute(SDL_GL_ACCELERATED_VISUAL, &accelerated); + SDL_GL_GetAttribute(SDL_GL_RETAINED_BACKING, &retained); + SDL_WindowFlags flags = app && app->window ? + SDL_GetWindowFlags(app->window) : 0; + SDL_Log("OpenGL presentation state: draw_buffer=0x%x read_buffer=0x%x " + "draw_fbo=%d read_fbo=%d rgba_bits=%d,%d,%d,%d depth_bits=%d " + "stencil_bits=%d sample_buffers=%d samples=%d double_buffer=%d " + "stereo=%d sdl_double_buffer=%d accelerated=%d retained=%d " + "window_flags=0x%llx state_error_before=0x%x state_error_after=0x%x", + draw_buffer, read_buffer, draw_framebuffer, read_framebuffer, + red_bits, green_bits, blue_bits, alpha_bits, depth_bits, stencil_bits, + sample_buffers, samples, double_buffer != GL_FALSE, + stereo != GL_FALSE, sdl_double_buffer, accelerated, retained, + (unsigned long long)flags, before, after); +} + +void bongo_cat_frame_presentation_prepare(BongoCatApp *app, + const unsigned char *pixels, int width, int height, bool visible) { + if (!app || !pixels || width < 1 || height < 1) return; + size_t bytes = (size_t)width * (size_t)height * 4; + if (bytes / 4 != (size_t)width * (size_t)height) return; + audit = (PresentationAudit){.prepared = true, .visible = visible, + .width = width, .height = height, + .rendered_hash = pixel_hash(pixels, bytes)}; + SDL_Log("Pre-presentation framebuffer: size=%dx%d visible=%d " + "rgba_hash=0x%016llx", width, height, visible, + (unsigned long long)audit.rendered_hash); + log_gl_presentation_state(app); +} + +static void log_post_present_buffer(const char *name, GLenum buffer) { + size_t total = (size_t)audit.width * (size_t)audit.height; + size_t bytes = total * 4; + unsigned char *pixels = calloc(1, bytes); + if (!pixels) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Post-presentation %s allocation failed for %zu bytes", name, bytes); + return; + } + GLint previous = GL_BACK; + glGetIntegerv(GL_READ_BUFFER, &previous); + GLenum before = glGetError(); + glReadBuffer(buffer); + glReadPixels(0, 0, audit.width, audit.height, + GL_RGBA, GL_UNSIGNED_BYTE, pixels); + GLenum after = glGetError(); + glReadBuffer((GLenum)previous); + GLenum restore_error = glGetError(); + unsigned long long alpha = 0, visible = 0, nonblack = 0; + for (size_t index = 0; index < bytes; index += 4) { + unsigned sum = (unsigned)pixels[index] + pixels[index + 1] + + pixels[index + 2]; + alpha += pixels[index + 3] > 8; + nonblack += sum > 30; + visible += pixels[index + 3] > 8 && sum > 30; + } + uint64_t hash = pixel_hash(pixels, bytes); + bool valid = after == GL_NO_ERROR; + SDL_Log("Post-presentation OpenGL buffer: name=%s enum=0x%x size=%dx%d " + "valid=%d alpha=%.2f%% nonblack=%.2f%% visible=%.2f%% " + "rgba_hash=0x%016llx matches_pre_swap=%d error_before=0x%x " + "error_after=0x%x restore_error=0x%x", + name, buffer, audit.width, audit.height, valid, + 100.0 * alpha / total, 100.0 * nonblack / total, + 100.0 * visible / total, (unsigned long long)hash, + valid && hash == audit.rendered_hash, before, after, restore_error); + free(pixels); +} + +void bongo_cat_frame_presented_audit(BongoCatApp *app) { + if (!app || audit.presented || !audit.prepared) return; + audit.presented = true; + SDL_Log("First-frame presentation: success=1 framebuffer_visible=%d " + "window_opacity=%.3f obs_background=%d obs_background_color=%s", + audit.visible, bongo_cat_platform_get_opacity(&app->platform), + app->config.window.obs_background, + bongo_cat_obs_background_color_name( + app->config.window.obs_background_color)); +#ifdef _WIN32 + HWND source = app->window ? (HWND)SDL_GetPointerProperty( + SDL_GetWindowProperties(app->window), + SDL_PROP_WINDOW_WIN32_HWND_POINTER, NULL) : NULL; + if (!source) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Capture diagnostic skipped because the Win32 window is unavailable"); + return; + } + HWND proxy = bongo_cat_windows_layered_proxy(source); + bool proxy_visible = proxy && IsWindow(proxy) && IsWindowVisible(proxy); + SDL_Log("Windows presented path: source=%p proxy=%p proxy_visible=%d", + (void *)source, (void *)proxy, proxy_visible); + if (!proxy_visible) { + log_post_present_buffer("front", GL_FRONT); + log_post_present_buffer("back", GL_BACK); + } else { + SDL_Log("Post-presentation OpenGL front-buffer readback skipped: " + "the visible output is the layered proxy"); + } + bongo_cat_windows_capture_log(source, "first-presented-source"); + bongo_cat_windows_diagnostics_probe_capture(source, "opengl-source"); + if (proxy && IsWindow(proxy)) { + bongo_cat_windows_capture_log(proxy, "first-presented-layered-proxy"); + bongo_cat_windows_diagnostics_probe_capture(proxy, "layered-proxy"); + } +#else + log_post_present_buffer("front", GL_FRONT); + log_post_present_buffer("back", GL_BACK); +#endif +} diff --git a/src/runtime/runtime.h b/src/runtime/runtime.h index b13fc63c..50d33ef5 100644 --- a/src/runtime/runtime.h +++ b/src/runtime/runtime.h @@ -82,6 +82,9 @@ void bongo_cat_live2d_audit_run(BongoCatApp *app); bool bongo_cat_live2d_visual_audit_run(BongoCatApp *app); bool bongo_cat_live2d_viewer_audit_run(BongoCatApp *app); void bongo_cat_frame_audit(BongoCatApp *app, int width, int height); +void bongo_cat_frame_presentation_prepare(BongoCatApp *app, + const unsigned char *pixels, int width, int height, bool visible); +void bongo_cat_frame_presented_audit(BongoCatApp *app); void bongo_cat_window_clear_background(BongoCatApp *app); void bongo_cat_frame_capture_pending(BongoCatApp *app, int width, int height); void bongo_cat_app_render_now(BongoCatApp *app); diff --git a/src/runtime/startup.c b/src/runtime/startup.c index 46444a38..9930fd70 100644 --- a/src/runtime/startup.c +++ b/src/runtime/startup.c @@ -17,27 +17,48 @@ static char startup_log_path[BONGO_CAT_PATH_CAP]; static char desktop_log_path[BONGO_CAT_PATH_CAP]; static bool startup_is_ready; -static void append_log(const char *path, int category, - SDL_LogPriority priority, const char *message) { +static void format_timestamp(char *target, size_t capacity) { + if (!target || !capacity) return; + SDL_Time ticks = 0; + SDL_DateTime date = {0}; + if (SDL_GetCurrentTime(&ticks) && SDL_TimeToDateTime(ticks, &date, true)) { + int offset_minutes = date.utc_offset >= 0 ? (date.utc_offset + 30) / 60 : -((-date.utc_offset + 30) / 60); + char sign = offset_minutes < 0 ? '-' : '+'; + if (offset_minutes < 0) offset_minutes = -offset_minutes; + snprintf(target, capacity, "%04d-%02d-%02d %02d:%02d:%02d.%03d %c%02d:%02d", + date.year, date.month, date.day, date.hour, date.minute, + date.second, date.nanosecond / 1000000, sign, + offset_minutes / 60, offset_minutes % 60); + } else { + snprintf(target, capacity, "%lld", (long long)time(NULL)); + } + target[capacity - 1] = '\0'; +} + +static void append_log(const char *path, const char *timestamp, + int category, SDL_LogPriority priority, const char *message) { FILE *file = path && path[0] ? bongo_cat_file_open(path, "ab") : NULL; if (!file) return; - fprintf(file, "%lld [%d:%d] %s\n", (long long)time(NULL), category, - (int)priority, message ? message : ""); + fprintf(file, "%s [%d:%d] %s\n", timestamp ? timestamp : "unknown-time", category, (int)priority, message ? message : ""); fclose(file); } static void SDLCALL log_output(void *userdata, int category, SDL_LogPriority priority, const char *message) { (void)userdata; - append_log(startup_log_path, category, priority, message); - append_log(desktop_log_path, category, priority, message); - fprintf(stderr, "%s\n", message ? message : ""); + char timestamp[64]; + format_timestamp(timestamp, sizeof(timestamp)); + append_log(startup_log_path, timestamp, category, priority, message); + append_log(desktop_log_path, timestamp, category, priority, message); + fprintf(stderr, "%s [%d:%d] %s\n", timestamp, category, (int)priority, message ? message : ""); } static bool reset_log(const char *path) { FILE *file = path && path[0] ? bongo_cat_file_open(path, "wb") : NULL; if (!file) return false; - fprintf(file, "BongoCat %s startup log\n", BONGO_CAT_VERSION); + char timestamp[64]; + format_timestamp(timestamp, sizeof(timestamp)); + fprintf(file, "%s BongoCat %s startup log\n", timestamp, BONGO_CAT_VERSION); return fclose(file) == 0; } diff --git a/src/ui/preferences_pages.c b/src/ui/preferences_pages.c index 9a5fa75b..83a4d7be 100644 --- a/src/ui/preferences_pages.c +++ b/src/ui/preferences_pages.c @@ -42,10 +42,11 @@ void bongo_cat_preferences_page_cat(BongoCatApp *app, struct nk_context *context &window->keep_in_screen) && window->keep_in_screen) bongo_cat_window_clamp_to_display(app); if (bongo_cat_pref_obs_background(context, "obs-background", tr(app, - "pages.preference.cat.labels.obsBackground", "OBS Background"), tr(app, - "pages.preference.cat.hints.obsBackground", "Enable when OBS Window " - "Capture is black.\nUse the Windows 7 compatibility method and remove " - "the background with a color key filter."), + "pages.preference.cat.labels.obsBackground", "Solid Background"), tr(app, + "pages.preference.cat.hints.obsBackground", "Window capture is black?"), tr(app, + "pages.preference.cat.hints.obsBackgroundHelp", "OBS: enable this option, use " + "the Windows 7 compatibility method, and remove the background with a " + "color key filter."), &window->obs_background, &window->obs_background_color)) app->dirty = true; float old_scale = window->scale_percent; diff --git a/src/ui/preferences_widgets.c b/src/ui/preferences_widgets.c index 6c19009e..afb7600c 100644 --- a/src/ui/preferences_widgets.c +++ b/src/ui/preferences_widgets.c @@ -6,6 +6,7 @@ #include "ui_catime.h" #include "ui_icons.h" #include "ui_paint.h" +#include "ui_tooltip.h" #include #include #include @@ -184,13 +185,14 @@ bool bongo_cat_pref_toggle(struct nk_context *context, const char *id, form_end(context, &saved); return changed; } bool bongo_cat_pref_obs_background(struct nk_context *context, const char *id, - const char *title, const char *detail, bool *enabled, + const char *title, const char *question, const char *reply, bool *enabled, BongoCatObsBackgroundColor *color) { - int lines = detail_lines(context, detail); FormStyle saved; + int lines = question && question[0] ? 1 : 0; FormStyle saved; if (!form_begin(context, id, lines, &saved)) return false; form_title_sized(context, title, 230.0f); bool changed = bongo_cat_pref_control_obs_background(context, id, enabled, color); - nk_layout_row_end(context); description(context, detail, lines); + nk_layout_row_end(context); + bongo_cat_ui_question_tooltip(context, question, reply); form_end(context, &saved); return changed; } bool bongo_cat_pref_float(struct nk_context *context, const char *id, diff --git a/src/ui/preferences_widgets.h b/src/ui/preferences_widgets.h index 09cd35cf..3d337a7d 100644 --- a/src/ui/preferences_widgets.h +++ b/src/ui/preferences_widgets.h @@ -9,7 +9,7 @@ void bongo_cat_pref_section(struct nk_context *context, const char *title); bool bongo_cat_pref_toggle(struct nk_context *context, const char *id, const char *title, const char *description, bool *value); bool bongo_cat_pref_obs_background(struct nk_context *context, const char *id, - const char *title, const char *description, bool *enabled, + const char *title, const char *question, const char *reply, bool *enabled, BongoCatObsBackgroundColor *color); bool bongo_cat_pref_float(struct nk_context *context, const char *id, const char *title, const char *description, float minimum, float *value, diff --git a/src/ui/ui_tooltip.c b/src/ui/ui_tooltip.c new file mode 100644 index 00000000..7ba9f99f --- /dev/null +++ b/src/ui/ui_tooltip.c @@ -0,0 +1,109 @@ +#include "ui_tooltip.h" +#include "ui_backend.h" +#include "ui_catime.h" + +#include + +static int tooltip_wrap_length(const struct nk_user_font *font, + const char *text, int length, float width) { + int consumed = 0; + int last_space = 0; + while (consumed < length) { + nk_rune rune = 0; + int glyph_length = nk_utf_decode(text + consumed, &rune, + length - consumed); + if (glyph_length <= 0) glyph_length = 1; + int next = consumed + glyph_length; + float measured = font->width(font->userdata, font->height, + text, next); + if (consumed > 0 && measured > width) + return last_space > 0 ? last_space : consumed; + consumed = next; + if (rune == ' ') last_space = consumed; + } + return consumed > 0 ? consumed : length; +} + +static void tooltip_multiline(struct nk_context *context, const char *text, + struct nk_color color) { + const struct nk_user_font *font = context->style.font; + struct nk_vec2 window_size = nk_window_get_size(context); + struct nk_vec2 mouse = context->input.mouse.pos; + struct nk_vec2 offset = context->style.window.tooltip_offset; + float margin = 8.0f; + float outer_padding = context->style.window.padding.x * 4.0f; + float left_space = mouse.x - offset.x - margin; + float right_space = window_size.x - mouse.x - offset.x - margin; + bool place_left = left_space > right_space; + float available = place_left ? left_space : right_space; + if (available <= outer_padding) { + available = NK_MAX(1.0f, window_size.x - margin * 2.0f); + place_left = false; + } + const char *segment = text; + float natural_width = 0.0f; + while (segment) { + const char *end = strchr(segment, '\n'); + int length = end ? (int)(end - segment) : nk_strlen(segment); + natural_width = NK_MAX(natural_width, font->width(font->userdata, + font->height, + segment, length)); + segment = end ? end + 1 : NULL; + } + float content_width = NK_MIN(natural_width, + NK_MAX(1.0f, available - outer_padding)); + float width = content_width + outer_padding; + enum nk_tooltip_pos position; + bool place_above = mouse.y > window_size.y * .55f; + if (place_above) + position = place_left ? NK_BOTTOM_RIGHT : NK_BOTTOM_LEFT; + else + position = place_left ? NK_TOP_RIGHT : NK_TOP_LEFT; + if (!nk_tooltip_begin_offset(context, width, position, offset)) return; + float height = font->height + context->style.window.padding.y * 2.0f; + segment = text; + while (segment) { + const char *end = strchr(segment, '\n'); + int length = end ? (int)(end - segment) : nk_strlen(segment); + if (!length) { + nk_layout_row_dynamic(context, height, 1); + nk_text_colored(context, "", 0, NK_TEXT_LEFT, color); + } else { + while (length > 0) { + int fitting = tooltip_wrap_length(font, segment, length, + content_width); + nk_layout_row_dynamic(context, height, 1); + nk_text_colored(context, segment, fitting, NK_TEXT_LEFT, + color); + segment += fitting; + length -= fitting; + } + } + if (!end) break; + segment = end ? end + 1 : NULL; + } + nk_tooltip_end(context); +} + +void bongo_cat_ui_question_tooltip(struct nk_context *context, + const char *question, const char *reply) { + if (!context || !question || !question[0] || !reply || !reply[0]) return; + nk_layout_row_dynamic(context, 19.0f, 1); + struct nk_rect bounds; + if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return; + const struct nk_user_font *font = bongo_cat_ui_caption_font(context); + BongoCatUIPalette palette = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); + float question_width = font->width(font->userdata, font->height, + question, nk_strlen(question)); + struct nk_rect text = nk_rect(bounds.x + 5.0f, + bounds.y + (bounds.h - font->height) * .5f, question_width, + font->height); + bool hover = nk_input_is_mouse_hovering_rect(&context->input, text); + if (hover) { + bongo_cat_ui_cursor_hover_rect(context, text, + BONGO_CAT_UI_CURSOR_POINTER); + } + nk_draw_text(nk_window_get_canvas(context), text, question, + nk_strlen(question), font, nk_rgba(0, 0, 0, 0), palette.accent); + if (hover) tooltip_multiline(context, reply, palette.pink); +} diff --git a/src/ui/ui_tooltip.h b/src/ui/ui_tooltip.h new file mode 100644 index 00000000..0717d40d --- /dev/null +++ b/src/ui/ui_tooltip.h @@ -0,0 +1,9 @@ +#ifndef BONGO_CAT_UI_TOOLTIP_H +#define BONGO_CAT_UI_TOOLTIP_H + +#include "nuklear_config.h" + +void bongo_cat_ui_question_tooltip(struct nk_context *context, + const char *question, const char *reply); + +#endif diff --git a/tests/test_windows_capture.c b/tests/test_windows_capture.c index 4aff8ce1..e52a07f7 100644 --- a/tests/test_windows_capture.c +++ b/tests/test_windows_capture.c @@ -1,4 +1,5 @@ #include "windows_capture.h" +#include "windows_diagnostics.h" #include "windows_direct_input.h" #include "windows_input.h" #include "windows_popup.h" @@ -92,6 +93,7 @@ static void test_capture_styles(void) { NULL, NULL, GetModuleHandleW(NULL), NULL); CHECK(window != NULL); if (window) { + CHECK(bongo_cat_windows_diagnostics_probe_capture(window, "unit-test")); CHECK(bongo_cat_windows_capture_configure(window)); LONG_PTR style = GetWindowLongPtrW(window, GWL_EXSTYLE); CHECK((style & WS_EX_APPWINDOW) == 0); From 1d3ecded33415001d37dc0314facaeeaf4afa3e7 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 15 Aug 2026 12:32:18 +0800 Subject: [PATCH 010/334] feat: unify extensible model packages and runtime compatibility --- CMakeLists.txt | 2 +- cmake/RuntimeSources.cmake | 6 +- docs/model-package-v2.md | 153 ++++++++++ docs/parity.md | 7 +- include/bongo_cat/app.h | 1 - include/bongo_cat/common.h | 7 + include/bongo_cat/model.h | 33 +++ include/bongo_cat/overlay.h | 3 +- resources/assets/locales/en-US.json | 1 + resources/assets/locales/zh-CN.json | 1 + resources/assets/locales/zh-TW.json | 1 + src/core/model_behavior.c | 8 +- src/core/model_catalog.c | 67 +---- src/core/model_package.c | 261 +++++++++++++++++ src/core/model_package.h | 9 + src/live2d/cubism_bridge.cpp | 2 - src/render/mver_pointer_overlay.c | 5 +- src/render/overlay.c | 33 +-- src/render/overlay_draw.c | 4 +- src/render/overlay_internal.h | 1 + src/runtime/app.c | 29 +- src/runtime/model_catalog_runtime.c | 5 +- src/runtime/model_files.c | 32 ++- src/runtime/model_import.c | 104 ++++--- src/runtime/model_import.h | 30 +- src/runtime/model_import_identity.c | 109 +++++++ src/runtime/model_import_metadata.c | 24 +- src/runtime/model_import_mver_metadata.c | 37 ++- src/runtime/model_import_package.c | 115 ++++++-- src/runtime/model_import_portable.c | 103 +++++-- src/runtime/model_import_portable_identity.c | 16 +- src/runtime/model_import_report.c | 5 +- src/runtime/mouse_mapping.c | 5 +- src/runtime/runtime.h | 1 + src/runtime/window_menu.h | 3 +- src/runtime/window_menu_preview.c | 59 ++-- src/ui/preferences_import.c | 24 +- src/ui/preferences_internal.h | 3 +- src/ui/preferences_model.c | 269 +++--------------- src/ui/preferences_model_card.c | 210 ++++++++++++++ src/ui/preferences_model_card.h | 16 ++ src/ui/preferences_model_cover.c | 97 ++++++- src/ui/preferences_model_cover.h | 3 +- .../model-packages/v1/.bongo-cat-package.json | 9 + .../fixtures/model-packages/v1/adapter/.keep | 1 + .../v1/payload/dummy.model3.json | 1 + .../model-packages/v2/.bongo-cat-package.json | 32 +++ .../v2/adapter/.bongo-cat-adapter.json | 7 + .../v2/payload/dummy.model3.json | 1 + tests/test_models.c | 21 ++ tests/test_mver_import.c | 2 +- tests/test_mver_pointer_import.c | 5 +- tests/test_mver_portable_identity.c | 28 ++ 53 files changed, 1525 insertions(+), 486 deletions(-) create mode 100644 docs/model-package-v2.md create mode 100644 src/core/model_package.c create mode 100644 src/core/model_package.h create mode 100644 src/runtime/model_import_identity.c create mode 100644 src/ui/preferences_model_card.c create mode 100644 src/ui/preferences_model_card.h create mode 100644 tests/fixtures/model-packages/v1/.bongo-cat-package.json create mode 100644 tests/fixtures/model-packages/v1/adapter/.keep create mode 100644 tests/fixtures/model-packages/v1/payload/dummy.model3.json create mode 100644 tests/fixtures/model-packages/v2/.bongo-cat-package.json create mode 100644 tests/fixtures/model-packages/v2/adapter/.bongo-cat-adapter.json create mode 100644 tests/fixtures/model-packages/v2/payload/dummy.model3.json diff --git a/CMakeLists.txt b/CMakeLists.txt index 0e4e2987..0da7ee6e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -62,7 +62,7 @@ add_library(bongo_cat_core STATIC src/core/file.c src/core/i18n.c src/core/input_state.c src/core/mver_pointer.c src/core/mouse_tracking.c src/core/sha256.c src/core/utf8.c - src/core/model_catalog.c + src/core/model_catalog.c src/core/model_package.c src/core/model_behavior.c src/core/path.c src/core/path_io.c src/core/json.c) target_sources(bongo_cat_core PRIVATE src/core/shortcut.c) diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 91391081..6d5a78da 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -16,7 +16,8 @@ set(BONGO_CAT_RUNTIME_SOURCES src/runtime/memory_policy.c src/runtime/mouse.c src/runtime/mouse_mapping.c src/runtime/model_files.c src/runtime/model_catalog_runtime.c src/runtime/model_update.c src/runtime/model_storage.c - src/runtime/model_import.c src/runtime/model_import_package.c + src/runtime/model_import.c src/runtime/model_import_identity.c + src/runtime/model_import_package.c src/runtime/model_import_discovery.c src/runtime/model_import_format.c src/runtime/model_import_portable.c src/runtime/model_import_portable_scan.c src/runtime/model_import_portable_identity.c @@ -53,7 +54,8 @@ set(BONGO_CAT_RUNTIME_SOURCES src/ui/preferences_behavior_rename.c src/ui/preferences_behavior_row.c src/ui/preferences_scrollbar.c src/ui/preferences_text_edit.c src/ui/preferences_text_session.c src/ui/preferences_notice.c - src/ui/preferences_model.c src/ui/preferences_model_rename.c + src/ui/preferences_model.c src/ui/preferences_model_card.c + src/ui/preferences_model_rename.c src/ui/preferences_model_cover.c src/ui/preferences_model_glyphs.c src/ui/preferences_pages.c src/ui/preferences_theme.c src/ui/preferences_shortcuts.c src/ui/preferences_shortcut_clear.c diff --git a/docs/model-package-v2.md b/docs/model-package-v2.md new file mode 100644 index 00000000..ebebe647 --- /dev/null +++ b/docs/model-package-v2.md @@ -0,0 +1,153 @@ +# BongoCat Model Package v2 + +## Purpose + +BongoCat uses its own installed package format. Tauri Live2D folders, +Bongo-Cat-Mver packages, and Mver image patches are import formats, not runtime +storage formats. + +The import boundary is: + +```text +Tauri / Mver / Mver patch + -> format importer + -> one BongoCat package per mode + -> normalized runtime adapter + -> application runtime +``` + +This keeps the large Mver model ecosystem available without carrying Mver's +three-mode executable layout into BongoCat's long-term data model. + +Bundled presets are read-only application assets and already follow the same +one-directory-per-mode runtime contract. The v2 disk manifest applies to +installed custom models; preset catalog entries receive equivalent synthetic +identity and capability fields without repackaging shipped assets. + +## Directory Contract + +```text +/ + .bongo-cat-package.json + payload/ + adapter/ + .bongo-cat-adapter.json + .bongo-cat-import-report.json + resources/ +``` + +`payload/` preserves the source files required by this mode. It is never +modified by runtime code. `adapter/` contains normalized BongoCat metadata and +derived assets. A package is independently installable and removable; sibling +modes are related only by `familyId`. + +For Mver imports, the original `config.json` may remain in `payload/` as source +provenance, while model and input-asset trees are copied only for the package's +mode. Neither loading nor removal depends on the other modes being installed. + +## Manifest v2 + +```json +{ + "schemaVersion": 2, + "packageId": "model--standard", + "contentDigest": "", + "familyId": "family-", + "displayName": "Example - Standard", + "mode": "standard", + "source": { + "format": "bongo-cat-mver", + "name": "Example", + "layout": "full-package", + "preserved": true + }, + "model": { + "directory": "payload/img/standard/cat_model", + "setting": "cat.model3.json" + }, + "runtime": { + "adapter": "adapter", + "metadata": "adapter/.bongo-cat-adapter.json", + "adapterSchemaVersion": 1, + "generatorVersion": 1 + }, + "capabilities": [ + "live2d", + "preview", + "runtime-adapter", + "input-images", + "behaviors", + "mver-projection", + "pointer-overlay" + ], + "extensions": { + "mver": { + "configuration": "payload/config.json" + } + } +} +``` + +`packageId` is the permanent application-facing ID. For unmanifested source +folders it is derived from the full candidate content digest and mode. An exact +re-import therefore resolves to the existing package instead of creating a +timestamp duplicate. `contentDigest` is a lowercase SHA-256 over the selected +model, per-file content fingerprints, source adapter inputs, mode, and import +format. `familyId` is present when one discovery operation yields related modes. + +The fields remain separate so a future signed or publisher-authored package can +keep a publisher ID across revisions while changing `contentDigest`. Raw folder +imports are intentionally content-addressed until such an identity exists. + +Readers ignore unknown capabilities, source fields, and extension namespaces. +Relative paths must stay inside the package. Schema v1 remains readable and +keeps its directory-name ID; it is not rewritten or destructively migrated. +`adapterSchemaVersion` describes the runtime contract. `generatorVersion` +tracks the importer implementation so derived adapters can be rebuilt in place +after conversion behavior changes without modifying preserved payloads. +The catalog accepts a v2 package only when its adapter metadata has the declared +schema, runtime kind, source format, render profile, and bindings structure. + +## Runtime Adapter + +`.bongo-cat-adapter.json` is source-format neutral: + +```json +{ + "schemaVersion": 1, + "kind": "bongo-cat-runtime-adapter", + "sourceFormat": "bongo-cat-mver", + "render": { "profile": "mver-0.1.6" }, + "bindings": [], + "standardPointer": {} +} +``` + +Tauri imports receive a minimal native adapter. Mver importers translate +render calibration, input bindings, pointer assets, motions, expressions, +effects, and audio defaults into this file. Runtime code does not read Mver +`config.json`. Existing `.bongo-cat-mver.json` adapters remain a read-only +fallback for installed v1 packages and nearby-model caches. + +## Configuration Ownership + +The package owns model-authored state: Live2D references, render calibration, +input asset mappings, pointer geometry, motions, expressions, effects, and +audio behavior. + +Application configuration owns user state: selected package ID, window +geometry, scale, visibility, click-through behavior, always-on-top state, FPS, +mirror preferences, hover behavior, custom labels, and shortcut overrides. + +Importers may read source application settings to reproduce model-authored +calibration, but they must not make source window or desktop settings part of +the user's BongoCat preferences. + +## Evolution Rules + +- Add optional fields or capability strings without changing `schemaVersion`. +- Increment the manifest schema only for incompatible structural changes. +- Keep format-specific data under `extensions.`. +- Never require sibling modes for loading, updating, or removing a package. +- Never make runtime behavior depend directly on an import format's config. +- Preserve v1 readers until an explicit, reversible migration is shipped. diff --git a/docs/parity.md b/docs/parity.md index 63bf31ad..b46b8dcb 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -81,8 +81,9 @@ the full display containing the pet; legacy mode retains Mver relative input. The display is selected from the pet's visible center. Negative-coordinate, stacked, and mixed-DPI layouts retain desktop coordinates, and display mode, topology, or scale changes invalidate and immediately resample the mapping. -`ParamAngleZ` and model-specific `ParamMouse*` channels are not synthesized by -the Viewer-equivalent path. +`ParamAngleZ` is not synthesized by the Viewer-equivalent path. Models that +author `ParamMouseX` or `ParamMouseY` receive the normalized pointer domain in +addition to Cubism dragging; missing parameters remain a no-op. Mver 0.1.6 remains the compatibility baseline for package discovery, imported files, configuration, shortcuts, window composition, and projection only. A @@ -90,7 +91,7 @@ Tauri or standalone Live2D model is imported through the same adapter and then runs through the Viewer-equivalent Cubism animation path. Mver imports retain the source package's `l2d_correct`, `window_size`, -`l2d_offset`, and horizontal-follow values in `.bongo-cat-mver.json`. The native +`l2d_offset`, and horizontal-follow values in `.bongo-cat-adapter.json`. The native renderer uses the Mver 0.1.6 projection order for those models. Expressions keep the authored canvas projection so the Live2D model remains aligned with the background and input layers. The compatibility matrix also converts the diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index bac894bc..934a61b4 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -95,7 +95,6 @@ typedef struct BongoCatApp { bool left_mouse_down; bool right_mouse_down; bool side_mouse_down; - bool model_mouse_parameters; bool model_pointer_anchor_ready; float model_pointer_anchor_x, model_pointer_anchor_y; bool window_minimized; diff --git a/include/bongo_cat/common.h b/include/bongo_cat/common.h index c7a22530..534ec344 100644 --- a/include/bongo_cat/common.h +++ b/include/bongo_cat/common.h @@ -23,6 +23,13 @@ #define BONGO_CAT_BEHAVIOR_BINDING_CAP 256 #define BONGO_CAT_MENU_LABEL_CAP (BONGO_CAT_ID_CAP + BONGO_CAT_SHORTCUT_CAP + 4) #define BONGO_CAT_AUTO_RELEASE_CAP 64 +#define BONGO_CAT_MODEL_PACKAGE_FILE ".bongo-cat-package.json" +#define BONGO_CAT_MODEL_ADAPTER_FILE ".bongo-cat-adapter.json" +#define BONGO_CAT_MODEL_LEGACY_ADAPTER_FILE ".bongo-cat-mver.json" +#define BONGO_CAT_MODEL_PACKAGE_LEGACY_SCHEMA 1 +#define BONGO_CAT_MODEL_PACKAGE_SCHEMA 2 +#define BONGO_CAT_MODEL_ADAPTER_SCHEMA 1 +#define BONGO_CAT_MODEL_ADAPTER_GENERATOR 1 typedef enum BongoCatResult { BONGO_CAT_OK = 0, diff --git a/include/bongo_cat/model.h b/include/bongo_cat/model.h index 48ab82e2..7f9e3274 100644 --- a/include/bongo_cat/model.h +++ b/include/bongo_cat/model.h @@ -3,14 +3,45 @@ #include "bongo_cat/config.h" +typedef enum BongoCatModelSourceFormat { + BONGO_CAT_MODEL_SOURCE_UNKNOWN, + BONGO_CAT_MODEL_SOURCE_BUILTIN, + BONGO_CAT_MODEL_SOURCE_TAURI, + BONGO_CAT_MODEL_SOURCE_MVER, + BONGO_CAT_MODEL_SOURCE_MVER_PATCH +} BongoCatModelSourceFormat; + +typedef enum BongoCatModelCapability { + BONGO_CAT_MODEL_CAPABILITY_LIVE2D = 1u << 0, + BONGO_CAT_MODEL_CAPABILITY_PREVIEW = 1u << 1, + BONGO_CAT_MODEL_CAPABILITY_RUNTIME_ADAPTER = 1u << 2, + BONGO_CAT_MODEL_CAPABILITY_INPUT_IMAGES = 1u << 3, + BONGO_CAT_MODEL_CAPABILITY_KEYBOARD_INPUT = 1u << 4, + BONGO_CAT_MODEL_CAPABILITY_GAMEPAD_INPUT = 1u << 5, + BONGO_CAT_MODEL_CAPABILITY_BEHAVIORS = 1u << 6, + BONGO_CAT_MODEL_CAPABILITY_AUDIO = 1u << 7, + BONGO_CAT_MODEL_CAPABILITY_EFFECTS = 1u << 8, + BONGO_CAT_MODEL_CAPABILITY_MVER_PROJECTION = 1u << 9, + BONGO_CAT_MODEL_CAPABILITY_POINTER_OVERLAY = 1u << 10, + BONGO_CAT_MODEL_CAPABILITY_IMAGE_PATCH = 1u << 11 +} BongoCatModelCapability; + typedef struct BongoCatModelEntry { char id[BONGO_CAT_ID_CAP]; + char package_id[BONGO_CAT_ID_CAP]; + char content_digest[65]; + char family_id[BONGO_CAT_ID_CAP]; char display_name[BONGO_CAT_ID_CAP]; char directory[BONGO_CAT_PATH_CAP]; char adapter_directory[BONGO_CAT_PATH_CAP]; char storage_directory[BONGO_CAT_PATH_CAP]; char setting_file[BONGO_CAT_PATH_CAP]; BongoCatModelMode mode; + BongoCatModelSourceFormat source_format; + uint32_t capabilities; + int package_schema; + int adapter_schema; + int adapter_generator; bool preset; bool managed; } BongoCatModelEntry; @@ -52,6 +83,8 @@ BongoCatResult bongo_cat_models_scan(BongoCatModelCatalog *catalog, const char * bool preset, BongoCatError *error); const BongoCatModelEntry *bongo_cat_models_find(const BongoCatModelCatalog *catalog, const char *id); +bool bongo_cat_model_adapter_metadata_path(const char *directory, + char *path, size_t capacity); const char *bongo_cat_model_default_name(const BongoCatModelEntry *entry); const char *bongo_cat_model_name(const BongoCatConfig *config, const BongoCatModelEntry *entry); diff --git a/include/bongo_cat/overlay.h b/include/bongo_cat/overlay.h index 2a05efa6..4be1f442 100644 --- a/include/bongo_cat/overlay.h +++ b/include/bongo_cat/overlay.h @@ -8,7 +8,8 @@ typedef struct BongoCatOverlay BongoCatOverlay; BongoCatOverlay *bongo_cat_overlay_create(BongoCatError *error); void bongo_cat_overlay_destroy(BongoCatOverlay *overlay); void bongo_cat_overlay_clear(BongoCatOverlay *overlay); -BongoCatResult bongo_cat_overlay_load(BongoCatOverlay *overlay, const char *model_directory, +BongoCatResult bongo_cat_overlay_load(BongoCatOverlay *overlay, + const char *model_directory, bool model_pointer_preferred, BongoCatError *error); int bongo_cat_overlay_key(BongoCatOverlay *overlay, const char *name, bool pressed); bool bongo_cat_overlay_effect(BongoCatOverlay *overlay, const char *path); diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index c762ceed..b433a1b3 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -85,6 +85,7 @@ "deleteSuccess": "Deleted \"%s\"", "deleteModel": "Are you sure you want to delete \"%s\"?", "importSuccess": "Imported Successfully", + "importExists": "Model already exists", "clickOrDragToImport": "Click or drag here to import", "openDirectoryFailed": "Unable to open model directory" }, diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index e845ec95..b8ae4265 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -85,6 +85,7 @@ "deleteSuccess": "已删除“%s”", "deleteModel": "你确定要删除“%s”吗?", "importSuccess": "导入成功", + "importExists": "模型已存在,无需重复导入", "clickOrDragToImport": "点击或拖动至此区域导入", "openDirectoryFailed": "无法打开模型目录" }, diff --git a/resources/assets/locales/zh-TW.json b/resources/assets/locales/zh-TW.json index 3950e91c..4f6f4a8c 100644 --- a/resources/assets/locales/zh-TW.json +++ b/resources/assets/locales/zh-TW.json @@ -85,6 +85,7 @@ "deleteSuccess": "已刪除「%s」", "deleteModel": "您確定要刪除「%s」嗎?", "importSuccess": "匯入成功", + "importExists": "模型已存在,無需重複匯入", "clickOrDragToImport": "點擊或拖曳至此區域匯入", "openDirectoryFailed": "無法開啟模型目錄" }, diff --git a/src/core/model_behavior.c b/src/core/model_behavior.c index 1ac255aa..6eb9f9aa 100644 --- a/src/core/model_behavior.c +++ b/src/core/model_behavior.c @@ -70,11 +70,11 @@ static bool read_expressions(BongoCatBehaviorCatalog *catalog, return true; } -static bool read_mver_assets(BongoCatBehaviorCatalog *catalog, +static bool read_adapter_assets(BongoCatBehaviorCatalog *catalog, const BongoCatModelEntry *model) { char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), model->adapter_directory, - ".bongo-cat-mver.json")) return false; + if (!bongo_cat_model_adapter_metadata_path(model->adapter_directory, + path, sizeof(path))) return false; yyjson_doc *document = bongo_cat_json_read_file(path, 0, NULL); if (!document) return true; yyjson_val *items = yyjson_obj_get(yyjson_doc_get_root(document), "bindings"); @@ -135,7 +135,7 @@ BongoCatResult bongo_cat_behaviors_load(BongoCatBehaviorCatalog *catalog, yyjson_val *references = yyjson_obj_get(yyjson_doc_get_root(document), "FileReferences"); bool ok = read_motions(catalog, model, yyjson_obj_get(references, "Motions")) && read_expressions(catalog, model, yyjson_obj_get(references, "Expressions")) && - read_mver_assets(catalog, model); + read_adapter_assets(catalog, model); yyjson_doc_free(document); if (!ok) { bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, "Too many model behaviors"); diff --git a/src/core/model_catalog.c b/src/core/model_catalog.c index bf2747b3..91829429 100644 --- a/src/core/model_catalog.c +++ b/src/core/model_catalog.c @@ -1,11 +1,10 @@ #include "bongo_cat/file.h" -#include "bongo_cat/json.h" #include "bongo_cat/model.h" #include "bongo_cat/path.h" +#include "model_package.h" #include #include -#include #ifdef _WIN32 #include "windows_utf8.h" @@ -41,65 +40,10 @@ static BongoCatModelMode infer_mode(const char *directory) { return bongo_cat_path_is_dir(path) ? BONGO_CAT_MODE_KEYBOARD : BONGO_CAT_MODE_STANDARD; } -static bool safe_relative(const char *value) { - if (!value || !value[0] || value[0] == '/' || value[0] == '\\' || strchr(value, ':')) - return false; - const char *cursor = value; - while (*cursor) { - while (*cursor == '/' || *cursor == '\\') cursor++; - if (cursor[0] == '.' && cursor[1] == '.' && - (!cursor[2] || cursor[2] == '/' || cursor[2] == '\\')) return false; - cursor = strpbrk(cursor, "/\\"); - if (!cursor) break; - } - return true; -} - -static BongoCatModelMode stored_mode(const char *value) { - if (value && strcmp(value, "keyboard") == 0) return BONGO_CAT_MODE_KEYBOARD; - if (value && strcmp(value, "gamepad") == 0) return BONGO_CAT_MODE_GAMEPAD; - return BONGO_CAT_MODE_STANDARD; -} - -static bool add_package(BongoCatModelCatalog *catalog, const char *directory, - bool preset, bool *handled) { - char descriptor[BONGO_CAT_PATH_CAP]; - *handled = bongo_cat_path_join(descriptor, sizeof(descriptor), directory, - ".bongo-cat-package.json") && bongo_cat_path_is_file(descriptor); - if (!*handled) return true; - yyjson_doc *document = bongo_cat_json_read_file(descriptor, 0, NULL); - yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; - const char *model = yyjson_get_str(yyjson_obj_get(root, "directory")); - const char *adapter = yyjson_get_str(yyjson_obj_get(root, "adapter")); - const char *setting = yyjson_get_str(yyjson_obj_get(root, "setting")); - bool valid = yyjson_is_obj(root) && - yyjson_get_int(yyjson_obj_get(root, "schemaVersion")) == 1 && - safe_relative(model) && safe_relative(adapter) && safe_relative(setting) && - catalog->count < BONGO_CAT_MODEL_CAP; - BongoCatModelEntry *entry = valid ? &catalog->entries[catalog->count] : NULL; - if (entry) valid = bongo_cat_path_join(entry->directory, - sizeof(entry->directory), directory, model) && - bongo_cat_path_join(entry->adapter_directory, - sizeof(entry->adapter_directory), directory, adapter); - char setting_path[BONGO_CAT_PATH_CAP]; - if (entry) valid = bongo_cat_path_join(setting_path, sizeof(setting_path), - entry->directory, setting) && bongo_cat_path_is_file(setting_path) && - bongo_cat_path_is_dir(entry->adapter_directory); - if (valid) { - snprintf(entry->id, sizeof(entry->id), "%s", bongo_cat_path_name(directory)); - snprintf(entry->storage_directory, sizeof(entry->storage_directory), "%s", directory); - snprintf(entry->setting_file, sizeof(entry->setting_file), "%s", setting); - entry->mode = stored_mode(yyjson_get_str(yyjson_obj_get(root, "mode"))); - entry->preset = preset; - catalog->count++; - } - yyjson_doc_free(document); - return valid; -} - static bool add_model(BongoCatModelCatalog *catalog, const char *directory, bool preset) { bool handled = false; - bool package_ok = add_package(catalog, directory, preset, &handled); + bool package_ok = bongo_cat_model_package_add(catalog, directory, + preset, &handled); if (handled) return package_ok; if (!package_ok) return false; if (!preset) return true; @@ -107,12 +51,17 @@ static bool add_model(BongoCatModelCatalog *catalog, const char *directory, bool char setting[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_find_suffix(directory, ".model3.json", setting, sizeof(setting))) return true; BongoCatModelEntry *entry = &catalog->entries[catalog->count++]; + memset(entry, 0, sizeof(*entry)); snprintf(entry->id, sizeof(entry->id), "%s", bongo_cat_path_name(directory)); + snprintf(entry->package_id, sizeof(entry->package_id), "%s", entry->id); snprintf(entry->directory, sizeof(entry->directory), "%s", directory); snprintf(entry->adapter_directory, sizeof(entry->adapter_directory), "%s", directory); snprintf(entry->storage_directory, sizeof(entry->storage_directory), "%s", directory); snprintf(entry->setting_file, sizeof(entry->setting_file), "%s", setting); entry->mode = infer_mode(directory); + entry->source_format = BONGO_CAT_MODEL_SOURCE_BUILTIN; + entry->capabilities = BONGO_CAT_MODEL_CAPABILITY_LIVE2D | + BONGO_CAT_MODEL_CAPABILITY_PREVIEW; entry->preset = preset; return true; } diff --git a/src/core/model_package.c b/src/core/model_package.c new file mode 100644 index 00000000..0187a305 --- /dev/null +++ b/src/core/model_package.c @@ -0,0 +1,261 @@ +#include "model_package.h" +#include "bongo_cat/json.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +static bool safe_relative(const char *value) { + if (!value || !value[0] || value[0] == '/' || value[0] == '\\' || + strchr(value, ':')) return false; + const char *cursor = value; + while (*cursor) { + while (*cursor == '/' || *cursor == '\\') cursor++; + if (cursor[0] == '.' && cursor[1] == '.' && + (!cursor[2] || cursor[2] == '/' || cursor[2] == '\\')) return false; + cursor = strpbrk(cursor, "/\\"); + if (!cursor) break; + } + return true; +} + +static bool mode_valid(const char *value) { + return value && (strcmp(value, "standard") == 0 || + strcmp(value, "keyboard") == 0 || strcmp(value, "gamepad") == 0); +} + +static BongoCatModelMode stored_mode(const char *value) { + if (value && strcmp(value, "keyboard") == 0) return BONGO_CAT_MODE_KEYBOARD; + if (value && strcmp(value, "gamepad") == 0) return BONGO_CAT_MODE_GAMEPAD; + return BONGO_CAT_MODE_STANDARD; +} + +static BongoCatModelSourceFormat stored_format(const char *value) { + if (value && strcmp(value, "tauri-live2d") == 0) + return BONGO_CAT_MODEL_SOURCE_TAURI; + if (value && strcmp(value, "bongo-cat-mver") == 0) + return BONGO_CAT_MODEL_SOURCE_MVER; + if (value && strcmp(value, "bongo-cat-mver-patch") == 0) + return BONGO_CAT_MODEL_SOURCE_MVER_PATCH; + return BONGO_CAT_MODEL_SOURCE_UNKNOWN; +} + +static bool safe_id(const char *value) { + if (!value || !value[0] || strlen(value) >= BONGO_CAT_ID_CAP || + value[0] == '.') return false; + for (const unsigned char *cursor = (const unsigned char *)value; + *cursor; ++cursor) + if (!((*cursor >= 'a' && *cursor <= 'z') || + (*cursor >= 'A' && *cursor <= 'Z') || + (*cursor >= '0' && *cursor <= '9') || *cursor == '-' || + *cursor == '_' || *cursor == '.')) return false; + return true; +} + +static bool legacy_id(const char *value) { + return value && value[0] && strlen(value) < BONGO_CAT_ID_CAP && + strcmp(value, ".") != 0 && strcmp(value, "..") != 0; +} + +static bool digest_valid(const char *value) { + if (!value || strlen(value) != 64) return false; + for (const unsigned char *cursor = (const unsigned char *)value; + *cursor; ++cursor) + if (!((*cursor >= '0' && *cursor <= '9') || + (*cursor >= 'a' && *cursor <= 'f'))) return false; + return true; +} + +static bool text_valid(const char *value) { + return value && value[0] && strlen(value) < BONGO_CAT_ID_CAP; +} + +static bool same_reference(const char *left, const char *right) { + while (left && right && *left && *right) { + char a = *left == '\\' ? '/' : *left; + char b = *right == '\\' ? '/' : *right; + if (a != b) return false; + left++; right++; + } + return left && right && !*left && !*right; +} + +static bool metadata_reference(const char *adapter, const char *metadata) { + char expected[BONGO_CAT_PATH_CAP]; + return bongo_cat_path_join(expected, sizeof(expected), adapter, + BONGO_CAT_MODEL_ADAPTER_FILE) && same_reference(expected, metadata); +} + +static bool adapter_valid(const char *path, const char *source_format) { + yyjson_doc *document = bongo_cat_json_read_file(path, 0, NULL); + yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; + const char *kind = yyjson_get_str(yyjson_obj_get(root, "kind")); + const char *format = yyjson_get_str(yyjson_obj_get(root, "sourceFormat")); + yyjson_val *render = yyjson_obj_get(root, "render"); + bool valid = yyjson_is_obj(root) && + yyjson_get_int(yyjson_obj_get(root, "schemaVersion")) == + BONGO_CAT_MODEL_ADAPTER_SCHEMA && kind && + strcmp(kind, "bongo-cat-runtime-adapter") == 0 && format && + strcmp(format, source_format) == 0 && yyjson_is_obj(render) && + text_valid(yyjson_get_str(yyjson_obj_get(render, "profile"))) && + yyjson_is_arr(yyjson_obj_get(root, "bindings")); + yyjson_doc_free(document); + return valid; +} + +typedef struct CapabilityName { const char *name; uint32_t flag; } CapabilityName; + +static uint32_t stored_capabilities(yyjson_val *values) { + static const CapabilityName names[] = { + {"live2d", BONGO_CAT_MODEL_CAPABILITY_LIVE2D}, + {"preview", BONGO_CAT_MODEL_CAPABILITY_PREVIEW}, + {"runtime-adapter", BONGO_CAT_MODEL_CAPABILITY_RUNTIME_ADAPTER}, + {"input-images", BONGO_CAT_MODEL_CAPABILITY_INPUT_IMAGES}, + {"keyboard-input", BONGO_CAT_MODEL_CAPABILITY_KEYBOARD_INPUT}, + {"gamepad-input", BONGO_CAT_MODEL_CAPABILITY_GAMEPAD_INPUT}, + {"behaviors", BONGO_CAT_MODEL_CAPABILITY_BEHAVIORS}, + {"audio", BONGO_CAT_MODEL_CAPABILITY_AUDIO}, + {"effects", BONGO_CAT_MODEL_CAPABILITY_EFFECTS}, + {"mver-projection", BONGO_CAT_MODEL_CAPABILITY_MVER_PROJECTION}, + {"pointer-overlay", BONGO_CAT_MODEL_CAPABILITY_POINTER_OVERLAY}, + {"image-patch", BONGO_CAT_MODEL_CAPABILITY_IMAGE_PATCH} + }; + uint32_t result = 0; + if (!yyjson_is_arr(values)) return result; + size_t index, count; yyjson_val *item; + yyjson_arr_foreach(values, index, count, item) { + const char *value = yyjson_get_str(item); + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) + if (value && strcmp(value, names[i].name) == 0) { + result |= names[i].flag; + break; + } + } + return result; +} + +static bool catalog_has_id(const BongoCatModelCatalog *catalog, + const char *id) { + for (size_t i = 0; i < catalog->count; ++i) + if (strcmp(catalog->entries[i].id, id) == 0) return true; + return false; +} + +bool bongo_cat_model_adapter_metadata_path(const char *directory, + char *path, size_t capacity) { + if (!directory || !path || !capacity || + !bongo_cat_path_join(path, capacity, directory, + BONGO_CAT_MODEL_ADAPTER_FILE)) return false; + if (bongo_cat_path_is_file(path)) return true; + return bongo_cat_path_join(path, capacity, directory, + BONGO_CAT_MODEL_LEGACY_ADAPTER_FILE); +} + +static bool resolve_entry(BongoCatModelEntry *entry, const char *package, + const char *model, const char *adapter, const char *setting, + const char *source_format, int schema) { + char setting_path[BONGO_CAT_PATH_CAP], metadata_path[BONGO_CAT_PATH_CAP]; + memset(entry, 0, sizeof(*entry)); + return bongo_cat_path_join(entry->directory, sizeof(entry->directory), + package, model) && + bongo_cat_path_join(entry->adapter_directory, + sizeof(entry->adapter_directory), package, adapter) && + bongo_cat_path_join(setting_path, sizeof(setting_path), entry->directory, + setting) && bongo_cat_path_is_file(setting_path) && + bongo_cat_path_is_dir(entry->adapter_directory) && + (schema == BONGO_CAT_MODEL_PACKAGE_LEGACY_SCHEMA || + (bongo_cat_path_join(metadata_path, + sizeof(metadata_path), entry->adapter_directory, + BONGO_CAT_MODEL_ADAPTER_FILE) && + adapter_valid(metadata_path, source_format))); +} + +bool bongo_cat_model_package_add(BongoCatModelCatalog *catalog, + const char *directory, bool preset, bool *handled) { + char descriptor[BONGO_CAT_PATH_CAP]; + *handled = bongo_cat_path_join(descriptor, sizeof(descriptor), directory, + BONGO_CAT_MODEL_PACKAGE_FILE) && bongo_cat_path_is_file(descriptor); + if (!*handled) return true; + yyjson_doc *document = bongo_cat_json_read_file(descriptor, 0, NULL); + yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; + int schema = (int)yyjson_get_int(yyjson_obj_get(root, "schemaVersion")); + bool current = schema == BONGO_CAT_MODEL_PACKAGE_SCHEMA; + yyjson_val *model_object = current ? yyjson_obj_get(root, "model") : root; + yyjson_val *runtime_object = current ? yyjson_obj_get(root, "runtime") : root; + yyjson_val *source_object = current ? yyjson_obj_get(root, "source") : root; + const char *model = yyjson_get_str(yyjson_obj_get(model_object, "directory")); + const char *adapter = yyjson_get_str(yyjson_obj_get(runtime_object, "adapter")); + const char *setting = yyjson_get_str(yyjson_obj_get(model_object, "setting")); + const char *mode = yyjson_get_str(yyjson_obj_get(root, "mode")); + const char *source_format = yyjson_get_str(yyjson_obj_get(source_object, "format")); + const char *source_name = yyjson_get_str(yyjson_obj_get(source_object, "name")); + const char *source_layout = yyjson_get_str(yyjson_obj_get(source_object, "layout")); + const char *display = yyjson_get_str(yyjson_obj_get(root, "displayName")); + const char *package_id = current + ? yyjson_get_str(yyjson_obj_get(root, "packageId")) + : bongo_cat_path_name(directory); + const char *digest = current + ? yyjson_get_str(yyjson_obj_get(root, "contentDigest")) : NULL; + const char *family = current + ? yyjson_get_str(yyjson_obj_get(root, "familyId")) : NULL; + const char *metadata = current + ? yyjson_get_str(yyjson_obj_get(runtime_object, "metadata")) : NULL; + int adapter_schema = (int)yyjson_get_int(yyjson_obj_get(runtime_object, + "adapterSchemaVersion")); + int adapter_generator = (int)yyjson_get_int(yyjson_obj_get(runtime_object, + "generatorVersion")); + uint32_t capabilities = current + ? stored_capabilities(yyjson_obj_get(root, "capabilities")) : 0; + uint32_t required = BONGO_CAT_MODEL_CAPABILITY_LIVE2D | + BONGO_CAT_MODEL_CAPABILITY_RUNTIME_ADAPTER; + bool valid = yyjson_is_obj(root) && + (schema == BONGO_CAT_MODEL_PACKAGE_LEGACY_SCHEMA || current) && + safe_relative(model) && safe_relative(adapter) && safe_relative(setting) && + (current ? safe_id(package_id) : legacy_id(package_id)) && + (!current || digest_valid(digest)) && + (!family || safe_id(family)) && (!current || + (yyjson_is_obj(model_object) && yyjson_is_obj(runtime_object) && + yyjson_is_obj(source_object) && safe_id(source_format) && + text_valid(source_name) && safe_id(source_layout) && + text_valid(display) && mode_valid(mode) && + yyjson_is_bool(yyjson_obj_get(source_object, "preserved")) && + safe_relative(metadata) && + metadata_reference(adapter, metadata) && + adapter_schema == BONGO_CAT_MODEL_ADAPTER_SCHEMA && + adapter_generator > 0 && + yyjson_is_arr(yyjson_obj_get(root, "capabilities")) && + (capabilities & required) == required && + yyjson_is_obj(yyjson_obj_get(root, "extensions")))) && + !catalog_has_id(catalog, package_id) && + catalog->count < BONGO_CAT_MODEL_CAP; + BongoCatModelEntry *entry = valid ? &catalog->entries[catalog->count] : NULL; + if (entry) valid = resolve_entry(entry, directory, model, adapter, setting, + source_format, schema); + if (valid) { + snprintf(entry->id, sizeof(entry->id), "%s", package_id); + snprintf(entry->package_id, sizeof(entry->package_id), "%s", package_id); + if (digest) snprintf(entry->content_digest, + sizeof(entry->content_digest), "%s", digest); + if (family) snprintf(entry->family_id, sizeof(entry->family_id), "%s", family); + if (display) snprintf(entry->display_name, + sizeof(entry->display_name), "%s", display); + snprintf(entry->storage_directory, sizeof(entry->storage_directory), + "%s", directory); + snprintf(entry->setting_file, sizeof(entry->setting_file), "%s", setting); + entry->mode = stored_mode(mode); + entry->source_format = stored_format(source_format); + entry->capabilities = current + ? capabilities + : BONGO_CAT_MODEL_CAPABILITY_LIVE2D | + BONGO_CAT_MODEL_CAPABILITY_PREVIEW | + BONGO_CAT_MODEL_CAPABILITY_RUNTIME_ADAPTER; + entry->package_schema = schema; + entry->adapter_schema = current ? adapter_schema : 0; + entry->adapter_generator = current ? adapter_generator : 0; + entry->preset = preset; + catalog->count++; + } + yyjson_doc_free(document); + return valid; +} diff --git a/src/core/model_package.h b/src/core/model_package.h new file mode 100644 index 00000000..8295e20f --- /dev/null +++ b/src/core/model_package.h @@ -0,0 +1,9 @@ +#ifndef BONGO_CAT_MODEL_PACKAGE_H +#define BONGO_CAT_MODEL_PACKAGE_H + +#include "bongo_cat/model.h" + +bool bongo_cat_model_package_add(BongoCatModelCatalog *catalog, + const char *directory, bool preset, bool *handled); + +#endif diff --git a/src/live2d/cubism_bridge.cpp b/src/live2d/cubism_bridge.cpp index c129feeb..f017160a 100644 --- a/src/live2d/cubism_bridge.cpp +++ b/src/live2d/cubism_bridge.cpp @@ -210,8 +210,6 @@ extern "C" BongoCatResult bongo_cat_live2d_load(BongoCatLive2D *runtime, } delete previous; runtime->model = model; - glFinish(); - bongo_cat_platform_trim_memory(); return BONGO_CAT_OK; } catch (const std::bad_alloc &) { bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, diff --git a/src/render/mver_pointer_overlay.c b/src/render/mver_pointer_overlay.c index bf378894..384a2f13 100644 --- a/src/render/mver_pointer_overlay.c +++ b/src/render/mver_pointer_overlay.c @@ -1,6 +1,7 @@ #include "mver_pointer_overlay_internal.h" #include "bongo_cat/image.h" #include "bongo_cat/json.h" +#include "bongo_cat/model.h" #include "bongo_cat/path.h" #include @@ -121,8 +122,8 @@ bool bongo_cat_mver_pointer_overlay_load(BongoCatMverPointerOverlay *value, if (!value || !directory) return false; clear_textures(value); char metadata[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(metadata, sizeof(metadata), directory, - ".bongo-cat-mver.json")) return false; + if (!bongo_cat_model_adapter_metadata_path(directory, metadata, + sizeof(metadata))) return false; yyjson_doc *document = bongo_cat_json_read_file(metadata, 0, NULL); yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; yyjson_val *pointer = yyjson_obj_get(root, "standardPointer"); diff --git a/src/render/overlay.c b/src/render/overlay.c index 4148379c..c68b32ff 100644 --- a/src/render/overlay.c +++ b/src/render/overlay.c @@ -29,6 +29,7 @@ static void clear_textures(BongoCatOverlay *value) { value->clean_paws = false; value->composed_cover = false; value->composite_dirty = false; + value->model_pointer_preferred = false; for (size_t i = 0; i < 4; ++i) { if (value->cache[i].texture) glDeleteTextures(1, &value->cache[i].texture); memset(&value->cache[i], 0, sizeof(value->cache[i])); @@ -89,7 +90,9 @@ void bongo_cat_overlay_clear(BongoCatOverlay *value) { value->directory[0] = '\0'; } -BongoCatResult bongo_cat_overlay_load(BongoCatOverlay *value, const char *directory, BongoCatError *error) { +BongoCatResult bongo_cat_overlay_load(BongoCatOverlay *value, + const char *directory, bool model_pointer_preferred, + BongoCatError *error) { if (!value || !directory) { bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, "Missing model overlay state or directory"); @@ -98,9 +101,6 @@ BongoCatResult bongo_cat_overlay_load(BongoCatOverlay *value, const char *direct BongoCatError local = {0}; BongoCatError *failure = error ? error : &local; *failure = (BongoCatError){0}; - BongoCatMverPointerOverlay *pointer = - bongo_cat_mver_pointer_overlay_create(failure); - if (!pointer) return failure->code ? failure->code : BONGO_CAT_ERROR_MEMORY; char path[BONGO_CAT_PATH_CAP]; /* Without the licensed Cubism runtime there is no model renderer. Use the model's composed preview so the desktop pet remains visually complete; @@ -108,25 +108,26 @@ BongoCatResult bongo_cat_overlay_load(BongoCatOverlay *value, const char *direct #ifdef BONGO_CAT_HAS_CUBISM if (!bongo_cat_path_join(path, sizeof(path), directory, "resources/background.png")) { - bongo_cat_mver_pointer_overlay_destroy(pointer); bongo_cat_error_set(failure, BONGO_CAT_ERROR_IO, "Model overlay path is too long"); return BONGO_CAT_ERROR_IO; } #else if (!bongo_cat_path_join(path, sizeof(path), directory, "resources/cover.png")) { - bongo_cat_mver_pointer_overlay_destroy(pointer); bongo_cat_error_set(failure, BONGO_CAT_ERROR_IO, "Model overlay path is too long"); return BONGO_CAT_ERROR_IO; } #endif + clear_textures(value); + bongo_cat_mver_pointer_overlay_clear(value->mver_pointer); GLuint background = 0; BongoCatError background_error = {0}; if (bongo_cat_path_is_file(path)) background = bongo_cat_image_texture(path, NULL, NULL, &background_error); BongoCatError pointer_error = {0}; - if (!bongo_cat_mver_pointer_overlay_load(pointer, directory, &pointer_error)) { - bongo_cat_mver_pointer_overlay_destroy(pointer); + if (!model_pointer_preferred && + !bongo_cat_mver_pointer_overlay_load(value->mver_pointer, directory, + &pointer_error)) { if (background) glDeleteTextures(1, &background); *failure = pointer_error; if (!failure->message[0] && background_error.message[0]) @@ -137,10 +138,8 @@ BongoCatResult bongo_cat_overlay_load(BongoCatOverlay *value, const char *direct } if (!background && background_error.message[0]) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", background_error.message); - BongoCatMverPointerOverlay *previous_pointer = value->mver_pointer; - clear_textures(value); - value->mver_pointer = pointer; value->background = background; + value->model_pointer_preferred = model_pointer_preferred; value->composed_cover = false; value->clean_paws = false; #ifndef BONGO_CAT_HAS_CUBISM @@ -149,7 +148,6 @@ BongoCatResult bongo_cat_overlay_load(BongoCatOverlay *value, const char *direct #endif snprintf(value->directory, sizeof(value->directory), "%s", directory); snprintf(value->background_path, sizeof(value->background_path), "%s", path); - if (previous_pointer) bongo_cat_mver_pointer_overlay_destroy(previous_pointer); return BONGO_CAT_OK; } @@ -226,15 +224,18 @@ bool bongo_cat_overlay_hand_active(const BongoCatOverlay *value, bool right) { } bool bongo_cat_overlay_mver_pointer_enabled(const BongoCatOverlay *value) { - return value && bongo_cat_mver_pointer_overlay_enabled(value->mver_pointer); + return value && !value->model_pointer_preferred && + bongo_cat_mver_pointer_overlay_enabled(value->mver_pointer); } bool bongo_cat_overlay_mver_pointer_left_handed(const BongoCatOverlay *value) { - return value && bongo_cat_mver_pointer_overlay_left_handed(value->mver_pointer); + return bongo_cat_overlay_mver_pointer_enabled(value) && + bongo_cat_mver_pointer_overlay_left_handed(value->mver_pointer); } void bongo_cat_overlay_set_mver_pointer(BongoCatOverlay *value, float x_ratio, float y_ratio, bool left, bool right, bool side) { - if (value) bongo_cat_mver_pointer_overlay_set(value->mver_pointer, - x_ratio, y_ratio, left, right, side); + if (bongo_cat_overlay_mver_pointer_enabled(value)) + bongo_cat_mver_pointer_overlay_set(value->mver_pointer, x_ratio, + y_ratio, left, right, side); } bool bongo_cat_overlay_effect(BongoCatOverlay *value, const char *path) { if (!value) return false; diff --git a/src/render/overlay_draw.c b/src/render/overlay_draw.c index 4c7f96e6..e890e56a 100644 --- a/src/render/overlay_draw.c +++ b/src/render/overlay_draw.c @@ -56,7 +56,7 @@ void bongo_cat_overlay_draw_keys(BongoCatOverlay *value, bool mirror) { } void bongo_cat_overlay_draw_pointer_before_keys(BongoCatOverlay *value) { - if (value) + if (bongo_cat_overlay_mver_pointer_enabled(value)) bongo_cat_mver_pointer_overlay_draw_before_keys(value->mver_pointer); } @@ -70,6 +70,6 @@ void bongo_cat_overlay_draw_effect(BongoCatOverlay *value, bool mirror) { } void bongo_cat_overlay_draw_pointer_after_keys(BongoCatOverlay *value) { - if (value) + if (bongo_cat_overlay_mver_pointer_enabled(value)) bongo_cat_mver_pointer_overlay_draw_after_keys(value->mver_pointer); } diff --git a/src/render/overlay_internal.h b/src/render/overlay_internal.h index f6b5ce97..6554115f 100644 --- a/src/render/overlay_internal.h +++ b/src/render/overlay_internal.h @@ -28,6 +28,7 @@ struct BongoCatOverlay { bool composed_cover; bool clean_paws; bool composite_dirty; + bool model_pointer_preferred; TextureSlot cache[4]; GLuint left; GLuint right; diff --git a/src/runtime/app.c b/src/runtime/app.c index 534dd18e..849aebca 100644 --- a/src/runtime/app.c +++ b/src/runtime/app.c @@ -182,15 +182,15 @@ static void update_model(BongoCatApp *app, uint64_t now) { if (app->smoke_freeze_model) return; bongo_cat_app_step_live2d(app, elapsed); } -static void render(BongoCatApp *app) { +static bool render(BongoCatApp *app, bool present) { uint64_t now = SDL_GetTicksNS(); - if (app->render_retry_ns > now) return; + if (app->render_retry_ns > now) return false; if (!SDL_GL_MakeCurrent(app->window, app->gl_context)) { app->dirty = true; app->render_retry_ns = now + 1000000000ull; SDL_LogError(SDL_LOG_CATEGORY_VIDEO, "Main GL context could not be activated: %s", SDL_GetError()); - return; + return false; } app->render_retry_ns = 0; int width, height; @@ -201,11 +201,17 @@ static void render(BongoCatApp *app) { bongo_cat_window_clear_background(app); bongo_cat_overlay_draw_background(app->overlay, app->config.model.mirror); bongo_cat_live2d_set_mirror(app->live2d, app->config.model.mirror); - bongo_cat_live2d_draw(app->live2d); if (!app->model_mouse_parameters) bongo_cat_overlay_draw_pointer_before_keys(app->overlay); + bongo_cat_live2d_draw(app->live2d); + bongo_cat_overlay_draw_pointer_before_keys(app->overlay); if (app->config.model.mouse_centered && app->pointer_known && !app->model_pointer_anchor_ready) bongo_cat_app_apply_mouse_position(app, app->pointer_x, app->pointer_y, 0.0f); bongo_cat_overlay_draw_keys(app->overlay, app->config.model.mirror); - bongo_cat_overlay_draw_effect(app->overlay, app->config.model.mirror); if (!app->model_mouse_parameters) bongo_cat_overlay_draw_pointer_after_keys(app->overlay); + bongo_cat_overlay_draw_effect(app->overlay, app->config.model.mirror); + bongo_cat_overlay_draw_pointer_after_keys(app->overlay); bongo_cat_frame_capture_pending(app, width, height); + if (!present) { + app->dirty = true; + return true; + } bongo_cat_frame_audit(app, width, height); bongo_cat_window_capture_pointer_hit(app); if (!bongo_cat_platform_present(&app->platform, width, height)) { @@ -213,14 +219,19 @@ static void render(BongoCatApp *app) { app->render_retry_ns = now + 1000000000ull; SDL_LogError(SDL_LOG_CATEGORY_VIDEO, "Main frame presentation failed: %s", SDL_GetError()); - return; + return false; } bongo_cat_frame_presented_audit(app); bongo_cat_startup_ready(app); bongo_cat_memory_policy_frame_presented(); app->dirty = false; bongo_cat_window_sync_click_through(app); bongo_cat_window_schedule_hit_check(app); + return true; } void bongo_cat_app_render_now(BongoCatApp *app) { if (app && app->window && - app->config.window.visible && !app->window_minimized) render(app); } + app->config.window.visible && !app->window_minimized) render(app, true); } +bool bongo_cat_app_capture_pending_frame(BongoCatApp *app) { + return app && app->window && app->pending_model_cover_path[0] && + !app->window_minimized && render(app, false); +} static void take_instance_wake(BongoCatApp *app) { if (!bongo_cat_platform_single_instance_take_wake()) return; bongo_cat_window_set_visible(app, true); @@ -252,9 +263,9 @@ static void loop(BongoCatApp *app) { bongo_cat_app_drain_input(app, true); if (bongo_cat_model_frame_due(app, now)) update_model(app, now); else if (!app->config.window.visible || app->window_minimized) { app->last_frame_ns = now; bongo_cat_memory_policy_idle(); } - if (app->config.window.visible && !app->window_minimized && app->dirty) render(app); + if (app->config.window.visible && !app->window_minimized && app->dirty) render(app, true); bongo_cat_preferences_render(app->preferences); - if (app->config.window.visible && !app->window_minimized && app->dirty) render(app); + if (app->config.window.visible && !app->window_minimized && app->dirty) render(app, true); bongo_cat_tray_sync(app->tray); bongo_cat_window_raise_when_due(app, now); bongo_cat_config_store_update(app, now); if (app->smoke_deadline_ns && now >= app->smoke_deadline_ns) app->running = false; diff --git a/src/runtime/model_catalog_runtime.c b/src/runtime/model_catalog_runtime.c index 21475705..a6c87292 100644 --- a/src/runtime/model_catalog_runtime.c +++ b/src/runtime/model_catalog_runtime.c @@ -125,9 +125,8 @@ void bongo_cat_app_rescan_models(BongoCatApp *app) { scan_portable_root(app, base); prune_behavior_shortcuts(app); for (size_t i = 0; i < app->models.count; ++i) - if (!app->models.entries[i].preset) - bongo_cat_import_apply_metadata(app, app->models.entries[i].id, - app->models.entries[i].adapter_directory); + bongo_cat_import_apply_metadata(app, app->models.entries[i].id, + app->models.entries[i].adapter_directory); } BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, diff --git a/src/runtime/model_files.c b/src/runtime/model_files.c index db2c66ec..88ae0ed2 100644 --- a/src/runtime/model_files.c +++ b/src/runtime/model_files.c @@ -24,9 +24,9 @@ static void request_model_frame(BongoCatApp *app) { app->dirty = true; } -static void apply_model_aspect(BongoCatApp *app, +static bool apply_model_aspect(BongoCatApp *app, const BongoCatLive2DRenderOptions *options) { - if (!app || !app->window) return; + if (!app || !app->window) return false; int reference_width = options && options->mver_projection ? options->reference_width : 612; int reference_height = options && options->mver_projection @@ -34,14 +34,15 @@ static void apply_model_aspect(BongoCatApp *app, int x, y, width, height; if (reference_width <= 0 || reference_height <= 0 || !SDL_GetWindowPosition(app->window, &x, &y) || - !SDL_GetWindowSize(app->window, &width, &height) || height <= 0) return; + !SDL_GetWindowSize(app->window, &width, &height) || height <= 0) + return false; int next_width = (int)((double)height * reference_width / reference_height + 0.5); if (next_width < 64) next_width = 64; if (next_width > 8192) next_width = 8192; - if (next_width != width) - bongo_cat_window_apply_geometry(app, x, y, - app->config.window.scale_percent, next_width, height); + if (next_width == width) return false; + return bongo_cat_window_apply_geometry(app, x, y, + app->config.window.scale_percent, next_width, height); } static void commit_model(BongoCatApp *app, @@ -84,9 +85,9 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, if (bongo_cat_behaviors_load(behaviors, entry, &optional) != BONGO_CAT_OK) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", optional.message); BongoCatLive2DRenderOptions render_options = {0}; - bool mver_profile = bongo_cat_import_mver_render_options( + bool adapted_profile = bongo_cat_import_render_options( entry->adapter_directory, &render_options); - if (mver_profile) SDL_Log("Mver runtime profile: projection=%.4f " + if (adapted_profile) SDL_Log("Imported runtime profile: projection=%.4f " "force_mouse=%d left_handed=%d pointer_bounds=%d", render_options.projection_scale, render_options.mouse_force_move, render_options.pointer_left_handed, render_options.custom_pointer_bounds); @@ -116,15 +117,18 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, request_model_frame(app); return false; } + BongoCatParameterRange pointer_x, pointer_y; + bool model_pointer = + bongo_cat_live2d_parameter(app->live2d, "ParamMouseX", &pointer_x) && + bongo_cat_live2d_parameter(app->live2d, "ParamMouseY", &pointer_y) && + pointer_x.maximum > pointer_x.minimum && + pointer_y.maximum > pointer_y.minimum; app->model_render_options = render_options; - BongoCatParameterRange mouse_range; app->model_mouse_parameters = - bongo_cat_live2d_parameter(app->live2d, "ParamMouseLeftDown", &mouse_range) || - bongo_cat_live2d_parameter(app->live2d, "ParamMouseRightDown", &mouse_range); bongo_cat_app_reset_pointer_tracking(app); bongo_cat_live2d_set_render_options(app->live2d, &render_options); - apply_model_aspect(app, &render_options); + bool geometry_changed = apply_model_aspect(app, &render_options); if (app->window) { - SDL_SyncWindow(app->window); + if (geometry_changed) SDL_SyncWindow(app->window); SDL_GetWindowSizeInPixels(app->window, &pixel_width, &pixel_height); bongo_cat_live2d_reshape(app->live2d, pixel_width, pixel_height); } @@ -132,7 +136,7 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, free(behaviors); optional = (BongoCatError){0}; if (bongo_cat_overlay_load(app->overlay, entry->adapter_directory, - &optional) != BONGO_CAT_OK) { + model_pointer, &optional) != BONGO_CAT_OK) { if (optional.message[0]) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", optional.message); bongo_cat_overlay_clear(app->overlay); diff --git a/src/runtime/model_import.c b/src/runtime/model_import.c index e5e22d70..bcea9f4c 100644 --- a/src/runtime/model_import.c +++ b/src/runtime/model_import.c @@ -12,6 +12,7 @@ typedef struct ImportInstall { char id[BONGO_CAT_ID_CAP]; char temporary[BONGO_CAT_PATH_CAP]; char target[BONGO_CAT_PATH_CAP]; + bool existing; bool committed; } ImportInstall; @@ -119,38 +120,53 @@ bool bongo_cat_import_prepare_adapter(const BongoCatImportCandidate *candidate, (!bongo_cat_path_is_dir(target) && !bongo_cat_path_create_directory(target))) return false; return copy_preview(candidate, target, error) && bongo_cat_import_mver_assets(candidate, target, error) && - bongo_cat_import_mver_metadata(candidate, target, error) && + bongo_cat_import_adapter_metadata(candidate, target, error) && bongo_cat_import_write_report(candidate, target, error) && write_mode(target, candidate->mode, error); } static void cleanup(ImportInstall *installs, size_t count, bool committed) { for (size_t i = 0; i < count; ++i) - bongo_cat_model_remove_tree(committed && installs[i].committed ? installs[i].target : - installs[i].temporary, NULL); + if (!installs[i].existing) + bongo_cat_model_remove_tree(committed && installs[i].committed + ? installs[i].target : installs[i].temporary, NULL); } static bool prepare_install(const BongoCatImportCandidate *candidate, - ImportInstall *install, const char *root, unsigned long long stamp, - size_t index, size_t count, BongoCatError *error) { - const char *mode = candidate->mode == BONGO_CAT_MODE_KEYBOARD ? "keyboard" : - candidate->mode == BONGO_CAT_MODE_GAMEPAD ? "gamepad" : "standard"; - if (count == 1) snprintf(install->id, sizeof(install->id), "model-%llx", stamp); - else snprintf(install->id, sizeof(install->id), "model-%llx-%s-%zu", - stamp, mode, index + 1); + const BongoCatPackageMetadata *metadata, + const BongoCatModelCatalog *existing, ImportInstall *install, + const char *root, size_t index, BongoCatError *error) { + const BongoCatModelEntry *duplicate = + bongo_cat_import_find_existing_package(existing, metadata, + candidate->mode); + if (duplicate) { + snprintf(install->id, sizeof(install->id), "%s", duplicate->id); + install->existing = true; + return true; + } + snprintf(install->id, sizeof(install->id), "%s", metadata->package_id); char temporary_name[BONGO_CAT_ID_CAP + 8]; - snprintf(temporary_name, sizeof(temporary_name), ".import-%llx-%zu.tmp", - stamp, index + 1); + snprintf(temporary_name, sizeof(temporary_name), ".import-%.16s-%zu.tmp", + metadata->content_digest, index + 1); BongoCatImportCandidate installed; if (!bongo_cat_path_join(install->temporary, sizeof(install->temporary), root, temporary_name) || - !bongo_cat_path_join(install->target, sizeof(install->target), root, install->id) || - !bongo_cat_import_prepare_package(candidate, install->temporary, + !bongo_cat_path_join(install->target, sizeof(install->target), root, + install->id)) return false; + if (bongo_cat_path_is_dir(install->target) || + bongo_cat_path_is_file(install->target)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Model package id collides with an existing directory: %s", + install->id); + return false; + } + if (!bongo_cat_import_prepare_package(candidate, install->temporary, &installed, error)) return false; char adapter[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(adapter, sizeof(adapter), install->temporary, "adapter") || - !bongo_cat_import_prepare_adapter(&installed, adapter, error) || - !bongo_cat_import_write_package(&installed, install->temporary, error)) return false; + !bongo_cat_import_prepare_adapter(candidate, adapter, error) || + !bongo_cat_import_write_package(&installed, metadata, + install->temporary, error)) return false; return bongo_cat_import_manifest_valid(installed.directory, installed.setting, error); } @@ -162,45 +178,64 @@ BongoCatResult bongo_cat_import_install(const char *source, const char *data_roo return BONGO_CAT_ERROR_ARGUMENT; BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); ImportInstall *installs = calloc(BONGO_CAT_IMPORT_CANDIDATE_CAP, sizeof(*installs)); - if (!discovery || !installs) { - free(discovery); free(installs); + BongoCatPackageMetadata *metadata = calloc(BONGO_CAT_IMPORT_CANDIDATE_CAP, + sizeof(*metadata)); + BongoCatModelCatalog *existing = calloc(1, sizeof(*existing)); + if (!discovery || !installs || !metadata || !existing) { + free(discovery); free(installs); free(metadata); free(existing); bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, "Cannot allocate model import workspace"); return BONGO_CAT_ERROR_MEMORY; } if (!bongo_cat_import_discover(source, discovery, error)) { - free(discovery); free(installs); return BONGO_CAT_ERROR_FORMAT; + free(discovery); free(installs); free(metadata); free(existing); + return BONGO_CAT_ERROR_FORMAT; } char root[BONGO_CAT_PATH_CAP]; if (!custom_root(data_root, root, sizeof(root)) || !bongo_cat_model_cleanup_imports(root, error)) { - free(discovery); free(installs); return BONGO_CAT_ERROR_IO; + free(discovery); free(installs); free(metadata); free(existing); + return BONGO_CAT_ERROR_IO; + } + bongo_cat_models_init(existing); + BongoCatResult scan = bongo_cat_models_scan(existing, root, false, error); + if (scan != BONGO_CAT_OK || + !bongo_cat_import_prepare_package_metadata(discovery, metadata, + error)) { + BongoCatResult result = scan != BONGO_CAT_OK ? scan : + error && error->code ? error->code : BONGO_CAT_ERROR_IO; + free(discovery); free(installs); free(metadata); free(existing); + return result; } - unsigned long long stamp = (unsigned long long)SDL_GetTicksNS(); for (size_t i = 0; i < discovery->count; ++i) { - if (prepare_install(&discovery->candidates[i], &installs[i], root, - stamp, i, discovery->count, error)) continue; + if (prepare_install(&discovery->candidates[i], &metadata[i], existing, + &installs[i], root, i, error)) continue; cleanup(installs, i + 1, false); BongoCatResult result = error && error->code == BONGO_CAT_ERROR_FORMAT ? BONGO_CAT_ERROR_FORMAT : BONGO_CAT_ERROR_IO; - free(discovery); free(installs); return result; + free(discovery); free(installs); free(metadata); free(existing); + return result; } for (size_t i = 0; i < discovery->count; ++i) { + if (installs[i].existing) continue; if (!bongo_cat_path_rename(installs[i].temporary, installs[i].target)) { bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, "Cannot finish model import: %s", SDL_GetError()); cleanup(installs, discovery->count, true); - free(discovery); free(installs); + free(discovery); free(installs); free(metadata); free(existing); return BONGO_CAT_ERROR_IO; } installs[i].committed = true; } if (receipt) { receipt->count = discovery->count; - for (size_t i = 0; i < discovery->count; ++i) + for (size_t i = 0; i < discovery->count; ++i) { snprintf(receipt->ids[i], sizeof(receipt->ids[i]), "%s", installs[i].id); + receipt->installed[i] = !installs[i].existing; + if (receipt->installed[i]) receipt->installed_count++; + } } - free(discovery); free(installs); + free(discovery); free(installs); free(metadata); free(existing); return BONGO_CAT_OK; } @@ -209,7 +244,8 @@ static void remove_receipt(const char *data_root, char root[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; if (!receipt || !custom_root(data_root, root, sizeof(root))) return; for (size_t i = 0; i < receipt->count; ++i) - if (bongo_cat_path_join(path, sizeof(path), root, receipt->ids[i])) + if (receipt->installed[i] && + bongo_cat_path_join(path, sizeof(path), root, receipt->ids[i])) bongo_cat_model_remove_tree(path, NULL); } @@ -226,15 +262,19 @@ BongoCatResult bongo_cat_app_import_model(BongoCatApp *app, const char *source, } char previous[BONGO_CAT_PATH_CAP]; snprintf(previous, sizeof(previous), "%s", app->config.current_model); + size_t preferred = 0; + for (size_t i = 0; i < receipt.count; ++i) + if (receipt.installed[i]) { preferred = i; break; } + const char *imported_id = receipt.count ? receipt.ids[preferred] : NULL; bongo_cat_app_rescan_models(app); - if (receipt.count && bongo_cat_app_select_model(app, receipt.ids[0])) + if (imported_id && bongo_cat_app_select_model(app, imported_id)) return BONGO_CAT_OK; #ifndef BONGO_CAT_HAS_CUBISM - const BongoCatModelEntry *entry = receipt.count ? - bongo_cat_models_find(&app->models, receipt.ids[0]) : NULL; + const BongoCatModelEntry *entry = imported_id ? + bongo_cat_models_find(&app->models, imported_id) : NULL; if (entry) { snprintf(app->config.current_model, sizeof(app->config.current_model), - "%s", receipt.ids[0]); + "%s", imported_id); app->config.current_mode = entry->mode; } return BONGO_CAT_OK; diff --git a/src/runtime/model_import.h b/src/runtime/model_import.h index 61f6c2aa..2846e13a 100644 --- a/src/runtime/model_import.h +++ b/src/runtime/model_import.h @@ -34,9 +34,20 @@ typedef struct BongoCatImportDiscovery { typedef struct BongoCatImportReceipt { char ids[BONGO_CAT_IMPORT_CANDIDATE_CAP][BONGO_CAT_ID_CAP]; + bool installed[BONGO_CAT_IMPORT_CANDIDATE_CAP]; size_t count; + size_t installed_count; } BongoCatImportReceipt; +typedef struct BongoCatPackageMetadata { + char package_id[BONGO_CAT_ID_CAP]; + char content_digest[65]; + char family_id[BONGO_CAT_ID_CAP]; + char display_name[BONGO_CAT_ID_CAP]; + char source_name[BONGO_CAT_ID_CAP]; + uint32_t capabilities; +} BongoCatPackageMetadata; + typedef BongoCatResult (*BongoCatPortableVisitor)(void *userdata, const char *source, BongoCatImportDiscovery *discovery, BongoCatError *error); @@ -56,7 +67,7 @@ bool bongo_cat_import_manifest_valid(const char *root, const char *setting, BongoCatError *error); bool bongo_cat_import_mver_assets(const BongoCatImportCandidate *candidate, const char *target, BongoCatError *error); -bool bongo_cat_import_mver_metadata(const BongoCatImportCandidate *candidate, +bool bongo_cat_import_adapter_metadata(const BongoCatImportCandidate *candidate, const char *target, BongoCatError *error); bool bongo_cat_import_write_report(const BongoCatImportCandidate *candidate, const char *target, BongoCatError *error); @@ -65,10 +76,23 @@ bool bongo_cat_import_prepare_adapter(const BongoCatImportCandidate *candidate, bool bongo_cat_import_prepare_package(const BongoCatImportCandidate *candidate, const char *target, BongoCatImportCandidate *installed, BongoCatError *error); bool bongo_cat_import_write_package(const BongoCatImportCandidate *candidate, - const char *target, BongoCatError *error); + const BongoCatPackageMetadata *metadata, const char *target, + BongoCatError *error); +bool bongo_cat_import_candidate_digest(const BongoCatImportCandidate *candidate, + char output[65], BongoCatError *error); +bool bongo_cat_import_prepare_package_metadata( + const BongoCatImportDiscovery *discovery, + BongoCatPackageMetadata *metadata, BongoCatError *error); +const BongoCatModelEntry *bongo_cat_import_find_existing_package( + const BongoCatModelCatalog *catalog, + const BongoCatPackageMetadata *metadata, BongoCatModelMode mode); +void bongo_cat_import_describe_portable_entry(BongoCatModelEntry *entry, + const BongoCatImportCandidate *candidate, const char *id, + const char *identity, const char *source_hash, const char *source, + const char *adapter); void bongo_cat_import_apply_metadata(BongoCatApp *app, const char *model_id, const char *directory); -bool bongo_cat_import_mver_render_options(const char *directory, +bool bongo_cat_import_render_options(const char *directory, BongoCatLive2DRenderOptions *options); BongoCatResult bongo_cat_import_portable_mver(BongoCatApp *app, const char *root, BongoCatError *error); diff --git a/src/runtime/model_import_identity.c b/src/runtime/model_import_identity.c new file mode 100644 index 00000000..88886073 --- /dev/null +++ b/src/runtime/model_import_identity.c @@ -0,0 +1,109 @@ +#include "model_import.h" +#include "bongo_cat/path.h" +#include "bongo_cat/sha256.h" + +#include +#include + +static uint32_t candidate_capabilities(const BongoCatImportCandidate *candidate) { + uint32_t result = BONGO_CAT_MODEL_CAPABILITY_LIVE2D | + BONGO_CAT_MODEL_CAPABILITY_PREVIEW | + BONGO_CAT_MODEL_CAPABILITY_RUNTIME_ADAPTER | + BONGO_CAT_MODEL_CAPABILITY_BEHAVIORS; + if (candidate->format == BONGO_CAT_IMPORT_TAURI) return result; + result |= BONGO_CAT_MODEL_CAPABILITY_INPUT_IMAGES | + BONGO_CAT_MODEL_CAPABILITY_KEYBOARD_INPUT | + BONGO_CAT_MODEL_CAPABILITY_AUDIO | + BONGO_CAT_MODEL_CAPABILITY_EFFECTS | + BONGO_CAT_MODEL_CAPABILITY_MVER_PROJECTION; + if (candidate->mode == BONGO_CAT_MODE_STANDARD) + result |= BONGO_CAT_MODEL_CAPABILITY_POINTER_OVERLAY; + if (candidate->mode == BONGO_CAT_MODE_GAMEPAD && candidate->gamepad_buttons) + result |= BONGO_CAT_MODEL_CAPABILITY_GAMEPAD_INPUT; + if (candidate->format == BONGO_CAT_IMPORT_MVER_PATCH) + result |= BONGO_CAT_MODEL_CAPABILITY_IMAGE_PATCH; + return result; +} + +static const char *source_root(const BongoCatImportCandidate *candidate) { + return candidate->format == BONGO_CAT_IMPORT_MVER_PATCH && + candidate->patch_root[0] ? candidate->patch_root : candidate->package_root; +} + +static const char *mode_title(BongoCatModelMode mode) { + if (mode == BONGO_CAT_MODE_KEYBOARD) return "Keyboard"; + if (mode == BONGO_CAT_MODE_GAMEPAD) return "Gamepad"; + return "Standard"; +} + +bool bongo_cat_import_prepare_package_metadata( + const BongoCatImportDiscovery *discovery, + BongoCatPackageMetadata *metadata, BongoCatError *error) { + char family_material[BONGO_CAT_IMPORT_CANDIDATE_CAP * 66 + 32]; + size_t used = 0; + for (size_t i = 0; i < discovery->count; ++i) { + const BongoCatImportCandidate *candidate = &discovery->candidates[i]; + BongoCatPackageMetadata *item = &metadata[i]; + if (!bongo_cat_import_candidate_digest(candidate, + item->content_digest, error)) return false; + int written = snprintf(family_material + used, + sizeof(family_material) - used, "%s|", item->content_digest); + if (written < 0 || (size_t)written >= sizeof(family_material) - used) + return false; + used += (size_t)written; + snprintf(item->package_id, sizeof(item->package_id), "model-%s-%s", + item->content_digest, bongo_cat_mode_name(candidate->mode)); + const char *name = bongo_cat_path_name(source_root(candidate)); + snprintf(item->source_name, sizeof(item->source_name), "%s", + name && name[0] ? name : "Imported model"); + if (discovery->count > 1) + snprintf(item->display_name, sizeof(item->display_name), "%s - %s", + item->source_name, mode_title(candidate->mode)); + else snprintf(item->display_name, sizeof(item->display_name), "%s", + item->source_name); + item->capabilities = candidate_capabilities(candidate); + } + if (discovery->count > 1) { + char digest[65]; + bongo_cat_sha256_bytes(family_material, used, digest); + for (size_t i = 0; i < discovery->count; ++i) + snprintf(metadata[i].family_id, sizeof(metadata[i].family_id), + "family-%s", digest); + } + return true; +} + +const BongoCatModelEntry *bongo_cat_import_find_existing_package( + const BongoCatModelCatalog *catalog, + const BongoCatPackageMetadata *metadata, BongoCatModelMode mode) { + for (size_t i = 0; i < catalog->count; ++i) { + const BongoCatModelEntry *entry = &catalog->entries[i]; + if (entry->package_schema == BONGO_CAT_MODEL_PACKAGE_SCHEMA && + entry->mode == mode && + strcmp(entry->content_digest, metadata->content_digest) == 0) + return entry; + } + return NULL; +} + +void bongo_cat_import_describe_portable_entry(BongoCatModelEntry *entry, + const BongoCatImportCandidate *candidate, const char *id, + const char *identity, const char *source_hash, const char *source, + const char *adapter) { + snprintf(entry->id, sizeof(entry->id), "%s", id); + snprintf(entry->package_id, sizeof(entry->package_id), "%s", id); + snprintf(entry->content_digest, sizeof(entry->content_digest), "%s", identity); + snprintf(entry->family_id, sizeof(entry->family_id), "family-mver-%.16s", + source_hash); + snprintf(entry->directory, sizeof(entry->directory), "%s", candidate->directory); + snprintf(entry->adapter_directory, sizeof(entry->adapter_directory), "%s", adapter); + snprintf(entry->storage_directory, sizeof(entry->storage_directory), "%s", source); + snprintf(entry->setting_file, sizeof(entry->setting_file), "%s", candidate->setting); + entry->mode = candidate->mode; + entry->source_format = candidate->format == BONGO_CAT_IMPORT_MVER_PATCH + ? BONGO_CAT_MODEL_SOURCE_MVER_PATCH : BONGO_CAT_MODEL_SOURCE_MVER; + entry->capabilities = candidate_capabilities(candidate); + entry->adapter_schema = BONGO_CAT_MODEL_ADAPTER_SCHEMA; + entry->adapter_generator = BONGO_CAT_MODEL_ADAPTER_GENERATOR; + entry->managed = true; +} diff --git a/src/runtime/model_import_metadata.c b/src/runtime/model_import_metadata.c index 62523c90..fd1de6e3 100644 --- a/src/runtime/model_import_metadata.c +++ b/src/runtime/model_import_metadata.c @@ -7,7 +7,14 @@ #include #include -#define MVER_METADATA ".bongo-cat-mver.json" +static bool metadata_version(yyjson_val *root) { + if (!yyjson_is_obj(root)) return false; + yyjson_val *schema = yyjson_obj_get(root, "schemaVersion"); + const char *kind = yyjson_get_str(yyjson_obj_get(root, "kind")); + return schema ? yyjson_get_int(schema) == BONGO_CAT_MODEL_ADAPTER_SCHEMA && kind && + strcmp(kind, "bongo-cat-runtime-adapter") == 0 : + yyjson_get_int(yyjson_obj_get(root, "version")) == 1; +} static bool same_family(const char *left, const char *right) { bool left_effect = left && (strcmp(left, "effect") == 0 || @@ -62,12 +69,12 @@ static bool behavior_id(char *id, size_t capacity, const char *model_id, void bongo_cat_import_apply_metadata(BongoCatApp *app, const char *model_id, const char *directory) { char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), directory, MVER_METADATA)) return; + if (!bongo_cat_model_adapter_metadata_path(directory, path, + sizeof(path))) return; yyjson_doc *document = bongo_cat_json_read_file(path, 0, NULL); yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; yyjson_val *bindings = yyjson_obj_get(root, "bindings"); - if (!yyjson_is_obj(root) || yyjson_get_int(yyjson_obj_get(root, "version")) != 1 || - !yyjson_is_arr(bindings)) { + if (!metadata_version(root) || !yyjson_is_arr(bindings)) { yyjson_doc_free(document); return; } @@ -94,13 +101,13 @@ void bongo_cat_import_apply_metadata(BongoCatApp *app, const char *model_id, yyjson_doc_free(document); } -bool bongo_cat_import_mver_render_options(const char *directory, +bool bongo_cat_import_render_options(const char *directory, BongoCatLive2DRenderOptions *options) { if (!options) return false; *options = (BongoCatLive2DRenderOptions){0}; char path[BONGO_CAT_PATH_CAP]; - if (!directory || !bongo_cat_path_join(path, sizeof(path), directory, - MVER_METADATA)) return false; + if (!bongo_cat_model_adapter_metadata_path(directory, path, + sizeof(path))) return false; yyjson_doc *document = bongo_cat_json_read_file(path, 0, NULL); yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; yyjson_val *render = yyjson_obj_get(root, "render"); @@ -108,8 +115,7 @@ bool bongo_cat_import_mver_render_options(const char *directory, yyjson_val *scale = yyjson_obj_get(render, "projectionScale"); int width = (int)yyjson_get_sint(yyjson_obj_get(render, "referenceWidth")); int height = (int)yyjson_get_sint(yyjson_obj_get(render, "referenceHeight")); - bool valid = yyjson_is_obj(root) && - yyjson_get_int(yyjson_obj_get(root, "version")) == 1 && + bool valid = metadata_version(root) && yyjson_is_obj(render) && profile && strcmp(profile, "mver-0.1.6") == 0 && yyjson_is_num(scale) && yyjson_get_num(scale) > 0.0 && yyjson_get_num(scale) <= 100.0 && width > 0 && height > 0; diff --git a/src/runtime/model_import_mver_metadata.c b/src/runtime/model_import_mver_metadata.c index 94c4e1b1..80f5788a 100644 --- a/src/runtime/model_import_mver_metadata.c +++ b/src/runtime/model_import_mver_metadata.c @@ -9,7 +9,11 @@ #include #include -#define MVER_METADATA ".bongo-cat-mver.json" +static const char *format_name(BongoCatImportFormat format) { + if (format == BONGO_CAT_IMPORT_MVER) return "bongo-cat-mver"; + if (format == BONGO_CAT_IMPORT_MVER_PATCH) return "bongo-cat-mver-patch"; + return "tauri-live2d"; +} static double number_or(yyjson_val *value, double fallback) { return yyjson_is_num(value) ? yyjson_get_num(value) : fallback; @@ -121,6 +125,11 @@ static bool add_render_profile(yyjson_mut_doc *output, yyjson_mut_val *root, add_standard_pointer(output, root, config, candidate); } +static bool add_native_render(yyjson_mut_doc *output, yyjson_mut_val *root) { + yyjson_mut_val *render = yyjson_mut_obj_add_obj(output, root, "render"); + return render && yyjson_mut_obj_add_str(output, render, "profile", "native"); +} + static bool sound_source(const BongoCatImportCandidate *candidate, size_t index, char *source, size_t capacity, char *relative, size_t relative_capacity) { static const char *extensions[] = {"wav", "ogg", "flac"}; @@ -183,35 +192,41 @@ static bool add_sounds(yyjson_mut_doc *output, yyjson_mut_val *items, return !emitted || !keep || add_sound_clear(output, items, config, candidate); } -bool bongo_cat_import_mver_metadata(const BongoCatImportCandidate *candidate, +bool bongo_cat_import_adapter_metadata(const BongoCatImportCandidate *candidate, const char *target, BongoCatError *error) { - if (candidate->format != BONGO_CAT_IMPORT_MVER && - candidate->format != BONGO_CAT_IMPORT_MVER_PATCH) return true; - yyjson_doc *source = bongo_cat_json_read_file(candidate->config, - YYJSON_READ_JSON5 | YYJSON_READ_ALLOW_INVALID_UNICODE, NULL); + bool mver = candidate->format != BONGO_CAT_IMPORT_TAURI; + yyjson_doc *source = mver ? bongo_cat_json_read_file(candidate->config, + YYJSON_READ_JSON5 | YYJSON_READ_ALLOW_INVALID_UNICODE, NULL) : NULL; yyjson_val *config = source ? yyjson_doc_get_root(source) : NULL; yyjson_val *mode = yyjson_obj_get(config, bongo_cat_mode_name(candidate->mode)); BongoCatMverLabels labels = {0}; - bongo_cat_mver_labels_load(candidate->config, + if (mver) bongo_cat_mver_labels_load(candidate->config, bongo_cat_mode_name(candidate->mode), &labels); yyjson_mut_doc *output = yyjson_mut_doc_new(NULL); yyjson_mut_val *root = output ? yyjson_mut_obj(output) : NULL; yyjson_mut_val *items = root ? yyjson_mut_obj_add_arr(output, root, "bindings") : NULL; if (output) yyjson_mut_doc_set_root(output, root); - bool ok = yyjson_is_obj(mode) && items && - yyjson_mut_obj_add_int(output, root, "version", 1) && + bool ok = items && + yyjson_mut_obj_add_int(output, root, "schemaVersion", + BONGO_CAT_MODEL_ADAPTER_SCHEMA) && + yyjson_mut_obj_add_str(output, root, "kind", "bongo-cat-runtime-adapter") && + yyjson_mut_obj_add_strcpy(output, root, "sourceFormat", + format_name(candidate->format)); + if (ok && mver) ok = yyjson_is_obj(mode) && add_render_profile(output, root, config, candidate) && bongo_cat_mver_add_behaviors(output, items, mode, candidate, &labels, error) && add_sounds(output, items, config, yyjson_obj_get(mode, "sounds"), candidate, &labels, target) && bongo_cat_mver_effects(output, items, config, mode, candidate, target); + else if (ok) ok = add_native_render(output, root); char path[BONGO_CAT_PATH_CAP]; - if (ok) ok = bongo_cat_path_join(path, sizeof(path), target, MVER_METADATA) && + if (ok) ok = bongo_cat_path_join(path, sizeof(path), target, + BONGO_CAT_MODEL_ADAPTER_FILE) && bongo_cat_json_write_file(path, output, YYJSON_WRITE_PRETTY, NULL); yyjson_mut_doc_free(output); yyjson_doc_free(source); if (!ok && error && !error->message[0]) bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Cannot convert Mver behavior metadata: %s", candidate->config); + "Cannot create runtime adapter metadata: %s", candidate->config); return ok; } diff --git a/src/runtime/model_import_package.c b/src/runtime/model_import_package.c index 9b537c59..c161bb49 100644 --- a/src/runtime/model_import_package.c +++ b/src/runtime/model_import_package.c @@ -8,8 +8,6 @@ #include #include -#define PACKAGE_FILE ".bongo-cat-package.json" - static bool separator(char value) { return value == '/' || value == '\\'; } static bool path_prefix(const char *root, const char *path, size_t length) { @@ -95,6 +93,19 @@ static bool copy_mver_payload(const BongoCatImportCandidate *candidate, target, error); } +static bool copy_tauri_payload(const BongoCatImportCandidate *candidate, + const char *target, BongoCatError *error) { + char relative[BONGO_CAT_PATH_CAP]; + if (!relative_path(candidate->package_root, candidate->directory, + relative, sizeof(relative))) return false; + if (!relative[0]) + return bongo_cat_copy_directory(candidate->directory, target, error) == + BONGO_CAT_OK; + return bongo_cat_path_create_directory(target) && + copy_relative_directory(candidate->package_root, candidate->directory, + target, error); +} + static bool copy_payload(const BongoCatImportCandidate *candidate, const char *target, BongoCatImportCandidate *installed, BongoCatError *error) { @@ -106,10 +117,10 @@ static bool copy_payload(const BongoCatImportCandidate *candidate, if (!bongo_cat_path_create_directory(payload) || !bongo_cat_path_join(base, sizeof(base), payload, "base") || !bongo_cat_path_join(patch_path, sizeof(patch_path), payload, "patch") || - bongo_cat_copy_directory(candidate->package_root, base, error) != - BONGO_CAT_OK || - bongo_cat_copy_directory(candidate->patch_root, patch_path, error) != - BONGO_CAT_OK || + !copy_mver_payload(candidate, base, error) || + !bongo_cat_path_create_directory(patch_path) || + (candidate->overrides[0] && !copy_relative_directory( + candidate->patch_root, candidate->overrides, patch_path, error)) || !rebase(candidate->patch_root, patch_path, candidate->overrides, installed->overrides, sizeof(installed->overrides))) return false; snprintf(installed->patch_root, sizeof(installed->patch_root), "%s", patch_path); @@ -118,8 +129,7 @@ static bool copy_payload(const BongoCatImportCandidate *candidate, if (!copy_mver_payload(candidate, base, error)) return false; } else { snprintf(base, sizeof(base), "%s", payload); - if (bongo_cat_copy_directory(candidate->package_root, base, error) != - BONGO_CAT_OK) return false; + if (!copy_tauri_payload(candidate, base, error)) return false; } if (!rebase(candidate->package_root, base, candidate->directory, installed->directory, sizeof(installed->directory)) || @@ -151,28 +161,97 @@ static const char *format_name(BongoCatImportFormat format) { return "tauri-live2d"; } +static const char *layout_name(BongoCatImportFormat format) { + if (format == BONGO_CAT_IMPORT_MVER) return "full-package"; + if (format == BONGO_CAT_IMPORT_MVER_PATCH) return "image-patch"; + return "single-model"; +} + +typedef struct CapabilityName { uint32_t flag; const char *name; } CapabilityName; + +static bool add_capabilities(yyjson_mut_doc *document, yyjson_mut_val *root, + uint32_t capabilities) { + static const CapabilityName names[] = { + {BONGO_CAT_MODEL_CAPABILITY_LIVE2D, "live2d"}, + {BONGO_CAT_MODEL_CAPABILITY_PREVIEW, "preview"}, + {BONGO_CAT_MODEL_CAPABILITY_RUNTIME_ADAPTER, "runtime-adapter"}, + {BONGO_CAT_MODEL_CAPABILITY_INPUT_IMAGES, "input-images"}, + {BONGO_CAT_MODEL_CAPABILITY_KEYBOARD_INPUT, "keyboard-input"}, + {BONGO_CAT_MODEL_CAPABILITY_GAMEPAD_INPUT, "gamepad-input"}, + {BONGO_CAT_MODEL_CAPABILITY_BEHAVIORS, "behaviors"}, + {BONGO_CAT_MODEL_CAPABILITY_AUDIO, "audio"}, + {BONGO_CAT_MODEL_CAPABILITY_EFFECTS, "effects"}, + {BONGO_CAT_MODEL_CAPABILITY_MVER_PROJECTION, "mver-projection"}, + {BONGO_CAT_MODEL_CAPABILITY_POINTER_OVERLAY, "pointer-overlay"}, + {BONGO_CAT_MODEL_CAPABILITY_IMAGE_PATCH, "image-patch"} + }; + yyjson_mut_val *array = yyjson_mut_obj_add_arr(document, root, "capabilities"); + if (!array) return false; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) + if ((capabilities & names[i].flag) && + !yyjson_mut_arr_add_str(document, array, names[i].name)) return false; + return true; +} + +static bool add_extensions(yyjson_mut_doc *document, yyjson_mut_val *root, + const BongoCatImportCandidate *candidate, const char *target) { + yyjson_mut_val *extensions = yyjson_mut_obj_add_obj(document, root, "extensions"); + if (!extensions) return false; + if (candidate->format == BONGO_CAT_IMPORT_TAURI) return true; + char config[BONGO_CAT_PATH_CAP]; + if (!relative_path(target, candidate->config, config, sizeof(config))) return false; + yyjson_mut_val *mver = yyjson_mut_obj_add_obj(document, extensions, "mver"); + return mver && yyjson_mut_obj_add_strcpy(document, mver, + "configuration", config); +} + bool bongo_cat_import_write_package(const BongoCatImportCandidate *candidate, - const char *target, BongoCatError *error) { + const BongoCatPackageMetadata *metadata, const char *target, + BongoCatError *error) { char directory[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; bool relative = relative_path(target, candidate->directory, directory, sizeof(directory)); if (relative && !directory[0]) snprintf(directory, sizeof(directory), "."); yyjson_mut_doc *document = yyjson_mut_doc_new(NULL); yyjson_mut_val *root = document ? yyjson_mut_obj(document) : NULL; + yyjson_mut_val *source = root ? yyjson_mut_obj_add_obj(document, root, "source") : NULL; + yyjson_mut_val *model = root ? yyjson_mut_obj_add_obj(document, root, "model") : NULL; + yyjson_mut_val *runtime = root ? yyjson_mut_obj_add_obj(document, root, "runtime") : NULL; if (document) yyjson_mut_doc_set_root(document, root); - bool ok = relative && root && - yyjson_mut_obj_add_int(document, root, "schemaVersion", 1) && - yyjson_mut_obj_add_str(document, root, "layout", "preserved-payload") && - yyjson_mut_obj_add_strcpy(document, root, "format", format_name(candidate->format)) && + bool ok = candidate && metadata && relative && root && source && model && runtime && + yyjson_mut_obj_add_int(document, root, "schemaVersion", + BONGO_CAT_MODEL_PACKAGE_SCHEMA) && + yyjson_mut_obj_add_strcpy(document, root, "packageId", metadata->package_id) && + yyjson_mut_obj_add_strcpy(document, root, "contentDigest", + metadata->content_digest) && + (!metadata->family_id[0] || yyjson_mut_obj_add_strcpy(document, root, + "familyId", metadata->family_id)) && + yyjson_mut_obj_add_strcpy(document, root, "displayName", + metadata->display_name) && yyjson_mut_obj_add_strcpy(document, root, "mode", bongo_cat_mode_name(candidate->mode)) && - yyjson_mut_obj_add_strcpy(document, root, "directory", directory) && - yyjson_mut_obj_add_str(document, root, "adapter", "adapter") && - yyjson_mut_obj_add_strcpy(document, root, "setting", candidate->setting) && - bongo_cat_path_join(path, sizeof(path), target, PACKAGE_FILE) && + yyjson_mut_obj_add_strcpy(document, source, "format", + format_name(candidate->format)) && + yyjson_mut_obj_add_strcpy(document, source, "name", metadata->source_name) && + yyjson_mut_obj_add_strcpy(document, source, "layout", + layout_name(candidate->format)) && + yyjson_mut_obj_add_bool(document, source, "preserved", true) && + yyjson_mut_obj_add_strcpy(document, model, "directory", directory) && + yyjson_mut_obj_add_strcpy(document, model, "setting", candidate->setting) && + yyjson_mut_obj_add_str(document, runtime, "adapter", "adapter") && + yyjson_mut_obj_add_str(document, runtime, "metadata", + "adapter/" BONGO_CAT_MODEL_ADAPTER_FILE) && + yyjson_mut_obj_add_int(document, runtime, "adapterSchemaVersion", + BONGO_CAT_MODEL_ADAPTER_SCHEMA) && + yyjson_mut_obj_add_int(document, runtime, "generatorVersion", + BONGO_CAT_MODEL_ADAPTER_GENERATOR) && + add_capabilities(document, root, metadata->capabilities) && + add_extensions(document, root, candidate, target) && + bongo_cat_path_join(path, sizeof(path), target, + BONGO_CAT_MODEL_PACKAGE_FILE) && bongo_cat_json_write_file(path, document, YYJSON_WRITE_PRETTY, NULL); yyjson_mut_doc_free(document); if (!ok) bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot write model package adapter description"); + "Cannot write model package description"); return ok; } diff --git a/src/runtime/model_import_portable.c b/src/runtime/model_import_portable.c index acb2073b..3736b8d9 100644 --- a/src/runtime/model_import_portable.c +++ b/src/runtime/model_import_portable.c @@ -14,6 +14,37 @@ #define PORTABLE_DIRECTORY "portable-mver" +static bool digest_valid(const char *value) { + if (!value || strlen(value) != 64) return false; + for (size_t i = 0; i < 64; ++i) { + char byte = value[i]; + if (!((byte >= '0' && byte <= '9') || + (byte >= 'a' && byte <= 'f'))) return false; + } + return true; +} + +static bool existing_identity(const BongoCatModelCatalog *models, + const char *identity, BongoCatModelMode mode, + const BongoCatModelEntry *ignored) { + if (!models || !digest_valid(identity)) return false; + for (size_t i = 0; i < models->count; ++i) { + const BongoCatModelEntry *entry = &models->entries[i]; + if (entry != ignored && entry->mode == mode && + digest_valid(entry->content_digest) && + strcmp(entry->content_digest, identity) == 0) return true; + } + return false; +} + +static BongoCatModelEntry *model_with_id(BongoCatApp *app, const char *id) { + if (!app || !id) return NULL; + for (size_t i = 0; i < app->models.count; ++i) + if (strcmp(app->models.entries[i].id, id) == 0) + return &app->models.entries[i]; + return NULL; +} + static bool parent_path(const char *path, char *parent, size_t capacity) { size_t length = path ? strlen(path) : 0; while (length && (path[length - 1] == '/' || path[length - 1] == '\\')) length--; @@ -71,7 +102,7 @@ static bool cached_identity(const char *target, const char *source, bool valid = yyjson_is_obj(root) && yyjson_get_int(yyjson_obj_get(root, "schemaVersion")) == BONGO_CAT_PORTABLE_SCHEMA_VERSION && stored_source && - stored_signature && stored_identity && strlen(stored_identity) == 64 && + stored_signature && digest_valid(stored_identity) && strcmp(source, stored_source) == 0 && strcmp(signature, stored_signature) == 0; if (valid) snprintf(identity, 65, "%s", stored_identity); @@ -79,6 +110,24 @@ static bool cached_identity(const char *target, const char *source, return valid; } +static bool marker_identity(const char *target, const char *source, + char identity[65]) { + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), target, + BONGO_CAT_PORTABLE_MARKER)) return false; + yyjson_doc *document = bongo_cat_json_read_file(path, 0, NULL); + yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; + const char *kind = yyjson_get_str(yyjson_obj_get(root, "kind")); + const char *stored_source = yyjson_get_str(yyjson_obj_get(root, "source")); + const char *stored_identity = yyjson_get_str(yyjson_obj_get(root, "identity")); + bool valid = yyjson_is_obj(root) && kind && stored_source && + strcmp(kind, "portable-mver") == 0 && strcmp(source, stored_source) == 0 && + digest_valid(stored_identity); + if (valid) snprintf(identity, 65, "%s", stored_identity); + yyjson_doc_free(document); + return valid; +} + static bool write_marker(const char *target, const char *source, const char *signature, const char *identity, BongoCatModelMode mode) { yyjson_mut_doc *document = yyjson_mut_doc_new(NULL); @@ -106,10 +155,16 @@ static bool previous_cache_usable(const char *target, const char *source) { !bongo_cat_path_join(marker, sizeof(marker), target, BONGO_CAT_PORTABLE_MARKER) || !bongo_cat_path_join(mode, sizeof(mode), target, ".bongo-cat-mode") || - !bongo_cat_path_join(metadata, sizeof(metadata), target, ".bongo-cat-mver.json") || + !bongo_cat_path_join(metadata, sizeof(metadata), target, + BONGO_CAT_MODEL_ADAPTER_FILE) || !bongo_cat_path_join(resources, sizeof(resources), target, "resources") || - !bongo_cat_path_is_file(mode) || !bongo_cat_path_is_file(metadata) || + !bongo_cat_path_is_file(mode) || !bongo_cat_path_is_dir(resources)) return false; + if (!bongo_cat_path_is_file(metadata) && + (!bongo_cat_path_join(metadata, sizeof(metadata), target, + BONGO_CAT_MODEL_LEGACY_ADAPTER_FILE) || + !bongo_cat_path_is_file(metadata))) + return false; yyjson_doc *document = bongo_cat_json_read_file(marker, 0, NULL); yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; const char *kind = yyjson_get_str(yyjson_obj_get(root, "kind")); @@ -146,7 +201,8 @@ static bool refresh_cache(const BongoCatImportCandidate *candidate, bongo_cat_path_rename(backup, target); if (bongo_cat_path_is_dir(target)) bongo_cat_model_remove_tree(backup, NULL); *created = !bongo_cat_path_is_dir(target); - if (!*created && marker_matches(target, source, signature, identity)) return true; + if (!*created && marker_matches(target, source, signature, identity) && + previous_cache_usable(target, source)) return true; if (!bongo_cat_path_create_directory(temporary) || !bongo_cat_import_prepare_adapter(candidate, temporary, error) || !write_marker(temporary, source, signature, identity, candidate->mode)) { @@ -177,37 +233,52 @@ static bool add_candidate(BongoCatApp *app, const char *cache_root, const char *source, const BongoCatImportCandidate *candidate, char *first_created, BongoCatError *error) { char source_hash[65], signature[65], identity[65], id[BONGO_CAT_ID_CAP]; + BongoCatModelEntry *same_id; bongo_cat_sha256_bytes(source, strlen(source), source_hash); snprintf(id, sizeof(id), "mver-%.16s-%s", source_hash, bongo_cat_mode_name(candidate->mode)); - if (bongo_cat_models_find(&app->models, id)) return true; + same_id = model_with_id(app, id); + if (same_id && !same_id->managed) return true; if (!bongo_cat_portable_signature(candidate, signature, error)) return false; char target[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(target, sizeof(target), cache_root, id) || (!cached_identity(target, source, signature, identity) && !bongo_cat_portable_identity(candidate, identity, error))) return false; + if (existing_identity(&app->models, identity, candidate->mode, + same_id)) return true; + if (!same_id && app->models.count >= BONGO_CAT_MODEL_CAP) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Too many models while adding portable Mver package"); + return false; + } bool created = false; if (!refresh_cache(candidate, cache_root, id, source, signature, identity, &created, error)) return false; - if (app->models.count >= BONGO_CAT_MODEL_CAP) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Too many models while adding portable Mver package"); - return false; + char active_identity[65]; + if (!marker_identity(target, source, active_identity)) return false; + if (strcmp(active_identity, identity) != 0) { + snprintf(identity, sizeof(identity), "%s", active_identity); + if (!same_id && existing_identity(&app->models, identity, + candidate->mode, NULL)) return true; } char adapter[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(adapter, sizeof(adapter), cache_root, id)) return false; + if (same_id) { + memset(same_id, 0, sizeof(*same_id)); + candidate_name(source, candidate, same_id->display_name, + sizeof(same_id->display_name)); + bongo_cat_import_describe_portable_entry(same_id, candidate, id, + identity, source_hash, source, adapter); + return true; + } BongoCatModelEntry *entry = &app->models.entries[app->models.count++]; - snprintf(entry->id, sizeof(entry->id), "%s", id); + memset(entry, 0, sizeof(*entry)); candidate_name(source, candidate, entry->display_name, sizeof(entry->display_name)); - snprintf(entry->directory, sizeof(entry->directory), "%s", candidate->directory); - snprintf(entry->adapter_directory, sizeof(entry->adapter_directory), "%s", adapter); - snprintf(entry->storage_directory, sizeof(entry->storage_directory), "%s", source); - snprintf(entry->setting_file, sizeof(entry->setting_file), "%s", candidate->setting); - entry->mode = candidate->mode; - entry->managed = true; + bongo_cat_import_describe_portable_entry(entry, candidate, id, identity, + source_hash, source, adapter); if (created && !first_created[0]) snprintf(first_created, BONGO_CAT_ID_CAP, "%s", id); return true; diff --git a/src/runtime/model_import_portable_identity.c b/src/runtime/model_import_portable_identity.c index 4c6990f8..7d48aa02 100644 --- a/src/runtime/model_import_portable_identity.c +++ b/src/runtime/model_import_portable_identity.c @@ -83,12 +83,13 @@ static bool stamp_root(const char *root, const char *group, return stamp_path(root, &walk); } -bool bongo_cat_portable_identity(const BongoCatImportCandidate *candidate, +bool bongo_cat_import_candidate_digest(const BongoCatImportCandidate *candidate, char output[65], BongoCatError *error) { if (!candidate || !output) return false; - char config_hash[65], setting[BONGO_CAT_PATH_CAP], setting_hash[65]; - if (bongo_cat_sha256_file(candidate->config, config_hash, error) != - BONGO_CAT_OK || !bongo_cat_path_join(setting, sizeof(setting), + char config_hash[65] = {0}, setting[BONGO_CAT_PATH_CAP], setting_hash[65]; + if ((candidate->config[0] && bongo_cat_sha256_file(candidate->config, + config_hash, error) != BONGO_CAT_OK) || + !bongo_cat_path_join(setting, sizeof(setting), candidate->directory, candidate->setting) || bongo_cat_sha256_file(setting, setting_hash, error) != BONGO_CAT_OK) return false; @@ -97,7 +98,7 @@ bool bongo_cat_portable_identity(const BongoCatImportCandidate *candidate, !stamp_root(candidate->assets, "assets", &stamp) || !stamp_root(candidate->overrides, "overrides", &stamp)) { bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot fingerprint portable Mver model assets: %s", + "Cannot fingerprint imported model assets: %s", candidate->package_root); return false; } @@ -113,3 +114,8 @@ bool bongo_cat_portable_identity(const BongoCatImportCandidate *candidate, bongo_cat_sha256_bytes(summary, (size_t)length, output); return true; } + +bool bongo_cat_portable_identity(const BongoCatImportCandidate *candidate, + char output[65], BongoCatError *error) { + return bongo_cat_import_candidate_digest(candidate, output, error); +} diff --git a/src/runtime/model_import_report.c b/src/runtime/model_import_report.c index 79244f58..c6a88adf 100644 --- a/src/runtime/model_import_report.c +++ b/src/runtime/model_import_report.c @@ -127,7 +127,8 @@ bool bongo_cat_import_write_report(const BongoCatImportCandidate *candidate, bool ok = root && stats && capabilities && degraded && yyjson_mut_obj_add_int(output, root, "schemaVersion", 1) && yyjson_mut_obj_add_strcpy(output, root, "format", format_name(candidate->format)) && - yyjson_mut_obj_add_str(output, root, "runtimeProfile", "mver-0.1.6") && + yyjson_mut_obj_add_str(output, root, "runtimeProfile", + mver ? "mver-0.1.6" : "native") && yyjson_mut_obj_add_strcpy(output, root, "mode", bongo_cat_mode_name(candidate->mode)) && yyjson_mut_obj_add_bool(output, capabilities, "live2dModel", true) && @@ -141,7 +142,7 @@ bool bongo_cat_import_write_report(const BongoCatImportCandidate *candidate, yyjson_mut_obj_add_bool(output, capabilities, "expressionsAndMotions", true) && yyjson_mut_obj_add_bool(output, capabilities, "shortcutAudio", mver) && yyjson_mut_obj_add_bool(output, capabilities, "imageEffects", mver) && - yyjson_mut_obj_add_bool(output, capabilities, "mverRuntime", true) && + yyjson_mut_obj_add_bool(output, capabilities, "mverRuntime", mver) && yyjson_mut_obj_add_bool(output, capabilities, "tauriAdapter", !mver) && yyjson_mut_obj_add_bool(output, capabilities, "mverProjection", mver) && yyjson_mut_obj_add_bool(output, capabilities, "mverPointerDomain", mver) && diff --git a/src/runtime/mouse_mapping.c b/src/runtime/mouse_mapping.c index 3c99a6e9..be0d56b5 100644 --- a/src/runtime/mouse_mapping.c +++ b/src/runtime/mouse_mapping.c @@ -148,8 +148,7 @@ void bongo_cat_app_apply_mouse_coordinates(BongoCatApp *app, double hand_x, float gaze_x_ratio = hand_x_ratio, gaze_y_ratio = hand_y_ratio; if (app->config.model.mouse_centered) model_pointer_ratios(app, gaze_x, gaze_y, &gaze_x_ratio, &gaze_y_ratio); - bool exact_pointer = !app->model_mouse_parameters && - bongo_cat_overlay_mver_pointer_enabled(app->overlay); + bool exact_pointer = bongo_cat_overlay_mver_pointer_enabled(app->overlay); bool mver = app->model_render_options.mver_projection; bool left_handed = app->model_render_options.pointer_left_handed || (exact_pointer && bongo_cat_overlay_mver_pointer_left_handed(app->overlay)); @@ -160,7 +159,7 @@ void bongo_cat_app_apply_mouse_coordinates(BongoCatApp *app, double hand_x, if (!mver && app->config.model.mouse_mirror) drag_x = -drag_x; bongo_cat_overlay_set_mver_pointer(app->overlay, hand_x_ratio, hand_y_ratio, app->left_mouse_down, app->right_mouse_down, app->side_mouse_down); - if (app->model_render_options.mver_projection && !exact_pointer) { + if (!exact_pointer) { set_parameter(app, "ParamMouseX", 1.0f - hand_x_ratio, hand_y_ratio); set_parameter(app, "ParamMouseY", hand_x_ratio, hand_y_ratio); } diff --git a/src/runtime/runtime.h b/src/runtime/runtime.h index 50d33ef5..470c3538 100644 --- a/src/runtime/runtime.h +++ b/src/runtime/runtime.h @@ -88,6 +88,7 @@ void bongo_cat_frame_presented_audit(BongoCatApp *app); void bongo_cat_window_clear_background(BongoCatApp *app); void bongo_cat_frame_capture_pending(BongoCatApp *app, int width, int height); void bongo_cat_app_render_now(BongoCatApp *app); +bool bongo_cat_app_capture_pending_frame(BongoCatApp *app); void bongo_cat_runtime_flow_update(BongoCatApp *app, uint64_t now); void bongo_cat_config_store_initialize(BongoCatApp *app); void bongo_cat_config_store_update(BongoCatApp *app, uint64_t now); diff --git a/src/runtime/window_menu.h b/src/runtime/window_menu.h index c02ab594..d853676f 100644 --- a/src/runtime/window_menu.h +++ b/src/runtime/window_menu.h @@ -8,7 +8,8 @@ typedef struct BongoCatWindowMenuPreview { char model[BONGO_CAT_ID_CAP]; float scale, opacity; int expression; - BongoCatMenuAction last, applied; + BongoCatMenuAction last, applied, pending_model; + uint64_t pending_model_since_ns; BongoCatModalFrame modal_frame; } BongoCatWindowMenuPreview; diff --git a/src/runtime/window_menu_preview.c b/src/runtime/window_menu_preview.c index 858fd9da..2e52605d 100644 --- a/src/runtime/window_menu_preview.c +++ b/src/runtime/window_menu_preview.c @@ -3,6 +3,8 @@ #include #include +#define MODEL_PREVIEW_DELAY_NS 150000000ull + static const BongoCatModelEntry *menu_model(const BongoCatApp *app, BongoCatMenuAction action) { if (!app || action < BONGO_CAT_MENU_MODEL_FIRST || @@ -38,7 +40,8 @@ void bongo_cat_window_menu_preview_init(BongoCatWindowMenuPreview *state, .scale = app ? app->config.window.scale_percent : 100.0f, .opacity = app ? app->config.window.opacity_percent : 100.0f, .expression = app ? bongo_cat_live2d_expression(app->live2d) : -1, - .last = BONGO_CAT_MENU_NONE, .applied = BONGO_CAT_MENU_NONE}; + .last = BONGO_CAT_MENU_NONE, .applied = BONGO_CAT_MENU_NONE, + .pending_model = BONGO_CAT_MENU_NONE}; bongo_cat_modal_frame_init(&state->modal_frame, app); if (app) snprintf(state->model, sizeof(state->model), "%.*s", (int)sizeof(state->model) - 1, app->config.current_model); @@ -59,24 +62,13 @@ void bongo_cat_window_menu_preview(void *userdata, BongoCatMenuAction action) { return; } state->last = action; - const BongoCatModelEntry *model = menu_model(app, action); - if (model) { - BongoCatError error = {0}; - if (!bongo_cat_app_select_model_with_error(app, model->id, &error)) { - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Model preview failed: %s", - error.message[0] ? error.message : "unknown error"); - if (strcmp(app->config.current_model, state->model) != 0) { - BongoCatError restore_error = {0}; - if (!bongo_cat_app_select_model_with_error(app, state->model, - &restore_error)) - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "Model preview recovery failed: %s", - restore_error.message[0] ? restore_error.message : - "unknown error"); - } - } + if (menu_model(app, action)) { + state->pending_model = action; + state->pending_model_since_ns = SDL_GetTicksNS(); + return; } - else if (action >= BONGO_CAT_MENU_SCALE_50 && + state->pending_model = BONGO_CAT_MENU_NONE; + if (action >= BONGO_CAT_MENU_SCALE_50 && action <= BONGO_CAT_MENU_SCALE_200) bongo_cat_window_set_scale(app, (float)(50 + 10 * (action - BONGO_CAT_MENU_SCALE_50))); @@ -100,13 +92,42 @@ void bongo_cat_window_menu_preview(void *userdata, BongoCatMenuAction action) { void bongo_cat_window_menu_preview_tick(void *userdata) { BongoCatWindowMenuPreview *state = userdata; - if (state) bongo_cat_modal_frame_tick(&state->modal_frame); + if (!state) return; + if (state->pending_model != BONGO_CAT_MENU_NONE && + SDL_GetTicksNS() - state->pending_model_since_ns >= + MODEL_PREVIEW_DELAY_NS) { + BongoCatMenuAction action = state->pending_model; + state->pending_model = BONGO_CAT_MENU_NONE; + const BongoCatModelEntry *model = action == state->last ? + menu_model(state->app, action) : NULL; + if (model) { + BongoCatError error = {0}; + if (!bongo_cat_app_select_model_with_error(state->app, model->id, + &error)) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Model preview failed: %s", + error.message[0] ? error.message : "unknown error"); + if (strcmp(state->app->config.current_model, + state->model) != 0) { + BongoCatError restore_error = {0}; + if (!bongo_cat_app_select_model_with_error(state->app, + state->model, &restore_error)) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Model preview recovery failed: %s", + restore_error.message[0] ? + restore_error.message : "unknown error"); + } + } + } + } + bongo_cat_modal_frame_tick(&state->modal_frame); } void bongo_cat_window_menu_restore(void *userdata, BongoCatMenuAction selected) { BongoCatWindowMenuPreview *state = userdata; if (!state || !state->app) return; BongoCatApp *app = state->app; + state->pending_model = BONGO_CAT_MENU_NONE; bool changed = false; bool keep_motion = selected >= BONGO_CAT_MENU_MOTION_FIRST && selected < BONGO_CAT_MENU_MOTION_FIRST + BONGO_CAT_BEHAVIOR_CAP; diff --git a/src/ui/preferences_import.c b/src/ui/preferences_import.c index 69e5fc59..ef63eb91 100644 --- a/src/ui/preferences_import.c +++ b/src/ui/preferences_import.c @@ -13,7 +13,9 @@ typedef struct BongoCatImportJob { char **paths; char data_root[BONGO_CAT_PATH_CAP]; char first_id[BONGO_CAT_ID_CAP]; - size_t imported_count; + bool first_id_installed; + size_t resolved_count; + size_t installed_count; BongoCatResult result; BongoCatError error; BongoCatImportDialog *dialog; @@ -134,17 +136,25 @@ static int SDLCALL import_worker(void *userdata) { BongoCatResult result = bongo_cat_import_install(job->paths[i], job->data_root, &receipt, &error); if (result == BONGO_CAT_OK) { - if (!job->first_id[0] && receipt.count) - snprintf(job->first_id, sizeof(job->first_id), "%s", receipt.ids[0]); - job->imported_count += receipt.count; + for (size_t j = 0; j < receipt.count; ++j) { + if (!job->first_id[0] || + (receipt.installed[j] && !job->first_id_installed)) { + snprintf(job->first_id, sizeof(job->first_id), "%s", + receipt.ids[j]); + job->first_id_installed = receipt.installed[j]; + } + } + job->resolved_count += receipt.count; + job->installed_count += receipt.installed_count; job->result = BONGO_CAT_OK; } else if (!job->error.message[0]) { job->result = result; job->error = error; } } - if (!job->imported_count && !job->error.message[0]) - bongo_cat_error_set(&job->error, job->result, + if (job->resolved_count) job->result = BONGO_CAT_OK; + if (!job->resolved_count && !job->error.message[0]) + bongo_cat_error_set(&job->error, BONGO_CAT_ERROR_FORMAT, "No model could be imported"); SDL_Event event = {0}; SDL_LockMutex(job->dialog->mutex); @@ -257,7 +267,7 @@ static void complete_job(BongoCatImportDialog *dialog, BongoCatApp *app, } } bongo_cat_preferences_import_complete(app, job->result, &job->error, - job->imported_count); + job->resolved_count, job->installed_count); free_job(job); release_dialog(dialog); } diff --git a/src/ui/preferences_internal.h b/src/ui/preferences_internal.h index 1958d909..417b232e 100644 --- a/src/ui/preferences_internal.h +++ b/src/ui/preferences_internal.h @@ -54,7 +54,8 @@ void bongo_cat_preferences_shortcut_smoke(BongoCatPreferences *value); void bongo_cat_preferences_import_path(BongoCatApp *app, SDL_Window *window, const char *path); void bongo_cat_preferences_import_complete(BongoCatApp *app, - BongoCatResult result, const BongoCatError *error, size_t imported_count); + BongoCatResult result, const BongoCatError *error, size_t resolved_count, + size_t installed_count); void bongo_cat_preferences_model_cache_clear(BongoCatApp *app); bool bongo_cat_preferences_remove_dialog_active(const BongoCatApp *app); void bongo_cat_preferences_remove_dialog_open(BongoCatApp *app, const char *id); diff --git a/src/ui/preferences_model.c b/src/ui/preferences_model.c index 86444e71..aeace18f 100644 --- a/src/ui/preferences_model.c +++ b/src/ui/preferences_model.c @@ -1,234 +1,46 @@ #include "preferences_state.h" +#include "preferences_model_card.h" #include "preferences_model_cover.h" -#include "preferences_widgets.h" #include "preferences_notice.h" -#include "ui_backend.h" -#include "ui_catime.h" -#include "ui_animation.h" -#include "ui_paint.h" -#include "ui_icons.h" +#include "preferences_widgets.h" #include "bongo_cat/i18n.h" -#include "bongo_cat/path.h" -#include "bongo_cat/platform.h" #include "bongo_cat/preferences.h" -#include #include #include +#define MODEL_CARD_HEIGHT 214 + static const char *tr(BongoCatApp *app, const char *key, const char *fallback) { return bongo_cat_i18n_get(app->i18n, key, fallback); } void bongo_cat_preferences_import_complete(BongoCatApp *app, - BongoCatResult result, const BongoCatError *error, size_t imported_count) { + BongoCatResult result, const BongoCatError *error, size_t resolved_count, + size_t installed_count) { if (!app || !app->preferences) return; - const char *message = result == BONGO_CAT_OK ? tr(app, - "pages.preference.model.hints.importSuccess", "Model imported") : - (error && error->message[0] ? error->message : "Model import failed"); + bool partial = result == BONGO_CAT_OK && error && error->message[0]; + const char *message = result != BONGO_CAT_OK || partial + ? (error && error->message[0] ? error->message : "Model import failed") + : installed_count ? tr(app, + "pages.preference.model.hints.importSuccess", "Model imported") + : tr(app, "pages.preference.model.hints.importExists", + "Model already exists"); if (app->smoke) { - if (result != BONGO_CAT_OK) app->exit_code = 1; - } else bongo_cat_preferences_notice_show(app, message, result != BONGO_CAT_OK); + if (result != BONGO_CAT_OK || partial) app->exit_code = 1; + } else bongo_cat_preferences_notice_show(app, message, + result != BONGO_CAT_OK || partial); if (result == BONGO_CAT_OK) { - bongo_cat_preferences_reload_fonts(app->preferences); - SDL_Log("Imported %zu model variant(s)", imported_count); + if (installed_count) + bongo_cat_preferences_reload_fonts(app->preferences); + SDL_Log("Resolved %zu model package(s); installed %zu new package(s)", + resolved_count, installed_count); } bongo_cat_preferences_invalidate(app->preferences); bongo_cat_preferences_render(app->preferences); } -static const char *mode_label(BongoCatApp *app, BongoCatModelMode mode) { - if (mode == BONGO_CAT_MODE_KEYBOARD) - return tr(app, "native.modeKeyboard", "Keyboard"); - if (mode == BONGO_CAT_MODE_GAMEPAD) - return tr(app, "native.modeGamepad", "Gamepad"); - return tr(app, "native.modeStandard", "Standard"); -} - -static void text(struct nk_context *context, struct nk_command_buffer *canvas, - struct nk_rect bounds, const char *value, struct nk_color color, - const struct nk_user_font *font) { - if (!font) font = context->style.font; - nk_draw_text(canvas, bounds, value, nk_strlen(value), font, - nk_rgba(0, 0, 0, 0), color); -} - -static struct nk_rect outline_bounds(struct nk_rect bounds, float thickness) { - float inset = thickness * .5f; - return nk_rect(bounds.x + inset, bounds.y + inset, - NK_MAX(1.0f, bounds.w - thickness), - NK_MAX(1.0f, bounds.h - thickness)); -} - -static bool import_card(BongoCatPreferences *value, - struct nk_context *context) { - struct nk_rect bounds; - if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return false; - bounds.y += 5.0f; bounds.h += 1.0f; - BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); - bool hover = nk_input_is_mouse_hovering_rect(&context->input, bounds); - float lift = bongo_cat_ui_animate_eased(context, "model-import-hover", - hover ? 1.0f : 0.0f, 250.0f, BONGO_CAT_UI_EASE_STANDARD); - bounds.y -= 2.0f * lift; - struct nk_command_buffer *canvas = nk_window_get_canvas(context); - if (hover && p.effects) bongo_cat_ui_paint_shadow(context, bounds, 14, - 0, 8, 24, 0, nk_rgba(p.pink.r, p.pink.g, p.pink.b, 89)); - nk_fill_rect(canvas, bounds, 14, hover ? p.hover_pink : p.hover); - bongo_cat_ui_paint_dashed_rounded(context, bounds, 14, 2, 5, 5, - hover ? p.pink : p.accent); - float cx = bounds.x + bounds.w * .5f, cy = bounds.y + 94; - bongo_cat_preferences_icon_draw(value, canvas, - BONGO_CAT_UI_ICON_UPLOAD, nk_rect(cx - 20, cy - 20, 40, 40), p.pink); - const char *label = tr(value->app, - "pages.preference.model.hints.clickOrDragToImport", "Import model directory"); - float width = value->ui.caption_font->width(value->ui.caption_font->userdata, - value->ui.caption_font->height, label, nk_strlen(label)); - text(context, canvas, nk_rect(cx - width * .5f, cy + 30, - NK_MIN(width + 1, bounds.w - 20), 24), label, p.accent, - value->ui.caption_font); - if (hover) bongo_cat_ui_cursor_hover_rect(context, bounds, - BONGO_CAT_UI_CURSOR_POINTER); - return hover && nk_input_is_mouse_click_in_rect(&context->input, - NK_BUTTON_LEFT, bounds); -} - -static void action_icon(BongoCatPreferences *value, - struct nk_command_buffer *canvas, struct nk_rect r, int icon, - struct nk_color color) { - bongo_cat_preferences_icon_draw(value, canvas, icon, - nk_rect(r.x + (r.w - 18) * .5f, r.y + (r.h - 18) * .5f, 18, 18), color); -} - -static void select_model(BongoCatPreferences *value, - const BongoCatModelEntry *entry) { - BongoCatError error = {0}; - if (bongo_cat_app_select_model_with_error(value->app, entry->id, &error)) { - bongo_cat_preferences_model_cover_capture(value->app, entry); - bongo_cat_preferences_reload_fonts(value); - bongo_cat_preferences_invalidate(value); - return; - } - const char *message = tr(value->app, "native.modelLoadFailed", - "Unable to display this model"); - bongo_cat_preferences_notice_show(value->app, message, true); - bongo_cat_preferences_invalidate(value); -} - -static void open_model_directory(BongoCatPreferences *value, - const BongoCatModelEntry *entry) { - const char *directory = entry->storage_directory[0] ? - entry->storage_directory : entry->directory; - if (bongo_cat_path_is_dir(directory) && - bongo_cat_platform_open_directory(directory)) return; - bongo_cat_preferences_notice_show(value->app, tr(value->app, - "pages.preference.model.hints.openDirectoryFailed", - "Unable to open model directory"), true); -} - -static void model_card(BongoCatPreferences *value, struct nk_context *context, - BongoCatModelEntry *entry) { - BongoCatApp *app = value->app; - bool selected = !strcmp(entry->id, app->config.current_model); - struct nk_rect bounds; - if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return; - bounds.y += 5.0f; bounds.h += 1.0f; - BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); - bool hover = nk_input_is_mouse_hovering_rect(&context->input, bounds); - char animation_id[BONGO_CAT_ID_CAP + 24]; - snprintf(animation_id, sizeof(animation_id), "model-hover-%s", entry->id); - float lift = bongo_cat_ui_animate_eased(context, animation_id, - hover ? 1.0f : 0.0f, 250.0f, BONGO_CAT_UI_EASE_SWIFT); - bounds.y -= 3.0f * lift; - struct nk_command_buffer *canvas = nk_window_get_canvas(context); - if (selected && p.effects) bongo_cat_ui_paint_shadow(context, bounds, - 14, 0, 8, 20, 0, nk_rgba(p.pink.r, p.pink.g, p.pink.b, 89)); - else if (hover && p.effects) bongo_cat_ui_paint_shadow(context, bounds, - 14, 0, 10, 28, 0, nk_rgba(p.accent.r, p.accent.g, p.accent.b, 46)); - nk_fill_rect(canvas, bounds, 14, p.surface); - float preview_height = NK_MIN(128.0f, bounds.w * 354.0f / 612.0f); - struct nk_rect preview = nk_rect(bounds.x + 1, bounds.y + 1, - bounds.w - 2, preview_height); - nk_fill_rect(canvas, preview, 12, p.field); - float raster_scale = value->ui.raster_scale > 0.0f ? - value->ui.raster_scale : 1.0f; - const BongoCatModelCover *cover = bongo_cat_preferences_model_cover( - app, entry, NK_MAX(1, (int)lroundf(preview.w * raster_scale)), - NK_MAX(1, (int)lroundf(preview.h * raster_scale))); - if (cover) { - float scale = NK_MIN(preview.w / cover->width, preview.h / cover->height); - struct nk_rect image = nk_rect(preview.x + (preview.w - cover->width * scale) * .5f, - preview.y + (preview.h - cover->height * scale) * .5f, - cover->width * scale, cover->height * scale); - struct nk_image texture = nk_image_id((int)cover->texture); - nk_draw_image(canvas, image, &texture, nk_rgb(255, 255, 255)); - } - float info_y = preview.y + preview.h + 8; - struct nk_rect name_bounds = nk_rect(bounds.x + 9, info_y - 3, - bounds.w - 18, 28); - bool name_consumes_click = bongo_cat_preferences_model_name_draw(value, - context, canvas, entry, name_bounds, p); - text(context, canvas, nk_rect(bounds.x + 13, info_y + 24, bounds.w - 26, 20), - mode_label(app, entry->mode), p.muted, value->ui.caption_font); - struct nk_rect actions = nk_rect(bounds.x + 1, bounds.y + bounds.h - 39, - bounds.w - 2, 38); - nk_fill_rect(canvas, actions, 0, p.field); - bool deletable = !entry->preset && !entry->managed; - float action_width = actions.w / (deletable ? 3.0f : 2.0f); - struct nk_rect first = nk_rect(actions.x, actions.y, action_width, actions.h); - struct nk_rect second = nk_rect(first.x + first.w, actions.y, - action_width, actions.h); - struct nk_rect third = nk_rect(second.x + second.w, actions.y, - action_width, actions.h); - nk_stroke_line(canvas, second.x, second.y + 10, second.x, - second.y + second.h - 10, 1, p.border_subtle); - bool first_hover = nk_input_is_mouse_hovering_rect(&context->input, first); - bool second_hover = nk_input_is_mouse_hovering_rect(&context->input, second); - bool third_hover = deletable && - nk_input_is_mouse_hovering_rect(&context->input, third); - if (first_hover) nk_fill_rect(canvas, first, 0, p.hover_pink); - if (second_hover) nk_fill_rect(canvas, second, 0, p.hover_pink); - if (third_hover) nk_fill_rect(canvas, third, 0, p.hover_pink); - action_icon(value, canvas, first, BONGO_CAT_UI_ICON_SMILE, - first_hover ? p.pink : p.muted); - action_icon(value, canvas, second, BONGO_CAT_UI_ICON_FOLDER, - second_hover ? p.pink : p.muted); - if (deletable) { - nk_stroke_line(canvas, third.x, third.y + 10, third.x, - third.y + third.h - 10, 1, p.border_subtle); - action_icon(value, canvas, third, BONGO_CAT_UI_ICON_TRASH, - third_hover ? p.danger : p.muted); - } - float outline_width = selected ? 2.0f : 1.0f; - struct nk_rect outline = outline_bounds(bounds, outline_width); - struct nk_color outline_color = selected ? p.pink : - bongo_cat_ui_color_mix(p.border_subtle, p.accent, lift); - nk_stroke_rect(canvas, outline, 14.0f - outline_width * .5f, - outline_width, outline_color); - if (!name_consumes_click && - (first_hover || second_hover || third_hover || hover)) - bongo_cat_ui_cursor_hover_rect(context, bounds, - BONGO_CAT_UI_CURSOR_POINTER); - if (first_hover && nk_input_is_mouse_click_in_rect(&context->input, - NK_BUTTON_LEFT, first)) { - if (selected) - bongo_cat_preferences_behavior_dialog_open(value); - else select_model(value, entry); - } else if (second_hover && nk_input_is_mouse_click_in_rect(&context->input, - NK_BUTTON_LEFT, second)) open_model_directory(value, entry); - else if (third_hover && nk_input_is_mouse_click_in_rect(&context->input, - NK_BUTTON_LEFT, third)) - bongo_cat_preferences_remove_dialog_open(app, entry->id); - else if (!name_consumes_click && hover && - nk_input_is_mouse_click_in_rect( - &context->input, NK_BUTTON_LEFT, bounds)) select_model(value, entry); -} - -static bool is_builtin(const char *id) { - return !strcmp(id, "standard") || !strcmp(id, "keyboard") || - !strcmp(id, "gamepad"); -} - static bool has_nearby(const BongoCatApp *app) { for (size_t i = 0; i < app->models.count; ++i) if (app->models.entries[i].managed) return true; @@ -239,13 +51,13 @@ static void smoke_model_behavior(BongoCatPreferences *value) { BongoCatApp *app = value->app; if (app->smoke_preference_model_select) { for (size_t i = 0; i < app->models.count; ++i) { - BongoCatModelEntry *entry = &app->models.entries[i]; - if (is_builtin(entry->id) || !strcmp(entry->id, + const BongoCatModelEntry *entry = &app->models.entries[i]; + if (entry->preset || !strcmp(entry->id, app->config.current_model)) continue; app->smoke_preference_model_select = false; value->smoke_behavior_open_pending = true; SDL_Log("Preferences smoke selecting model %s", entry->id); - select_model(value, entry); + bongo_cat_preferences_model_select(value, entry); return; } } @@ -255,45 +67,40 @@ static void smoke_model_behavior(BongoCatPreferences *value) { } } -static void draw_named(BongoCatPreferences *value, struct nk_context *context, - const char *id) { - for (size_t i = 0; i < value->app->models.count; ++i) - if (!strcmp(value->app->models.entries[i].id, id)) { - model_card(value, context, &value->app->models.entries[i]); return; - } +static void draw_models(BongoCatPreferences *value, struct nk_context *context, + bool managed) { + for (size_t i = 0; i < value->app->models.count; ++i) { + const BongoCatModelEntry *entry = &value->app->models.entries[i]; + if (entry->managed == managed) + bongo_cat_preferences_model_card(value, context, entry); + } } void bongo_cat_preferences_page_model(BongoCatPreferences *value, struct nk_context *context) { BongoCatApp *app = value->app; smoke_model_behavior(value); - bongo_cat_preferences_model_covers_begin(); + bongo_cat_preferences_model_covers_begin(app); bongo_cat_pref_section(context, tr(app, "pages.preference.model.title", "Installed models")); float width = nk_window_get_content_region(context).w; int columns = width >= 780 ? 4 : width >= 620 ? 3 : width >= 400 ? 2 : 1; struct nk_vec2 old_spacing = context->style.window.spacing; context->style.window.spacing = nk_vec2(14, 17); - nk_layout_row_dynamic(context, 222, columns); - if (import_card(value, context)) + nk_layout_row_dynamic(context, MODEL_CARD_HEIGHT, columns); + if (bongo_cat_preferences_model_import_card(value, context)) bongo_cat_preferences_request_model_import(app->preferences); - draw_named(value, context, "standard"); - draw_named(value, context, "keyboard"); - draw_named(value, context, "gamepad"); - for (size_t i = 0; i < app->models.count; ++i) - if (!is_builtin(app->models.entries[i].id) && - !app->models.entries[i].managed) - model_card(value, context, &app->models.entries[i]); + draw_models(value, context, false); context->style.window.spacing = old_spacing; if (has_nearby(app)) { bongo_cat_pref_section(context, tr(app, "pages.preference.model.labels.nearbyModels", "Nearby folders")); context->style.window.spacing = nk_vec2(14, 17); - nk_layout_row_dynamic(context, 222, columns); - for (size_t i = 0; i < app->models.count; ++i) - if (app->models.entries[i].managed) - model_card(value, context, &app->models.entries[i]); + nk_layout_row_dynamic(context, MODEL_CARD_HEIGHT, columns); + draw_models(value, context, true); context->style.window.spacing = old_spacing; } bongo_cat_preferences_model_covers_prune(app); + if (bongo_cat_preferences_model_cover_generate_current(app)) + bongo_cat_preferences_invalidate(value); } diff --git a/src/ui/preferences_model_card.c b/src/ui/preferences_model_card.c new file mode 100644 index 00000000..22650c72 --- /dev/null +++ b/src/ui/preferences_model_card.c @@ -0,0 +1,210 @@ +#include "preferences_model_card.h" +#include "preferences_model_cover.h" +#include "preferences_notice.h" +#include "preferences_state.h" +#include "ui_animation.h" +#include "ui_backend.h" +#include "ui_icons.h" +#include "ui_paint.h" +#include "bongo_cat/i18n.h" +#include "bongo_cat/path.h" +#include "bongo_cat/platform.h" + +#include +#include +#include + +static const char *tr(BongoCatApp *app, const char *key, + const char *fallback) { + return bongo_cat_i18n_get(app->i18n, key, fallback); +} + +static void text(struct nk_context *context, struct nk_command_buffer *canvas, + struct nk_rect bounds, const char *value, struct nk_color color, + const struct nk_user_font *font) { + if (!font) font = context->style.font; + nk_draw_text(canvas, bounds, value, nk_strlen(value), font, + nk_rgba(0, 0, 0, 0), color); +} + +static struct nk_rect outline_bounds(struct nk_rect bounds, float thickness) { + float inset = thickness * .5f; + return nk_rect(bounds.x + inset, bounds.y + inset, + NK_MAX(1.0f, bounds.w - thickness), + NK_MAX(1.0f, bounds.h - thickness)); +} + +bool bongo_cat_preferences_model_import_card(BongoCatPreferences *value, + struct nk_context *context) { + struct nk_rect bounds; + if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return false; + bounds.y += 5.0f; bounds.h += 1.0f; + BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); + bool hover = nk_input_is_mouse_hovering_rect(&context->input, bounds); + float lift = bongo_cat_ui_animate_eased(context, "model-import-hover", + hover ? 1.0f : 0.0f, 250.0f, BONGO_CAT_UI_EASE_STANDARD); + bounds.y -= 2.0f * lift; + struct nk_command_buffer *canvas = nk_window_get_canvas(context); + if (hover && p.effects) bongo_cat_ui_paint_shadow(context, bounds, 14, + 0, 8, 24, 0, nk_rgba(p.pink.r, p.pink.g, p.pink.b, 89)); + nk_fill_rect(canvas, bounds, 14, hover ? p.hover_pink : p.hover); + bongo_cat_ui_paint_dashed_rounded(context, bounds, 14, 2, 5, 5, + hover ? p.pink : p.accent); + float cx = bounds.x + bounds.w * .5f, cy = bounds.y + 91; + bongo_cat_preferences_icon_draw(value, canvas, + BONGO_CAT_UI_ICON_UPLOAD, nk_rect(cx - 20, cy - 20, 40, 40), p.pink); + const char *label = tr(value->app, + "pages.preference.model.hints.clickOrDragToImport", "Import model directory"); + float width = value->ui.caption_font->width(value->ui.caption_font->userdata, + value->ui.caption_font->height, label, nk_strlen(label)); + text(context, canvas, nk_rect(cx - width * .5f, cy + 30, + NK_MIN(width + 1, bounds.w - 20), 24), label, p.accent, + value->ui.caption_font); + if (hover) bongo_cat_ui_cursor_hover_rect(context, bounds, + BONGO_CAT_UI_CURSOR_POINTER); + return hover && nk_input_is_mouse_click_in_rect(&context->input, + NK_BUTTON_LEFT, bounds); +} + +static void action_icon(BongoCatPreferences *value, + struct nk_command_buffer *canvas, struct nk_rect bounds, int icon, + struct nk_color color) { + bongo_cat_preferences_icon_draw(value, canvas, icon, + nk_rect(bounds.x + (bounds.w - 18) * .5f, + bounds.y + (bounds.h - 18) * .5f, 18, 18), color); +} + +void bongo_cat_preferences_model_select(BongoCatPreferences *value, + const BongoCatModelEntry *entry) { + BongoCatError error = {0}; + if (bongo_cat_app_select_model_with_error(value->app, entry->id, &error)) { + bongo_cat_preferences_model_cover_capture(value->app, entry); + bongo_cat_preferences_invalidate(value); + return; + } + bongo_cat_preferences_notice_show(value->app, tr(value->app, + "native.modelLoadFailed", "Unable to display this model"), true); + bongo_cat_preferences_invalidate(value); +} + +static void open_model_directory(BongoCatPreferences *value, + const BongoCatModelEntry *entry) { + const char *directory = entry->storage_directory[0] ? + entry->storage_directory : entry->directory; + if (bongo_cat_path_is_dir(directory) && + bongo_cat_platform_open_directory(directory)) return; + bongo_cat_preferences_notice_show(value->app, tr(value->app, + "pages.preference.model.hints.openDirectoryFailed", + "Unable to open model directory"), true); +} + +static void draw_cover(BongoCatPreferences *value, + struct nk_command_buffer *canvas, struct nk_rect preview, + const BongoCatModelEntry *entry, BongoCatUIPalette p) { + float raster_scale = value->ui.raster_scale > 0.0f ? + value->ui.raster_scale : 1.0f; + const BongoCatModelCover *cover = bongo_cat_preferences_model_cover( + value->app, entry, NK_MAX(1, (int)lroundf(preview.w * raster_scale)), + NK_MAX(1, (int)lroundf(preview.h * raster_scale))); + if (!cover) { + bongo_cat_preferences_icon_draw(value, canvas, BONGO_CAT_UI_ICON_CAT, + nk_rect(preview.x + (preview.w - 34) * .5f, + preview.y + (preview.h - 34) * .5f, 34, 34), p.muted); + return; + } + float scale = NK_MIN(preview.w / cover->width, preview.h / cover->height); + struct nk_rect image = nk_rect( + preview.x + (preview.w - cover->width * scale) * .5f, + preview.y + (preview.h - cover->height * scale) * .5f, + cover->width * scale, cover->height * scale); + struct nk_image texture = nk_image_id((int)cover->texture); + nk_draw_image(canvas, image, &texture, nk_rgb(255, 255, 255)); +} + +static void draw_actions(BongoCatPreferences *value, + struct nk_context *context, struct nk_command_buffer *canvas, + struct nk_rect bounds, const BongoCatModelEntry *entry, + BongoCatUIPalette p, bool selected, bool *action_hover) { + struct nk_rect actions = nk_rect(bounds.x + 1, bounds.y + bounds.h - 39, + bounds.w - 2, 38); + nk_fill_rect(canvas, actions, 0, p.field); + bool deletable = !entry->preset && !entry->managed; + float width = actions.w / (deletable ? 3.0f : 2.0f); + struct nk_rect items[3] = { + nk_rect(actions.x, actions.y, width, actions.h), + nk_rect(actions.x + width, actions.y, width, actions.h), + nk_rect(actions.x + width * 2, actions.y, width, actions.h)}; + bool hover[3] = { + nk_input_is_mouse_hovering_rect(&context->input, items[0]), + nk_input_is_mouse_hovering_rect(&context->input, items[1]), + deletable && nk_input_is_mouse_hovering_rect(&context->input, items[2])}; + for (int i = 1; i < (deletable ? 3 : 2); ++i) + nk_stroke_line(canvas, items[i].x, items[i].y + 10, + items[i].x, items[i].y + items[i].h - 10, 1, p.border_subtle); + for (int i = 0; i < 3; ++i) + if (hover[i]) nk_fill_rect(canvas, items[i], 0, p.hover_pink); + action_icon(value, canvas, items[0], BONGO_CAT_UI_ICON_SMILE, + hover[0] ? p.pink : p.muted); + action_icon(value, canvas, items[1], BONGO_CAT_UI_ICON_FOLDER, + hover[1] ? p.pink : p.muted); + if (deletable) action_icon(value, canvas, items[2], BONGO_CAT_UI_ICON_TRASH, + hover[2] ? p.danger : p.muted); + *action_hover = hover[0] || hover[1] || hover[2]; + if (hover[0] && nk_input_is_mouse_click_in_rect(&context->input, + NK_BUTTON_LEFT, items[0])) { + if (selected) bongo_cat_preferences_behavior_dialog_open(value); + else bongo_cat_preferences_model_select(value, entry); + } else if (hover[1] && nk_input_is_mouse_click_in_rect(&context->input, + NK_BUTTON_LEFT, items[1])) open_model_directory(value, entry); + else if (hover[2] && nk_input_is_mouse_click_in_rect(&context->input, + NK_BUTTON_LEFT, items[2])) + bongo_cat_preferences_remove_dialog_open(value->app, entry->id); +} + +void bongo_cat_preferences_model_card(BongoCatPreferences *value, + struct nk_context *context, const BongoCatModelEntry *entry) { + BongoCatApp *app = value->app; + bool selected = !strcmp(entry->id, app->config.current_model); + struct nk_rect bounds; + if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return; + bounds.y += 5.0f; bounds.h += 1.0f; + BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); + bool hover = nk_input_is_mouse_hovering_rect(&context->input, bounds); + char animation_id[BONGO_CAT_ID_CAP + 24]; + snprintf(animation_id, sizeof(animation_id), "model-hover-%s", entry->id); + float lift = bongo_cat_ui_animate_eased(context, animation_id, + hover ? 1.0f : 0.0f, 250.0f, BONGO_CAT_UI_EASE_SWIFT); + bounds.y -= 3.0f * lift; + struct nk_command_buffer *canvas = nk_window_get_canvas(context); + if (selected && p.effects) bongo_cat_ui_paint_shadow(context, bounds, + 14, 0, 8, 20, 0, nk_rgba(p.pink.r, p.pink.g, p.pink.b, 89)); + else if (hover && p.effects) bongo_cat_ui_paint_shadow(context, bounds, + 14, 0, 10, 28, 0, nk_rgba(p.accent.r, p.accent.g, p.accent.b, 46)); + nk_fill_rect(canvas, bounds, 14, p.surface); + float preview_height = NK_MIN(128.0f, bounds.w * 354.0f / 612.0f); + struct nk_rect preview = nk_rect(bounds.x + 1, bounds.y + 1, + bounds.w - 2, preview_height); + nk_fill_rect(canvas, preview, 12, p.field); + draw_cover(value, canvas, preview, entry, p); + float info_y = preview.y + preview.h + 8; + struct nk_rect name_bounds = nk_rect(bounds.x + 9, info_y + 1, + bounds.w - 18, 30); + bool name_hover = bongo_cat_preferences_model_name_draw(value, + context, canvas, entry, name_bounds, p); + bool action_hover = false; + draw_actions(value, context, canvas, bounds, entry, p, selected, + &action_hover); + float outline_width = selected ? 2.0f : 1.0f; + struct nk_rect outline = outline_bounds(bounds, outline_width); + struct nk_color outline_color = selected ? p.pink : + bongo_cat_ui_color_mix(p.border_subtle, p.accent, lift); + nk_stroke_rect(canvas, outline, 14.0f - outline_width * .5f, + outline_width, outline_color); + if (!name_hover && (action_hover || hover)) + bongo_cat_ui_cursor_hover_rect(context, bounds, + BONGO_CAT_UI_CURSOR_POINTER); + if (!name_hover && !action_hover && hover && + nk_input_is_mouse_click_in_rect(&context->input, + NK_BUTTON_LEFT, bounds)) + bongo_cat_preferences_model_select(value, entry); +} diff --git a/src/ui/preferences_model_card.h b/src/ui/preferences_model_card.h new file mode 100644 index 00000000..53c559a8 --- /dev/null +++ b/src/ui/preferences_model_card.h @@ -0,0 +1,16 @@ +#ifndef BONGO_CAT_PREFERENCES_MODEL_CARD_H +#define BONGO_CAT_PREFERENCES_MODEL_CARD_H + +#include "bongo_cat/model.h" +#include "bongo_cat/preferences.h" + +struct nk_context; + +bool bongo_cat_preferences_model_import_card(BongoCatPreferences *value, + struct nk_context *context); +void bongo_cat_preferences_model_card(BongoCatPreferences *value, + struct nk_context *context, const BongoCatModelEntry *entry); +void bongo_cat_preferences_model_select(BongoCatPreferences *value, + const BongoCatModelEntry *entry); + +#endif diff --git a/src/ui/preferences_model_cover.c b/src/ui/preferences_model_cover.c index 23391969..2c4d9d59 100644 --- a/src/ui/preferences_model_cover.c +++ b/src/ui/preferences_model_cover.c @@ -16,9 +16,31 @@ typedef struct ModelCoverSlot { uint64_t generation; } ModelCoverSlot; +typedef struct ModelCoverAttempt { + BongoCatApp *app; + char model_id[BONGO_CAT_ID_CAP]; +} ModelCoverAttempt; + static ModelCoverSlot cover_cache[BONGO_CAT_MODEL_CAP]; +static ModelCoverAttempt cover_attempts[BONGO_CAT_MODEL_CAP]; static uint64_t cover_generation; +static void clear_attempts(BongoCatApp *app) { + for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) + if (!app || cover_attempts[i].app == app) + memset(&cover_attempts[i], 0, sizeof(cover_attempts[i])); +} + +static void prune_attempts(BongoCatApp *app) { + if (!app) return; + for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) { + ModelCoverAttempt *attempt = &cover_attempts[i]; + if (attempt->app == app && !bongo_cat_models_find( + &app->models, attempt->model_id)) + memset(attempt, 0, sizeof(*attempt)); + } +} + void bongo_cat_preferences_model_cover_cache_clear(BongoCatApp *app) { for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) { ModelCoverSlot *slot = &cover_cache[i]; @@ -31,11 +53,13 @@ void bongo_cat_preferences_model_cover_cache_clear(BongoCatApp *app) { void bongo_cat_preferences_model_cache_clear(BongoCatApp *app) { bongo_cat_preferences_remove_dialog_clear(app); bongo_cat_preferences_model_cover_cache_clear(app); + clear_attempts(app); } -void bongo_cat_preferences_model_covers_begin(void) { +void bongo_cat_preferences_model_covers_begin(BongoCatApp *app) { cover_generation++; if (!cover_generation) cover_generation++; + prune_attempts(app); } const BongoCatModelCover *bongo_cat_preferences_model_cover( @@ -73,7 +97,7 @@ const BongoCatModelCover *bongo_cat_preferences_model_cover( bool bongo_cat_preferences_model_cover_capture(BongoCatApp *app, const BongoCatModelEntry *entry) { if (!app || !entry || !app->window || !app->gl_context || - !app->config.window.visible) return false; + app->window_minimized) return false; char path[BONGO_CAT_PATH_CAP], resources[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(resources, sizeof(resources), entry->adapter_directory, "resources") || @@ -92,17 +116,80 @@ bool bongo_cat_preferences_model_cover_capture(BongoCatApp *app, bongo_cat_file_remove(temporary); snprintf(app->pending_model_cover_path, sizeof(app->pending_model_cover_path), "%s", temporary); - bongo_cat_app_render_now(app); + bongo_cat_app_capture_pending_frame(app); + app->pending_model_cover_path[0] = '\0'; if (previous_window && previous_context && (previous_window != SDL_GL_GetCurrentWindow() || previous_context != SDL_GL_GetCurrentContext())) SDL_GL_MakeCurrent(previous_window, previous_context); - if (!bongo_cat_path_is_file(temporary) || - !bongo_cat_file_replace(temporary, path, true)) return false; + if (!bongo_cat_path_is_file(temporary)) return false; + if (!bongo_cat_file_replace(temporary, path, true)) { + bongo_cat_file_remove(temporary); + return false; + } if (fallback) bongo_cat_file_remove(marker); return true; } +static bool cover_needs_capture(const BongoCatModelEntry *entry) { + char resources[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; + char marker[BONGO_CAT_PATH_CAP]; + return entry && bongo_cat_path_join(resources, sizeof(resources), + entry->adapter_directory, "resources") && + bongo_cat_path_join(path, sizeof(path), resources, "cover.png") && + bongo_cat_path_join(marker, sizeof(marker), resources, + ".bongo-cat-cover-fallback") && + (!bongo_cat_path_is_file(path) || bongo_cat_path_is_file(marker)); +} + +static bool cover_attempted(BongoCatApp *app, const char *model_id) { + for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) + if (cover_attempts[i].app == app && + !strcmp(cover_attempts[i].model_id, model_id)) return true; + return false; +} + +static bool mark_cover_attempt(BongoCatApp *app, const char *model_id) { + if (cover_attempted(app, model_id)) return false; + for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) { + ModelCoverAttempt *attempt = &cover_attempts[i]; + if (attempt->app) continue; + attempt->app = app; + snprintf(attempt->model_id, sizeof(attempt->model_id), "%s", model_id); + return true; + } + return false; +} + +static void forget_cached_cover(BongoCatApp *app, + const BongoCatModelEntry *entry) { + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), entry->adapter_directory, + "resources/cover.png")) return; + for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) { + ModelCoverSlot *slot = &cover_cache[i]; + if (slot->app != app || strcmp(slot->path, path)) continue; + if (slot->image.texture) glDeleteTextures(1, &slot->image.texture); + memset(slot, 0, sizeof(*slot)); + return; + } +} + +bool bongo_cat_preferences_model_cover_generate_current(BongoCatApp *app) { + if (!app || !app->window || !app->gl_context || + app->window_minimized) return false; + const BongoCatModelEntry *entry = bongo_cat_models_find( + &app->models, app->loaded_model); + if (!entry || strcmp(app->config.current_model, entry->id) || + !cover_needs_capture(entry) || !mark_cover_attempt(app, entry->id)) + return false; + bool captured = bongo_cat_preferences_model_cover_capture(app, entry); + if (captured) forget_cached_cover(app, entry); + else SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Cannot generate cover for model %s", entry->id); + return true; +} + void bongo_cat_preferences_model_covers_prune(BongoCatApp *app) { for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) { ModelCoverSlot *slot = &cover_cache[i]; diff --git a/src/ui/preferences_model_cover.h b/src/ui/preferences_model_cover.h index 0fdab386..5f40a68d 100644 --- a/src/ui/preferences_model_cover.h +++ b/src/ui/preferences_model_cover.h @@ -11,13 +11,14 @@ typedef struct BongoCatModelCover { int height; } BongoCatModelCover; -void bongo_cat_preferences_model_covers_begin(void); +void bongo_cat_preferences_model_covers_begin(BongoCatApp *app); void bongo_cat_preferences_model_cover_cache_clear(BongoCatApp *app); const BongoCatModelCover *bongo_cat_preferences_model_cover( BongoCatApp *app, const BongoCatModelEntry *entry, int pixel_width, int pixel_height); bool bongo_cat_preferences_model_cover_capture(BongoCatApp *app, const BongoCatModelEntry *entry); +bool bongo_cat_preferences_model_cover_generate_current(BongoCatApp *app); void bongo_cat_preferences_model_covers_prune(BongoCatApp *app); #endif diff --git a/tests/fixtures/model-packages/v1/.bongo-cat-package.json b/tests/fixtures/model-packages/v1/.bongo-cat-package.json new file mode 100644 index 00000000..9d34ab12 --- /dev/null +++ b/tests/fixtures/model-packages/v1/.bongo-cat-package.json @@ -0,0 +1,9 @@ +{ + "schemaVersion": 1, + "layout": "preserved-payload", + "format": "tauri-live2d", + "mode": "standard", + "directory": "payload", + "adapter": "adapter", + "setting": "dummy.model3.json" +} diff --git a/tests/fixtures/model-packages/v1/adapter/.keep b/tests/fixtures/model-packages/v1/adapter/.keep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/fixtures/model-packages/v1/adapter/.keep @@ -0,0 +1 @@ + diff --git a/tests/fixtures/model-packages/v1/payload/dummy.model3.json b/tests/fixtures/model-packages/v1/payload/dummy.model3.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/tests/fixtures/model-packages/v1/payload/dummy.model3.json @@ -0,0 +1 @@ +{} diff --git a/tests/fixtures/model-packages/v2/.bongo-cat-package.json b/tests/fixtures/model-packages/v2/.bongo-cat-package.json new file mode 100644 index 00000000..baeb4f9a --- /dev/null +++ b/tests/fixtures/model-packages/v2/.bongo-cat-package.json @@ -0,0 +1,32 @@ +{ + "schemaVersion": 2, + "packageId": "test-package-v2", + "contentDigest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "familyId": "test-family", + "displayName": "Test Package v2", + "mode": "keyboard", + "source": { + "format": "tauri-live2d", + "name": "Fixture", + "layout": "single-model", + "preserved": true + }, + "model": { + "directory": "payload", + "setting": "dummy.model3.json" + }, + "runtime": { + "adapter": "adapter", + "metadata": "adapter/.bongo-cat-adapter.json", + "adapterSchemaVersion": 1, + "generatorVersion": 1 + }, + "capabilities": [ + "live2d", + "preview", + "runtime-adapter", + "keyboard-input", + "future-capability" + ], + "extensions": {} +} diff --git a/tests/fixtures/model-packages/v2/adapter/.bongo-cat-adapter.json b/tests/fixtures/model-packages/v2/adapter/.bongo-cat-adapter.json new file mode 100644 index 00000000..0687d338 --- /dev/null +++ b/tests/fixtures/model-packages/v2/adapter/.bongo-cat-adapter.json @@ -0,0 +1,7 @@ +{ + "schemaVersion": 1, + "kind": "bongo-cat-runtime-adapter", + "sourceFormat": "tauri-live2d", + "render": { "profile": "native" }, + "bindings": [] +} diff --git a/tests/fixtures/model-packages/v2/payload/dummy.model3.json b/tests/fixtures/model-packages/v2/payload/dummy.model3.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/tests/fixtures/model-packages/v2/payload/dummy.model3.json @@ -0,0 +1 @@ +{} diff --git a/tests/test_models.c b/tests/test_models.c index e7731595..a7f006d5 100644 --- a/tests/test_models.c +++ b/tests/test_models.c @@ -145,4 +145,25 @@ void test_models(void) { CHECK(strcmp(behaviors.entries[0].group, "CAT_motion") == 0); CHECK(strstr(behaviors.entries[0].sound, "live2d_motion1.flac") != NULL); CHECK(behaviors.entries[6].kind == BONGO_CAT_BEHAVIOR_EXPRESSION); + + bongo_cat_models_init(&catalog); + CHECK(bongo_cat_models_scan(&catalog, + BONGO_CAT_NATIVE_SOURCE_DIR "/tests/fixtures/model-packages", + false, &error) == BONGO_CAT_OK); + CHECK(catalog.count == 2); + const BongoCatModelEntry *v1 = bongo_cat_models_find(&catalog, "v1"); + const BongoCatModelEntry *v2 = bongo_cat_models_find(&catalog, + "test-package-v2"); + CHECK(v1 && v1->package_schema == 1 && + strcmp(v1->package_id, "v1") == 0 && + v1->source_format == BONGO_CAT_MODEL_SOURCE_TAURI); + CHECK(v2 && v2->package_schema == 2 && + strcmp(v2->content_digest, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") == 0 && + strcmp(v2->family_id, "test-family") == 0 && + strcmp(v2->display_name, "Test Package v2") == 0 && + v2->mode == BONGO_CAT_MODE_KEYBOARD && + v2->source_format == BONGO_CAT_MODEL_SOURCE_TAURI && + v2->adapter_schema == 1 && v2->adapter_generator == 1 && + (v2->capabilities & BONGO_CAT_MODEL_CAPABILITY_KEYBOARD_INPUT)); } diff --git a/tests/test_mver_import.c b/tests/test_mver_import.c index b958ffbf..1c953da5 100644 --- a/tests/test_mver_import.c +++ b/tests/test_mver_import.c @@ -233,7 +233,7 @@ static void portable_model(void) { CHECK(test_mver_pointer_adapter( app->models.entries[0].adapter_directory, true)); BongoCatLive2DRenderOptions render = {0}; - CHECK(bongo_cat_import_mver_render_options( + CHECK(bongo_cat_import_render_options( app->models.entries[0].adapter_directory, &render)); CHECK(render.mver_projection && render.source_mirror); CHECK(render.projection_scale > 1.986f && render.projection_scale < 1.988f); diff --git a/tests/test_mver_pointer_import.c b/tests/test_mver_pointer_import.c index 3913f046..71ee7ff0 100644 --- a/tests/test_mver_pointer_import.c +++ b/tests/test_mver_pointer_import.c @@ -49,7 +49,8 @@ bool test_mver_pointer_adapter(const char *adapter, bool expected_enabled) { "resources/mver-pointer/arm.png") && bongo_cat_path_is_file(path) && bongo_cat_path_join(path, sizeof(path), adapter, "resources/mver-pointer/tablet.png") && bongo_cat_path_is_file(path) && - bongo_cat_path_join(path, sizeof(path), adapter, ".bongo-cat-mver.json"); + bongo_cat_path_join(path, sizeof(path), adapter, + ".bongo-cat-adapter.json"); yyjson_doc *metadata = files ? bongo_cat_json_read_file(path, 0, NULL) : NULL; yyjson_val *pointer = metadata ? yyjson_obj_get( yyjson_doc_get_root(metadata), "standardPointer") : NULL; @@ -61,7 +62,7 @@ bool test_mver_pointer_adapter(const char *adapter, bool expected_enabled) { yyjson_get_int(yyjson_obj_get(pointer, "lineBlue")) == 3; yyjson_doc_free(metadata); BongoCatLive2DRenderOptions render = {0}; - return valid && bongo_cat_import_mver_render_options(adapter, &render) && + return valid && bongo_cat_import_render_options(adapter, &render) && render.pointer_left_handed && render.mouse_force_move && render.mouse_speed > 1.249f && render.mouse_speed < 1.251f; } diff --git a/tests/test_mver_portable_identity.c b/tests/test_mver_portable_identity.c index b364d6f2..12edd156 100644 --- a/tests/test_mver_portable_identity.c +++ b/tests/test_mver_portable_identity.c @@ -43,6 +43,34 @@ int test_mver_portable_identity(void) { bongo_cat_models_init(&app->models); snprintf(app->data_root, sizeof(app->data_root), "%s", data); BongoCatError error = {0}; + BongoCatImportReceipt first_install = {0}, repeated_install = {0}; + CHECK(bongo_cat_import_install(source, data, &first_install, &error) == + BONGO_CAT_OK); + CHECK(first_install.count == 1 && first_install.installed_count == 1 && + first_install.installed[0]); + CHECK(bongo_cat_import_install(source, data, &repeated_install, &error) == + BONGO_CAT_OK); + CHECK(repeated_install.count == 1 && repeated_install.installed_count == 0 && + !repeated_install.installed[0] && + strcmp(first_install.ids[0], repeated_install.ids[0]) == 0); + char custom_root[BONGO_CAT_PATH_CAP], adapter_metadata[BONGO_CAT_PATH_CAP]; + CHECK(child(custom_root, sizeof(custom_root), data, "custom-models", false)); + BongoCatModelCatalog *installed = calloc(1, sizeof(*installed)); + CHECK(installed != NULL); + if (installed) { + CHECK(bongo_cat_models_scan(installed, custom_root, false, &error) == + BONGO_CAT_OK); + CHECK(installed->count == 1 && installed->entries[0].package_schema == 2 && + installed->entries[0].source_format == BONGO_CAT_MODEL_SOURCE_MVER && + installed->entries[0].content_digest[0] != '\0' && + (installed->entries[0].capabilities & + BONGO_CAT_MODEL_CAPABILITY_MVER_PROJECTION)); + CHECK(child(adapter_metadata, sizeof(adapter_metadata), + installed->entries[0].adapter_directory, + ".bongo-cat-adapter.json", false)); + CHECK(bongo_cat_path_is_file(adapter_metadata)); + free(installed); + } CHECK(bongo_cat_import_portable_mver(app, source, &error) == BONGO_CAT_OK); CHECK(app->models.count == 1); char old_id[BONGO_CAT_ID_CAP]; From a2bdfb3d3411ec6d2a60397f6aa81d74822ea416 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 15 Aug 2026 14:20:32 +0800 Subject: [PATCH 011/334] refactor: establish clean settings and storage architecture --- CMakeLists.txt | 2 +- README.md | 25 +- cmake/RuntimeSources.cmake | 9 +- docs/model-format-audit-2026-07-24.md | 126 -------- docs/model-package-v2.md | 16 +- docs/storage-layout.md | 105 +++++++ include/bongo_cat/app.h | 17 +- include/bongo_cat/common.h | 2 - include/bongo_cat/config.h | 70 +++-- include/bongo_cat/model.h | 2 +- include/bongo_cat/utf8.h | 2 +- src/core/config_defaults.c | 74 ++--- src/core/config_file.c | 10 +- src/core/config_internal.h | 5 +- src/core/config_io.c | 224 +++++++++----- src/core/config_session_io.c | 76 +++-- src/core/model_catalog.c | 4 +- src/core/model_package.c | 87 ++---- src/core/utf8.c | 2 +- src/runtime/app.c | 61 ++-- src/runtime/assets.c | 2 +- src/runtime/config_store.c | 98 +++--- src/runtime/frame_audit.c | 16 +- src/runtime/frame_clock.c | 32 +- src/runtime/frame_presentation_audit.c | 4 +- src/runtime/gamepad.c | 6 +- src/runtime/live2d_audit.c | 15 +- src/runtime/live2d_viewer_audit.c | 2 +- src/runtime/live2d_visual_audit.c | 2 +- src/runtime/model_catalog_runtime.c | 22 +- src/runtime/model_files.c | 14 +- src/runtime/model_import.c | 8 +- src/runtime/model_import.h | 12 +- src/runtime/model_import_discovery.c | 2 +- src/runtime/model_import_identity.c | 2 +- src/runtime/model_import_metadata.c | 16 +- src/runtime/model_import_mver_labels.c | 2 +- ...mport_portable.c => model_import_nearby.c} | 104 ++++--- ...ntity.c => model_import_nearby_identity.c} | 4 +- src/runtime/model_import_nearby_internal.h | 13 + ...able_scan.c => model_import_nearby_scan.c} | 66 ++-- ...ture.c => model_import_nearby_signature.c} | 18 +- src/runtime/model_import_portable_internal.h | 16 - src/runtime/model_import_portable_migration.c | 165 ---------- src/runtime/mouse.c | 12 +- src/runtime/mouse_mapping.c | 24 +- src/runtime/runtime_flow.c | 6 +- src/runtime/shortcuts.c | 30 +- src/runtime/startup.c | 118 ++----- src/runtime/storage_paths.c | 120 ++++++++ src/runtime/storage_paths.h | 9 + src/runtime/tray.c | 44 +-- src/runtime/window.c | 30 +- src/runtime/window_background.c | 2 +- src/runtime/window_display.c | 16 +- src/runtime/window_drag.c | 4 +- src/runtime/window_geometry.c | 41 +-- src/runtime/window_hit.c | 12 +- src/runtime/window_menu_actions.c | 40 +-- src/runtime/window_menu_behavior.c | 8 +- src/runtime/window_menu_preview.c | 20 +- src/runtime/window_wheel.c | 62 ++-- src/ui/preferences.c | 6 +- src/ui/preferences_behavior_rename.c | 4 +- src/ui/preferences_behavior_row.c | 4 +- src/ui/preferences_dialog.c | 2 +- src/ui/preferences_language.c | 8 +- src/ui/preferences_model.c | 2 +- src/ui/preferences_model_card.c | 2 +- src/ui/preferences_model_cover.c | 2 +- src/ui/preferences_model_glyphs.c | 6 +- src/ui/preferences_model_rename.c | 6 +- src/ui/preferences_pages.c | 32 +- src/ui/preferences_scale.c | 2 +- src/ui/preferences_shortcuts.c | 4 +- src/ui/preferences_smoke.c | 6 +- .../model-packages/v1/.bongo-cat-package.json | 9 - .../fixtures/model-packages/v1/adapter/.keep | 1 - .../v1/payload/dummy.model3.json | 1 - tests/test_config.c | 287 ++++++++---------- tests/test_models.c | 6 +- tests/test_mver_import.c | 73 ++--- ...identity.c => test_mver_nearby_identity.c} | 54 ++-- tests/test_mver_support.c | 2 +- tests/test_mver_support.h | 2 +- 85 files changed, 1293 insertions(+), 1386 deletions(-) delete mode 100644 docs/model-format-audit-2026-07-24.md create mode 100644 docs/storage-layout.md rename src/runtime/{model_import_portable.c => model_import_nearby.c} (82%) rename src/runtime/{model_import_portable_identity.c => model_import_nearby_identity.c} (97%) create mode 100644 src/runtime/model_import_nearby_internal.h rename src/runtime/{model_import_portable_scan.c => model_import_nearby_scan.c} (76%) rename src/runtime/{model_import_portable_signature.c => model_import_nearby_signature.c} (86%) delete mode 100644 src/runtime/model_import_portable_internal.h delete mode 100644 src/runtime/model_import_portable_migration.c create mode 100644 src/runtime/storage_paths.c create mode 100644 src/runtime/storage_paths.h delete mode 100644 tests/fixtures/model-packages/v1/.bongo-cat-package.json delete mode 100644 tests/fixtures/model-packages/v1/adapter/.keep delete mode 100644 tests/fixtures/model-packages/v1/payload/dummy.model3.json rename tests/{test_mver_portable_identity.c => test_mver_nearby_identity.c} (70%) diff --git a/CMakeLists.txt b/CMakeLists.txt index 0da7ee6e..ccbe6817 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -271,7 +271,7 @@ if(BUILD_TESTING) tests/test_mver_import.c tests/test_mver_pointer_import.c tests/test_preferences_text.c - tests/test_mver_portable_identity.c + tests/test_mver_nearby_identity.c tests/test_mver_support.c) add_executable(bongo_cat_mver_import_tests ${BONGO_CAT_MVER_IMPORT_TEST_SOURCES}) diff --git a/README.md b/README.md index 4a2637ac..fe309ca1 100644 --- a/README.md +++ b/README.md @@ -187,25 +187,25 @@ cmake --install build --config Release --component Runtime --prefix out ``` The install tree is portable. Windows embeds the resource archive; Unix builds -place `assets/` beside the executable or application bundle. Settings and -imported models are kept in the per-user data directory. -Native preferences and session files use schema version 2. Version 1 and any -other unsupported schema are rejected and rebuilt from current defaults; no -configuration migration path is retained. +place `assets/` beside the executable or application bundle. User-owned files +follow [`docs/storage-layout.md`](docs/storage-layout.md). Settings and session +state use separate schema-1 documents. Unsupported formats are rejected; the +unreleased application has no configuration migration layer. ## Runtime Data and Test Switches -The default data directory comes from SDL's preference path and contains -`preferences.json`, `session.json`, `custom-models/`, and the generated `portable-mver/` adapter -cache. A Bongo-Cat-Mver package can be used without importing by placing its +Storage is separated into `config`, `data`, `cache`, `state`, and `logs`. +Installed packages live under `data/models`; generated nearby-Mver adapters +live under `cache/model-adapters`. A Bongo-Cat-Mver package can be used without +installing it by placing its `config.json` and `img/` beside `BongoCat`, by placing the intact package directory there, or by keeping both under the same immediate parent directory. A collection such as `露西亚-誓焰版` may be selected as one folder; its full package and nested image patches are discovered together. -Tests and portable launches may override both paths: +Tests and isolated launches may override the complete storage layout: ```text -BongoCat --data-root=C:\path\to\data --preferences=C:\path\to\preferences.json --session=C:\path\to\session.json +BongoCat --storage-root=C:\path\to\isolated-storage ``` Arguments beginning with `--ci-` are test instrumentation. They select a model, @@ -214,10 +214,9 @@ stable end-user command-line interface. ## Tests -The CTest suite covers strict configuration-format validation, model discovery, +The CTest suite covers strict settings/session validation, model discovery, input ordering and recovery, shortcuts, localization, UI helpers, application -state, and SHA-256 resource validation. Only the current configuration format -is accepted; older schemas are rejected without migration. +state, and SHA-256 resource validation. Only current schemas are accepted. ```powershell ctest --test-dir build --output-on-failure diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 6d5a78da..6b3e930e 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -7,7 +7,7 @@ set(BONGO_CAT_RUNTIME_SOURCES src/render/overlay.c src/render/overlay_draw.c src/render/mver_pointer_overlay.c src/render/mver_pointer_overlay_draw.c src/runtime/app.c src/runtime/config_store.c - src/runtime/assets.c src/runtime/startup.c + src/runtime/assets.c src/runtime/startup.c src/runtime/storage_paths.c src/runtime/frame_audit.c src/runtime/frame_presentation_audit.c src/runtime/frame_clock.c src/runtime/modal_frame.c src/runtime/gamepad.c @@ -19,10 +19,9 @@ set(BONGO_CAT_RUNTIME_SOURCES src/runtime/model_import.c src/runtime/model_import_identity.c src/runtime/model_import_package.c src/runtime/model_import_discovery.c src/runtime/model_import_format.c - src/runtime/model_import_portable.c src/runtime/model_import_portable_scan.c - src/runtime/model_import_portable_identity.c - src/runtime/model_import_portable_migration.c - src/runtime/model_import_portable_signature.c + src/runtime/model_import_nearby.c src/runtime/model_import_nearby_scan.c + src/runtime/model_import_nearby_identity.c + src/runtime/model_import_nearby_signature.c src/runtime/model_import_mver_patch.c src/runtime/model_import_mver.c src/runtime/model_import_mver_image.c src/runtime/model_import_mver_shortcut.c src/runtime/model_import_mver_effect.c diff --git a/docs/model-format-audit-2026-07-24.md b/docs/model-format-audit-2026-07-24.md deleted file mode 100644 index 32ee5e18..00000000 --- a/docs/model-format-audit-2026-07-24.md +++ /dev/null @@ -1,126 +0,0 @@ -# Tauri / Bongo-Cat-Mver 模型格式审计 - -日期:2026-07-24 -原样结构支持更新:2026-07-25 -产品:BongoCat 0.1.0 - -## 结论 - -BongoCat 现在以同一条事务式导入流水线支持三类输入: - -- Tauri/普通 Live2D v3 模型目录。 -- 带 `config.json` 与 `img/` 的完整 Bongo-Cat-Mver 模型包。 -- 只有图片差异的 Mver patch 包,例如“Z-无按键显示”和“Z-有按键显示”。 -- 放在可执行文件同目录(或其一级子目录)的完整 Mver 原生目录。 - -真实 Tauri、完整 Mver 和两个 Z patch 均会安装 `standard`、`keyboard`、 -`gamepad` 三个模型,并逐个通过 Cubism 原生运行时加载。导入后的 `payload` 是用户模型目录的 -逐文件原样副本,BongoCat 生成的预览、输入贴图、媒体和元数据只写入并列的 `adapter` 目录;不会向 -用户源目录或 `payload` 增删、改名、合成或覆盖文件。无法安全跨机器迁移的固定画布坐标、 -工作区坐标和设备精灵几何会写入 -`.bongo-cat-import-report.json`,不再被静默忽略。 - -项目尚未发布,因此用户数据目录中的自定义模型不提供旧统一目录布局兼容或自动迁移;没有当前 -版本旁路描述文件的目录仍会被扫描器忽略。作为明确的生态兼容入口,可执行文件旁的完整 Mver -目录会直接作为只读源加载,BongoCat 只在用户数据的 `portable-mver` 中缓存合成图片、行为元数据和 -兼容报告,不复制、重命名或修改原目录。内置模型仍由只读的预设扫描路径直接加载。 - -## 格式契约 - -| 类型 | 必要结构 | 结果 | -|---|---|---| -| Tauri / Live2D | 唯一合法 `.model3.json` 及其必需引用 | 导入一个或多个模型 | -| 完整 Mver | `config.json`、`img`、合法 mode 配置、编号资源和 Live2D manifest | 每个合法 mode 导入一个模型 | -| Mver patch | 唯一 patch `img` 根及唯一同名完整基础包 | 分别原样保存 base/patch,由 adapter 按文件覆盖语义解析 | -| 运行目录 Mver | 可执行文件旁的完整 Mver 根,或包含完整包/图片补丁的模型容器 | 直接引用 Live2D 源文件,只缓存 BongoCat adapter | - -同目录多 manifest、多个 patch 根、无基础包或多个基础包都被拒绝。图片 patch 不会被伪装成 -可独立运行的模型。 - -## 已映射能力 - -- Live2D v3 MOC、纹理、Physics、Pose、DisplayInfo、Expressions 和 Motions。 -- Tauri 与 Mver 的预览图、背景和三种 mode。 -- Windows VK 到 BongoCat 跨平台快捷键名称的转换,包括组合键和左右修饰键。 -- `input_mode=1` 的 Mver 手柄按钮;`input_mode=0` 保持键盘协议。 -- standard/keyboard/gamepad 的左右手编号图片,支持稀疏编号并报告缺项。 -- `CAT_motion`、`CAT_motion_lock` 和 expression 的逐索引严格映射。 -- Mver `face` 图片表情及 `emoticonKeep` / `emoticonClear`。 -- Mver 独立音频及 `soundKeep` / `soundClear`;关闭 keep 时松键停止。 -- 不同尺寸 straight-alpha 图片合成,不再按最小画布裁切。 -- Z patch 的逐文件覆盖和基础资源继承。 - -## 明确降级 - -以下字段依赖 Mver 固定 1400x1400 SFML 画布、Windows 桌面坐标或其专用手臂曲线,不能直接 -持久化到跨平台模型: - -- `window_size`、`topWindow`、`workarea`。 -- `offsetX/Y`、`scalar`、`hand_offset`。 -- `l2d_offset`、`l2d_correct`。 -- `leftHanded`、`mouse_force_move`。 -- standard 的 `arm/up/mouse/tablet` 动态设备精灵几何。 -- gamepad 的 `stick_offset_L/R`。 - -BongoCat 对应功能继续由用户的窗口、鼠标镜像、模型缩放和手柄轴设置控制。导入报告会逐模型记录 -这些降级项、原因、能力布尔值、声明/可用/缺失资源数以及可选 motion Sound 缺失数。 - -## 审计 TODO - -### P0:协议和行为正确性 - -- [x] M01 固化三类格式契约和稳定的失败规则。 -- [x] M02 将 Z 目录实现为显式 patch 工作流,禁止独立伪模型。 -- [x] M03 校验编号资源、损坏 PNG 和稀疏资源,并输出数量诊断。 -- [x] M04 用 `input_mode` 决定手柄/键盘协议,覆盖低 VK 值和组合键。 -- [x] M05 精确映射 `CAT_motion` / `CAT_motion_lock`,校验配置行数。 -- [x] M06 统一 motion Sound、独立音频、全局开关和 keep/clear 语义。 - -### P1:Mver 可见行为 - -- [x] M07 支持图片 `face`、保持、清除和 momentary 松键恢复。 -- [x] M08 审计鼠标/数位板资源;固定画布设备几何改为显式降级报告。 -- [x] M09 对可移植状态做映射,对机器相关状态做逐字段报告。 -- [x] M10 预检全部 manifest 必需引用;缺失 motion Sound 作为可选诊断。 -- [x] M11 同目录多 manifest 明确拒绝,递归候选去重并稳定排序。 -- [x] M12 限制向上找包边界,patch 基础包必须唯一。 -- [x] M13 修复不同尺寸合成裁切,并测试半透明与透明空白像素。 -- [x] M14 保持 staging/rename/rollback 事务,启动时安全清理本应用临时目录。 -- [x] M15 元数据只接受版本 1,只补缺失默认绑定并保留用户覆盖。 - -### P2:测试、跨平台和组织 - -- [x] M16 增加纯 C 的 Mver chord/device 协议测试,并保留端到端安装测试。 -- [x] M17 增加环境变量驱动的可选真实样本测试入口。 -- [x] M18 对 Tauri、完整 Mver、两个 Z patch 逐模型执行 Cubism smoke。 -- [x] M19 统一 CTest 命令、测试计数和审计文档。 -- [x] M20 曾将当时的本地审计脚本纳入 300 行门禁并拆分超长脚本;这些脚本现已移出仓库。 -- [x] M21 将 discovery、patch、shortcut、motion、effect、metadata、report、storage 分层。 -- [x] M22 将 Mver 的 Windows VK 解析限制在 adapter,运行时只接收 BongoCat 输入名。 -- [x] M23 清理历史命名噪声;旧 schema 测试变量不再使用 `legacy_path`。 -- [x] M24 将原模型与 BongoCat 适配产物分离为 `payload` / `adapter`,并验证源目录未变化。 -- [x] M25 移除未发布旧自定义目录布局的兼容扫描,只接受当前旁路描述文件。 -- [x] M26 按 Mver 原版相对路径习惯自动发现运行目录模型(包括嵌套的完整包与图片补丁),并保持源目录只读。 -- [x] M27 对原版常见的“配置绑定多于 manifest 入口”仅截取可用入口;手动导入仍保留严格校验。 - -## 自动化证据 - -- `model-import-unit`:VK、组合键、`input_mode`、手柄映射,以及运行目录 Mver - 直接/嵌套容器、图片补丁发现和 adapter 缓存测试。 -- `model-import-formats`:32 个合成端到端场景,包括歧义、回滚、patch、稀疏资源、 - momentary、清除、缺失可选音频、非法 chord 和不同尺寸合成。 -- `model-import-real-samples`:由 `BONGO_CAT_TAURI_SOURCE`、 - `BONGO_CAT_MVER_SOURCE`、`BONGO_CAT_MVER_PATCH_SOURCES` 驱动;未配置时明确 Skip。 -- 合成与真实样本审计会比较导入前后源目录签名,并验证安装 payload 的相对路径和 SHA-256 - 与对应 Tauri、Mver、base、patch 源目录完全一致。 -- Windows 多配置生成器的标准命令为: - `ctest --test-dir build-cubism -C Release --output-on-failure`。 - -真实样本结果:Tauri 3/3、A-露西亚 3/3、Z-无按键显示 3/3、Z-有按键显示 3/3, -安装、报告生成和逐模型 Cubism 加载均通过。 - -## 外部门禁 - -本次 Windows 审计不能替代 Linux/macOS 的原生构建,也不能模拟真实手柄热插拔、不同桌面缩放 -和声卡故障。相关代码只使用 SDL、标准 C 和 yyjson 的跨平台接口;这些环境仍应由对应系统的 CI -和真实硬件回归承担。 diff --git a/docs/model-package-v2.md b/docs/model-package-v2.md index ebebe647..2d3384d7 100644 --- a/docs/model-package-v2.md +++ b/docs/model-package-v2.md @@ -100,8 +100,7 @@ keep a publisher ID across revisions while changing `contentDigest`. Raw folder imports are intentionally content-addressed until such an identity exists. Readers ignore unknown capabilities, source fields, and extension namespaces. -Relative paths must stay inside the package. Schema v1 remains readable and -keeps its directory-name ID; it is not rewritten or destructively migrated. +Relative paths must stay inside the package. Only schema v2 is accepted. `adapterSchemaVersion` describes the runtime contract. `generatorVersion` tracks the importer implementation so derived adapters can be rebuilt in place after conversion behavior changes without modifying preserved payloads. @@ -126,8 +125,8 @@ schema, runtime kind, source format, render profile, and bindings structure. Tauri imports receive a minimal native adapter. Mver importers translate render calibration, input bindings, pointer assets, motions, expressions, effects, and audio defaults into this file. Runtime code does not read Mver -`config.json`. Existing `.bongo-cat-mver.json` adapters remain a read-only -fallback for installed v1 packages and nearby-model caches. +`config.json`. Generated and installed adapters use this filename and schema; +there is no alternate adapter reader. ## Configuration Ownership @@ -135,9 +134,10 @@ The package owns model-authored state: Live2D references, render calibration, input asset mappings, pointer geometry, motions, expressions, effects, and audio behavior. -Application configuration owns user state: selected package ID, window -geometry, scale, visibility, click-through behavior, always-on-top state, FPS, -mirror preferences, hover behavior, custom labels, and shortcut overrides. +Application settings own long-lived user choices: click-through and +always-on-top behavior, FPS, mirror and pointer preferences, hover behavior, +custom labels, shortcut overrides, language, and theme. Session state owns the +selected package ID plus window geometry, scale, opacity, and visibility. Importers may read source application settings to reproduce model-authored calibration, but they must not make source window or desktop settings part of @@ -150,4 +150,4 @@ the user's BongoCat preferences. - Keep format-specific data under `extensions.`. - Never require sibling modes for loading, updating, or removing a package. - Never make runtime behavior depend directly on an import format's config. -- Preserve v1 readers until an explicit, reversible migration is shipped. +- Reject unsupported schemas instead of guessing or silently migrating them. diff --git a/docs/storage-layout.md b/docs/storage-layout.md new file mode 100644 index 00000000..50c8d242 --- /dev/null +++ b/docs/storage-layout.md @@ -0,0 +1,105 @@ +# Storage Layout + +BongoCat owns its application data independently of both supported source +formats. Tauri Live2D and Bongo-Cat-Mver are model import formats only. + +## Ownership + +```text +config/settings.json long-lived user preferences +data/models/ installed model package v2 directories +cache/model-adapters/ generated adapters for nearby Mver folders +state/session.json disposable window and active-model state +state/ diagnostic and smoke-run artifacts +logs/ startup and failure logs +``` + +`settings.json` uses format `bongocat/settings`, schema 1. It contains rendering +and pointer preferences, window behavior, application preferences, shortcuts, +behavior overrides, model display-name overrides, and an `extensions` object. +Unknown namespaces inside `extensions` are preserved across load and save; the +serialized object is limited to 4095 bytes so settings memory remains bounded. + +`session.json` uses format `bongocat/session`, schema 1. It contains only the +active model ID and window visibility, scale, opacity, optional known position, +and size. +Deleting it must not change the user's preferences or installed models. + +```json +{ + "format": "bongocat/settings", + "schemaVersion": 1, + "rendering": { + "modelMirrored": false, + "pointerMirrored": false, + "centerPointerTracking": true, + "ignorePointerInput": false, + "inputReleaseDelaySeconds": 3.0, + "maximumFps": 60 + }, + "window": { + "clickThrough": false, + "alwaysOnTop": true, + "hideOnPointerOver": false, + "keepOnScreen": false, + "captureBackground": false, + "captureBackgroundColor": "#00ff00", + "hideDelaySeconds": 0.0 + }, + "application": { + "launchAtLogin": false, + "showTrayIcon": true, + "theme": "auto", + "language": "en-US" + }, + "shortcuts": { + "toggleVisibility": "", + "openSettings": "", + "toggleModelMirror": "", + "toggleClickThrough": "", + "toggleAlwaysOnTop": "" + }, + "behaviorOverrides": [], + "modelOverrides": [], + "extensions": {} +} +``` + +```json +{ + "format": "bongocat/session", + "schemaVersion": 1, + "window": { + "visible": true, + "scalePercent": 100.0, + "opacityPercent": 100.0, + "position": { "x": 0, "y": 0 }, + "size": { "width": 612, "height": 354 } + }, + "activeModelId": "standard" +} +``` + +`position` is omitted until the application has observed a real window +position. Once present, `(0, 0)` is treated as an intentional location. + +Both documents are written atomically through a temporary file, a durable +flush, and replacement. There are no readers or migrations for pre-release +configuration formats. + +## Platform Roots + +| Platform | Config and data | State, cache, and logs | +| --- | --- | --- | +| Windows | `%APPDATA%\BongoCat\config`; `%LOCALAPPDATA%\BongoCat\data` | `%LOCALAPPDATA%\BongoCat\state`, `cache`, `logs` | +| macOS | `~/Library/Application Support/BongoCat/config`, `data` | Application Support `state`; `~/Library/Caches/BongoCat`; `~/Library/Logs/BongoCat` | +| Linux | `$XDG_CONFIG_HOME/bongocat`, `$XDG_DATA_HOME/bongocat` | `$XDG_STATE_HOME/bongocat`, `$XDG_CACHE_HOME/bongocat`; logs under state | + +Linux falls back to the corresponding `~/.config`, `~/.local/share`, +`~/.local/state`, and `~/.cache` roots when an XDG variable is unset. +Windows keeps only small preferences in the roaming profile; installed models +stay local so large packages are not copied by profile synchronization. + +`--storage-root=` is the only path override. It creates `config`, `data`, +`cache`, `state`, and `logs` beneath the supplied root for isolated development +or CI runs. diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index 934a61b4..575423d5 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -16,7 +16,8 @@ typedef struct BongoCatPreferences BongoCatPreferences; typedef struct BongoCatI18n BongoCatI18n; typedef struct BongoCatApp { - BongoCatConfig config; + BongoCatSettings settings; + BongoCatSessionState session; BongoCatInputState input; BongoCatShortcutState shortcut_state; BongoCatModelCatalog models; @@ -31,9 +32,14 @@ typedef struct BongoCatApp { BongoCatPreferences *preferences; SDL_Window *window; void *gl_context; - char preferences_path[BONGO_CAT_PATH_CAP]; + char settings_path[BONGO_CAT_PATH_CAP]; char session_path[BONGO_CAT_PATH_CAP]; + char config_root[BONGO_CAT_PATH_CAP]; char data_root[BONGO_CAT_PATH_CAP]; + char cache_root[BONGO_CAT_PATH_CAP]; + char state_root[BONGO_CAT_PATH_CAP]; + char log_root[BONGO_CAT_PATH_CAP]; + char storage_root[BONGO_CAT_PATH_CAP]; char asset_root[BONGO_CAT_PATH_CAP]; char locale_root[BONGO_CAT_PATH_CAP]; char smoke_import_path[BONGO_CAT_PATH_CAP]; @@ -43,8 +49,9 @@ typedef struct BongoCatApp { char smoke_viewer_trace[BONGO_CAT_PATH_CAP]; char pending_model_cover_path[BONGO_CAT_PATH_CAP]; char loaded_model[BONGO_CAT_ID_CAP]; + BongoCatModelMode loaded_mode; bool running; - bool preferences_store_valid; + bool settings_store_valid; bool session_store_valid; bool autostart_launch; uint64_t startup_raise_due_ns; @@ -79,9 +86,9 @@ typedef struct BongoCatApp { uint64_t display_recovery_due_ns; uint64_t mouse_last_ns; uint64_t frame_audit_bmp_ns; - uint64_t preferences_saved_hash, preferences_observed_hash; + uint64_t settings_saved_hash, settings_observed_hash; uint64_t session_saved_hash, session_observed_hash; - uint64_t preferences_save_due_ns, session_save_due_ns; + uint64_t settings_save_due_ns, session_save_due_ns; BongoCatMouseTracking mouse_tracking; BongoCatMverPointerState mver_pointer; bool hover_inside; diff --git a/include/bongo_cat/common.h b/include/bongo_cat/common.h index 534ec344..394e453c 100644 --- a/include/bongo_cat/common.h +++ b/include/bongo_cat/common.h @@ -25,8 +25,6 @@ #define BONGO_CAT_AUTO_RELEASE_CAP 64 #define BONGO_CAT_MODEL_PACKAGE_FILE ".bongo-cat-package.json" #define BONGO_CAT_MODEL_ADAPTER_FILE ".bongo-cat-adapter.json" -#define BONGO_CAT_MODEL_LEGACY_ADAPTER_FILE ".bongo-cat-mver.json" -#define BONGO_CAT_MODEL_PACKAGE_LEGACY_SCHEMA 1 #define BONGO_CAT_MODEL_PACKAGE_SCHEMA 2 #define BONGO_CAT_MODEL_ADAPTER_SCHEMA 1 #define BONGO_CAT_MODEL_ADAPTER_GENERATOR 1 diff --git a/include/bongo_cat/config.h b/include/bongo_cat/config.h index aaa8e2ed..495e84e4 100644 --- a/include/bongo_cat/config.h +++ b/include/bongo_cat/config.h @@ -35,46 +35,50 @@ typedef enum BongoCatObsBackgroundColor { BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT } BongoCatObsBackgroundColor; -typedef struct BongoCatModelOptions { +typedef struct BongoCatModelPreferences { bool mirror; bool mouse_mirror; bool mouse_centered; bool ignore_mouse; float auto_release_seconds; int max_fps; -} BongoCatModelOptions; +} BongoCatModelPreferences; -typedef struct BongoCatWindowOptions { - bool visible; +typedef struct BongoCatWindowPreferences { bool pass_through; bool always_on_top; bool hide_on_hover; bool keep_in_screen; bool obs_background; BongoCatObsBackgroundColor obs_background_color; + float hide_delay_seconds; +} BongoCatWindowPreferences; + +typedef struct BongoCatWindowState { + bool visible; + bool position_known; float scale_percent; float opacity_percent; - float hide_delay_seconds; int x; int y; int width; int height; -} BongoCatWindowOptions; +} BongoCatWindowState; -typedef struct BongoCatAppOptions { +typedef struct BongoCatApplicationPreferences { bool autostart; bool tray_visible; BongoCatTheme theme; BongoCatLanguage language; -} BongoCatAppOptions; +} BongoCatApplicationPreferences; -typedef struct BongoCatShortcutOptions { +typedef struct BongoCatShortcutPreferences { char visible_cat[BONGO_CAT_SHORTCUT_CAP]; char visible_preferences[BONGO_CAT_SHORTCUT_CAP]; char mirror[BONGO_CAT_SHORTCUT_CAP]; char pass_through[BONGO_CAT_SHORTCUT_CAP]; char always_on_top[BONGO_CAT_SHORTCUT_CAP]; -} BongoCatShortcutOptions; +} BongoCatShortcutPreferences; typedef struct BongoCatBehaviorShortcut { char id[BONGO_CAT_BEHAVIOR_ID_CAP]; @@ -87,39 +91,47 @@ typedef struct BongoCatModelLabel { char label[BONGO_CAT_ID_CAP]; } BongoCatModelLabel; -typedef struct BongoCatConfig { - BongoCatModelOptions model; - BongoCatWindowOptions window; - BongoCatAppOptions app; - BongoCatShortcutOptions shortcuts; +#define BONGO_CAT_SETTINGS_EXTENSIONS_CAP 4096 + +typedef struct BongoCatSettings { + BongoCatModelPreferences model; + BongoCatWindowPreferences window; + BongoCatApplicationPreferences app; + BongoCatShortcutPreferences shortcuts; BongoCatBehaviorShortcut behavior_shortcuts[BONGO_CAT_BEHAVIOR_BINDING_CAP]; size_t behavior_shortcut_count; BongoCatModelLabel model_labels[BONGO_CAT_MODEL_CAP]; size_t model_label_count; - char current_model[BONGO_CAT_PATH_CAP]; - BongoCatModelMode current_mode; -} BongoCatConfig; + char extensions_json[BONGO_CAT_SETTINGS_EXTENSIONS_CAP]; +} BongoCatSettings; + +typedef struct BongoCatSessionState { + BongoCatWindowState window; + char active_model_id[BONGO_CAT_ID_CAP]; +} BongoCatSessionState; #ifdef __cplusplus extern "C" { #endif -void bongo_cat_config_defaults(BongoCatConfig *config); -void bongo_cat_config_validate(BongoCatConfig *config); -bool bongo_cat_config_shortcut_conflicts(const BongoCatConfig *config, +void bongo_cat_settings_defaults(BongoCatSettings *settings); +void bongo_cat_settings_validate(BongoCatSettings *settings); +void bongo_cat_session_defaults(BongoCatSessionState *session); +void bongo_cat_session_validate(BongoCatSessionState *session); +bool bongo_cat_settings_shortcut_conflicts(const BongoCatSettings *settings, const char *shortcut, const char *exclude); -const char *bongo_cat_config_model_label(const BongoCatConfig *config, +const char *bongo_cat_settings_model_label(const BongoCatSettings *settings, const char *id); -bool bongo_cat_config_set_model_label(BongoCatConfig *config, +bool bongo_cat_settings_set_model_label(BongoCatSettings *settings, const char *id, const char *label); -BongoCatResult bongo_cat_preferences_load(const char *path, - BongoCatConfig *config, BongoCatError *error); -BongoCatResult bongo_cat_preferences_save(const char *path, - const BongoCatConfig *config, BongoCatError *error); +BongoCatResult bongo_cat_settings_load(const char *path, + BongoCatSettings *settings, BongoCatError *error); +BongoCatResult bongo_cat_settings_save(const char *path, + const BongoCatSettings *settings, BongoCatError *error); BongoCatResult bongo_cat_session_load(const char *path, - BongoCatConfig *config, BongoCatError *error); + BongoCatSessionState *session, BongoCatError *error); BongoCatResult bongo_cat_session_save(const char *path, - const BongoCatConfig *config, BongoCatError *error); + const BongoCatSessionState *session, BongoCatError *error); const char *bongo_cat_theme_name(BongoCatTheme value); const char *bongo_cat_language_name(BongoCatLanguage value); const char *bongo_cat_mode_name(BongoCatModelMode value); diff --git a/include/bongo_cat/model.h b/include/bongo_cat/model.h index 7f9e3274..e63993ad 100644 --- a/include/bongo_cat/model.h +++ b/include/bongo_cat/model.h @@ -86,7 +86,7 @@ const BongoCatModelEntry *bongo_cat_models_find(const BongoCatModelCatalog *cata bool bongo_cat_model_adapter_metadata_path(const char *directory, char *path, size_t capacity); const char *bongo_cat_model_default_name(const BongoCatModelEntry *entry); -const char *bongo_cat_model_name(const BongoCatConfig *config, +const char *bongo_cat_model_name(const BongoCatSettings *settings, const BongoCatModelEntry *entry); BongoCatResult bongo_cat_behaviors_load(BongoCatBehaviorCatalog *catalog, const BongoCatModelEntry *model, BongoCatError *error); diff --git a/include/bongo_cat/utf8.h b/include/bongo_cat/utf8.h index beab0859..1ae9e3f6 100644 --- a/include/bongo_cat/utf8.h +++ b/include/bongo_cat/utf8.h @@ -5,7 +5,7 @@ #include bool bongo_cat_utf8_valid(const char *text); -bool bongo_cat_utf8_normalize_legacy(const char *text, +bool bongo_cat_utf8_normalize_mver(const char *text, char *output, size_t capacity); #endif diff --git a/src/core/config_defaults.c b/src/core/config_defaults.c index 6e947cd9..810bb373 100644 --- a/src/core/config_defaults.c +++ b/src/core/config_defaults.c @@ -18,7 +18,7 @@ static bool shortcut_equal(const char *left, const char *right) { return *left == *right; } -bool bongo_cat_config_shortcut_conflicts(const BongoCatConfig *config, +bool bongo_cat_settings_shortcut_conflicts(const BongoCatSettings *config, const char *shortcut, const char *exclude) { if (!config || !shortcut || !shortcut[0]) return false; const char *global[] = {config->shortcuts.visible_cat, @@ -34,7 +34,7 @@ bool bongo_cat_config_shortcut_conflicts(const BongoCatConfig *config, return false; } -const char *bongo_cat_config_model_label(const BongoCatConfig *config, +const char *bongo_cat_settings_model_label(const BongoCatSettings *config, const char *id) { if (!config || !id) return NULL; for (size_t i = 0; i < config->model_label_count; ++i) @@ -43,7 +43,7 @@ const char *bongo_cat_config_model_label(const BongoCatConfig *config, return NULL; } -bool bongo_cat_config_set_model_label(BongoCatConfig *config, +bool bongo_cat_settings_set_model_label(BongoCatSettings *config, const char *id, const char *label) { if (!config || !id || !id[0]) return false; size_t index = config->model_label_count; @@ -78,7 +78,7 @@ bool bongo_cat_config_set_model_label(BongoCatConfig *config, return true; } -static void validate_shortcuts(BongoCatConfig *config) { +static void validate_shortcuts(BongoCatSettings *config) { char *global[] = {config->shortcuts.visible_cat, config->shortcuts.visible_preferences, config->shortcuts.mirror, config->shortcuts.pass_through, config->shortcuts.always_on_top}; @@ -99,49 +99,46 @@ static void validate_shortcuts(BongoCatConfig *config) { } } -void bongo_cat_config_defaults(BongoCatConfig *config) { +void bongo_cat_settings_defaults(BongoCatSettings *config) { if (!config) return; memset(config, 0, sizeof(*config)); config->model.auto_release_seconds = BONGO_CAT_DEFAULT_AUTO_RELEASE_SECONDS; config->model.mouse_centered = true; config->model.max_fps = BONGO_CAT_DEFAULT_MAX_FPS; - config->window.visible = true; config->window.always_on_top = true; config->window.keep_in_screen = false; config->window.obs_background_color = BONGO_CAT_OBS_BACKGROUND_GREEN; - config->window.scale_percent = BONGO_CAT_DEFAULT_WINDOW_SCALE_PERCENT; - config->window.opacity_percent = - BONGO_CAT_DEFAULT_WINDOW_OPACITY_PERCENT; - config->window.width = 612; - config->window.height = 354; config->app.tray_visible = true; config->app.theme = BONGO_CAT_THEME_AUTO; config->app.language = BONGO_CAT_LANG_EN_US; - config->current_mode = BONGO_CAT_MODE_GAMEPAD; - memcpy(config->current_model, "standard", sizeof("standard")); + memcpy(config->extensions_json, "{}", sizeof("{}")); +} + +void bongo_cat_session_defaults(BongoCatSessionState *session) { + if (!session) return; + memset(session, 0, sizeof(*session)); + session->window.visible = true; + session->window.scale_percent = BONGO_CAT_DEFAULT_WINDOW_SCALE_PERCENT; + session->window.opacity_percent = + BONGO_CAT_DEFAULT_WINDOW_OPACITY_PERCENT; + session->window.width = 612; + session->window.height = 354; + memcpy(session->active_model_id, "standard", sizeof("standard")); } -void bongo_cat_config_validate(BongoCatConfig *config) { +void bongo_cat_settings_validate(BongoCatSettings *config) { if (!config) return; config->model.auto_release_seconds = clampf(config->model.auto_release_seconds, 0.05f, 30.0f); if (config->model.max_fps < 1) config->model.max_fps = 1; if (config->model.max_fps > 240) config->model.max_fps = 240; - config->window.scale_percent = clampf(config->window.scale_percent, 10.0f, 500.0f); - config->window.opacity_percent = clampf(config->window.opacity_percent, 10.0f, 100.0f); config->window.hide_delay_seconds = clampf(config->window.hide_delay_seconds, 0.0f, 60.0f); if ((unsigned)config->window.obs_background_color >= BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT) config->window.obs_background_color = BONGO_CAT_OBS_BACKGROUND_GREEN; - if (config->window.width < 64) config->window.width = 64; - if (config->window.height < 64) config->window.height = 64; - if (config->window.width > 8192) config->window.width = 8192; - if (config->window.height > 8192) config->window.height = 8192; if (config->app.theme > BONGO_CAT_THEME_DARK) config->app.theme = BONGO_CAT_THEME_AUTO; if ((unsigned)config->app.language >= BONGO_CAT_LANG_COUNT) config->app.language = BONGO_CAT_LANG_EN_US; - if (config->current_mode > BONGO_CAT_MODE_GAMEPAD) config->current_mode = BONGO_CAT_MODE_STANDARD; - config->current_model[sizeof(config->current_model) - 1] = '\0'; if (config->behavior_shortcut_count > BONGO_CAT_BEHAVIOR_BINDING_CAP) config->behavior_shortcut_count = BONGO_CAT_BEHAVIOR_BINDING_CAP; for (size_t i = 0; i < config->behavior_shortcut_count; ++i) { @@ -149,29 +146,36 @@ void bongo_cat_config_validate(BongoCatConfig *config) { sizeof(config->behavior_shortcuts[i].id) - 1] = '\0'; config->behavior_shortcuts[i].shortcut[BONGO_CAT_SHORTCUT_CAP - 1] = '\0'; config->behavior_shortcuts[i].label[BONGO_CAT_ID_CAP - 1] = '\0'; - char normalized[BONGO_CAT_ID_CAP] = {0}; - if (bongo_cat_utf8_normalize_legacy( - config->behavior_shortcuts[i].label, normalized, - sizeof(normalized))) - memcpy(config->behavior_shortcuts[i].label, normalized, - sizeof(normalized)); - else config->behavior_shortcuts[i].label[0] = '\0'; + if (!bongo_cat_utf8_valid(config->behavior_shortcuts[i].label)) + config->behavior_shortcuts[i].label[0] = '\0'; } if (config->model_label_count > BONGO_CAT_MODEL_CAP) config->model_label_count = BONGO_CAT_MODEL_CAP; for (size_t i = 0; i < config->model_label_count; ++i) { config->model_labels[i].id[BONGO_CAT_ID_CAP - 1] = '\0'; config->model_labels[i].label[BONGO_CAT_ID_CAP - 1] = '\0'; - char normalized[BONGO_CAT_ID_CAP] = {0}; - if (config->model_labels[i].id[0] && bongo_cat_utf8_normalize_legacy( - config->model_labels[i].label, normalized, sizeof(normalized))) - memcpy(config->model_labels[i].label, normalized, - sizeof(normalized)); - else config->model_labels[i].label[0] = '\0'; + if (!config->model_labels[i].id[0] || + !bongo_cat_utf8_valid(config->model_labels[i].label)) + config->model_labels[i].label[0] = '\0'; } validate_shortcuts(config); } +void bongo_cat_session_validate(BongoCatSessionState *session) { + if (!session) return; + session->window.scale_percent = clampf(session->window.scale_percent, + 10.0f, 500.0f); + session->window.opacity_percent = clampf(session->window.opacity_percent, + 10.0f, 100.0f); + if (session->window.width < 64) session->window.width = 64; + if (session->window.height < 64) session->window.height = 64; + if (session->window.width > 8192) session->window.width = 8192; + if (session->window.height > 8192) session->window.height = 8192; + session->active_model_id[sizeof(session->active_model_id) - 1] = '\0'; + if (!session->active_model_id[0]) + memcpy(session->active_model_id, "standard", sizeof("standard")); +} + const char *bongo_cat_theme_name(BongoCatTheme value) { const char *names[] = {"auto", "light", "dark"}; return value <= BONGO_CAT_THEME_DARK ? names[value] : names[0]; diff --git a/src/core/config_file.c b/src/core/config_file.c index 26dfa5f1..b0c70f20 100644 --- a/src/core/config_file.c +++ b/src/core/config_file.c @@ -14,7 +14,7 @@ #endif yyjson_doc *bongo_cat_config_read_document(const char *path, - const char *format, BongoCatError *error) { + const char *format, int schema, BongoCatError *error) { yyjson_read_err json_error = {0}; FILE *file = bongo_cat_file_open(path, "rb"); yyjson_doc *document = file ? yyjson_read_fp(file, 0, NULL, &json_error) : NULL; @@ -27,15 +27,15 @@ yyjson_doc *bongo_cat_config_read_document(const char *path, } yyjson_val *root = yyjson_doc_get_root(document); yyjson_val *format_value = yyjson_obj_get(root, "format"); - yyjson_val *version = yyjson_obj_get(root, "version"); + yyjson_val *version = yyjson_obj_get(root, "schemaVersion"); if (!yyjson_is_obj(root) || !yyjson_is_str(format_value) || strcmp(yyjson_get_str(format_value), format) != 0 || !yyjson_is_int(version) || - yyjson_get_sint(version) != BONGO_CAT_CONFIG_VERSION) { + yyjson_get_sint(version) != schema) { yyjson_doc_free(document); bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Unsupported configuration format; expected %s version %d", - format, BONGO_CAT_CONFIG_VERSION); + "Unsupported configuration format; expected %s schema %d", + format, schema); return NULL; } return document; diff --git a/src/core/config_internal.h b/src/core/config_internal.h index 3412cd4a..02f461ff 100644 --- a/src/core/config_internal.h +++ b/src/core/config_internal.h @@ -4,10 +4,11 @@ #include "bongo_cat/config.h" #include -#define BONGO_CAT_CONFIG_VERSION 2 +#define BONGO_CAT_SETTINGS_SCHEMA 1 +#define BONGO_CAT_SESSION_SCHEMA 1 yyjson_doc *bongo_cat_config_read_document(const char *path, - const char *format, BongoCatError *error); + const char *format, int schema, BongoCatError *error); BongoCatResult bongo_cat_config_write_document(const char *path, yyjson_mut_doc *document, const char *description, BongoCatError *error); diff --git a/src/core/config_io.c b/src/core/config_io.c index 59d69f2e..d3073301 100644 --- a/src/core/config_io.c +++ b/src/core/config_io.c @@ -2,9 +2,10 @@ #include "bongo_cat/path.h" #include +#include #include -#define PREFERENCES_FORMAT "bongo-cat/preferences" +#define SETTINGS_FORMAT "bongocat/settings" static bool get_bool(yyjson_val *obj, const char *key, bool fallback) { yyjson_val *value = yyjson_obj_get(obj, key); @@ -45,50 +46,56 @@ static BongoCatObsBackgroundColor parse_obs_background_color( return fallback; } -static void read_model(yyjson_val *obj, BongoCatModelOptions *value) { +static void read_model(yyjson_val *obj, BongoCatModelPreferences *value) { if (!yyjson_is_obj(obj)) return; - value->mirror = get_bool(obj, "mirror", value->mirror); - value->mouse_mirror = get_bool(obj, "mouseMirror", value->mouse_mirror); - value->mouse_centered = get_bool(obj, "mouseCenterTracking", value->mouse_centered); - value->ignore_mouse = get_bool(obj, "ignoreMouse", value->ignore_mouse); - value->auto_release_seconds = get_float(obj, "autoReleaseDelay", value->auto_release_seconds); - value->max_fps = get_int(obj, "maxFPS", value->max_fps); -} -static void read_window(yyjson_val *obj, BongoCatWindowOptions *value) { + value->mirror = get_bool(obj, "modelMirrored", value->mirror); + value->mouse_mirror = get_bool(obj, "pointerMirrored", value->mouse_mirror); + value->mouse_centered = get_bool(obj, "centerPointerTracking", value->mouse_centered); + value->ignore_mouse = get_bool(obj, "ignorePointerInput", value->ignore_mouse); + value->auto_release_seconds = get_float(obj, "inputReleaseDelaySeconds", + value->auto_release_seconds); + value->max_fps = get_int(obj, "maximumFps", value->max_fps); +} +static void read_window(yyjson_val *obj, BongoCatWindowPreferences *value) { if (!yyjson_is_obj(obj)) return; - value->pass_through = get_bool(obj, "passThrough", value->pass_through); + value->pass_through = get_bool(obj, "clickThrough", value->pass_through); value->always_on_top = get_bool(obj, "alwaysOnTop", value->always_on_top); - value->hide_on_hover = get_bool(obj, "hideOnHover", value->hide_on_hover); - value->keep_in_screen = get_bool(obj, "keepInScreen", value->keep_in_screen); - value->obs_background = get_bool(obj, "obsBackground", value->obs_background); + value->hide_on_hover = get_bool(obj, "hideOnPointerOver", value->hide_on_hover); + value->keep_in_screen = get_bool(obj, "keepOnScreen", value->keep_in_screen); + value->obs_background = get_bool(obj, "captureBackground", value->obs_background); value->obs_background_color = parse_obs_background_color( - get_string(obj, "obsBackgroundColor"), value->obs_background_color); - value->hide_delay_seconds = get_float(obj, "hideOnHoverDelay", value->hide_delay_seconds); + get_string(obj, "captureBackgroundColor"), value->obs_background_color); + value->hide_delay_seconds = get_float(obj, "hideDelaySeconds", + value->hide_delay_seconds); } -static void read_app(yyjson_val *obj, BongoCatAppOptions *value) { +static void read_app(yyjson_val *obj, BongoCatApplicationPreferences *value) { if (!yyjson_is_obj(obj)) return; - value->autostart = get_bool(obj, "autostart", value->autostart); - value->tray_visible = get_bool(obj, "trayVisible", value->tray_visible); + value->autostart = get_bool(obj, "launchAtLogin", value->autostart); + value->tray_visible = get_bool(obj, "showTrayIcon", value->tray_visible); value->theme = parse_theme(get_string(obj, "theme")); value->language = parse_language(get_string(obj, "language")); } -static void read_shortcuts(yyjson_val *obj, BongoCatShortcutOptions *value) { +static void read_shortcuts(yyjson_val *obj, BongoCatShortcutPreferences *value) { if (!yyjson_is_obj(obj)) return; - copy_string(value->visible_cat, sizeof(value->visible_cat), get_string(obj, "visibleCat")); + copy_string(value->visible_cat, sizeof(value->visible_cat), + get_string(obj, "toggleVisibility")); copy_string(value->visible_preferences, sizeof(value->visible_preferences), - get_string(obj, "visiblePreference")); - copy_string(value->mirror, sizeof(value->mirror), get_string(obj, "mirrorMode")); - copy_string(value->pass_through, sizeof(value->pass_through), get_string(obj, "penetrable")); - copy_string(value->always_on_top, sizeof(value->always_on_top), get_string(obj, "alwaysOnTop")); + get_string(obj, "openSettings")); + copy_string(value->mirror, sizeof(value->mirror), + get_string(obj, "toggleModelMirror")); + copy_string(value->pass_through, sizeof(value->pass_through), + get_string(obj, "toggleClickThrough")); + copy_string(value->always_on_top, sizeof(value->always_on_top), + get_string(obj, "toggleAlwaysOnTop")); } -static void read_behaviors(yyjson_val *array, BongoCatConfig *config) { +static void read_behaviors(yyjson_val *array, BongoCatSettings *config) { if (!yyjson_is_arr(array)) return; config->behavior_shortcut_count = 0; size_t index, count; yyjson_val *item; yyjson_arr_foreach(array, index, count, item) { - const char *id = get_string(item, "id"); + const char *id = get_string(item, "behaviorId"); const char *shortcut = get_string(item, "shortcut"); - const char *label = get_string(item, "label"); + const char *label = get_string(item, "displayName"); if (!id || (!shortcut && !label) || config->behavior_shortcut_count >= BONGO_CAT_BEHAVIOR_BINDING_CAP) continue; @@ -100,13 +107,13 @@ static void read_behaviors(yyjson_val *array, BongoCatConfig *config) { } } -static void read_model_labels(yyjson_val *array, BongoCatConfig *config) { +static void read_model_labels(yyjson_val *array, BongoCatSettings *config) { if (!yyjson_is_arr(array)) return; config->model_label_count = 0; size_t index, count; yyjson_val *item; yyjson_arr_foreach(array, index, count, item) { - const char *id = get_string(item, "id"); - const char *label = get_string(item, "label"); + const char *id = get_string(item, "modelId"); + const char *label = get_string(item, "displayName"); if (!id || !id[0] || !label || !label[0] || config->model_label_count >= BONGO_CAT_MODEL_CAP) continue; BongoCatModelLabel *entry = @@ -116,108 +123,169 @@ static void read_model_labels(yyjson_val *array, BongoCatConfig *config) { } } -BongoCatResult bongo_cat_preferences_load(const char *path, - BongoCatConfig *config, BongoCatError *error) { +static BongoCatResult read_extensions(yyjson_val *value, + BongoCatSettings *settings, BongoCatError *error) { + if (!value) return BONGO_CAT_OK; + if (!yyjson_is_obj(value)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Settings extensions must be a JSON object"); + return BONGO_CAT_ERROR_FORMAT; + } + size_t length = 0; + char *json = yyjson_val_write(value, YYJSON_WRITE_NOFLAG, &length); + if (!json) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Cannot preserve settings extensions"); + return BONGO_CAT_ERROR_MEMORY; + } + if (length >= sizeof(settings->extensions_json)) { + free(json); + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Settings extensions exceed the %u-byte limit", + (unsigned)(sizeof(settings->extensions_json) - 1)); + return BONGO_CAT_ERROR_FORMAT; + } + memset(settings->extensions_json, 0, sizeof(settings->extensions_json)); + memcpy(settings->extensions_json, json, length + 1); + free(json); + return BONGO_CAT_OK; +} + +BongoCatResult bongo_cat_settings_load(const char *path, + BongoCatSettings *config, BongoCatError *error) { if (!path || !config) return BONGO_CAT_ERROR_ARGUMENT; if (!bongo_cat_path_is_file(path)) return BONGO_CAT_OK; - yyjson_doc *document = bongo_cat_config_read_document(path, PREFERENCES_FORMAT, error); + yyjson_doc *document = bongo_cat_config_read_document(path, + SETTINGS_FORMAT, BONGO_CAT_SETTINGS_SCHEMA, error); if (!document) return BONGO_CAT_ERROR_FORMAT; - BongoCatConfig loaded = *config; + BongoCatSettings loaded = *config; yyjson_val *root = yyjson_doc_get_root(document); - read_model(yyjson_obj_get(root, "model"), &loaded.model); + read_model(yyjson_obj_get(root, "rendering"), &loaded.model); read_window(yyjson_obj_get(root, "window"), &loaded.window); - read_app(yyjson_obj_get(root, "app"), &loaded.app); + read_app(yyjson_obj_get(root, "application"), &loaded.app); read_shortcuts(yyjson_obj_get(root, "shortcuts"), &loaded.shortcuts); - read_behaviors(yyjson_obj_get(root, "behaviorShortcuts"), &loaded); - read_model_labels(yyjson_obj_get(root, "modelLabels"), &loaded); + read_behaviors(yyjson_obj_get(root, "behaviorOverrides"), &loaded); + read_model_labels(yyjson_obj_get(root, "modelOverrides"), &loaded); + BongoCatResult extensions = read_extensions( + yyjson_obj_get(root, "extensions"), &loaded, error); yyjson_doc_free(document); - bongo_cat_config_validate(&loaded); + if (extensions != BONGO_CAT_OK) return extensions; + bongo_cat_settings_validate(&loaded); *config = loaded; return BONGO_CAT_OK; } static void write_model(yyjson_mut_doc *doc, yyjson_mut_val *obj, - const BongoCatModelOptions *v) { - yyjson_mut_obj_add_bool(doc, obj, "mirror", v->mirror); - yyjson_mut_obj_add_bool(doc, obj, "mouseMirror", v->mouse_mirror); - yyjson_mut_obj_add_bool(doc, obj, "mouseCenterTracking", v->mouse_centered); - yyjson_mut_obj_add_bool(doc, obj, "ignoreMouse", v->ignore_mouse); - yyjson_mut_obj_add_real(doc, obj, "autoReleaseDelay", v->auto_release_seconds); - yyjson_mut_obj_add_int(doc, obj, "maxFPS", v->max_fps); + const BongoCatModelPreferences *v) { + yyjson_mut_obj_add_bool(doc, obj, "modelMirrored", v->mirror); + yyjson_mut_obj_add_bool(doc, obj, "pointerMirrored", v->mouse_mirror); + yyjson_mut_obj_add_bool(doc, obj, "centerPointerTracking", v->mouse_centered); + yyjson_mut_obj_add_bool(doc, obj, "ignorePointerInput", v->ignore_mouse); + yyjson_mut_obj_add_real(doc, obj, "inputReleaseDelaySeconds", + v->auto_release_seconds); + yyjson_mut_obj_add_int(doc, obj, "maximumFps", v->max_fps); } static void write_window(yyjson_mut_doc *doc, yyjson_mut_val *obj, - const BongoCatWindowOptions *v) { - yyjson_mut_obj_add_bool(doc, obj, "passThrough", v->pass_through); + const BongoCatWindowPreferences *v) { + yyjson_mut_obj_add_bool(doc, obj, "clickThrough", v->pass_through); yyjson_mut_obj_add_bool(doc, obj, "alwaysOnTop", v->always_on_top); - yyjson_mut_obj_add_bool(doc, obj, "hideOnHover", v->hide_on_hover); - yyjson_mut_obj_add_bool(doc, obj, "keepInScreen", v->keep_in_screen); - yyjson_mut_obj_add_bool(doc, obj, "obsBackground", v->obs_background); - yyjson_mut_obj_add_strcpy(doc, obj, "obsBackgroundColor", + yyjson_mut_obj_add_bool(doc, obj, "hideOnPointerOver", v->hide_on_hover); + yyjson_mut_obj_add_bool(doc, obj, "keepOnScreen", v->keep_in_screen); + yyjson_mut_obj_add_bool(doc, obj, "captureBackground", v->obs_background); + yyjson_mut_obj_add_strcpy(doc, obj, "captureBackgroundColor", bongo_cat_obs_background_color_name(v->obs_background_color)); - yyjson_mut_obj_add_real(doc, obj, "hideOnHoverDelay", v->hide_delay_seconds); + yyjson_mut_obj_add_real(doc, obj, "hideDelaySeconds", v->hide_delay_seconds); } static void write_app(yyjson_mut_doc *doc, yyjson_mut_val *obj, - const BongoCatAppOptions *v) { - yyjson_mut_obj_add_bool(doc, obj, "autostart", v->autostart); - yyjson_mut_obj_add_bool(doc, obj, "trayVisible", v->tray_visible); + const BongoCatApplicationPreferences *v) { + yyjson_mut_obj_add_bool(doc, obj, "launchAtLogin", v->autostart); + yyjson_mut_obj_add_bool(doc, obj, "showTrayIcon", v->tray_visible); yyjson_mut_obj_add_strcpy(doc, obj, "theme", bongo_cat_theme_name(v->theme)); yyjson_mut_obj_add_strcpy(doc, obj, "language", bongo_cat_language_name(v->language)); } static void write_shortcuts(yyjson_mut_doc *doc, yyjson_mut_val *obj, - const BongoCatShortcutOptions *v) { - yyjson_mut_obj_add_strcpy(doc, obj, "visibleCat", v->visible_cat); - yyjson_mut_obj_add_strcpy(doc, obj, "visiblePreference", v->visible_preferences); - yyjson_mut_obj_add_strcpy(doc, obj, "mirrorMode", v->mirror); - yyjson_mut_obj_add_strcpy(doc, obj, "penetrable", v->pass_through); - yyjson_mut_obj_add_strcpy(doc, obj, "alwaysOnTop", v->always_on_top); + const BongoCatShortcutPreferences *v) { + yyjson_mut_obj_add_strcpy(doc, obj, "toggleVisibility", v->visible_cat); + yyjson_mut_obj_add_strcpy(doc, obj, "openSettings", v->visible_preferences); + yyjson_mut_obj_add_strcpy(doc, obj, "toggleModelMirror", v->mirror); + yyjson_mut_obj_add_strcpy(doc, obj, "toggleClickThrough", v->pass_through); + yyjson_mut_obj_add_strcpy(doc, obj, "toggleAlwaysOnTop", v->always_on_top); } static void write_behaviors(yyjson_mut_doc *doc, yyjson_mut_val *root, - const BongoCatConfig *config) { + const BongoCatSettings *config) { yyjson_mut_val *array = yyjson_mut_arr(doc); - yyjson_mut_obj_add_val(doc, root, "behaviorShortcuts", array); + yyjson_mut_obj_add_val(doc, root, "behaviorOverrides", array); for (size_t i = 0; i < config->behavior_shortcut_count; ++i) { const BongoCatBehaviorShortcut *value = &config->behavior_shortcuts[i]; if (!value->id[0] || (!value->shortcut[0] && !value->label[0])) continue; yyjson_mut_val *item = yyjson_mut_obj(doc); - yyjson_mut_obj_add_strcpy(doc, item, "id", value->id); + yyjson_mut_obj_add_strcpy(doc, item, "behaviorId", value->id); if (value->shortcut[0]) yyjson_mut_obj_add_strcpy(doc, item, "shortcut", value->shortcut); if (value->label[0]) - yyjson_mut_obj_add_strcpy(doc, item, "label", value->label); + yyjson_mut_obj_add_strcpy(doc, item, "displayName", value->label); yyjson_mut_arr_add_val(array, item); } } static void write_model_labels(yyjson_mut_doc *doc, yyjson_mut_val *root, - const BongoCatConfig *config) { + const BongoCatSettings *config) { yyjson_mut_val *array = yyjson_mut_arr(doc); - yyjson_mut_obj_add_val(doc, root, "modelLabels", array); + yyjson_mut_obj_add_val(doc, root, "modelOverrides", array); for (size_t i = 0; i < config->model_label_count; ++i) { const BongoCatModelLabel *value = &config->model_labels[i]; if (!value->id[0] || !value->label[0]) continue; yyjson_mut_val *item = yyjson_mut_obj(doc); - yyjson_mut_obj_add_strcpy(doc, item, "id", value->id); - yyjson_mut_obj_add_strcpy(doc, item, "label", value->label); + yyjson_mut_obj_add_strcpy(doc, item, "modelId", value->id); + yyjson_mut_obj_add_strcpy(doc, item, "displayName", value->label); yyjson_mut_arr_add_val(array, item); } } -BongoCatResult bongo_cat_preferences_save(const char *path, - const BongoCatConfig *config, BongoCatError *error) { +static yyjson_mut_val *write_extensions(yyjson_mut_doc *target, + const BongoCatSettings *settings, BongoCatError *error) { + const char *json = settings->extensions_json; + const char *end = memchr(json, '\0', sizeof(settings->extensions_json)); + if (!end) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Settings extensions are not null-terminated"); + return NULL; + } + size_t length = (size_t)(end - json); + if (!length) { json = "{}"; length = 2; } + yyjson_doc *source = yyjson_read(json, length, 0); + yyjson_val *root = source ? yyjson_doc_get_root(source) : NULL; + yyjson_mut_val *copy = yyjson_is_obj(root) + ? yyjson_val_mut_copy(target, root) : NULL; + yyjson_doc_free(source); + if (!copy) bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Settings extensions are not a valid JSON object"); + return copy; +} + +BongoCatResult bongo_cat_settings_save(const char *path, + const BongoCatSettings *config, BongoCatError *error) { if (!path || !config) return BONGO_CAT_ERROR_ARGUMENT; yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); if (!doc) return BONGO_CAT_ERROR_MEMORY; yyjson_mut_val *root = yyjson_mut_obj(doc); yyjson_mut_doc_set_root(doc, root); - yyjson_mut_obj_add_strcpy(doc, root, "format", PREFERENCES_FORMAT); - yyjson_mut_obj_add_int(doc, root, "version", BONGO_CAT_CONFIG_VERSION); - write_model(doc, yyjson_mut_obj_add_obj(doc, root, "model"), &config->model); + yyjson_mut_val *extensions = write_extensions(doc, config, error); + if (!extensions) { + yyjson_mut_doc_free(doc); + return error && error->code ? error->code : BONGO_CAT_ERROR_FORMAT; + } + yyjson_mut_obj_add_strcpy(doc, root, "format", SETTINGS_FORMAT); + yyjson_mut_obj_add_int(doc, root, "schemaVersion", + BONGO_CAT_SETTINGS_SCHEMA); + write_model(doc, yyjson_mut_obj_add_obj(doc, root, "rendering"), &config->model); write_window(doc, yyjson_mut_obj_add_obj(doc, root, "window"), &config->window); - write_app(doc, yyjson_mut_obj_add_obj(doc, root, "app"), &config->app); + write_app(doc, yyjson_mut_obj_add_obj(doc, root, "application"), &config->app); write_shortcuts(doc, yyjson_mut_obj_add_obj(doc, root, "shortcuts"), &config->shortcuts); write_behaviors(doc, root, config); write_model_labels(doc, root, config); + yyjson_mut_obj_add_val(doc, root, "extensions", extensions); BongoCatResult result = bongo_cat_config_write_document(path, doc, - "preferences file", error); + "settings file", error); yyjson_mut_doc_free(doc); return result; } diff --git a/src/core/config_session_io.c b/src/core/config_session_io.c index da5b943f..a71fdc18 100644 --- a/src/core/config_session_io.c +++ b/src/core/config_session_io.c @@ -3,7 +3,7 @@ #include -#define SESSION_FORMAT "bongo-cat/session" +#define SESSION_FORMAT "bongocat/session" static bool get_bool(yyjson_val *obj, const char *key, bool fallback) { yyjson_val *value = yyjson_obj_get(obj, key); @@ -19,49 +19,71 @@ static float get_float(yyjson_val *obj, const char *key, float fallback) { } BongoCatResult bongo_cat_session_load(const char *path, - BongoCatConfig *config, BongoCatError *error) { - if (!path || !config) return BONGO_CAT_ERROR_ARGUMENT; + BongoCatSessionState *session, BongoCatError *error) { + if (!path || !session) return BONGO_CAT_ERROR_ARGUMENT; if (!bongo_cat_path_is_file(path)) return BONGO_CAT_OK; - yyjson_doc *document = bongo_cat_config_read_document(path, SESSION_FORMAT, error); + yyjson_doc *document = bongo_cat_config_read_document(path, + SESSION_FORMAT, BONGO_CAT_SESSION_SCHEMA, error); if (!document) return BONGO_CAT_ERROR_FORMAT; - BongoCatConfig loaded = *config; + BongoCatSessionState loaded = *session; yyjson_val *root = yyjson_doc_get_root(document); yyjson_val *window = yyjson_obj_get(root, "window"); if (yyjson_is_obj(window)) { loaded.window.visible = get_bool(window, "visible", loaded.window.visible); - loaded.window.scale_percent = get_float(window, "scale", loaded.window.scale_percent); - loaded.window.opacity_percent = get_float(window, "opacity", loaded.window.opacity_percent); - loaded.window.x = get_int(window, "x", loaded.window.x); - loaded.window.y = get_int(window, "y", loaded.window.y); - loaded.window.width = get_int(window, "width", loaded.window.width); - loaded.window.height = get_int(window, "height", loaded.window.height); + loaded.window.scale_percent = get_float(window, "scalePercent", + loaded.window.scale_percent); + loaded.window.opacity_percent = get_float(window, "opacityPercent", + loaded.window.opacity_percent); + yyjson_val *position = yyjson_obj_get(window, "position"); + if (yyjson_is_obj(position)) { + yyjson_val *x = yyjson_obj_get(position, "x"); + yyjson_val *y = yyjson_obj_get(position, "y"); + if (yyjson_is_num(x) && yyjson_is_num(y)) { + loaded.window.x = (int)yyjson_get_sint(x); + loaded.window.y = (int)yyjson_get_sint(y); + loaded.window.position_known = true; + } + } + yyjson_val *size = yyjson_obj_get(window, "size"); + if (yyjson_is_obj(size)) { + loaded.window.width = get_int(size, "width", loaded.window.width); + loaded.window.height = get_int(size, "height", loaded.window.height); + } } - yyjson_val *model = yyjson_obj_get(root, "currentModel"); - if (yyjson_is_str(model)) snprintf(loaded.current_model, - sizeof(loaded.current_model), "%s", yyjson_get_str(model)); + yyjson_val *model = yyjson_obj_get(root, "activeModelId"); + if (yyjson_is_str(model)) snprintf(loaded.active_model_id, + sizeof(loaded.active_model_id), "%s", yyjson_get_str(model)); yyjson_doc_free(document); - bongo_cat_config_validate(&loaded); - *config = loaded; + bongo_cat_session_validate(&loaded); + *session = loaded; return BONGO_CAT_OK; } BongoCatResult bongo_cat_session_save(const char *path, - const BongoCatConfig *config, BongoCatError *error) { - if (!path || !config) return BONGO_CAT_ERROR_ARGUMENT; + const BongoCatSessionState *session, BongoCatError *error) { + if (!path || !session) return BONGO_CAT_ERROR_ARGUMENT; yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); if (!doc) return BONGO_CAT_ERROR_MEMORY; yyjson_mut_val *root = yyjson_mut_obj(doc); yyjson_mut_doc_set_root(doc, root); yyjson_mut_obj_add_strcpy(doc, root, "format", SESSION_FORMAT); - yyjson_mut_obj_add_int(doc, root, "version", BONGO_CAT_CONFIG_VERSION); + yyjson_mut_obj_add_int(doc, root, "schemaVersion", BONGO_CAT_SESSION_SCHEMA); yyjson_mut_val *window = yyjson_mut_obj_add_obj(doc, root, "window"); - yyjson_mut_obj_add_bool(doc, window, "visible", config->window.visible); - yyjson_mut_obj_add_real(doc, window, "scale", config->window.scale_percent); - yyjson_mut_obj_add_real(doc, window, "opacity", config->window.opacity_percent); - yyjson_mut_obj_add_int(doc, window, "x", config->window.x); - yyjson_mut_obj_add_int(doc, window, "y", config->window.y); - yyjson_mut_obj_add_int(doc, window, "width", config->window.width); - yyjson_mut_obj_add_int(doc, window, "height", config->window.height); - yyjson_mut_obj_add_strcpy(doc, root, "currentModel", config->current_model); + yyjson_mut_obj_add_bool(doc, window, "visible", session->window.visible); + yyjson_mut_obj_add_real(doc, window, "scalePercent", + session->window.scale_percent); + yyjson_mut_obj_add_real(doc, window, "opacityPercent", + session->window.opacity_percent); + if (session->window.position_known) { + yyjson_mut_val *position = yyjson_mut_obj_add_obj(doc, window, + "position"); + yyjson_mut_obj_add_int(doc, position, "x", session->window.x); + yyjson_mut_obj_add_int(doc, position, "y", session->window.y); + } + yyjson_mut_val *size = yyjson_mut_obj_add_obj(doc, window, "size"); + yyjson_mut_obj_add_int(doc, size, "width", session->window.width); + yyjson_mut_obj_add_int(doc, size, "height", session->window.height); + yyjson_mut_obj_add_strcpy(doc, root, "activeModelId", + session->active_model_id); BongoCatResult result = bongo_cat_config_write_document(path, doc, "session file", error); yyjson_mut_doc_free(doc); diff --git a/src/core/model_catalog.c b/src/core/model_catalog.c index 91829429..2dff46bd 100644 --- a/src/core/model_catalog.c +++ b/src/core/model_catalog.c @@ -142,9 +142,9 @@ const char *bongo_cat_model_default_name(const BongoCatModelEntry *entry) { return entry->id; } -const char *bongo_cat_model_name(const BongoCatConfig *config, +const char *bongo_cat_model_name(const BongoCatSettings *settings, const BongoCatModelEntry *entry) { if (!entry) return "model"; - const char *custom = bongo_cat_config_model_label(config, entry->id); + const char *custom = bongo_cat_settings_model_label(settings, entry->id); return custom && custom[0] ? custom : bongo_cat_model_default_name(entry); } diff --git a/src/core/model_package.c b/src/core/model_package.c index 0187a305..d11c8a41 100644 --- a/src/core/model_package.c +++ b/src/core/model_package.c @@ -53,11 +53,6 @@ static bool safe_id(const char *value) { return true; } -static bool legacy_id(const char *value) { - return value && value[0] && strlen(value) < BONGO_CAT_ID_CAP && - strcmp(value, ".") != 0 && strcmp(value, "..") != 0; -} - static bool digest_valid(const char *value) { if (!value || strlen(value) != 64) return false; for (const unsigned char *cursor = (const unsigned char *)value; @@ -144,12 +139,9 @@ static bool catalog_has_id(const BongoCatModelCatalog *catalog, bool bongo_cat_model_adapter_metadata_path(const char *directory, char *path, size_t capacity) { - if (!directory || !path || !capacity || - !bongo_cat_path_join(path, capacity, directory, - BONGO_CAT_MODEL_ADAPTER_FILE)) return false; - if (bongo_cat_path_is_file(path)) return true; - return bongo_cat_path_join(path, capacity, directory, - BONGO_CAT_MODEL_LEGACY_ADAPTER_FILE); + return directory && path && capacity && + bongo_cat_path_join(path, capacity, directory, + BONGO_CAT_MODEL_ADAPTER_FILE); } static bool resolve_entry(BongoCatModelEntry *entry, const char *package, @@ -164,11 +156,10 @@ static bool resolve_entry(BongoCatModelEntry *entry, const char *package, bongo_cat_path_join(setting_path, sizeof(setting_path), entry->directory, setting) && bongo_cat_path_is_file(setting_path) && bongo_cat_path_is_dir(entry->adapter_directory) && - (schema == BONGO_CAT_MODEL_PACKAGE_LEGACY_SCHEMA || - (bongo_cat_path_join(metadata_path, - sizeof(metadata_path), entry->adapter_directory, - BONGO_CAT_MODEL_ADAPTER_FILE) && - adapter_valid(metadata_path, source_format))); + schema == BONGO_CAT_MODEL_PACKAGE_SCHEMA && + bongo_cat_path_join(metadata_path, sizeof(metadata_path), + entry->adapter_directory, BONGO_CAT_MODEL_ADAPTER_FILE) && + adapter_valid(metadata_path, source_format); } bool bongo_cat_model_package_add(BongoCatModelCatalog *catalog, @@ -180,10 +171,9 @@ bool bongo_cat_model_package_add(BongoCatModelCatalog *catalog, yyjson_doc *document = bongo_cat_json_read_file(descriptor, 0, NULL); yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; int schema = (int)yyjson_get_int(yyjson_obj_get(root, "schemaVersion")); - bool current = schema == BONGO_CAT_MODEL_PACKAGE_SCHEMA; - yyjson_val *model_object = current ? yyjson_obj_get(root, "model") : root; - yyjson_val *runtime_object = current ? yyjson_obj_get(root, "runtime") : root; - yyjson_val *source_object = current ? yyjson_obj_get(root, "source") : root; + yyjson_val *model_object = yyjson_obj_get(root, "model"); + yyjson_val *runtime_object = yyjson_obj_get(root, "runtime"); + yyjson_val *source_object = yyjson_obj_get(root, "source"); const char *model = yyjson_get_str(yyjson_obj_get(model_object, "directory")); const char *adapter = yyjson_get_str(yyjson_obj_get(runtime_object, "adapter")); const char *setting = yyjson_get_str(yyjson_obj_get(model_object, "setting")); @@ -192,41 +182,34 @@ bool bongo_cat_model_package_add(BongoCatModelCatalog *catalog, const char *source_name = yyjson_get_str(yyjson_obj_get(source_object, "name")); const char *source_layout = yyjson_get_str(yyjson_obj_get(source_object, "layout")); const char *display = yyjson_get_str(yyjson_obj_get(root, "displayName")); - const char *package_id = current - ? yyjson_get_str(yyjson_obj_get(root, "packageId")) - : bongo_cat_path_name(directory); - const char *digest = current - ? yyjson_get_str(yyjson_obj_get(root, "contentDigest")) : NULL; - const char *family = current - ? yyjson_get_str(yyjson_obj_get(root, "familyId")) : NULL; - const char *metadata = current - ? yyjson_get_str(yyjson_obj_get(runtime_object, "metadata")) : NULL; + const char *package_id = yyjson_get_str(yyjson_obj_get(root, "packageId")); + const char *digest = yyjson_get_str(yyjson_obj_get(root, "contentDigest")); + const char *family = yyjson_get_str(yyjson_obj_get(root, "familyId")); + const char *metadata = yyjson_get_str(yyjson_obj_get(runtime_object, "metadata")); int adapter_schema = (int)yyjson_get_int(yyjson_obj_get(runtime_object, "adapterSchemaVersion")); int adapter_generator = (int)yyjson_get_int(yyjson_obj_get(runtime_object, "generatorVersion")); - uint32_t capabilities = current - ? stored_capabilities(yyjson_obj_get(root, "capabilities")) : 0; + uint32_t capabilities = stored_capabilities( + yyjson_obj_get(root, "capabilities")); uint32_t required = BONGO_CAT_MODEL_CAPABILITY_LIVE2D | BONGO_CAT_MODEL_CAPABILITY_RUNTIME_ADAPTER; bool valid = yyjson_is_obj(root) && - (schema == BONGO_CAT_MODEL_PACKAGE_LEGACY_SCHEMA || current) && + schema == BONGO_CAT_MODEL_PACKAGE_SCHEMA && safe_relative(model) && safe_relative(adapter) && safe_relative(setting) && - (current ? safe_id(package_id) : legacy_id(package_id)) && - (!current || digest_valid(digest)) && - (!family || safe_id(family)) && (!current || - (yyjson_is_obj(model_object) && yyjson_is_obj(runtime_object) && - yyjson_is_obj(source_object) && safe_id(source_format) && - text_valid(source_name) && safe_id(source_layout) && - text_valid(display) && mode_valid(mode) && - yyjson_is_bool(yyjson_obj_get(source_object, "preserved")) && - safe_relative(metadata) && - metadata_reference(adapter, metadata) && - adapter_schema == BONGO_CAT_MODEL_ADAPTER_SCHEMA && - adapter_generator > 0 && - yyjson_is_arr(yyjson_obj_get(root, "capabilities")) && - (capabilities & required) == required && - yyjson_is_obj(yyjson_obj_get(root, "extensions")))) && + safe_id(package_id) && digest_valid(digest) && + (!family || safe_id(family)) && + yyjson_is_obj(model_object) && yyjson_is_obj(runtime_object) && + yyjson_is_obj(source_object) && safe_id(source_format) && + text_valid(source_name) && safe_id(source_layout) && + text_valid(display) && mode_valid(mode) && + yyjson_is_bool(yyjson_obj_get(source_object, "preserved")) && + safe_relative(metadata) && metadata_reference(adapter, metadata) && + adapter_schema == BONGO_CAT_MODEL_ADAPTER_SCHEMA && + adapter_generator > 0 && + yyjson_is_arr(yyjson_obj_get(root, "capabilities")) && + (capabilities & required) == required && + yyjson_is_obj(yyjson_obj_get(root, "extensions")) && !catalog_has_id(catalog, package_id) && catalog->count < BONGO_CAT_MODEL_CAP; BongoCatModelEntry *entry = valid ? &catalog->entries[catalog->count] : NULL; @@ -245,14 +228,10 @@ bool bongo_cat_model_package_add(BongoCatModelCatalog *catalog, snprintf(entry->setting_file, sizeof(entry->setting_file), "%s", setting); entry->mode = stored_mode(mode); entry->source_format = stored_format(source_format); - entry->capabilities = current - ? capabilities - : BONGO_CAT_MODEL_CAPABILITY_LIVE2D | - BONGO_CAT_MODEL_CAPABILITY_PREVIEW | - BONGO_CAT_MODEL_CAPABILITY_RUNTIME_ADAPTER; + entry->capabilities = capabilities; entry->package_schema = schema; - entry->adapter_schema = current ? adapter_schema : 0; - entry->adapter_generator = current ? adapter_generator : 0; + entry->adapter_schema = adapter_schema; + entry->adapter_generator = adapter_generator; entry->preset = preset; catalog->count++; } diff --git a/src/core/utf8.c b/src/core/utf8.c index dfdc6b3b..e05440d3 100644 --- a/src/core/utf8.c +++ b/src/core/utf8.c @@ -45,7 +45,7 @@ bool bongo_cat_utf8_valid(const char *text) { return true; } -bool bongo_cat_utf8_normalize_legacy(const char *text, +bool bongo_cat_utf8_normalize_mver(const char *text, char *output, size_t capacity) { if (!text || !output || !capacity) return false; size_t length = strlen(text); diff --git a/src/runtime/app.c b/src/runtime/app.c index 849aebca..12a1217d 100644 --- a/src/runtime/app.c +++ b/src/runtime/app.c @@ -14,7 +14,7 @@ static void scan_models(BongoCatApp *app) { bongo_cat_app_rescan_models(app); } static bool load_selected_model(BongoCatApp *app, BongoCatError *error) { - const char *candidates[] = {app->config.current_model, "standard", "keyboard", "gamepad"}; + const char *candidates[] = {app->session.active_model_id, "standard", "keyboard", "gamepad"}; for (size_t i = 0; i < 4; ++i) { bool duplicate = false; for (size_t j = 0; j < i; ++j) @@ -37,19 +37,20 @@ static bool initialize(BongoCatApp *app, int argc, char **argv, BongoCatError *e app->smoke_language = -1; app->smoke_theme = -1; app->smoke_preference_page = -1; - bongo_cat_config_defaults(&app->config); + bongo_cat_settings_defaults(&app->settings); + bongo_cat_session_defaults(&app->session); bongo_cat_input_init(&app->input); bongo_cat_shortcut_init(&app->shortcut_state); bongo_cat_models_init(&app->models); if (!bongo_cat_startup_prepare(app, argc, argv, error)) return false; - BongoCatResult loaded = bongo_cat_preferences_load( - app->preferences_path, &app->config, error); - app->preferences_store_valid = loaded == BONGO_CAT_OK && - bongo_cat_path_is_file(app->preferences_path); + BongoCatResult loaded = bongo_cat_settings_load( + app->settings_path, &app->settings, error); + app->settings_store_valid = loaded == BONGO_CAT_OK && + bongo_cat_path_is_file(app->settings_path); if (loaded != BONGO_CAT_OK) - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Preferences ignored: %s", error->message); + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Settings ignored: %s", error->message); *error = (BongoCatError){0}; - loaded = bongo_cat_session_load(app->session_path, &app->config, error); + loaded = bongo_cat_session_load(app->session_path, &app->session, error); app->session_store_valid = loaded == BONGO_CAT_OK && bongo_cat_path_is_file(app->session_path); if (loaded != BONGO_CAT_OK) @@ -57,20 +58,20 @@ static bool initialize(BongoCatApp *app, int argc, char **argv, BongoCatError *e *error = (BongoCatError){0}; bongo_cat_config_store_initialize(app); if (app->smoke_language >= 0) - app->config.app.language = (BongoCatLanguage)app->smoke_language; - if (app->smoke_theme >= 0) app->config.app.theme = (BongoCatTheme)app->smoke_theme; - if (app->smoke_pass_through) app->config.window.pass_through = true; + app->settings.app.language = (BongoCatLanguage)app->smoke_language; + if (app->smoke_theme >= 0) app->settings.app.theme = (BongoCatTheme)app->smoke_theme; + if (app->smoke_pass_through) app->settings.window.pass_through = true; if (app->smoke_model[0]) - snprintf(app->config.current_model, sizeof(app->config.current_model), + snprintf(app->session.active_model_id, sizeof(app->session.active_model_id), "%s", app->smoke_model); - if (!app->autostart_launch) app->config.window.visible = true; + if (!app->autostart_launch) app->session.window.visible = true; bongo_cat_startup_stage(app, "configuration-ready"); if (bongo_cat_window_create(app, error) != BONGO_CAT_OK) return false; bongo_cat_startup_stage(app, "window-ready"); if (bongo_cat_app_locate_assets(app, error) != BONGO_CAT_OK) return false; bongo_cat_startup_stage(app, "assets-ready"); BongoCatError optional = {0}; - app->i18n = bongo_cat_i18n_create(app->locale_root, app->config.app.language, &optional); + app->i18n = bongo_cat_i18n_create(app->locale_root, app->settings.app.language, &optional); if (!app->i18n) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", optional.message); if (bongo_cat_platform_init(&app->platform, app->window, &app->input, error) != BONGO_CAT_OK) return false; bongo_cat_window_apply(app); @@ -93,19 +94,19 @@ static bool initialize(BongoCatApp *app, int argc, char **argv, BongoCatError *e bongo_cat_startup_ci_failure(app, &import_error); } else if (app->smoke_remove_imported) { char imported[BONGO_CAT_PATH_CAP]; - snprintf(imported, sizeof(imported), "%s", app->config.current_model); + snprintf(imported, sizeof(imported), "%s", app->session.active_model_id); if (bongo_cat_app_remove_model(app, imported, &import_error) != BONGO_CAT_OK) bongo_cat_startup_ci_failure(app, &import_error); } } app->running = true; optional = (BongoCatError){0}; app->tray = bongo_cat_tray_create(app, &optional); - if (!app->tray && app->config.app.tray_visible) + if (!app->tray && app->settings.app.tray_visible) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", optional.message); app->preferences = bongo_cat_preferences_create(app); if (!app->preferences) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Preferences are unavailable because their state could not be allocated"); - if (!app->tray && !app->config.window.visible) { + if (!app->tray && !app->session.window.visible) { bongo_cat_window_set_visible(app, true); } bongo_cat_live2d_audit_run(app); @@ -135,9 +136,9 @@ static bool initialize(BongoCatApp *app, int argc, char **argv, BongoCatError *e if (app->smoke_preferences) bongo_cat_preferences_show(app->preferences); app->last_frame_ns = SDL_GetTicksNS(); app->dirty = true; - app->startup_raise_due_ns = !app->smoke && !app->autostart_launch && app->config.window.visible ? app->last_frame_ns + 250000000ull : 0; + app->startup_raise_due_ns = !app->smoke && !app->autostart_launch && app->session.window.visible ? app->last_frame_ns + 250000000ull : 0; if (app->smoke_deadline_ns) app->smoke_deadline_ns += app->last_frame_ns; - if (!app->config.window.visible) bongo_cat_startup_ready(app); + if (!app->session.window.visible) bongo_cat_startup_ready(app); return true; } static void handle_event(BongoCatApp *app, const SDL_Event *event) { @@ -152,7 +153,7 @@ void bongo_cat_app_drain_input(BongoCatApp *app, bool allow_shortcuts) { if (app->smoke_ignore_global_input) continue; if (app->smoke_input_audit) { char path[BONGO_CAT_PATH_CAP]; - bongo_cat_path_join(path, sizeof(path), app->data_root, "input-audit.txt"); + bongo_cat_path_join(path, sizeof(path), app->state_root, "input-audit.txt"); FILE *file = bongo_cat_file_open(path, "ab"); if (file) { fprintf(file, "kind=%d name=%s value=%.3f\n", event.kind, event.name, event.value); fclose(file); } @@ -160,7 +161,7 @@ void bongo_cat_app_drain_input(BongoCatApp *app, bool allow_shortcuts) { uint64_t delay = strcmp(event.name, "CapsLock") == 0 ? 100 : 0; #ifdef _WIN32 if (event.kind == BONGO_CAT_INPUT_KEY_DOWN && !delay) - delay = (uint64_t)(app->config.model.auto_release_seconds * 1000.0f); + delay = (uint64_t)(app->settings.model.auto_release_seconds * 1000.0f); #endif bongo_cat_input_auto_release(&app->input, &event, delay); if (allow_shortcuts && !bongo_cat_preferences_shortcuts_blocked(app->preferences)) @@ -199,13 +200,13 @@ static bool render(BongoCatApp *app, bool present) { glDisable(GL_SCISSOR_TEST); glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); bongo_cat_window_clear_background(app); - bongo_cat_overlay_draw_background(app->overlay, app->config.model.mirror); - bongo_cat_live2d_set_mirror(app->live2d, app->config.model.mirror); + bongo_cat_overlay_draw_background(app->overlay, app->settings.model.mirror); + bongo_cat_live2d_set_mirror(app->live2d, app->settings.model.mirror); bongo_cat_live2d_draw(app->live2d); bongo_cat_overlay_draw_pointer_before_keys(app->overlay); - if (app->config.model.mouse_centered && app->pointer_known && !app->model_pointer_anchor_ready) bongo_cat_app_apply_mouse_position(app, app->pointer_x, app->pointer_y, 0.0f); - bongo_cat_overlay_draw_keys(app->overlay, app->config.model.mirror); - bongo_cat_overlay_draw_effect(app->overlay, app->config.model.mirror); + if (app->settings.model.mouse_centered && app->pointer_known && !app->model_pointer_anchor_ready) bongo_cat_app_apply_mouse_position(app, app->pointer_x, app->pointer_y, 0.0f); + bongo_cat_overlay_draw_keys(app->overlay, app->settings.model.mirror); + bongo_cat_overlay_draw_effect(app->overlay, app->settings.model.mirror); bongo_cat_overlay_draw_pointer_after_keys(app->overlay); bongo_cat_frame_capture_pending(app, width, height); if (!present) { @@ -227,7 +228,7 @@ static bool render(BongoCatApp *app, bool present) { return true; } void bongo_cat_app_render_now(BongoCatApp *app) { if (app && app->window && - app->config.window.visible && !app->window_minimized) render(app, true); } + app->session.window.visible && !app->window_minimized) render(app, true); } bool bongo_cat_app_capture_pending_frame(BongoCatApp *app) { return app && app->window && app->pending_model_cover_path[0] && !app->window_minimized && render(app, false); @@ -262,10 +263,10 @@ static void loop(BongoCatApp *app) { bongo_cat_app_update_hover(app, now); bongo_cat_app_drain_input(app, true); if (bongo_cat_model_frame_due(app, now)) update_model(app, now); - else if (!app->config.window.visible || app->window_minimized) { app->last_frame_ns = now; bongo_cat_memory_policy_idle(); } - if (app->config.window.visible && !app->window_minimized && app->dirty) render(app, true); + else if (!app->session.window.visible || app->window_minimized) { app->last_frame_ns = now; bongo_cat_memory_policy_idle(); } + if (app->session.window.visible && !app->window_minimized && app->dirty) render(app, true); bongo_cat_preferences_render(app->preferences); - if (app->config.window.visible && !app->window_minimized && app->dirty) render(app, true); + if (app->session.window.visible && !app->window_minimized && app->dirty) render(app, true); bongo_cat_tray_sync(app->tray); bongo_cat_window_raise_when_due(app, now); bongo_cat_config_store_update(app, now); if (app->smoke_deadline_ns && now >= app->smoke_deadline_ns) app->running = false; diff --git a/src/runtime/assets.c b/src/runtime/assets.c index b13829e2..c4a2b65d 100644 --- a/src/runtime/assets.c +++ b/src/runtime/assets.c @@ -29,7 +29,7 @@ BongoCatResult bongo_cat_app_locate_assets(BongoCatApp *app, if (!asset_directory(app->asset_root, sizeof(app->asset_root), base ? base : "")) { char cache[BONGO_CAT_PATH_CAP], name[64]; snprintf(name, sizeof(name), "embedded-assets-%s", BONGO_CAT_VERSION); - if (bongo_cat_path_join(cache, sizeof(cache), app->data_root, name)) { + if (bongo_cat_path_join(cache, sizeof(cache), app->cache_root, name)) { BongoCatError embedded = {0}; if (bongo_cat_platform_embedded_assets(cache, &embedded) == BONGO_CAT_OK) { asset_directory(app->asset_root, sizeof(app->asset_root), cache); diff --git a/src/runtime/config_store.c b/src/runtime/config_store.c index 833e3ff3..38e8a90c 100644 --- a/src/runtime/config_store.c +++ b/src/runtime/config_store.c @@ -15,68 +15,64 @@ static uint64_t hash_bytes(uint64_t hash, const void *data, size_t size) { return hash; } -static uint64_t preferences_hash(const BongoCatConfig *config) { +static uint64_t settings_hash(const BongoCatSettings *settings) { uint64_t hash = 1469598103934665603ull; #define HASH_FIELD(field) hash = hash_bytes(hash, &(field), sizeof(field)) - HASH_FIELD(config->model); - HASH_FIELD(config->window.pass_through); - HASH_FIELD(config->window.always_on_top); - HASH_FIELD(config->window.hide_on_hover); - HASH_FIELD(config->window.keep_in_screen); - HASH_FIELD(config->window.obs_background); - HASH_FIELD(config->window.obs_background_color); - HASH_FIELD(config->window.hide_delay_seconds); - HASH_FIELD(config->app); - HASH_FIELD(config->shortcuts); - HASH_FIELD(config->behavior_shortcut_count); - hash = hash_bytes(hash, config->behavior_shortcuts, - config->behavior_shortcut_count * sizeof(config->behavior_shortcuts[0])); - HASH_FIELD(config->model_label_count); - hash = hash_bytes(hash, config->model_labels, - config->model_label_count * sizeof(config->model_labels[0])); + HASH_FIELD(settings->model); + HASH_FIELD(settings->window); + HASH_FIELD(settings->app); + HASH_FIELD(settings->shortcuts); + HASH_FIELD(settings->behavior_shortcut_count); + hash = hash_bytes(hash, settings->behavior_shortcuts, + settings->behavior_shortcut_count * sizeof(settings->behavior_shortcuts[0])); + HASH_FIELD(settings->model_label_count); + hash = hash_bytes(hash, settings->model_labels, + settings->model_label_count * sizeof(settings->model_labels[0])); + HASH_FIELD(settings->extensions_json); #undef HASH_FIELD return hash; } -static uint64_t session_hash(const BongoCatConfig *config) { +static uint64_t session_hash(const BongoCatSessionState *session) { uint64_t hash = 1469598103934665603ull; #define HASH_FIELD(field) hash = hash_bytes(hash, &(field), sizeof(field)) - HASH_FIELD(config->window.visible); - HASH_FIELD(config->window.scale_percent); - HASH_FIELD(config->window.opacity_percent); - HASH_FIELD(config->window.x); - HASH_FIELD(config->window.y); - HASH_FIELD(config->window.width); - HASH_FIELD(config->window.height); - HASH_FIELD(config->current_model); + HASH_FIELD(session->window.visible); + HASH_FIELD(session->window.position_known); + HASH_FIELD(session->window.scale_percent); + HASH_FIELD(session->window.opacity_percent); + HASH_FIELD(session->window.x); + HASH_FIELD(session->window.y); + HASH_FIELD(session->window.width); + HASH_FIELD(session->window.height); + HASH_FIELD(session->active_model_id); #undef HASH_FIELD return hash; } void bongo_cat_config_store_initialize(BongoCatApp *app) { - uint64_t preferences = preferences_hash(&app->config); - uint64_t session = session_hash(&app->config); - app->preferences_observed_hash = preferences; + uint64_t settings = settings_hash(&app->settings); + uint64_t session = session_hash(&app->session); + app->settings_observed_hash = settings; app->session_observed_hash = session; - app->preferences_saved_hash = app->preferences_store_valid ? preferences : 0; + app->settings_saved_hash = app->settings_store_valid ? settings : 0; app->session_saved_hash = app->session_store_valid ? session : 0; } -static bool save_preferences(BongoCatApp *app, uint64_t hash) { +static bool save_settings(BongoCatApp *app, uint64_t hash) { BongoCatError error = {0}; - if (bongo_cat_preferences_save(app->preferences_path, - &app->config, &error) != BONGO_CAT_OK) { + if (bongo_cat_settings_save(app->settings_path, + &app->settings, &error) != BONGO_CAT_OK) { SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "%s", error.message); return false; } - app->preferences_saved_hash = hash; + app->settings_saved_hash = hash; return true; } static bool save_session(BongoCatApp *app, uint64_t hash) { BongoCatError error = {0}; if (bongo_cat_session_save(app->session_path, - &app->config, &error) != BONGO_CAT_OK) { + &app->session, &error) != BONGO_CAT_OK) { SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "%s", error.message); return false; } @@ -85,21 +81,21 @@ static bool save_session(BongoCatApp *app, uint64_t hash) { } void bongo_cat_config_store_update(BongoCatApp *app, uint64_t now) { - if (!app || app->smoke || !app->preferences_path[0] || !app->session_path[0]) return; - uint64_t preferences = preferences_hash(&app->config); - uint64_t session = session_hash(&app->config); - if (preferences != app->preferences_observed_hash) { - app->preferences_observed_hash = preferences; - app->preferences_save_due_ns = now + SAVE_DELAY_NS; - } else if (preferences != app->preferences_saved_hash && - !app->preferences_save_due_ns) app->preferences_save_due_ns = now + SAVE_DELAY_NS; + if (!app || app->smoke || !app->settings_path[0] || !app->session_path[0]) return; + uint64_t settings = settings_hash(&app->settings); + uint64_t session = session_hash(&app->session); + if (settings != app->settings_observed_hash) { + app->settings_observed_hash = settings; + app->settings_save_due_ns = now + SAVE_DELAY_NS; + } else if (settings != app->settings_saved_hash && + !app->settings_save_due_ns) app->settings_save_due_ns = now + SAVE_DELAY_NS; if (session != app->session_observed_hash) { app->session_observed_hash = session; app->session_save_due_ns = now + SAVE_DELAY_NS; } else if (session != app->session_saved_hash && !app->session_save_due_ns) app->session_save_due_ns = now + SAVE_DELAY_NS; - if (app->preferences_save_due_ns && now >= app->preferences_save_due_ns) - app->preferences_save_due_ns = save_preferences(app, preferences) + if (app->settings_save_due_ns && now >= app->settings_save_due_ns) + app->settings_save_due_ns = save_settings(app, settings) ? 0 : now + RETRY_DELAY_NS; if (app->session_save_due_ns && now >= app->session_save_due_ns) app->session_save_due_ns = save_session(app, session) @@ -107,12 +103,12 @@ void bongo_cat_config_store_update(BongoCatApp *app, uint64_t now) { } void bongo_cat_config_store_flush(BongoCatApp *app) { - if (!app || app->smoke || !app->preferences_path[0] || !app->session_path[0]) return; - uint64_t preferences = preferences_hash(&app->config); - uint64_t session = session_hash(&app->config); - if (preferences != app->preferences_saved_hash) save_preferences(app, preferences); + if (!app || app->smoke || !app->settings_path[0] || !app->session_path[0]) return; + uint64_t settings = settings_hash(&app->settings); + uint64_t session = session_hash(&app->session); + if (settings != app->settings_saved_hash) save_settings(app, settings); if (session != app->session_saved_hash) save_session(app, session); - app->preferences_observed_hash = preferences; + app->settings_observed_hash = settings; app->session_observed_hash = session; - app->preferences_save_due_ns = app->session_save_due_ns = 0; + app->settings_save_due_ns = app->session_save_due_ns = 0; } diff --git a/src/runtime/frame_audit.c b/src/runtime/frame_audit.c index c3965a31..abc77af6 100644 --- a/src/runtime/frame_audit.c +++ b/src/runtime/frame_audit.c @@ -129,7 +129,8 @@ static void log_first_frame(BongoCatApp *app, int width, int height) { transparent += value < 16; opaque_samples += value > 239; } char path[BONGO_CAT_PATH_CAP]; - bongo_cat_path_join(path, sizeof(path), app->data_root, "frame-alpha.txt"); + bongo_cat_path_join(path, sizeof(path), app->state_root, + "frame-alpha.txt"); FILE *file = bongo_cat_file_open(path, "wb"); if (file) { fprintf(file, "samples=5 transparent=%u opaque=%u sample_buffers=%d " @@ -145,7 +146,8 @@ static void record_frame(BongoCatApp *app, const unsigned char *pixels, int width, int height, size_t pitch) { if (!app->smoke_frame_series) return; char path[BONGO_CAT_PATH_CAP]; - bongo_cat_path_join(path, sizeof(path), app->data_root, "frame-series.csv"); + bongo_cat_path_join(path, sizeof(path), app->state_root, + "frame-series.csv"); bool header = !bongo_cat_path_is_file(path); FILE *file = bongo_cat_file_open(path, "ab"); if (!file) return; @@ -156,15 +158,15 @@ static void record_frame(BongoCatApp *app, const unsigned char *pixels, FrameStats stats = frame_stats(pixels, width, height, pitch); bool model_consistent = bongo_cat_live2d_ready(app->live2d) && app->loaded_model[0] && - strcmp(app->loaded_model, app->config.current_model) == 0; + strcmp(app->loaded_model, app->session.active_model_id) == 0; bool os_visible = (SDL_GetWindowFlags(app->window) & SDL_WINDOW_HIDDEN) == 0; fprintf(file, "%llu,%d,%d,%u,%u,%.3f,%.3f,%.5f,%s,%d,%u,%d,%d\n", (unsigned long long)SDL_GetTicksNS(), width, height, stats.visible, stats.alpha, - app->config.window.scale_percent, app->config.window.opacity_percent, + app->session.window.scale_percent, app->session.window.opacity_percent, bongo_cat_platform_get_opacity(&app->platform), - bongo_cat_mode_name(app->config.current_mode), model_consistent, - app->model_selection_serial, app->config.window.visible, os_visible); + bongo_cat_mode_name(app->loaded_mode), model_consistent, + app->model_selection_serial, app->session.window.visible, os_visible); fclose(file); } @@ -196,7 +198,7 @@ void bongo_cat_frame_audit(BongoCatApp *app, int width, int height) { } SDL_Surface *surface = SDL_CreateSurfaceFrom(width, height, SDL_PIXELFORMAT_RGBA32, pixels, (int)pitch); - bongo_cat_path_join(path, sizeof(path), app->data_root, "frame.bmp"); + bongo_cat_path_join(path, sizeof(path), app->state_root, "frame.bmp"); if (surface) { SDL_SaveBMP(surface, path); SDL_DestroySurface(surface); } free(row); free(pixels); } diff --git a/src/runtime/frame_clock.c b/src/runtime/frame_clock.c index f79545d5..17ffa4bc 100644 --- a/src/runtime/frame_clock.c +++ b/src/runtime/frame_clock.c @@ -4,8 +4,8 @@ static uint64_t frame_interval_ns(const BongoCatApp *app) { #ifdef BONGO_CAT_HAS_CUBISM - int fps = app && app->config.model.max_fps > 0 ? - app->config.model.max_fps : 60; + int fps = app && app->settings.model.max_fps > 0 ? + app->settings.model.max_fps : 60; return 1000000000ull / (uint64_t)fps; #else (void)app; @@ -21,7 +21,7 @@ static int remaining_ms(uint64_t deadline, uint64_t now) { } bool bongo_cat_model_frame_due(const BongoCatApp *app, uint64_t now) { - return app && app->config.window.visible && !app->window_minimized && + return app && app->session.window.visible && !app->window_minimized && (!app->last_frame_ns || now >= app->last_frame_ns + frame_interval_ns(app)); } @@ -29,16 +29,16 @@ bool bongo_cat_model_frame_due(const BongoCatApp *app, uint64_t now) { int bongo_cat_window_wait_timeout(const BongoCatApp *app, uint64_t now) { if (!app) return 250; uint64_t frame_deadline = app->last_frame_ns + frame_interval_ns(app); - int wait_ms = app->config.window.visible && !app->window_minimized ? remaining_ms( + int wait_ms = app->session.window.visible && !app->window_minimized ? remaining_ms( frame_deadline, now) : 250; if (bongo_cat_preferences_needs_frame(app->preferences) && wait_ms > 4) wait_ms = 4; if (app->wheel_animation_active && wait_ms > 8) wait_ms = 8; - if (app->config.window.visible && !app->window_minimized && + if (app->session.window.visible && !app->window_minimized && app->click_through_applied && wait_ms > 16) wait_ms = 16; - bool pending_hit = app->config.window.visible && !app->window_minimized && + bool pending_hit = app->session.window.visible && !app->window_minimized && app->pointer_hit_dirty && - app->pointer_hit_deadline_ns && !app->config.window.pass_through && + app->pointer_hit_deadline_ns && !app->settings.window.pass_through && !app->hover_hidden && !app->left_mouse_down && !app->right_mouse_down; if (!pending_hit) return wait_ms; int hit_wait = remaining_ms(app->pointer_hit_deadline_ns, now); @@ -64,20 +64,20 @@ bool bongo_cat_window_wait_timeout_self_test(void) { BongoCatApp *app = calloc(1, sizeof(*app)); if (!app) return false; bool passed = false; - app->config.window.visible = true; - app->config.model.max_fps = 60; + app->session.window.visible = true; + app->settings.model.max_fps = 60; app->last_frame_ns = now; #ifdef BONGO_CAT_HAS_CUBISM - app->config.model.max_fps = 30; + app->settings.model.max_fps = 30; uint64_t interval_30 = frame_interval_ns(app); - app->config.model.max_fps = 60; + app->settings.model.max_fps = 60; uint64_t interval_60 = frame_interval_ns(app); - app->config.model.max_fps = 120; + app->settings.model.max_fps = 120; uint64_t interval_120 = frame_interval_ns(app); if (!(interval_30 > interval_60 && interval_60 > interval_120) || bongo_cat_model_frame_due(app, now + interval_120 - 1) || !bongo_cat_model_frame_due(app, now + interval_120)) goto done; - app->config.model.max_fps = 60; + app->settings.model.max_fps = 60; #endif int frame_wait = remaining_ms(now + frame_interval_ns(app), now); if (bongo_cat_window_wait_timeout(app, now) != frame_wait || @@ -89,13 +89,13 @@ bool bongo_cat_window_wait_timeout_self_test(void) { if (bongo_cat_window_wait_timeout(app, now) != 0) goto done; app->pointer_hit_dirty = false; #ifdef BONGO_CAT_HAS_CUBISM - app->config.model.max_fps = 1; + app->settings.model.max_fps = 1; #endif app->click_through_applied = true; if (bongo_cat_window_wait_timeout(app, now) != 16) goto done; app->click_through_applied = false; #ifdef BONGO_CAT_HAS_CUBISM - app->config.model.max_fps = 60; + app->settings.model.max_fps = 60; #endif if (!bongo_cat_model_frame_due(app, now + frame_interval_ns(app))) goto done; @@ -103,7 +103,7 @@ bool bongo_cat_window_wait_timeout_self_test(void) { if (bongo_cat_model_frame_due(app, now + frame_interval_ns(app)) || bongo_cat_window_wait_timeout(app, now) != 250) goto done; app->window_minimized = false; - app->config.window.visible = false; + app->session.window.visible = false; if (bongo_cat_window_wait_timeout(app, now) != 250) goto done; app->wheel_animation_active = true; passed = bongo_cat_window_wait_timeout(app, now) == 8; diff --git a/src/runtime/frame_presentation_audit.c b/src/runtime/frame_presentation_audit.c index 9cd9bdb4..52e2b0a6 100644 --- a/src/runtime/frame_presentation_audit.c +++ b/src/runtime/frame_presentation_audit.c @@ -143,9 +143,9 @@ void bongo_cat_frame_presented_audit(BongoCatApp *app) { SDL_Log("First-frame presentation: success=1 framebuffer_visible=%d " "window_opacity=%.3f obs_background=%d obs_background_color=%s", audit.visible, bongo_cat_platform_get_opacity(&app->platform), - app->config.window.obs_background, + app->settings.window.obs_background, bongo_cat_obs_background_color_name( - app->config.window.obs_background_color)); + app->settings.window.obs_background_color)); #ifdef _WIN32 HWND source = app->window ? (HWND)SDL_GetPointerProperty( SDL_GetWindowProperties(app->window), diff --git a/src/runtime/gamepad.c b/src/runtime/gamepad.c index 1791f40b..af58f01b 100644 --- a/src/runtime/gamepad.c +++ b/src/runtime/gamepad.c @@ -114,7 +114,7 @@ void bongo_cat_gamepads_set_enabled(BongoCatApp *app, bool enabled) { void bongo_cat_gamepad_event(BongoCatApp *app, const void *raw) { const SDL_Event *event = raw; if (event->type == SDL_EVENT_GAMEPAD_ADDED) { - if (app->config.current_mode == BONGO_CAT_MODE_GAMEPAD && + if (app->loaded_mode == BONGO_CAT_MODE_GAMEPAD && !app->active_gamepad) { SDL_Gamepad *gamepad = SDL_GetGamepadFromID(event->gdevice.which); if (!gamepad) gamepad = SDL_OpenGamepad(event->gdevice.which); @@ -128,12 +128,12 @@ void bongo_cat_gamepad_event(BongoCatApp *app, const void *raw) { if (event->gdevice.which == app->active_gamepad) { app->active_gamepad = 0; bongo_cat_app_reset_gamepad(app); - if (app->config.current_mode == BONGO_CAT_MODE_GAMEPAD) + if (app->loaded_mode == BONGO_CAT_MODE_GAMEPAD) select_first_gamepad(app); } return; } - if (app->config.current_mode != BONGO_CAT_MODE_GAMEPAD) return; + if (app->loaded_mode != BONGO_CAT_MODE_GAMEPAD) return; BongoCatInputEvent input = {0}; SDL_JoystickID source; if (event->type == SDL_EVENT_GAMEPAD_AXIS_MOTION) { diff --git a/src/runtime/live2d_audit.c b/src/runtime/live2d_audit.c index e877de93..a5af384c 100644 --- a/src/runtime/live2d_audit.c +++ b/src/runtime/live2d_audit.c @@ -64,7 +64,7 @@ static bool pointer(BongoCatApp *app, bool mirror) { SDL_Rect bounds; double center_x, center_y; if (!pointer_test_center(app, &bounds, ¢er_x, ¢er_y)) return false; - app->config.model.mouse_mirror = mirror; + app->settings.model.mouse_mirror = mirror; app->left_mouse_down = false; bongo_cat_app_apply_mouse_position(app, center_x + bounds.w * 0.4, center_y - bounds.h * 0.4, 1.0f / 60.0f); @@ -79,7 +79,7 @@ static bool reverse_pointer(BongoCatApp *app) { if (!pointer_test_center(app, &bounds, ¢er_x, ¢er_y)) return false; pointer_audit = (PointerAudit){.ran = true, .mver = app->model_render_options.mver_projection}; - app->config.model.mouse_mirror = false; + app->settings.model.mouse_mirror = false; app->left_mouse_down = false; const float ratios[4][2] = { {-0.4f, -0.4f}, {0.4f, -0.4f}, {-0.4f, 0.4f}, {0.4f, 0.4f}}; @@ -119,7 +119,7 @@ static bool apply(BongoCatApp *app, const char *scenario) { if (strcmp(scenario, "viewer-sequence") == 0) return bongo_cat_live2d_viewer_audit_run(app); if (strcmp(scenario, "mouse-screen") == 0) return bongo_cat_app_audit_screen_pointer(app); if (strcmp(scenario, "mouse-hand-screen") == 0) return bongo_cat_app_audit_display_pointer(app); - if (strcmp(scenario, "mirror") == 0) app->config.model.mirror = true; + if (strcmp(scenario, "mirror") == 0) app->settings.model.mirror = true; else if (strcmp(scenario, "mouse-move") == 0) return pointer(app, false); else if (strcmp(scenario, "mouse-move-mirror") == 0) return pointer(app, true); else if (strcmp(scenario, "mouse-reverse") == 0) @@ -189,7 +189,7 @@ static bool signed_value(BongoCatApp *app, const char *id, bool positive) { static bool assertions(BongoCatApp *app, const char *scenario, bool operation) { if (!operation) return false; if (strncmp(scenario, "switch:", 7) == 0) - return strcmp(app->config.current_model, scenario + 7) == 0; + return strcmp(app->session.active_model_id, scenario + 7) == 0; if (strcmp(scenario, "idle") == 0 || strcmp(scenario, "mouse-screen") == 0 || strcmp(scenario, "mouse-hand-screen") == 0 || strcmp(scenario, "mirror") == 0 || strcmp(scenario, "visual-consistency") == 0 || strcmp(scenario, "viewer-sequence") == 0 || @@ -258,14 +258,15 @@ void bongo_cat_live2d_audit_run(BongoCatApp *app) { bool result = apply(app, app->smoke_live2d_scenario); double duration_ms = (SDL_GetTicksNS() - started) / 1000000.0; char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), app->data_root, "live2d-audit.txt")) return; + if (!bongo_cat_path_join(path, sizeof(path), app->state_root, + "live2d-audit.txt")) return; FILE *file = bongo_cat_file_open(path, "wb"); if (!file) return; bool verified = assertions(app, app->smoke_live2d_scenario, result); fprintf(file, "scenario=%s\nmodel=%s\nmode=%s\noperation=%s\nassertions=%s\n" "duration_ms=%.3f\n", - app->smoke_live2d_scenario, app->config.current_model, - bongo_cat_mode_name(app->config.current_mode), result ? "accepted" : "rejected", + app->smoke_live2d_scenario, app->session.active_model_id, + bongo_cat_mode_name(app->loaded_mode), result ? "accepted" : "rejected", verified ? "passed" : "failed", duration_ms); #ifdef BONGO_CAT_HAS_CUBISM fputs("renderer=cubism-native\n", file); diff --git a/src/runtime/live2d_viewer_audit.c b/src/runtime/live2d_viewer_audit.c index e401a240..66b2a780 100644 --- a/src/runtime/live2d_viewer_audit.c +++ b/src/runtime/live2d_viewer_audit.c @@ -186,7 +186,7 @@ bool bongo_cat_live2d_viewer_audit_run(BongoCatApp *app) { SDL_GetWindowSizeInPixels(app->window, &audit_width, &audit_height); bongo_cat_live2d_resize(app->live2d, audit_width, audit_height); char root[BONGO_CAT_PATH_CAP], native[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(root, sizeof(root), app->data_root, + if (!bongo_cat_path_join(root, sizeof(root), app->state_root, "cubism-viewer-audit") || !bongo_cat_path_join(native, sizeof(native), root, "native") || !bongo_cat_path_create_directory(root) || diff --git a/src/runtime/live2d_visual_audit.c b/src/runtime/live2d_visual_audit.c index 70f99785..22ea1321 100644 --- a/src/runtime/live2d_visual_audit.c +++ b/src/runtime/live2d_visual_audit.c @@ -162,7 +162,7 @@ static bool motion_replay_matrix(FILE *file, BongoCatApp *app, bool bongo_cat_live2d_visual_audit_run(BongoCatApp *app) { if (!app || !app->live2d || !app->window) return false; char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), app->data_root, + if (!bongo_cat_path_join(path, sizeof(path), app->state_root, "live2d-visual-audit.csv")) return false; FILE *file = bongo_cat_file_open(path, "wb"); if (!file) return false; diff --git a/src/runtime/model_catalog_runtime.c b/src/runtime/model_catalog_runtime.c index a6c87292..6c52d1b1 100644 --- a/src/runtime/model_catalog_runtime.c +++ b/src/runtime/model_catalog_runtime.c @@ -68,7 +68,7 @@ BongoCatResult bongo_cat_copy_directory(const char *source, static bool custom_root(BongoCatApp *app, char *path, size_t capacity) { return app->data_root[0] && bongo_cat_path_join(path, capacity, - app->data_root, "custom-models") && + app->data_root, "models") && bongo_cat_path_create_directory(path); } @@ -87,20 +87,20 @@ static bool shortcut_model_exists(const BongoCatModelCatalog *models, static void prune_behavior_shortcuts(BongoCatApp *app) { size_t output = 0; - for (size_t i = 0; i < app->config.behavior_shortcut_count; ++i) { - BongoCatBehaviorShortcut *value = &app->config.behavior_shortcuts[i]; + for (size_t i = 0; i < app->settings.behavior_shortcut_count; ++i) { + BongoCatBehaviorShortcut *value = &app->settings.behavior_shortcuts[i]; if (!shortcut_model_exists(&app->models, value->id)) continue; - if (output != i) app->config.behavior_shortcuts[output] = *value; + if (output != i) app->settings.behavior_shortcuts[output] = *value; output++; } - app->config.behavior_shortcut_count = output; + app->settings.behavior_shortcut_count = output; } -static void scan_portable_root(BongoCatApp *app, const char *root) { +static void scan_nearby_root(BongoCatApp *app, const char *root) { if (!root || !root[0] || !bongo_cat_path_is_dir(root)) return; size_t before = app->models.count; BongoCatError error = {0}; - BongoCatResult result = bongo_cat_import_portable_mver_scan(app, root, + BongoCatResult result = bongo_cat_import_nearby_mver_scan(app, root, &error); size_t added = app->models.count - before; if (added) SDL_Log("Nearby model scan added %llu model modes from %s", @@ -122,7 +122,7 @@ void bongo_cat_app_rescan_models(BongoCatApp *app) { } const char *base = SDL_GetBasePath(); if (!SDL_getenv("BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN")) - scan_portable_root(app, base); + scan_nearby_root(app, base); prune_behavior_shortcuts(app); for (size_t i = 0; i < app->models.count; ++i) bongo_cat_import_apply_metadata(app, app->models.entries[i].id, @@ -144,11 +144,11 @@ BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, } if (entry->preset || entry->managed) { bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, entry->managed ? - "Portable Mver models are managed by their source directory: %s" : + "Nearby Mver models are managed by their source directory: %s" : "Built-in models cannot be removed: %s", id); return BONGO_CAT_ERROR_ARGUMENT; } - bool selected = !strcmp(id, app->config.current_model) || + bool selected = !strcmp(id, app->session.active_model_id) || !strcmp(id, app->loaded_model); char directory[BONGO_CAT_PATH_CAP]; snprintf(directory, sizeof(directory), "%s", entry->storage_directory); @@ -183,7 +183,7 @@ BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, } return BONGO_CAT_ERROR_IO; } - bongo_cat_config_set_model_label(&app->config, id, ""); + bongo_cat_settings_set_model_label(&app->settings, id, ""); bongo_cat_app_rescan_models(app); return BONGO_CAT_OK; } diff --git a/src/runtime/model_files.c b/src/runtime/model_files.c index 88ae0ed2..31c2614d 100644 --- a/src/runtime/model_files.c +++ b/src/runtime/model_files.c @@ -8,14 +8,14 @@ #include #include static void select_model_state(BongoCatApp *app, const BongoCatModelEntry *entry) { - snprintf(app->config.current_model, sizeof(app->config.current_model), "%s", + snprintf(app->session.active_model_id, sizeof(app->session.active_model_id), "%s", entry->id); - app->config.current_mode = entry->mode; + app->loaded_mode = entry->mode; bongo_cat_gamepads_set_enabled(app, entry->mode == BONGO_CAT_MODE_GAMEPAD); } static void request_model_frame(BongoCatApp *app) { if (!app) return; - if (app->window && app->config.window.visible) { + if (app->window && app->session.window.visible) { if (SDL_GetWindowFlags(app->window) & SDL_WINDOW_HIDDEN) bongo_cat_window_set_visible(app, true); else bongo_cat_window_clamp_to_display(app); @@ -42,13 +42,13 @@ static bool apply_model_aspect(BongoCatApp *app, if (next_width > 8192) next_width = 8192; if (next_width == width) return false; return bongo_cat_window_apply_geometry(app, x, y, - app->config.window.scale_percent, next_width, height); + app->session.window.scale_percent, next_width, height); } static void commit_model(BongoCatApp *app, const BongoCatModelEntry *entry, bool force_refresh) { - bool changed = strcmp(app->config.current_model, entry->id) != 0 || - app->config.current_mode != entry->mode; + bool changed = strcmp(app->session.active_model_id, entry->id) != 0 || + app->loaded_mode != entry->mode; if (!changed && !force_refresh) return; select_model_state(app, entry); app->model_selection_serial++; @@ -91,7 +91,7 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, "force_mouse=%d left_handed=%d pointer_bounds=%d", render_options.projection_scale, render_options.mouse_force_move, render_options.pointer_left_handed, render_options.custom_pointer_bounds); - int pixel_width = app->config.window.width, pixel_height = app->config.window.height; + int pixel_width = app->session.window.width, pixel_height = app->session.window.height; if (app->window) SDL_GetWindowSizeInPixels(app->window, &pixel_width, &pixel_height); SDL_Window *previous_window = SDL_GL_GetCurrentWindow(); SDL_GLContext previous_context = SDL_GL_GetCurrentContext(); diff --git a/src/runtime/model_import.c b/src/runtime/model_import.c index bcea9f4c..7e56a8fc 100644 --- a/src/runtime/model_import.c +++ b/src/runtime/model_import.c @@ -18,7 +18,7 @@ typedef struct ImportInstall { static bool custom_root(const char *data_root, char *path, size_t capacity) { return data_root && data_root[0] && - bongo_cat_path_join(path, capacity, data_root, "custom-models") && + bongo_cat_path_join(path, capacity, data_root, "models") && bongo_cat_path_create_directory(path); } @@ -261,7 +261,7 @@ BongoCatResult bongo_cat_app_import_model(BongoCatApp *app, const char *source, return result; } char previous[BONGO_CAT_PATH_CAP]; - snprintf(previous, sizeof(previous), "%s", app->config.current_model); + snprintf(previous, sizeof(previous), "%s", app->session.active_model_id); size_t preferred = 0; for (size_t i = 0; i < receipt.count; ++i) if (receipt.installed[i]) { preferred = i; break; } @@ -273,9 +273,9 @@ BongoCatResult bongo_cat_app_import_model(BongoCatApp *app, const char *source, const BongoCatModelEntry *entry = imported_id ? bongo_cat_models_find(&app->models, imported_id) : NULL; if (entry) { - snprintf(app->config.current_model, sizeof(app->config.current_model), + snprintf(app->session.active_model_id, sizeof(app->session.active_model_id), "%s", imported_id); - app->config.current_mode = entry->mode; + app->loaded_mode = entry->mode; } return BONGO_CAT_OK; #else diff --git a/src/runtime/model_import.h b/src/runtime/model_import.h index 2846e13a..aecb9f46 100644 --- a/src/runtime/model_import.h +++ b/src/runtime/model_import.h @@ -48,7 +48,7 @@ typedef struct BongoCatPackageMetadata { uint32_t capabilities; } BongoCatPackageMetadata; -typedef BongoCatResult (*BongoCatPortableVisitor)(void *userdata, +typedef BongoCatResult (*BongoCatNearbyVisitor)(void *userdata, const char *source, BongoCatImportDiscovery *discovery, BongoCatError *error); typedef struct BongoCatApp BongoCatApp; @@ -86,7 +86,7 @@ bool bongo_cat_import_prepare_package_metadata( const BongoCatModelEntry *bongo_cat_import_find_existing_package( const BongoCatModelCatalog *catalog, const BongoCatPackageMetadata *metadata, BongoCatModelMode mode); -void bongo_cat_import_describe_portable_entry(BongoCatModelEntry *entry, +void bongo_cat_import_describe_nearby_entry(BongoCatModelEntry *entry, const BongoCatImportCandidate *candidate, const char *id, const char *identity, const char *source_hash, const char *source, const char *adapter); @@ -94,14 +94,14 @@ void bongo_cat_import_apply_metadata(BongoCatApp *app, const char *model_id, const char *directory); bool bongo_cat_import_render_options(const char *directory, BongoCatLive2DRenderOptions *options); -BongoCatResult bongo_cat_import_portable_mver(BongoCatApp *app, +BongoCatResult bongo_cat_import_nearby_mver_root(BongoCatApp *app, const char *root, BongoCatError *error); -BongoCatResult bongo_cat_import_portable_mver_scan(BongoCatApp *app, +BongoCatResult bongo_cat_import_nearby_mver_scan(BongoCatApp *app, const char *root, BongoCatError *error); BongoCatResult bongo_cat_import_nearby_mver(BongoCatApp *app, const char *root, BongoCatError *error); -BongoCatResult bongo_cat_import_portable_scan(const char *root, - BongoCatPortableVisitor visitor, void *userdata, BongoCatError *error); +BongoCatResult bongo_cat_import_nearby_scan(const char *root, + BongoCatNearbyVisitor visitor, void *userdata, BongoCatError *error); BongoCatResult bongo_cat_import_install(const char *source, const char *data_root, BongoCatImportReceipt *receipt, BongoCatError *error); diff --git a/src/runtime/model_import_discovery.c b/src/runtime/model_import_discovery.c index 0d3e8966..200a6e65 100644 --- a/src/runtime/model_import_discovery.c +++ b/src/runtime/model_import_discovery.c @@ -249,7 +249,7 @@ bool bongo_cat_import_discover(const char *source, BongoCatImportDiscovery *disc if (patch > 0) return true; memset(discovery, 0, sizeof(*discovery)); ContainerDiscovery container = {discovery}; - BongoCatResult container_result = bongo_cat_import_portable_scan(source, + BongoCatResult container_result = bongo_cat_import_nearby_scan(source, collect_container, &container, error); if (container_result != BONGO_CAT_OK) return false; if (discovery->count) { diff --git a/src/runtime/model_import_identity.c b/src/runtime/model_import_identity.c index 88886073..39a16c1c 100644 --- a/src/runtime/model_import_identity.c +++ b/src/runtime/model_import_identity.c @@ -86,7 +86,7 @@ const BongoCatModelEntry *bongo_cat_import_find_existing_package( return NULL; } -void bongo_cat_import_describe_portable_entry(BongoCatModelEntry *entry, +void bongo_cat_import_describe_nearby_entry(BongoCatModelEntry *entry, const BongoCatImportCandidate *candidate, const char *id, const char *identity, const char *source_hash, const char *source, const char *adapter) { diff --git a/src/runtime/model_import_metadata.c b/src/runtime/model_import_metadata.c index fd1de6e3..c5630291 100644 --- a/src/runtime/model_import_metadata.c +++ b/src/runtime/model_import_metadata.c @@ -11,9 +11,9 @@ static bool metadata_version(yyjson_val *root) { if (!yyjson_is_obj(root)) return false; yyjson_val *schema = yyjson_obj_get(root, "schemaVersion"); const char *kind = yyjson_get_str(yyjson_obj_get(root, "kind")); - return schema ? yyjson_get_int(schema) == BONGO_CAT_MODEL_ADAPTER_SCHEMA && kind && - strcmp(kind, "bongo-cat-runtime-adapter") == 0 : - yyjson_get_int(yyjson_obj_get(root, "version")) == 1; + return yyjson_is_int(schema) && + yyjson_get_int(schema) == BONGO_CAT_MODEL_ADAPTER_SCHEMA && kind && + strcmp(kind, "bongo-cat-runtime-adapter") == 0; } static bool same_family(const char *left, const char *right) { @@ -39,9 +39,9 @@ static int sequential_index(yyjson_val *bindings, size_t index, const char *kind } static BongoCatBehaviorShortcut *shortcut_for(BongoCatApp *app, const char *id) { - for (size_t i = 0; i < app->config.behavior_shortcut_count; ++i) - if (strcmp(app->config.behavior_shortcuts[i].id, id) == 0) - return &app->config.behavior_shortcuts[i]; + for (size_t i = 0; i < app->settings.behavior_shortcut_count; ++i) + if (strcmp(app->settings.behavior_shortcuts[i].id, id) == 0) + return &app->settings.behavior_shortcuts[i]; return NULL; } @@ -91,9 +91,9 @@ void bongo_cat_import_apply_metadata(BongoCatApp *app, const char *model_id, snprintf(existing->label, sizeof(existing->label), "%s", label); continue; } - if (app->config.behavior_shortcut_count >= BONGO_CAT_BEHAVIOR_BINDING_CAP) break; + if (app->settings.behavior_shortcut_count >= BONGO_CAT_BEHAVIOR_BINDING_CAP) break; BongoCatBehaviorShortcut *value = - &app->config.behavior_shortcuts[app->config.behavior_shortcut_count++]; + &app->settings.behavior_shortcuts[app->settings.behavior_shortcut_count++]; snprintf(value->id, sizeof(value->id), "%s", id); snprintf(value->shortcut, sizeof(value->shortcut), "%s", shortcut); if (label) snprintf(value->label, sizeof(value->label), "%s", label); diff --git a/src/runtime/model_import_mver_labels.c b/src/runtime/model_import_mver_labels.c index afca5a36..eac88ce3 100644 --- a/src/runtime/model_import_mver_labels.c +++ b/src/runtime/model_import_mver_labels.c @@ -142,7 +142,7 @@ static void collect_field(BongoCatMverLabels *labels, TextSpan mode, TextSpan row = {cursor, after}; char label[BONGO_CAT_ID_CAP], normalized[BONGO_CAT_ID_CAP]; if (line_label(row, label, sizeof(label)) && - bongo_cat_utf8_normalize_legacy(label, normalized, + bongo_cat_utf8_normalize_mver(label, normalized, sizeof(normalized)) && labels->count < BONGO_CAT_BEHAVIOR_CAP) { BongoCatMverLabelEntry *entry = &labels->entries[labels->count++]; diff --git a/src/runtime/model_import_portable.c b/src/runtime/model_import_nearby.c similarity index 82% rename from src/runtime/model_import_portable.c rename to src/runtime/model_import_nearby.c index 3736b8d9..f3013db9 100644 --- a/src/runtime/model_import_portable.c +++ b/src/runtime/model_import_nearby.c @@ -1,5 +1,5 @@ #include "model_import.h" -#include "model_import_portable_internal.h" +#include "model_import_nearby_internal.h" #include "model_storage.h" #include "runtime.h" #include "bongo_cat/json.h" @@ -12,7 +12,16 @@ #include #include -#define PORTABLE_DIRECTORY "portable-mver" +#define MODEL_ADAPTER_DIRECTORY "model-adapters" +#define MODEL_CACHE_KIND "bongocat/nearby-model-cache" + +static bool marker_header_valid(yyjson_val *root) { + const char *kind = yyjson_get_str(yyjson_obj_get(root, "kind")); + return yyjson_is_obj(root) && + yyjson_get_int(yyjson_obj_get(root, "schemaVersion")) == + BONGO_CAT_NEARBY_CACHE_SCHEMA && kind && + strcmp(kind, MODEL_CACHE_KIND) == 0; +} static bool digest_valid(const char *value) { if (!value || strlen(value) != 64) return false; @@ -73,15 +82,13 @@ static bool marker_matches(const char *target, const char *source, const char *signature, const char *identity) { char path[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(path, sizeof(path), target, - BONGO_CAT_PORTABLE_MARKER)) return false; + BONGO_CAT_NEARBY_CACHE_MARKER)) return false; yyjson_doc *document = bongo_cat_json_read_file(path, 0, NULL); yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; const char *stored_source = yyjson_get_str(yyjson_obj_get(root, "source")); const char *stored_signature = yyjson_get_str(yyjson_obj_get(root, "signature")); const char *stored_identity = yyjson_get_str(yyjson_obj_get(root, "identity")); - bool matches = yyjson_is_obj(root) && - yyjson_get_int(yyjson_obj_get(root, "schemaVersion")) == - BONGO_CAT_PORTABLE_SCHEMA_VERSION && stored_source && + bool matches = marker_header_valid(root) && stored_source && stored_signature && stored_identity && strcmp(source, stored_source) == 0 && strcmp(signature, stored_signature) == 0 && strcmp(identity, stored_identity) == 0; @@ -93,15 +100,13 @@ static bool cached_identity(const char *target, const char *source, const char *signature, char identity[65]) { char path[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(path, sizeof(path), target, - BONGO_CAT_PORTABLE_MARKER)) return false; + BONGO_CAT_NEARBY_CACHE_MARKER)) return false; yyjson_doc *document = bongo_cat_json_read_file(path, 0, NULL); yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; const char *stored_source = yyjson_get_str(yyjson_obj_get(root, "source")); const char *stored_signature = yyjson_get_str(yyjson_obj_get(root, "signature")); const char *stored_identity = yyjson_get_str(yyjson_obj_get(root, "identity")); - bool valid = yyjson_is_obj(root) && - yyjson_get_int(yyjson_obj_get(root, "schemaVersion")) == - BONGO_CAT_PORTABLE_SCHEMA_VERSION && stored_source && + bool valid = marker_header_valid(root) && stored_source && stored_signature && digest_valid(stored_identity) && strcmp(source, stored_source) == 0 && strcmp(signature, stored_signature) == 0; @@ -114,14 +119,13 @@ static bool marker_identity(const char *target, const char *source, char identity[65]) { char path[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(path, sizeof(path), target, - BONGO_CAT_PORTABLE_MARKER)) return false; + BONGO_CAT_NEARBY_CACHE_MARKER)) return false; yyjson_doc *document = bongo_cat_json_read_file(path, 0, NULL); yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; - const char *kind = yyjson_get_str(yyjson_obj_get(root, "kind")); const char *stored_source = yyjson_get_str(yyjson_obj_get(root, "source")); const char *stored_identity = yyjson_get_str(yyjson_obj_get(root, "identity")); - bool valid = yyjson_is_obj(root) && kind && stored_source && - strcmp(kind, "portable-mver") == 0 && strcmp(source, stored_source) == 0 && + bool valid = marker_header_valid(root) && stored_source && + strcmp(source, stored_source) == 0 && digest_valid(stored_identity); if (valid) snprintf(identity, 65, "%s", stored_identity); yyjson_doc_free(document); @@ -135,14 +139,14 @@ static bool write_marker(const char *target, const char *source, if (document) yyjson_mut_doc_set_root(document, root); char path[BONGO_CAT_PATH_CAP]; bool ok = root && yyjson_mut_obj_add_int(document, root, "schemaVersion", - BONGO_CAT_PORTABLE_SCHEMA_VERSION) && - yyjson_mut_obj_add_str(document, root, "kind", "portable-mver") && + BONGO_CAT_NEARBY_CACHE_SCHEMA) && + yyjson_mut_obj_add_str(document, root, "kind", MODEL_CACHE_KIND) && yyjson_mut_obj_add_strcpy(document, root, "source", source) && yyjson_mut_obj_add_strcpy(document, root, "signature", signature) && yyjson_mut_obj_add_strcpy(document, root, "identity", identity) && yyjson_mut_obj_add_strcpy(document, root, "mode", bongo_cat_mode_name(mode)) && bongo_cat_path_join(path, sizeof(path), target, - BONGO_CAT_PORTABLE_MARKER) && + BONGO_CAT_NEARBY_CACHE_MARKER) && bongo_cat_json_write_file(path, document, YYJSON_WRITE_PRETTY, NULL); yyjson_mut_doc_free(document); return ok; @@ -153,24 +157,19 @@ static bool previous_cache_usable(const char *target, const char *source) { char metadata[BONGO_CAT_PATH_CAP], resources[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_is_dir(target) || !bongo_cat_path_join(marker, sizeof(marker), target, - BONGO_CAT_PORTABLE_MARKER) || + BONGO_CAT_NEARBY_CACHE_MARKER) || !bongo_cat_path_join(mode, sizeof(mode), target, ".bongo-cat-mode") || !bongo_cat_path_join(metadata, sizeof(metadata), target, BONGO_CAT_MODEL_ADAPTER_FILE) || !bongo_cat_path_join(resources, sizeof(resources), target, "resources") || !bongo_cat_path_is_file(mode) || !bongo_cat_path_is_dir(resources)) return false; - if (!bongo_cat_path_is_file(metadata) && - (!bongo_cat_path_join(metadata, sizeof(metadata), target, - BONGO_CAT_MODEL_LEGACY_ADAPTER_FILE) || - !bongo_cat_path_is_file(metadata))) - return false; + if (!bongo_cat_path_is_file(metadata)) return false; yyjson_doc *document = bongo_cat_json_read_file(marker, 0, NULL); yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; - const char *kind = yyjson_get_str(yyjson_obj_get(root, "kind")); const char *stored_source = yyjson_get_str(yyjson_obj_get(root, "source")); - bool usable = yyjson_is_obj(root) && kind && stored_source && - strcmp(kind, "portable-mver") == 0 && strcmp(stored_source, source) == 0; + bool usable = marker_header_valid(root) && stored_source && + strcmp(stored_source, source) == 0; yyjson_doc_free(document); return usable; } @@ -208,21 +207,21 @@ static bool refresh_cache(const BongoCatImportCandidate *candidate, !write_marker(temporary, source, signature, identity, candidate->mode)) { bongo_cat_model_remove_tree(temporary, NULL); if (error && !error->message[0]) bongo_cat_error_set(error, - BONGO_CAT_ERROR_IO, "Cannot build portable Mver adapter: %s", source); + BONGO_CAT_ERROR_IO, "Cannot build nearby Mver adapter: %s", source); return use_previous_cache(target, source, error); } bool had_target = bongo_cat_path_is_dir(target); if (had_target && !bongo_cat_path_rename(target, backup)) { bongo_cat_model_remove_tree(temporary, NULL); bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot update portable Mver adapter: %s", SDL_GetError()); + "Cannot update nearby Mver adapter: %s", SDL_GetError()); return use_previous_cache(target, source, error); } if (!bongo_cat_path_rename(temporary, target)) { if (had_target) bongo_cat_path_rename(backup, target); bongo_cat_model_remove_tree(temporary, NULL); bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot activate portable Mver adapter: %s", SDL_GetError()); + "Cannot activate nearby Mver adapter: %s", SDL_GetError()); return use_previous_cache(target, source, error); } bongo_cat_model_remove_tree(backup, NULL); @@ -239,17 +238,17 @@ static bool add_candidate(BongoCatApp *app, const char *cache_root, bongo_cat_mode_name(candidate->mode)); same_id = model_with_id(app, id); if (same_id && !same_id->managed) return true; - if (!bongo_cat_portable_signature(candidate, signature, error)) + if (!bongo_cat_nearby_signature(candidate, signature, error)) return false; char target[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(target, sizeof(target), cache_root, id) || (!cached_identity(target, source, signature, identity) && - !bongo_cat_portable_identity(candidate, identity, error))) return false; + !bongo_cat_nearby_identity(candidate, identity, error))) return false; if (existing_identity(&app->models, identity, candidate->mode, same_id)) return true; if (!same_id && app->models.count >= BONGO_CAT_MODEL_CAP) { bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Too many models while adding portable Mver package"); + "Too many models while adding nearby Mver package"); return false; } bool created = false; @@ -269,7 +268,7 @@ static bool add_candidate(BongoCatApp *app, const char *cache_root, memset(same_id, 0, sizeof(*same_id)); candidate_name(source, candidate, same_id->display_name, sizeof(same_id->display_name)); - bongo_cat_import_describe_portable_entry(same_id, candidate, id, + bongo_cat_import_describe_nearby_entry(same_id, candidate, id, identity, source_hash, source, adapter); return true; } @@ -277,7 +276,7 @@ static bool add_candidate(BongoCatApp *app, const char *cache_root, memset(entry, 0, sizeof(*entry)); candidate_name(source, candidate, entry->display_name, sizeof(entry->display_name)); - bongo_cat_import_describe_portable_entry(entry, candidate, id, identity, + bongo_cat_import_describe_nearby_entry(entry, candidate, id, identity, source_hash, source, adapter); if (created && !first_created[0]) snprintf(first_created, BONGO_CAT_ID_CAP, "%s", id); @@ -295,24 +294,25 @@ static BongoCatResult add_discovery(BongoCatApp *app, const char *cache_root, return BONGO_CAT_OK; } -typedef struct PortableAdd { +typedef struct NearbyAdd { BongoCatApp *app; const char *cache_root; char *first_created; -} PortableAdd; +} NearbyAdd; static BongoCatResult add_scanned(void *userdata, const char *source, BongoCatImportDiscovery *discovery, BongoCatError *error) { - PortableAdd *add = userdata; + NearbyAdd *add = userdata; return add_discovery(add->app, add->cache_root, source, discovery, add->first_created, error); } -static BongoCatResult import_portable_mver(BongoCatApp *app, +static BongoCatResult import_nearby_mver_root(BongoCatApp *app, const char *root, bool bounded_scan, BongoCatError *error) { - if (!app || !root || !app->data_root[0]) return BONGO_CAT_ERROR_ARGUMENT; + if (!app || !root || !app->cache_root[0]) return BONGO_CAT_ERROR_ARGUMENT; if (!bongo_cat_path_is_dir(root)) return BONGO_CAT_OK; char cache_root[BONGO_CAT_PATH_CAP], first_created[BONGO_CAT_ID_CAP] = {0}; - if (!bongo_cat_path_join(cache_root, sizeof(cache_root), app->data_root, - PORTABLE_DIRECTORY) || !bongo_cat_path_create_directory(cache_root)) return BONGO_CAT_ERROR_IO; + if (!bongo_cat_path_join(cache_root, sizeof(cache_root), app->cache_root, + MODEL_ADAPTER_DIRECTORY) || + !bongo_cat_path_create_directory(cache_root)) return BONGO_CAT_ERROR_IO; BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); if (!discovery) return BONGO_CAT_ERROR_MEMORY; int direct = bounded_scan @@ -331,38 +331,36 @@ static BongoCatResult import_portable_mver(BongoCatApp *app, BONGO_CAT_OK; if (direct <= 0) { if (error) *error = (BongoCatError){0}; - PortableAdd add = {app, cache_root, first_created}; - result = bongo_cat_import_portable_scan(root, add_scanned, &add, error); + NearbyAdd add = {app, cache_root, first_created}; + result = bongo_cat_import_nearby_scan(root, add_scanned, &add, error); } - if (result == BONGO_CAT_OK) - bongo_cat_portable_migrate_config(app, cache_root); - const char *selected = app->config.current_model; + const char *selected = app->session.active_model_id; if (first_created[0] && (!selected[0] || strcmp(selected, "standard") == 0 || strcmp(selected, "keyboard") == 0 || strcmp(selected, "gamepad") == 0)) - snprintf(app->config.current_model, sizeof(app->config.current_model), "%s", first_created); + snprintf(app->session.active_model_id, sizeof(app->session.active_model_id), "%s", first_created); free(discovery); return result; } -BongoCatResult bongo_cat_import_portable_mver(BongoCatApp *app, +BongoCatResult bongo_cat_import_nearby_mver_root(BongoCatApp *app, const char *root, BongoCatError *error) { - return import_portable_mver(app, root, false, error); + return import_nearby_mver_root(app, root, false, error); } -BongoCatResult bongo_cat_import_portable_mver_scan(BongoCatApp *app, +BongoCatResult bongo_cat_import_nearby_mver_scan(BongoCatApp *app, const char *root, BongoCatError *error) { - return import_portable_mver(app, root, true, error); + return import_nearby_mver_root(app, root, true, error); } BongoCatResult bongo_cat_import_nearby_mver(BongoCatApp *app, const char *root, BongoCatError *error) { if (!app || !root) return BONGO_CAT_ERROR_ARGUMENT; size_t before = app->models.count; - BongoCatResult result = bongo_cat_import_portable_mver(app, root, error); + BongoCatResult result = bongo_cat_import_nearby_mver_root(app, root, error); if (result != BONGO_CAT_OK || app->models.count != before) return result; char parent[BONGO_CAT_PATH_CAP]; if (!parent_path(root, parent, sizeof(parent)) || strcmp(parent, root) == 0) return BONGO_CAT_OK; if (error) *error = (BongoCatError){0}; - return bongo_cat_import_portable_mver(app, parent, error); + return bongo_cat_import_nearby_mver_root(app, parent, error); } diff --git a/src/runtime/model_import_portable_identity.c b/src/runtime/model_import_nearby_identity.c similarity index 97% rename from src/runtime/model_import_portable_identity.c rename to src/runtime/model_import_nearby_identity.c index 7d48aa02..82fb5d78 100644 --- a/src/runtime/model_import_portable_identity.c +++ b/src/runtime/model_import_nearby_identity.c @@ -1,4 +1,4 @@ -#include "model_import_portable_internal.h" +#include "model_import_nearby_internal.h" #include "bongo_cat/path.h" #include "bongo_cat/sha256.h" @@ -115,7 +115,7 @@ bool bongo_cat_import_candidate_digest(const BongoCatImportCandidate *candidate, return true; } -bool bongo_cat_portable_identity(const BongoCatImportCandidate *candidate, +bool bongo_cat_nearby_identity(const BongoCatImportCandidate *candidate, char output[65], BongoCatError *error) { return bongo_cat_import_candidate_digest(candidate, output, error); } diff --git a/src/runtime/model_import_nearby_internal.h b/src/runtime/model_import_nearby_internal.h new file mode 100644 index 00000000..366e7e04 --- /dev/null +++ b/src/runtime/model_import_nearby_internal.h @@ -0,0 +1,13 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_NEARBY_INTERNAL_H +#define BONGO_CAT_MODEL_IMPORT_NEARBY_INTERNAL_H + +#include "model_import.h" + +#define BONGO_CAT_NEARBY_CACHE_MARKER ".bongo-cat-cache.json" +#define BONGO_CAT_NEARBY_CACHE_SCHEMA 1 + +bool bongo_cat_nearby_identity(const BongoCatImportCandidate *candidate, + char output[65], BongoCatError *error); +bool bongo_cat_nearby_signature(const BongoCatImportCandidate *candidate, + char output[65], BongoCatError *error); +#endif diff --git a/src/runtime/model_import_portable_scan.c b/src/runtime/model_import_nearby_scan.c similarity index 76% rename from src/runtime/model_import_portable_scan.c rename to src/runtime/model_import_nearby_scan.c index 47772e42..1c84a6f5 100644 --- a/src/runtime/model_import_portable_scan.c +++ b/src/runtime/model_import_nearby_scan.c @@ -6,44 +6,44 @@ #include #include -#define PORTABLE_CHILD_CAP 64 -#define PORTABLE_ENTRY_CAP 256 -#define PORTABLE_NODE_CAP 256 -#define PORTABLE_SCAN_DEPTH 3 -#define PORTABLE_SCAN_BUDGET_NS 2000000000ull +#define NEARBY_CHILD_CAP 64 +#define NEARBY_ENTRY_CAP 256 +#define NEARBY_NODE_CAP 256 +#define NEARBY_SCAN_DEPTH 3 +#define NEARBY_SCAN_BUDGET_NS 2000000000ull typedef struct ChildList { - char paths[PORTABLE_CHILD_CAP][BONGO_CAT_PATH_CAP]; + char paths[NEARBY_CHILD_CAP][BONGO_CAT_PATH_CAP]; size_t count, entries; bool limited; } ChildList; -typedef struct PortableWorkspace { +typedef struct NearbyWorkspace { BongoCatImportDiscovery discovery; ChildList children; -} PortableWorkspace; +} NearbyWorkspace; -typedef struct PortableScan { - BongoCatPortableVisitor visitor; +typedef struct NearbyScan { + BongoCatNearbyVisitor visitor; void *userdata; BongoCatError *error; size_t nodes; uint64_t deadline; bool limited; -} PortableScan; +} NearbyScan; -typedef struct PortableQueue { - char paths[PORTABLE_NODE_CAP][BONGO_CAT_PATH_CAP]; - unsigned char depths[PORTABLE_NODE_CAP], priorities[PORTABLE_NODE_CAP]; +typedef struct NearbyQueue { + char paths[NEARBY_NODE_CAP][BONGO_CAT_PATH_CAP]; + unsigned char depths[NEARBY_NODE_CAP], priorities[NEARBY_NODE_CAP]; size_t head, count; -} PortableQueue; +} NearbyQueue; typedef struct ModelHint { size_t entries; bool found; } ModelHint; static BongoCatPathVisit collect_child(void *userdata, const char *dirname, const char *name) { ChildList *list = userdata; - if (++list->entries > PORTABLE_ENTRY_CAP || list->count >= PORTABLE_CHILD_CAP) { + if (++list->entries > NEARBY_ENTRY_CAP || list->count >= NEARBY_CHILD_CAP) { list->limited = true; return BONGO_CAT_PATH_SUCCESS; } if (name[0] == '.') return BONGO_CAT_PATH_CONTINUE; @@ -62,8 +62,8 @@ static int compare_path(const void *left, const void *right) { #endif } -static bool within_budget(PortableScan *scan) { - if (scan->nodes >= PORTABLE_NODE_CAP || SDL_GetTicksNS() >= scan->deadline) { +static bool within_budget(NearbyScan *scan) { + if (scan->nodes >= NEARBY_NODE_CAP || SDL_GetTicksNS() >= scan->deadline) { scan->limited = true; return false; } scan->nodes++; return true; @@ -78,7 +78,7 @@ static bool direct_image_root(const char *path) { static BongoCatPathVisit find_model_hint(void *userdata, const char *dirname, const char *name) { ModelHint *hint = userdata; - if (++hint->entries > PORTABLE_CHILD_CAP) return BONGO_CAT_PATH_SUCCESS; + if (++hint->entries > NEARBY_CHILD_CAP) return BONGO_CAT_PATH_SUCCESS; char path[BONGO_CAT_PATH_CAP]; if (name[0] != '.' && bongo_cat_path_join(path, sizeof(path), dirname, name) && bongo_cat_path_is_dir(path) && direct_image_root(path)) { @@ -94,10 +94,10 @@ static bool model_hint(const char *path) { return hint.found; } -static void enqueue_children(PortableScan *scan, PortableQueue *queue, +static void enqueue_children(NearbyScan *scan, NearbyQueue *queue, const ChildList *children, int depth) { for (size_t i = 0; i < children->count; ++i) { - if (queue->count >= PORTABLE_NODE_CAP) { + if (queue->count >= NEARBY_NODE_CAP) { scan->limited = true; return; } snprintf(queue->paths[queue->count], BONGO_CAT_PATH_CAP, "%s", @@ -107,7 +107,7 @@ static void enqueue_children(PortableScan *scan, PortableQueue *queue, } } -static void prioritize_next(PortableQueue *queue) { +static void prioritize_next(NearbyQueue *queue) { size_t preferred = queue->head; while (preferred < queue->count && !queue->priorities[preferred]) preferred++; if (preferred >= queue->count || preferred == queue->head) return; @@ -122,13 +122,13 @@ static void prioritize_next(PortableQueue *queue) { queue->priorities[queue->head] = 1; } -static BongoCatResult scan_node(PortableScan *scan, PortableQueue *queue, +static BongoCatResult scan_node(NearbyScan *scan, NearbyQueue *queue, const char *source, int depth) { if (!within_budget(scan)) return BONGO_CAT_OK; - PortableWorkspace *work = calloc(1, sizeof(*work)); + NearbyWorkspace *work = calloc(1, sizeof(*work)); if (!work) { bongo_cat_error_set(scan->error, BONGO_CAT_ERROR_MEMORY, - "Cannot allocate portable model scan workspace"); + "Cannot allocate nearby model scan workspace"); return BONGO_CAT_ERROR_MEMORY; } BongoCatError local = {0}; @@ -147,7 +147,7 @@ static BongoCatResult scan_node(PortableScan *scan, PortableQueue *queue, else scan->deadline = UINT64_MAX; free(work); return result; } - if (depth < PORTABLE_SCAN_DEPTH && + if (depth < NEARBY_SCAN_DEPTH && bongo_cat_path_enumerate(source, collect_child, &work->children)) { if (work->children.limited) scan->limited = true; qsort(work->children.paths, work->children.count, @@ -157,14 +157,14 @@ static BongoCatResult scan_node(PortableScan *scan, PortableQueue *queue, free(work); return BONGO_CAT_OK; } -BongoCatResult bongo_cat_import_portable_scan(const char *root, - BongoCatPortableVisitor visitor, void *userdata, BongoCatError *error) { +BongoCatResult bongo_cat_import_nearby_scan(const char *root, + BongoCatNearbyVisitor visitor, void *userdata, BongoCatError *error) { if (!root || !visitor) return BONGO_CAT_ERROR_ARGUMENT; - PortableWorkspace *work = calloc(1, sizeof(*work)); - PortableQueue *queue = calloc(1, sizeof(*queue)); + NearbyWorkspace *work = calloc(1, sizeof(*work)); + NearbyQueue *queue = calloc(1, sizeof(*queue)); if (!work || !queue) { free(work); free(queue); return BONGO_CAT_ERROR_MEMORY; } - PortableScan scan = {visitor, userdata, error, 0, - SDL_GetTicksNS() + PORTABLE_SCAN_BUDGET_NS, false}; + NearbyScan scan = {visitor, userdata, error, 0, + SDL_GetTicksNS() + NEARBY_SCAN_BUDGET_NS, false}; BongoCatResult result = BONGO_CAT_OK; if (bongo_cat_path_enumerate(root, collect_child, &work->children)) { if (work->children.limited) scan.limited = true; @@ -180,6 +180,6 @@ BongoCatResult bongo_cat_import_portable_scan(const char *root, } } if (scan.limited) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "Portable model discovery reached its startup scan budget"); + "Nearby model discovery reached its startup scan budget"); free(queue); free(work); return result; } diff --git a/src/runtime/model_import_portable_signature.c b/src/runtime/model_import_nearby_signature.c similarity index 86% rename from src/runtime/model_import_portable_signature.c rename to src/runtime/model_import_nearby_signature.c index 0a56b9fc..ae13dc85 100644 --- a/src/runtime/model_import_portable_signature.c +++ b/src/runtime/model_import_nearby_signature.c @@ -1,16 +1,16 @@ -#include "model_import_portable_internal.h" +#include "model_import_nearby_internal.h" #include "bongo_cat/path.h" #include "bongo_cat/sha256.h" #include #include -typedef struct PortableStamp { +typedef struct NearbyStamp { uint64_t sum, exclusive, bytes, latest, files, entries; -} PortableStamp; +} NearbyStamp; typedef struct StampWalk { - PortableStamp *stamp; + NearbyStamp *stamp; int depth; } StampWalk; @@ -32,7 +32,7 @@ static uint64_t mix(uint64_t value) { return value ^ (value >> 31); } -static bool stamp_path(const char *path, PortableStamp *stamp, int depth); +static bool stamp_path(const char *path, NearbyStamp *stamp, int depth); static BongoCatPathVisit stamp_item(void *userdata, const char *dirname, const char *name) { @@ -44,7 +44,7 @@ static BongoCatPathVisit stamp_item(void *userdata, ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; } -static bool stamp_path(const char *path, PortableStamp *stamp, int depth) { +static bool stamp_path(const char *path, NearbyStamp *stamp, int depth) { if (bongo_cat_path_is_dir(path)) { if (depth >= 24) return false; StampWalk walk = {stamp, depth + 1}; @@ -63,18 +63,18 @@ static bool stamp_path(const char *path, PortableStamp *stamp, int depth) { return true; } -bool bongo_cat_portable_signature(const BongoCatImportCandidate *candidate, +bool bongo_cat_nearby_signature(const BongoCatImportCandidate *candidate, char output[65], BongoCatError *error) { char config_hash[65], summary[256]; if (bongo_cat_sha256_file(candidate->config, config_hash, error) != BONGO_CAT_OK) return false; - PortableStamp stamp = {0}; + NearbyStamp stamp = {0}; if (!stamp_path(candidate->directory, &stamp, 0) || !stamp_path(candidate->assets, &stamp, 0) || (candidate->overrides[0] && !stamp_path(candidate->overrides, &stamp, 0))) { bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot inspect portable Mver model assets: %s", + "Cannot inspect nearby Mver model assets: %s", candidate->package_root); return false; } diff --git a/src/runtime/model_import_portable_internal.h b/src/runtime/model_import_portable_internal.h deleted file mode 100644 index ffe4f020..00000000 --- a/src/runtime/model_import_portable_internal.h +++ /dev/null @@ -1,16 +0,0 @@ -#ifndef BONGO_CAT_MODEL_IMPORT_PORTABLE_INTERNAL_H -#define BONGO_CAT_MODEL_IMPORT_PORTABLE_INTERNAL_H - -#include "model_import.h" - -#define BONGO_CAT_PORTABLE_MARKER ".bongo-cat-portable.json" -#define BONGO_CAT_PORTABLE_SCHEMA_VERSION 10 - -bool bongo_cat_portable_identity(const BongoCatImportCandidate *candidate, - char output[65], BongoCatError *error); -bool bongo_cat_portable_signature(const BongoCatImportCandidate *candidate, - char output[65], BongoCatError *error); -void bongo_cat_portable_migrate_config(BongoCatApp *app, - const char *cache_root); - -#endif diff --git a/src/runtime/model_import_portable_migration.c b/src/runtime/model_import_portable_migration.c deleted file mode 100644 index f4c6a93a..00000000 --- a/src/runtime/model_import_portable_migration.c +++ /dev/null @@ -1,165 +0,0 @@ -#include "model_import_portable_internal.h" -#include "runtime.h" -#include "bongo_cat/json.h" -#include "bongo_cat/path.h" - -#include -#include -#include -#include - -#define PORTABLE_CACHE_CAP 256 - -typedef struct PortableCacheEntry { - char id[BONGO_CAT_ID_CAP]; - char source[BONGO_CAT_PATH_CAP]; - char identity[65]; - BongoCatModelMode model_mode; - bool active; -} PortableCacheEntry; - -typedef struct PortableCacheList { - BongoCatApp *app; - PortableCacheEntry entries[PORTABLE_CACHE_CAP]; - size_t count; - bool overflow; -} PortableCacheList; - -static bool parse_mode(const char *value, BongoCatModelMode *model_mode) { - for (int i = 0; i <= BONGO_CAT_MODE_GAMEPAD; ++i) - if (value && strcmp(value, bongo_cat_mode_name((BongoCatModelMode)i)) == 0) { - *model_mode = (BongoCatModelMode)i; return true; - } - return false; -} - -static BongoCatPathVisit collect_cache(void *userdata, - const char *dirname, const char *name) { - PortableCacheList *list = userdata; - char directory[BONGO_CAT_PATH_CAP], marker[BONGO_CAT_PATH_CAP]; - if (name[0] == '.' || !bongo_cat_path_join(directory, sizeof(directory), - dirname, name) || !bongo_cat_path_is_dir(directory) || - !bongo_cat_path_join(marker, sizeof(marker), directory, - BONGO_CAT_PORTABLE_MARKER)) return BONGO_CAT_PATH_CONTINUE; - yyjson_doc *document = bongo_cat_json_read_file(marker, 0, NULL); - yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; - const char *kind = yyjson_get_str(yyjson_obj_get(root, "kind")); - const char *source = yyjson_get_str(yyjson_obj_get(root, "source")); - const char *identity = yyjson_get_str(yyjson_obj_get(root, "identity")); - const char *mode_name = yyjson_get_str(yyjson_obj_get(root, "mode")); - BongoCatModelMode model_mode = BONGO_CAT_MODE_STANDARD; - bool valid = yyjson_is_obj(root) && kind && source && identity && - strlen(identity) == 64 && strcmp(kind, "portable-mver") == 0 && - parse_mode(mode_name, &model_mode); - if (valid && list->count >= PORTABLE_CACHE_CAP) list->overflow = true; - if (valid && !list->overflow) { - PortableCacheEntry *entry = &list->entries[list->count++]; - snprintf(entry->id, sizeof(entry->id), "%s", name); - snprintf(entry->source, sizeof(entry->source), "%s", source); - snprintf(entry->identity, sizeof(entry->identity), "%s", identity); - entry->model_mode = model_mode; - entry->active = bongo_cat_models_find(&list->app->models, entry->id) != NULL; - } - yyjson_doc_free(document); - return list->overflow ? BONGO_CAT_PATH_SUCCESS : BONGO_CAT_PATH_CONTINUE; -} - -static bool shortcut_prefix(const char *id, const char *model_id) { - size_t length = strlen(model_id); - return strncmp(id, model_id, length) == 0 && id[length] == ':'; -} - -static bool config_references(const BongoCatConfig *config, const char *id) { - if (strcmp(config->current_model, id) == 0 || - bongo_cat_config_model_label(config, id)) return true; - for (size_t i = 0; i < config->behavior_shortcut_count; ++i) - if (shortcut_prefix(config->behavior_shortcuts[i].id, id)) return true; - return false; -} - -static void migrate_label(BongoCatConfig *config, const char *old_id, - const char *new_id) { - const char *new_label = bongo_cat_config_model_label(config, new_id); - for (size_t i = 0; i < config->model_label_count; ++i) { - BongoCatModelLabel *label = &config->model_labels[i]; - if (strcmp(label->id, old_id) != 0) continue; - if (!new_label) snprintf(label->id, sizeof(label->id), "%s", new_id); - else bongo_cat_config_set_model_label(config, old_id, ""); - return; - } -} - -static size_t shortcut_at(const BongoCatConfig *config, const char *id) { - for (size_t i = 0; i < config->behavior_shortcut_count; ++i) - if (strcmp(config->behavior_shortcuts[i].id, id) == 0) return i; - return config->behavior_shortcut_count; -} - -static void remove_shortcut(BongoCatConfig *config, size_t index) { - config->behavior_shortcut_count--; - if (index < config->behavior_shortcut_count) - memmove(&config->behavior_shortcuts[index], - &config->behavior_shortcuts[index + 1], - (config->behavior_shortcut_count - index) * - sizeof(config->behavior_shortcuts[0])); - memset(&config->behavior_shortcuts[config->behavior_shortcut_count], 0, - sizeof(config->behavior_shortcuts[0])); -} - -static void migrate_shortcuts(BongoCatConfig *config, const char *old_id, - const char *new_id) { - size_t old_length = strlen(old_id); - for (size_t i = 0; i < config->behavior_shortcut_count;) { - BongoCatBehaviorShortcut *old = &config->behavior_shortcuts[i]; - if (!shortcut_prefix(old->id, old_id)) { i++; continue; } - char id[BONGO_CAT_BEHAVIOR_ID_CAP]; - snprintf(id, sizeof(id), "%s%s", new_id, old->id + old_length); - size_t target = shortcut_at(config, id); - if (target == config->behavior_shortcut_count) { - snprintf(old->id, sizeof(old->id), "%s", id); i++; continue; - } - BongoCatBehaviorShortcut *existing = &config->behavior_shortcuts[target]; - if (!existing->shortcut[0]) snprintf(existing->shortcut, - sizeof(existing->shortcut), "%s", old->shortcut); - if (!existing->label[0]) snprintf(existing->label, - sizeof(existing->label), "%s", old->label); - remove_shortcut(config, i); - } -} - -static void migrate(BongoCatApp *app, const char *old_id, - const char *new_id) { - migrate_label(&app->config, old_id, new_id); - migrate_shortcuts(&app->config, old_id, new_id); - if (strcmp(app->config.current_model, old_id) == 0) - snprintf(app->config.current_model, sizeof(app->config.current_model), - "%s", new_id); - SDL_Log("Migrated portable model preferences from %s to %s", old_id, new_id); -} - -void bongo_cat_portable_migrate_config(BongoCatApp *app, - const char *cache_root) { - PortableCacheList *list = calloc(1, sizeof(*list)); - if (!app || !cache_root || !list) { free(list); return; } - list->app = app; - if (!bongo_cat_path_enumerate(cache_root, collect_cache, list) || - list->overflow) { free(list); return; } - for (size_t i = 0; i < list->count; ++i) { - PortableCacheEntry *old = &list->entries[i]; - if (old->active || bongo_cat_path_is_dir(old->source) || - !config_references(&app->config, old->id)) continue; - PortableCacheEntry *replacement = NULL; - size_t candidates = 0, active = 0; - for (size_t j = 0; j < list->count; ++j) { - PortableCacheEntry *item = &list->entries[j]; - if (item == old || item->model_mode != old->model_mode || - strcmp(item->identity, old->identity) != 0 || - (!item->active && !bongo_cat_path_is_dir(item->source))) continue; - candidates++; - if (item->active) { replacement = item; active++; } - } - if (candidates == 1 && active == 1) - migrate(app, old->id, replacement->id); - } - free(list); -} diff --git a/src/runtime/mouse.c b/src/runtime/mouse.c index 59eec97e..8858dd29 100644 --- a/src/runtime/mouse.c +++ b/src/runtime/mouse.c @@ -8,7 +8,7 @@ static void audit_mouse(BongoCatApp *app, double x, double y) { if (!app->smoke_input_audit) return; char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), app->data_root, "input-audit.txt")) return; + if (!bongo_cat_path_join(path, sizeof(path), app->state_root, "input-audit.txt")) return; FILE *file = bongo_cat_file_open(path, "ab"); if (!file) return; fprintf(file, "mouse x=%.2f y=%.2f\n", x, y); @@ -28,17 +28,17 @@ void bongo_cat_app_track_hover(BongoCatApp *app, double x, double y) { if (inside == app->hover_inside) return; app->hover_inside = inside; app->hover_deadline_ns = inside ? SDL_GetTicksNS() + - (uint64_t)(app->config.window.hide_delay_seconds * 1000000000.0) : 0; + (uint64_t)(app->settings.window.hide_delay_seconds * 1000000000.0) : 0; if (!inside && app->hover_hidden) { bongo_cat_platform_set_opacity(&app->platform, - app->config.window.opacity_percent / 100.0f); + app->session.window.opacity_percent / 100.0f); app->hover_hidden = false; bongo_cat_window_sync_click_through(app); } } void bongo_cat_app_update_hover(BongoCatApp *app, uint64_t now) { - if (!app->config.window.hide_on_hover || !app->hover_inside || app->hover_hidden || + if (!app->settings.window.hide_on_hover || !app->hover_inside || app->hover_hidden || !app->hover_deadline_ns || now < app->hover_deadline_ns) return; bongo_cat_platform_set_opacity(&app->platform, 0.0f); app->hover_hidden = true; @@ -93,10 +93,10 @@ void bongo_cat_app_apply_mouse(BongoCatApp *app) { double model_x = target_x, model_y = target_y; bool model_moved = moved; if (app->model_render_options.mver_projection && - !app->config.model.mouse_centered && + !app->settings.model.mouse_centered && !bongo_cat_app_map_mver_pointer(app, target_x, target_y, &model_x, &model_y, &model_moved)) return; - if (app->config.model.ignore_mouse || (!model_moved && !button_changed)) return; + if (app->settings.model.ignore_mouse || (!model_moved && !button_changed)) return; bongo_cat_app_apply_mouse_coordinates(app, model_x, model_y, target_x, target_y); } diff --git a/src/runtime/mouse_mapping.c b/src/runtime/mouse_mapping.c index be0d56b5..ce11cd66 100644 --- a/src/runtime/mouse_mapping.c +++ b/src/runtime/mouse_mapping.c @@ -16,7 +16,7 @@ static void set_parameter(BongoCatApp *app, const char *id, size_t length = strlen(id); char axis = length ? id[length - 1] : 'X'; float value = bongo_cat_mouse_parameter_value(range.minimum, range.maximum, - x_ratio, y_ratio, axis, app->config.model.mouse_mirror); + x_ratio, y_ratio, axis, app->settings.model.mouse_mirror); bongo_cat_live2d_set_parameter(app->live2d, id, value); } @@ -133,7 +133,7 @@ static bool model_pointer_ratios(BongoCatApp *app, double x, double y, void bongo_cat_app_apply_mouse_coordinates(BongoCatApp *app, double hand_x, double hand_y, double gaze_x, double gaze_y) { SDL_Point point = {(int)hand_x, (int)hand_y}; SDL_Rect bounds; - bool screen_mapped = app->config.model.mouse_centered; + bool screen_mapped = app->settings.model.mouse_centered; if (!(screen_mapped ? model_pointer_bounds(app, &bounds, NULL, NULL) : mver_pointer_bounds(app, &bounds))) { SDL_DisplayID display = SDL_GetDisplayForPoint(&point); @@ -146,7 +146,7 @@ void bongo_cat_app_apply_mouse_coordinates(BongoCatApp *app, double hand_x, if (!bongo_cat_mver_pointer_ratios(hand_x, hand_y, &pointer_bounds, &hand_x_ratio, &hand_y_ratio)) return; float gaze_x_ratio = hand_x_ratio, gaze_y_ratio = hand_y_ratio; - if (app->config.model.mouse_centered) + if (app->settings.model.mouse_centered) model_pointer_ratios(app, gaze_x, gaze_y, &gaze_x_ratio, &gaze_y_ratio); bool exact_pointer = bongo_cat_overlay_mver_pointer_enabled(app->overlay); bool mver = app->model_render_options.mver_projection; @@ -156,7 +156,7 @@ void bongo_cat_app_apply_mouse_coordinates(BongoCatApp *app, double hand_x, float drag_x = mver ? 2.0f * mver_x - 1.0f : 1.0f - 2.0f * gaze_x_ratio; float drag_y = 1.0f - 2.0f * gaze_y_ratio; - if (!mver && app->config.model.mouse_mirror) drag_x = -drag_x; + if (!mver && app->settings.model.mouse_mirror) drag_x = -drag_x; bongo_cat_overlay_set_mver_pointer(app->overlay, hand_x_ratio, hand_y_ratio, app->left_mouse_down, app->right_mouse_down, app->side_mouse_down); if (!exact_pointer) { @@ -178,7 +178,7 @@ void bongo_cat_app_reset_pointer_tracking(BongoCatApp *app) { void bongo_cat_app_apply_mouse_position(BongoCatApp *app, double x, double y, float elapsed_seconds) { - if (!app || app->config.model.ignore_mouse) return; + if (!app || app->settings.model.ignore_mouse) return; (void)elapsed_seconds; bongo_cat_app_apply_mouse_coordinates(app, x, y, x, y); } @@ -187,8 +187,8 @@ bool bongo_cat_app_audit_screen_pointer(BongoCatApp *app) { SDL_Rect bounds; SDL_DisplayID display = SDL_GetPrimaryDisplay(); if (!app || !display || !SDL_GetDisplayBounds(display, &bounds)) return false; - bool mouse_centered = app->config.model.mouse_centered; - app->config.model.mouse_centered = false; + bool mouse_centered = app->settings.model.mouse_centered; + app->settings.model.mouse_centered = false; bongo_cat_app_apply_mouse_position(app, bounds.x + bounds.w * 0.5, bounds.y + bounds.h * 0.5, 1.0f / 60.0f); for (int frame = 0; frame < 90; ++frame) @@ -197,17 +197,17 @@ bool bongo_cat_app_audit_screen_pointer(BongoCatApp *app) { bool passed = bongo_cat_live2d_parameter(app->live2d, "ParamAngleX", &x) && bongo_cat_live2d_parameter(app->live2d, "ParamAngleY", &y) && fabsf(x.value) < 0.5f && fabsf(y.value) < 0.5f; - app->config.model.mouse_centered = mouse_centered; + app->settings.model.mouse_centered = mouse_centered; return passed; } bool bongo_cat_app_audit_display_pointer(BongoCatApp *app) { SDL_Rect bounds; if (!app) return false; - bool centered = app->config.model.mouse_centered; - app->config.model.mouse_centered = true; + bool centered = app->settings.model.mouse_centered; + app->settings.model.mouse_centered = true; if (!model_pointer_bounds(app, &bounds, NULL, NULL)) { - app->config.model.mouse_centered = centered; + app->settings.model.mouse_centered = centered; return false; } BongoCatParameterRange tl_x = {0}, tl_y = {0}, br_x = {0}, br_y = {0}; @@ -218,7 +218,7 @@ bool bongo_cat_app_audit_display_pointer(BongoCatApp *app) { bounds.y + bounds.h - 1, 0.0f); passed = bongo_cat_live2d_parameter(app->live2d, "ParamMouseX", &br_x) && bongo_cat_live2d_parameter(app->live2d, "ParamMouseY", &br_y) && passed; - app->config.model.mouse_centered = centered; + app->settings.model.mouse_centered = centered; return passed && tl_x.value < -20.0f && tl_y.value > 20.0f && br_x.value > 20.0f && br_y.value < -20.0f; } diff --git a/src/runtime/runtime_flow.c b/src/runtime/runtime_flow.c index b7092814..a434267c 100644 --- a/src/runtime/runtime_flow.c +++ b/src/runtime/runtime_flow.c @@ -8,7 +8,7 @@ static void mark(BongoCatApp *app, const char *name) { char path[BONGO_CAT_PATH_CAP]; - bongo_cat_path_join(path, sizeof(path), app->data_root, "runtime-flow-stage.txt"); + bongo_cat_path_join(path, sizeof(path), app->state_root, "runtime-flow-stage.txt"); FILE *file = bongo_cat_file_open(path, "wb"); if (file) { fputs(name, file); fclose(file); } } @@ -19,7 +19,7 @@ static void scale(BongoCatApp *app, float value) { } static void opacity(BongoCatApp *app, float value) { - app->config.window.opacity_percent = value; + app->session.window.opacity_percent = value; bongo_cat_platform_set_opacity(&app->platform, value / 100.0f); } @@ -37,7 +37,7 @@ void bongo_cat_runtime_flow_update(BongoCatApp *app, uint64_t now) { if (!app->smoke_runtime_flow_ns) { app->smoke_runtime_flow_ns = now; snprintf(app->smoke_runtime_model, sizeof(app->smoke_runtime_model), "%.*s", - (int)sizeof(app->smoke_runtime_model) - 1, app->config.current_model); + (int)sizeof(app->smoke_runtime_model) - 1, app->session.active_model_id); mark(app, "startup"); return; } uint64_t elapsed = now - app->smoke_runtime_flow_ns; diff --git a/src/runtime/shortcuts.c b/src/runtime/shortcuts.c index f9dae451..42316cba 100644 --- a/src/runtime/shortcuts.c +++ b/src/runtime/shortcuts.c @@ -8,7 +8,7 @@ #include static void visible(BongoCatApp *app) { - bongo_cat_window_set_visible(app, !app->config.window.visible); + bongo_cat_window_set_visible(app, !app->session.window.visible); } bool bongo_cat_app_run_behavior(BongoCatApp *app, @@ -38,8 +38,8 @@ bool bongo_cat_app_run_behavior(BongoCatApp *app, static bool behavior_shortcut(BongoCatApp *app, const BongoCatInputEvent *event) { bool handled = false; - for (size_t i = 0; i < app->config.behavior_shortcut_count; ++i) { - BongoCatBehaviorShortcut *shortcut = &app->config.behavior_shortcuts[i]; + for (size_t i = 0; i < app->settings.behavior_shortcut_count; ++i) { + BongoCatBehaviorShortcut *shortcut = &app->settings.behavior_shortcuts[i]; for (size_t j = 0; j < app->behaviors.count; ++j) { BongoCatBehaviorEntry *behavior = &app->behaviors.entries[j]; if (strcmp(shortcut->id, behavior->id) != 0) continue; @@ -78,7 +78,7 @@ void bongo_cat_app_shortcuts(BongoCatApp *app, const BongoCatInputEvent *event) behavior_shortcut(app, event); return; } - BongoCatShortcutOptions *shortcuts = &app->config.shortcuts; + BongoCatShortcutPreferences *shortcuts = &app->settings.shortcuts; if (bongo_cat_shortcut_matches(&app->shortcut_state, event, shortcuts->visible_cat)) { visible(app); } else if (bongo_cat_shortcut_matches(&app->shortcut_state, event, @@ -86,19 +86,19 @@ void bongo_cat_app_shortcuts(BongoCatApp *app, const BongoCatInputEvent *event) bongo_cat_preferences_visible(app->preferences) ? bongo_cat_preferences_close(app->preferences) : bongo_cat_preferences_show(app->preferences); } else if (bongo_cat_shortcut_matches(&app->shortcut_state, event, shortcuts->mirror)) { - app->config.model.mirror = !app->config.model.mirror; + app->settings.model.mirror = !app->settings.model.mirror; app->model_pointer_anchor_ready = false; app->dirty = true; bongo_cat_preferences_invalidate(app->preferences); } else if (bongo_cat_shortcut_matches(&app->shortcut_state, event, shortcuts->pass_through)) { - app->config.window.pass_through = !app->config.window.pass_through; + app->settings.window.pass_through = !app->settings.window.pass_through; bongo_cat_window_mark_hit_dirty(app); bongo_cat_window_sync_click_through(app); bongo_cat_preferences_invalidate(app->preferences); } else if (bongo_cat_shortcut_matches(&app->shortcut_state, event, shortcuts->always_on_top)) { - app->config.window.always_on_top = !app->config.window.always_on_top; - bongo_cat_platform_set_always_on_top(&app->platform, app->config.window.always_on_top); + app->settings.window.always_on_top = !app->settings.window.always_on_top; + bongo_cat_platform_set_always_on_top(&app->platform, app->settings.window.always_on_top); bongo_cat_window_mark_hit_dirty(app); bongo_cat_window_sync_click_through(app); bongo_cat_preferences_invalidate(app->preferences); @@ -118,17 +118,17 @@ static void test_press(BongoCatApp *app, const char *name) { bool bongo_cat_app_shortcuts_self_test(BongoCatApp *app) { if (!app || !app->preferences) return false; - BongoCatShortcutOptions *keys = &app->config.shortcuts; + BongoCatShortcutPreferences *keys = &app->settings.shortcuts; snprintf(keys->visible_cat, sizeof(keys->visible_cat), "Control+B"); snprintf(keys->visible_preferences, sizeof(keys->visible_preferences), "Control+Comma"); snprintf(keys->mirror, sizeof(keys->mirror), "Control+M"); snprintf(keys->pass_through, sizeof(keys->pass_through), "Control+P"); snprintf(keys->always_on_top, sizeof(keys->always_on_top), "Control+T"); - app->config.window.visible = true; - app->config.model.mirror = false; + app->session.window.visible = true; + app->settings.model.mirror = false; app->model_pointer_anchor_ready = true; - app->config.window.pass_through = false; - app->config.window.always_on_top = false; + app->settings.window.pass_through = false; + app->settings.window.always_on_top = false; test_key(app, BONGO_CAT_INPUT_KEY_DOWN, "ControlLeft"); test_press(app, "KeyB"); test_press(app, "KeyM"); @@ -136,9 +136,9 @@ bool bongo_cat_app_shortcuts_self_test(BongoCatApp *app) { test_press(app, "KeyT"); test_press(app, "Comma"); test_key(app, BONGO_CAT_INPUT_KEY_UP, "ControlLeft"); - bool result = !app->config.window.visible && app->config.model.mirror && + bool result = !app->session.window.visible && app->settings.model.mirror && !app->model_pointer_anchor_ready && - app->config.window.pass_through && app->config.window.always_on_top && + app->settings.window.pass_through && app->settings.window.always_on_top && bongo_cat_preferences_visible(app->preferences); bongo_cat_preferences_close(app->preferences); return result; diff --git a/src/runtime/startup.c b/src/runtime/startup.c index 9930fd70..13c9758e 100644 --- a/src/runtime/startup.c +++ b/src/runtime/startup.c @@ -1,6 +1,7 @@ #include "runtime.h" #include "bongo_cat/file.h" #include "bongo_cat/path.h" +#include "storage_paths.h" #include #include @@ -9,12 +10,9 @@ #ifdef _WIN32 #include -#else -#include #endif static char startup_log_path[BONGO_CAT_PATH_CAP]; -static char desktop_log_path[BONGO_CAT_PATH_CAP]; static bool startup_is_ready; static void format_timestamp(char *target, size_t capacity) { @@ -49,7 +47,6 @@ static void SDLCALL log_output(void *userdata, int category, char timestamp[64]; format_timestamp(timestamp, sizeof(timestamp)); append_log(startup_log_path, timestamp, category, priority, message); - append_log(desktop_log_path, timestamp, category, priority, message); fprintf(stderr, "%s [%d:%d] %s\n", timestamp, category, (int)priority, message ? message : ""); } @@ -109,15 +106,9 @@ static bool parse_arguments(BongoCatApp *app, int argc, char **argv, uint64_t delay = strtoull(arg + 13, NULL, 10); app->smoke_deadline_ns = delay > UINT64_MAX / 1000000ull ? UINT64_MAX : delay * 1000000ull; - } else if (strncmp(arg, "--preferences=", 14) == 0) { - if (!store_argument(app->preferences_path, sizeof(app->preferences_path), - arg + 14, "Preferences", error)) return false; - } else if (strncmp(arg, "--session=", 10) == 0) { - if (!store_argument(app->session_path, sizeof(app->session_path), - arg + 10, "Session", error)) return false; - } else if (strncmp(arg, "--data-root=", 12) == 0) { - if (!store_argument(app->data_root, sizeof(app->data_root), - arg + 12, "Data", error)) return false; + } else if (strncmp(arg, "--storage-root=", 15) == 0) { + if (!store_argument(app->storage_root, sizeof(app->storage_root), + arg + 15, "Storage", error)) return false; } else if (strncmp(arg, "--ci-import=", 12) == 0) { if (!store_argument(app->smoke_import_path, sizeof(app->smoke_import_path), arg + 12, "Import", error)) return false; @@ -136,61 +127,14 @@ static bool parse_arguments(BongoCatApp *app, int argc, char **argv, return true; } -static bool writable_directory(const char *root) { - char probe[BONGO_CAT_PATH_CAP]; - if (!root || !root[0] || !bongo_cat_path_create_directory(root) || - !bongo_cat_path_join(probe, sizeof(probe), root, ".startup-write-test")) return false; - FILE *file = bongo_cat_file_open(probe, "wb"); - bool ok = file && fputc('1', file) != EOF; - if (file && fclose(file) != 0) ok = false; - bongo_cat_file_remove(probe); - return ok; -} - -static bool use_root(BongoCatApp *app, const char *root) { - if (!root || !root[0] || !store_argument(app->data_root, - sizeof(app->data_root), root, "Data", NULL)) return false; - if (writable_directory(app->data_root)) return true; - app->data_root[0] = '\0'; return false; -} - -static bool locate_data_root(BongoCatApp *app, BongoCatError *error) { - if (app->data_root[0]) { - if (writable_directory(app->data_root)) return true; - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "The selected data directory is not writable: %s", app->data_root); - return false; - } - char *preferred = SDL_GetPrefPath("BongoCat", BONGO_CAT_NAME); - bool found = use_root(app, preferred); - SDL_free(preferred); - char fallback[BONGO_CAT_PATH_CAP]; - const char *home = SDL_GetUserFolder(SDL_FOLDER_HOME); - if (!found && home && bongo_cat_path_join(fallback, sizeof(fallback), - home, ".bongo-cat")) found = use_root(app, fallback); - const char *temporary = SDL_getenv( -#ifdef _WIN32 - "TEMP"); const char *temporary_name = "BongoCat"; -#else - "TMPDIR"); if (!temporary || !temporary[0]) temporary = "/tmp"; - char temporary_name[64]; snprintf(temporary_name, sizeof(temporary_name), - "bongo-cat-%lu", (unsigned long)getuid()); -#endif - if (!found && temporary && bongo_cat_path_join(fallback, sizeof(fallback), - temporary, temporary_name)) found = use_root(app, fallback); - if (!found) bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "No writable application data directory is available"); - return found; -} - static void begin_log(BongoCatApp *app) { char previous[BONGO_CAT_PATH_CAP], stage_path[BONGO_CAT_PATH_CAP]; char interrupted[128] = {0}; bongo_cat_path_join(startup_log_path, sizeof(startup_log_path), - app->data_root, "startup.log"); - bongo_cat_path_join(previous, sizeof(previous), app->data_root, + app->log_root, "startup.log"); + bongo_cat_path_join(previous, sizeof(previous), app->log_root, "startup-previous.log"); - bongo_cat_path_join(stage_path, sizeof(stage_path), app->data_root, + bongo_cat_path_join(stage_path, sizeof(stage_path), app->state_root, "startup-stage.txt"); FILE *stage = bongo_cat_file_open(stage_path, "rb"); if (stage) { @@ -201,41 +145,24 @@ static void begin_log(BongoCatApp *app) { if (bongo_cat_path_is_file(startup_log_path)) bongo_cat_file_replace(startup_log_path, previous, true); reset_log(startup_log_path); -#ifdef _WIN32 - const char *desktop = SDL_GetUserFolder(SDL_FOLDER_DESKTOP); - if (!desktop || !bongo_cat_path_join(desktop_log_path, - sizeof(desktop_log_path), desktop, "BongoCat-OBS-diagnostic.log") || - !reset_log(desktop_log_path)) desktop_log_path[0] = '\0'; -#endif SDL_SetLogOutputFunction(log_output, NULL); - SDL_Log("Startup on %s; data=%s", SDL_GetPlatform(), app->data_root); -#ifdef _WIN32 - if (desktop_log_path[0]) - SDL_Log("Desktop diagnostic log: %s", desktop_log_path); - else SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "Desktop diagnostic log is unavailable; using the application data log"); -#endif + SDL_Log("Startup on %s; storage=%s", SDL_GetPlatform(), app->storage_root[0] + ? app->storage_root : app->config_root); if (interrupted[0]) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Previous startup ended before readiness at stage: %s", interrupted); } bool bongo_cat_startup_prepare(BongoCatApp *app, int argc, char **argv, BongoCatError *error) { - if (!parse_arguments(app, argc, argv, error) || !locate_data_root(app, error)) return false; - if ((!app->preferences_path[0] && !bongo_cat_path_join(app->preferences_path, - sizeof(app->preferences_path), app->data_root, "preferences.json")) || - (!app->session_path[0] && !bongo_cat_path_join(app->session_path, - sizeof(app->session_path), app->data_root, "session.json"))) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Configuration path is too long"); return false; - } + if (!parse_arguments(app, argc, argv, error) || + !bongo_cat_storage_paths_prepare(app, error)) return false; begin_log(app); bongo_cat_startup_stage(app, "paths-ready"); return true; } void bongo_cat_startup_stage(BongoCatApp *app, const char *stage) { - if (!app || !app->data_root[0] || !stage) return; + if (!app || !app->state_root[0] || !stage) return; char path[BONGO_CAT_PATH_CAP]; - if (bongo_cat_path_join(path, sizeof(path), app->data_root, "startup-stage.txt")) { + if (bongo_cat_path_join(path, sizeof(path), app->state_root, "startup-stage.txt")) { FILE *file = bongo_cat_file_open(path, "wb"); if (file) { fputs(stage, file); fclose(file); } } @@ -245,19 +172,18 @@ void bongo_cat_startup_stage(BongoCatApp *app, const char *stage) { void bongo_cat_startup_ready(BongoCatApp *app) { if (!app || startup_is_ready) return; startup_is_ready = true; - const char *names[] = {"startup-stage.txt", "startup-error.log"}; - for (size_t i = 0; i < 2; ++i) { - char path[BONGO_CAT_PATH_CAP]; - if (bongo_cat_path_join(path, sizeof(path), app->data_root, names[i])) - bongo_cat_file_remove(path); - } + char path[BONGO_CAT_PATH_CAP]; + if (bongo_cat_path_join(path, sizeof(path), app->state_root, + "startup-stage.txt")) bongo_cat_file_remove(path); + if (bongo_cat_path_join(path, sizeof(path), app->log_root, + "startup-error.log")) bongo_cat_file_remove(path); SDL_Log("Startup ready"); } static void write_error(BongoCatApp *app, const char *name, const char *message) { - if (!app || !app->data_root[0]) return; + if (!app || !app->log_root[0]) return; char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), app->data_root, name)) return; + if (!bongo_cat_path_join(path, sizeof(path), app->log_root, name)) return; FILE *file = bongo_cat_file_open(path, "wb"); if (file) { fputs(message, file); fputc('\n', file); fclose(file); } } @@ -280,8 +206,8 @@ void bongo_cat_startup_failure(BongoCatApp *app, const BongoCatError *error) { SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Startup failed: %s", message); char body[BONGO_CAT_PATH_CAP + 384]; snprintf(body, sizeof(body), "BongoCat could not start.\n\n%s%s%s", message, - app && app->data_root[0] ? "\n\nDiagnostic log:\n" : "", - app && app->data_root[0] ? startup_log_path : ""); + app && app->log_root[0] ? "\n\nDiagnostic log:\n" : "", + app && app->log_root[0] ? startup_log_path : ""); if (app && app->smoke) return; if (!SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, BONGO_CAT_NAME, body, app ? app->window : NULL)) { diff --git a/src/runtime/storage_paths.c b/src/runtime/storage_paths.c new file mode 100644 index 00000000..936685e6 --- /dev/null +++ b/src/runtime/storage_paths.c @@ -0,0 +1,120 @@ +#include "storage_paths.h" + +#include "bongo_cat/file.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +static bool child_path(char *target, size_t capacity, const char *root, + const char *child) { + return root && root[0] && + bongo_cat_path_join(target, capacity, root, child); +} + +static bool writable_directory(const char *root) { + char probe[BONGO_CAT_PATH_CAP]; + if (!root || !root[0] || !bongo_cat_path_create_directory(root) || + !bongo_cat_path_join(probe, sizeof(probe), root, + ".bongocat-write-probe")) return false; + FILE *file = bongo_cat_file_open(probe, "wb"); + bool written = file && fputc('1', file) != EOF; + if (file && fclose(file) != 0) written = false; + bongo_cat_file_remove(probe); + return written; +} + +static bool isolated_roots(BongoCatApp *app) { + return child_path(app->config_root, sizeof(app->config_root), + app->storage_root, "config") && + child_path(app->data_root, sizeof(app->data_root), + app->storage_root, "data") && + child_path(app->cache_root, sizeof(app->cache_root), + app->storage_root, "cache") && + child_path(app->state_root, sizeof(app->state_root), + app->storage_root, "state") && + child_path(app->log_root, sizeof(app->log_root), + app->storage_root, "logs"); +} + +#ifdef _WIN32 +static bool platform_roots(BongoCatApp *app) { + const char *roaming = SDL_getenv("APPDATA"); + const char *local = SDL_getenv("LOCALAPPDATA"); + char roaming_app[BONGO_CAT_PATH_CAP], local_app[BONGO_CAT_PATH_CAP]; + return child_path(roaming_app, sizeof(roaming_app), roaming, BONGO_CAT_NAME) && + child_path(local_app, sizeof(local_app), local, BONGO_CAT_NAME) && + child_path(app->config_root, sizeof(app->config_root), + roaming_app, "config") && + child_path(app->data_root, sizeof(app->data_root), + local_app, "data") && + child_path(app->cache_root, sizeof(app->cache_root), + local_app, "cache") && + child_path(app->state_root, sizeof(app->state_root), + local_app, "state") && + child_path(app->log_root, sizeof(app->log_root), + local_app, "logs"); +} +#elif defined(__APPLE__) +static bool platform_roots(BongoCatApp *app) { + const char *home = SDL_GetUserFolder(SDL_FOLDER_HOME); + char library[BONGO_CAT_PATH_CAP], support[BONGO_CAT_PATH_CAP]; + char app_support[BONGO_CAT_PATH_CAP]; + return child_path(library, sizeof(library), home, "Library") && + child_path(support, sizeof(support), library, "Application Support") && + child_path(app_support, sizeof(app_support), support, BONGO_CAT_NAME) && + child_path(app->config_root, sizeof(app->config_root), + app_support, "config") && + child_path(app->data_root, sizeof(app->data_root), app_support, "data") && + child_path(app->state_root, sizeof(app->state_root), app_support, "state") && + child_path(app->cache_root, sizeof(app->cache_root), + library, "Caches/BongoCat") && + child_path(app->log_root, sizeof(app->log_root), + library, "Logs/BongoCat"); +} +#else +static bool xdg_root(char *target, size_t capacity, const char *variable, + const char *home_suffix) { + const char *configured = SDL_getenv(variable); + if (configured && configured[0]) + return child_path(target, capacity, configured, "bongocat"); + const char *home = SDL_GetUserFolder(SDL_FOLDER_HOME); + char fallback[BONGO_CAT_PATH_CAP]; + return child_path(fallback, sizeof(fallback), home, home_suffix) && + child_path(target, capacity, fallback, "bongocat"); +} + +static bool platform_roots(BongoCatApp *app) { + if (!xdg_root(app->config_root, sizeof(app->config_root), + "XDG_CONFIG_HOME", ".config") || + !xdg_root(app->data_root, sizeof(app->data_root), + "XDG_DATA_HOME", ".local/share") || + !xdg_root(app->state_root, sizeof(app->state_root), + "XDG_STATE_HOME", ".local/state") || + !xdg_root(app->cache_root, sizeof(app->cache_root), + "XDG_CACHE_HOME", ".cache")) return false; + return child_path(app->log_root, sizeof(app->log_root), + app->state_root, "logs"); +} +#endif + +bool bongo_cat_storage_paths_prepare(BongoCatApp *app, + BongoCatError *error) { + if (!app) return false; + bool resolved = app->storage_root[0] + ? isolated_roots(app) : platform_roots(app); + const char *roots[] = {app->config_root, app->data_root, app->cache_root, + app->state_root, app->log_root}; + for (size_t i = 0; resolved && i < sizeof(roots) / sizeof(roots[0]); ++i) + resolved = writable_directory(roots[i]); + resolved = resolved && child_path(app->settings_path, + sizeof(app->settings_path), app->config_root, "settings.json") && + child_path(app->session_path, sizeof(app->session_path), + app->state_root, "session.json"); + if (resolved) return true; + app->settings_path[0] = app->session_path[0] = '\0'; + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Application storage directories are unavailable or not writable"); + return false; +} diff --git a/src/runtime/storage_paths.h b/src/runtime/storage_paths.h new file mode 100644 index 00000000..6fc5677a --- /dev/null +++ b/src/runtime/storage_paths.h @@ -0,0 +1,9 @@ +#ifndef BONGO_CAT_STORAGE_PATHS_H +#define BONGO_CAT_STORAGE_PATHS_H + +#include "bongo_cat/app.h" + +bool bongo_cat_storage_paths_prepare(BongoCatApp *app, + BongoCatError *error); + +#endif diff --git a/src/runtime/tray.c b/src/runtime/tray.c index 869a143f..caa2c9ed 100644 --- a/src/runtime/tray.c +++ b/src/runtime/tray.c @@ -31,7 +31,7 @@ static void on_visible(void *userdata, SDL_TrayEntry *entry) { (void)entry; BongoCatTray *tray = userdata; BongoCatApp *app = tray->app; - bongo_cat_window_set_visible(app, !app->config.window.visible); + bongo_cat_window_set_visible(app, !app->session.window.visible); bongo_cat_tray_sync(tray); bongo_cat_preferences_invalidate(app->preferences); } @@ -39,7 +39,7 @@ static void on_visible(void *userdata, SDL_TrayEntry *entry) { static void on_pass_through(void *userdata, SDL_TrayEntry *entry) { (void)entry; BongoCatTray *tray = userdata; - tray->app->config.window.pass_through = !tray->app->config.window.pass_through; + tray->app->settings.window.pass_through = !tray->app->settings.window.pass_through; bongo_cat_window_mark_hit_dirty(tray->app); bongo_cat_window_sync_click_through(tray->app); bongo_cat_tray_sync(tray); @@ -49,9 +49,9 @@ static void on_pass_through(void *userdata, SDL_TrayEntry *entry) { static void on_always_on_top(void *userdata, SDL_TrayEntry *entry) { (void)entry; BongoCatTray *tray = userdata; - tray->app->config.window.always_on_top = !tray->app->config.window.always_on_top; + tray->app->settings.window.always_on_top = !tray->app->settings.window.always_on_top; bongo_cat_platform_set_always_on_top(&tray->app->platform, - tray->app->config.window.always_on_top); + tray->app->settings.window.always_on_top); bongo_cat_window_mark_hit_dirty(tray->app); bongo_cat_window_sync_click_through(tray->app); bongo_cat_tray_sync(tray); @@ -150,7 +150,7 @@ static bool create_native_tray(BongoCatTray *tray, BongoCatError *error) { } BongoCatTray *bongo_cat_tray_create(BongoCatApp *app, BongoCatError *error) { - if (!app || !app->config.app.tray_visible) return NULL; + if (!app || !app->settings.app.tray_visible) return NULL; BongoCatTray *tray = calloc(1, sizeof(*tray)); if (!tray) return NULL; tray->app = app; @@ -198,18 +198,18 @@ static bool restore_native_tray(BongoCatTray *tray) { void bongo_cat_tray_sync(BongoCatTray *tray) { if (!tray || !restore_native_tray(tray)) return; BongoCatApp *app = tray->app; - if (tray->state_valid && tray->last_visible == app->config.window.visible && - tray->last_pass_through == app->config.window.pass_through && - tray->last_always_on_top == app->config.window.always_on_top && - tray->last_language == app->config.app.language) return; + if (tray->state_valid && tray->last_visible == app->session.window.visible && + tray->last_pass_through == app->settings.window.pass_through && + tray->last_always_on_top == app->settings.window.always_on_top && + tray->last_language == app->settings.app.language) return; tray->state_valid = true; - tray->last_visible = app->config.window.visible; - tray->last_pass_through = app->config.window.pass_through; - tray->last_always_on_top = app->config.window.always_on_top; - tray->last_language = app->config.app.language; - SDL_SetTrayEntryChecked(tray->visible, tray->app->config.window.visible); - SDL_SetTrayEntryChecked(tray->pass_through, tray->app->config.window.pass_through); - SDL_SetTrayEntryChecked(tray->always_on_top, tray->app->config.window.always_on_top); + tray->last_visible = app->session.window.visible; + tray->last_pass_through = app->settings.window.pass_through; + tray->last_always_on_top = app->settings.window.always_on_top; + tray->last_language = app->settings.app.language; + SDL_SetTrayEntryChecked(tray->visible, tray->app->session.window.visible); + SDL_SetTrayEntryChecked(tray->pass_through, tray->app->settings.window.pass_through); + SDL_SetTrayEntryChecked(tray->always_on_top, tray->app->settings.window.always_on_top); // The checkbox already communicates visibility; keep the action name // stable so users do not have to reinterpret the menu after each click. SDL_SetTrayEntryLabel(tray->visible, bongo_cat_i18n_get(tray->app->i18n, @@ -242,9 +242,9 @@ bool bongo_cat_tray_self_test(BongoCatTray *tray) { SDL_Log("Tray self-test: shell restart restored"); #endif BongoCatApp *app = tray->app; - bool visible = app->config.window.visible; - bool pass_through = app->config.window.pass_through; - bool always_on_top = app->config.window.always_on_top; + bool visible = app->session.window.visible; + bool pass_through = app->settings.window.pass_through; + bool always_on_top = app->settings.window.always_on_top; on_visible(tray, tray->visible); on_visible(tray, tray->visible); on_pass_through(tray, tray->pass_through); on_pass_through(tray, tray->pass_through); @@ -257,9 +257,9 @@ bool bongo_cat_tray_self_test(BongoCatTray *tray) { bongo_cat_preferences_close(app->preferences); SDL_Log("Tray self-test: preferences closed"); bool result = preferences && bongo_cat_modal_frame_self_test() && - app->config.window.visible == visible && - app->config.window.pass_through == pass_through && - app->config.window.always_on_top == always_on_top; + app->session.window.visible == visible && + app->settings.window.pass_through == pass_through && + app->settings.window.always_on_top == always_on_top; SDL_Log("Tray self-test: result=%d", result); return result; } diff --git a/src/runtime/window.c b/src/runtime/window.c index 9e5c79f8..6041f181 100644 --- a/src/runtime/window.c +++ b/src/runtime/window.c @@ -30,8 +30,8 @@ static bool try_window(BongoCatApp *app, bool transparent, bool multisampling, SDL_WINDOW_HIGH_PIXEL_DENSITY | SDL_WINDOW_HIDDEN; if (transparent) flags |= SDL_WINDOW_TRANSPARENT; app->window = SDL_CreateWindow(BONGO_CAT_PET_WINDOW_TITLE, - app->config.window.width, - app->config.window.height, flags); + app->session.window.width, + app->session.window.height, flags); if (!app->window) { snprintf(failure, capacity, "Window creation: %s", SDL_GetError()); return false; } @@ -86,18 +86,21 @@ BongoCatResult bongo_cat_window_create(BongoCatApp *app, BongoCatError *error) { } void bongo_cat_window_apply(BongoCatApp *app) { - BongoCatWindowOptions *value = &app->config.window; + BongoCatWindowPreferences *preferences = &app->settings.window; + BongoCatWindowState *state = &app->session.window; bongo_cat_platform_set_opacity(&app->platform, - value->opacity_percent / 100.0f); - SDL_SetWindowSize(app->window, value->width, value->height); - if (value->x || value->y) SDL_SetWindowPosition(app->window, value->x, value->y); + state->opacity_percent / 100.0f); + SDL_SetWindowSize(app->window, state->width, state->height); + if (state->position_known) + SDL_SetWindowPosition(app->window, state->x, state->y); SDL_SyncWindow(app->window); - if (value->keep_in_screen) bongo_cat_window_clamp_to_display(app); + if (preferences->keep_in_screen) bongo_cat_window_clamp_to_display(app); else bongo_cat_window_recover_to_display(app); SDL_SyncWindow(app->window); - bongo_cat_platform_set_visible(&app->platform, value->visible); + bongo_cat_platform_set_visible(&app->platform, state->visible); bongo_cat_window_sync_click_through(app); - bongo_cat_platform_set_always_on_top(&app->platform, value->always_on_top); + bongo_cat_platform_set_always_on_top(&app->platform, + preferences->always_on_top); } bool bongo_cat_window_event(BongoCatApp *app, const SDL_Event *event) { @@ -117,8 +120,8 @@ bool bongo_cat_window_event(BongoCatApp *app, const SDL_Event *event) { bongo_cat_window_drag_end(app); } if (event->type == SDL_EVENT_WINDOW_RESIZED) { - app->config.window.width = event->window.data1; - app->config.window.height = event->window.data2; + app->session.window.width = event->window.data1; + app->session.window.height = event->window.data2; bongo_cat_window_clamp_to_display(app); app->dirty = true; } @@ -140,8 +143,9 @@ bool bongo_cat_window_event(BongoCatApp *app, const SDL_Event *event) { app->resize_pending = true; bongo_cat_window_mark_hit_dirty(app); } else if (event->type == SDL_EVENT_WINDOW_MOVED) { - app->config.window.x = event->window.data1; - app->config.window.y = event->window.data2; + app->session.window.x = event->window.data1; + app->session.window.y = event->window.data2; + app->session.window.position_known = true; app->pointer_known = false; if (!app->window_drag_active) bongo_cat_window_clamp_to_display(app); bongo_cat_window_mark_hit_dirty(app); diff --git a/src/runtime/window_background.c b/src/runtime/window_background.c index a33b1e45..a3e903a5 100644 --- a/src/runtime/window_background.c +++ b/src/runtime/window_background.c @@ -3,7 +3,7 @@ #include void bongo_cat_window_clear_background(BongoCatApp *app) { - BongoCatWindowOptions *window = &app->config.window; + BongoCatWindowPreferences *window = &app->settings.window; uint32_t rgb = bongo_cat_obs_background_color_rgb( window->obs_background_color); float alpha = window->obs_background ? 1.0f : 0.0f; diff --git a/src/runtime/window_display.c b/src/runtime/window_display.c index 6a26d43e..9e71299f 100644 --- a/src/runtime/window_display.c +++ b/src/runtime/window_display.c @@ -170,15 +170,16 @@ static bool fit_to_display(BongoCatApp *app, SDL_DisplayID display, SDL_Point next = fitted_position(window, &bounds); if (next.x == window->x && next.y == window->y) return false; if (!SDL_SetWindowPosition(app->window, next.x, next.y)) return false; - app->config.window.x = next.x; - app->config.window.y = next.y; + app->session.window.x = next.x; + app->session.window.y = next.y; + app->session.window.position_known = true; bongo_cat_window_mark_hit_dirty(app); return true; } void bongo_cat_window_clamp_to_display(BongoCatApp *app) { SDL_Rect rect; - if (!app || !app->config.window.keep_in_screen || !window_rect(app, &rect)) return; + if (!app || !app->settings.window.keep_in_screen || !window_rect(app, &rect)) return; if (available_displays_cover(app, &rect)) return; fit_to_display(app, target_display(app, &rect), &rect); } @@ -189,7 +190,7 @@ void bongo_cat_window_drag_to(BongoCatApp *app, int x, int y) { width <= 0 || height <= 0) return; SDL_Rect requested = {x, y, width, height}; SDL_Point next = {x, y}; - if (app->config.window.keep_in_screen && + if (app->settings.window.keep_in_screen && !available_displays_cover(app, &requested)) { SDL_Rect bounds; SDL_DisplayID display = target_display(app, &requested); if (display && (SDL_GetDisplayUsableBounds(display, &bounds) || @@ -198,7 +199,8 @@ void bongo_cat_window_drag_to(BongoCatApp *app, int x, int y) { if (SDL_GetWindowPosition(app->window, ¤t_x, ¤t_y) && current_x == next.x && current_y == next.y) return; if (!SDL_SetWindowPosition(app->window, next.x, next.y)) return; - app->config.window.x = next.x; app->config.window.y = next.y; + app->session.window.x = next.x; app->session.window.y = next.y; + app->session.window.position_known = true; bongo_cat_window_mark_hit_dirty(app); } @@ -216,7 +218,7 @@ void bongo_cat_window_display_event(BongoCatApp *app, const SDL_Event *event) { if (!app || !event) return; if (event->type < SDL_EVENT_DISPLAY_FIRST || event->type > SDL_EVENT_DISPLAY_LAST) return; - if (app->window_drag_active && app->config.window.keep_in_screen) + if (app->window_drag_active && app->settings.window.keep_in_screen) bongo_cat_window_drag_bounds_refresh(app); bongo_cat_app_reset_pointer_tracking(app); app->display_recovery_due_ns = SDL_GetTicksNS() + DISPLAY_RECOVERY_DELAY_NS; @@ -226,7 +228,7 @@ void bongo_cat_window_update_display_recovery(BongoCatApp *app, uint64_t now) { if (!app || !app->display_recovery_due_ns || now < app->display_recovery_due_ns) return; app->display_recovery_due_ns = 0; - if (app->config.window.keep_in_screen) bongo_cat_window_clamp_to_display(app); + if (app->settings.window.keep_in_screen) bongo_cat_window_clamp_to_display(app); else bongo_cat_window_recover_to_display(app); } diff --git a/src/runtime/window_drag.c b/src/runtime/window_drag.c index f2bcc758..3463c176 100644 --- a/src/runtime/window_drag.c +++ b/src/runtime/window_drag.c @@ -33,7 +33,7 @@ void bongo_cat_window_drag_motion(BongoCatApp *app, bongo_cat_window_drag_end(app); return; } if (app->window_drag_active) { - if (app->config.window.keep_in_screen) + if (app->settings.window.keep_in_screen) move_with_pointer(app, pointer_x, pointer_y); return; } @@ -42,7 +42,7 @@ void bongo_cat_window_drag_motion(BongoCatApp *app, if (x * x + y * y < 9.0f) return; app->drag_candidate = false; app->window_drag_active = true; - if (app->config.window.keep_in_screen) { + if (app->settings.window.keep_in_screen) { bongo_cat_window_drag_bounds_refresh(app); if (!SDL_CaptureMouse(true)) SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, "Mouse capture is unavailable during constrained window drag: %s", diff --git a/src/runtime/window_geometry.c b/src/runtime/window_geometry.c index a18732e4..39b8e3fa 100644 --- a/src/runtime/window_geometry.c +++ b/src/runtime/window_geometry.c @@ -38,11 +38,12 @@ bool bongo_cat_window_apply_geometry(BongoCatApp *app, int x, int y, height > WINDOW_MAX_DIMENSION) return false; if (!bongo_cat_platform_set_geometry(&app->platform, x, y, width, height)) return false; - app->config.window.scale_percent = scale; - app->config.window.x = x; - app->config.window.y = y; - app->config.window.width = width; - app->config.window.height = height; + app->session.window.scale_percent = scale; + app->session.window.x = x; + app->session.window.y = y; + app->session.window.position_known = true; + app->session.window.width = width; + app->session.window.height = height; if (SDL_GetWindowSizeInPixels(app->window, &app->resize_pixel_width, &app->resize_pixel_height)) app->resize_pending = true; @@ -59,9 +60,9 @@ bool bongo_cat_window_set_scale(BongoCatApp *app, float scale) { float actual; int next_width, next_height; if (!bongo_cat_window_scaled_size(width, height, - app->config.window.scale_percent, scale, + app->session.window.scale_percent, scale, &actual, &next_width, &next_height)) return false; - if (actual == app->config.window.scale_percent && + if (actual == app->session.window.scale_percent && next_width == width && next_height == height) return false; return bongo_cat_window_apply_geometry(app, x, y, actual, next_width, next_height); @@ -75,7 +76,7 @@ void bongo_cat_window_resize_by_pointer(BongoCatApp *app, const SDL_Event *event if (!app->resize_gesture) { if (!SDL_GetWindowSize(app->window, &app->resize_base_width, &app->resize_base_height)) return; - app->resize_scale_start = app->config.window.scale_percent; + app->resize_scale_start = app->session.window.scale_percent; app->resize_scale_target = app->resize_scale_start; app->resize_gesture = true; } @@ -90,20 +91,21 @@ void bongo_cat_window_resize_by_pointer(BongoCatApp *app, const SDL_Event *event !SDL_GetWindowPosition(app->window, &x, &y)) return; app->resize_scale_target = actual; if (!bongo_cat_window_apply_geometry(app, x, y, actual, width, height)) - app->resize_scale_target = app->config.window.scale_percent; + app->resize_scale_target = app->session.window.scale_percent; } bool bongo_cat_window_geometry_self_test(BongoCatApp *app) { if (!app || !app->window) return false; SDL_SyncWindow(app->window); - BongoCatWindowOptions backup = app->config.window; + BongoCatWindowPreferences preferences_backup = app->settings.window; + BongoCatWindowState state_backup = app->session.window; int original_x, original_y, original_width, original_height; SDL_GetWindowPosition(app->window, &original_x, &original_y); SDL_GetWindowSize(app->window, &original_width, &original_height); SDL_DisplayID display = SDL_GetDisplayForWindow(app->window); SDL_Rect bounds; if (!display || !SDL_GetDisplayUsableBounds(display, &bounds)) return false; - app->config.window.keep_in_screen = true; + app->settings.window.keep_in_screen = true; app->model_pointer_anchor_ready = true; bongo_cat_window_apply_geometry(app, bounds.x - 2000, bounds.y - 2000, 100.0f, 320, 240); @@ -123,10 +125,10 @@ bool bongo_cat_window_geometry_self_test(BongoCatApp *app) { bongo_cat_window_menu_action(app, BONGO_CAT_MENU_OPACITY_50); bool opacity = SDL_fabsf(bongo_cat_platform_get_opacity( &app->platform) - 0.5f) < 0.02f; - app->config.window.hide_on_hover = true; - app->config.window.hide_delay_seconds = 0.0f; - app->config.window.pass_through = false; - app->config.window.opacity_percent = 100.0f; + app->settings.window.hide_on_hover = true; + app->settings.window.hide_delay_seconds = 0.0f; + app->settings.window.pass_through = false; + app->session.window.opacity_percent = 100.0f; bongo_cat_app_track_hover(app, x + 10, y + 10); bongo_cat_app_update_hover(app, SDL_GetTicksNS() + 1); bool hidden = app->hover_hidden && @@ -157,7 +159,7 @@ bool bongo_cat_window_geometry_self_test(BongoCatApp *app) { SDL_GetWindowSize(app->window, &width, &height); int gesture_width = width, gesture_height = height; bool gesture = app->resize_gesture && - app->config.window.scale_percent == 120.0f && + app->session.window.scale_percent == 120.0f && width == 384 && height == 288; SDL_Event released = {.type = SDL_EVENT_MOUSE_BUTTON_UP}; released.button.button = SDL_BUTTON_RIGHT; @@ -176,10 +178,11 @@ bool bongo_cat_window_geometry_self_test(BongoCatApp *app) { app->resize_gesture = false; SDL_SetModState(old_modifiers); bongo_cat_window_apply_geometry(app, original_x, original_y, - backup.scale_percent, original_width, original_height); - app->config.window = backup; + state_backup.scale_percent, original_width, original_height); + app->settings.window = preferences_backup; + app->session.window = state_backup; bongo_cat_platform_set_opacity(&app->platform, - backup.opacity_percent / 100.0f); + state_backup.opacity_percent / 100.0f); bongo_cat_window_sync_click_through(app); SDL_SyncWindow(app->window); bool passed = clamped && anchor_reset && scaled && opacity && hidden && restored && diff --git a/src/runtime/window_hit.c b/src/runtime/window_hit.c index 7592edce..5c11bbd1 100644 --- a/src/runtime/window_hit.c +++ b/src/runtime/window_hit.c @@ -34,7 +34,7 @@ bool bongo_cat_window_visible_at_pointer(BongoCatApp *app, float x, float y) { void bongo_cat_window_capture_pointer_hit(BongoCatApp *app) { if (!app || !app->window || !app->pointer_known || - app->config.window.pass_through || app->hover_hidden || + app->settings.window.pass_through || app->hover_hidden || app->left_mouse_down || app->right_mouse_down) return; float local_x, local_y; bool inside = bongo_cat_platform_pointer_local(&app->platform, @@ -53,7 +53,7 @@ void bongo_cat_window_mark_hit_dirty(BongoCatApp *app) { void bongo_cat_window_set_visible(BongoCatApp *app, bool visible) { if (!app || !app->window) return; - app->config.window.visible = visible; + app->session.window.visible = visible; if (!visible) { bongo_cat_platform_set_visible(&app->platform, false); return; @@ -64,8 +64,8 @@ void bongo_cat_window_set_visible(BongoCatApp *app, bool visible) { app->hover_hidden = false; bongo_cat_app_reset_pointer_tracking(app); bongo_cat_platform_set_opacity(&app->platform, - app->config.window.opacity_percent / 100.0f); - if (app->config.window.keep_in_screen) bongo_cat_window_clamp_to_display(app); + app->session.window.opacity_percent / 100.0f); + if (app->settings.window.keep_in_screen) bongo_cat_window_clamp_to_display(app); else bongo_cat_window_recover_to_display(app); bongo_cat_platform_set_visible(&app->platform, true); bongo_cat_window_mark_hit_dirty(app); @@ -99,13 +99,13 @@ void bongo_cat_window_schedule_hit_check(BongoCatApp *app) { void bongo_cat_window_sync_click_through(BongoCatApp *app) { if (!app || !app->window) return; - bool forced = app->config.window.pass_through || app->hover_hidden; + bool forced = app->settings.window.pass_through || app->hover_hidden; if (!forced && !bongo_cat_platform_dynamic_hit_supported()) { app->pointer_transparent = false; app->pointer_hit_dirty = false; } if (!forced && (app->left_mouse_down || app->right_mouse_down)) return; - if (!forced && app->config.window.visible && app->pointer_known && + if (!forced && app->session.window.visible && app->pointer_known && app->pointer_hit_dirty && (!app->pointer_hit_deadline_ns || SDL_GetTicksNS() >= app->pointer_hit_deadline_ns)) { float local_x, local_y; diff --git a/src/runtime/window_menu_actions.c b/src/runtime/window_menu_actions.c index 8bbe21fc..717fe86c 100644 --- a/src/runtime/window_menu_actions.c +++ b/src/runtime/window_menu_actions.c @@ -32,9 +32,9 @@ void bongo_cat_window_show_context_menu(BongoCatApp *app) { const char *model_names[BONGO_CAT_MODEL_CAP]; size_t current_model = app->models.count; for (size_t i = 0; i < app->models.count; ++i) { - model_names[i] = bongo_cat_model_name(&app->config, + model_names[i] = bongo_cat_model_name(&app->settings, &app->models.entries[i]); - if (!strcmp(app->models.entries[i].id, app->config.current_model)) + if (!strcmp(app->models.entries[i].id, app->session.active_model_id)) current_model = i; } char motion_names[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; @@ -44,8 +44,8 @@ void bongo_cat_window_show_context_menu(BongoCatApp *app) { bongo_cat_window_behavior_labels(app, motion_names, motion_checked, &motion_count, expression_names, &expression_count, ¤t_expression); - bool dark_theme = app->config.app.theme == BONGO_CAT_THEME_DARK || - (app->config.app.theme == BONGO_CAT_THEME_AUTO && + bool dark_theme = app->settings.app.theme == BONGO_CAT_THEME_DARK || + (app->settings.app.theme == BONGO_CAT_THEME_AUTO && SDL_GetSystemTheme() == SDL_SYSTEM_THEME_DARK); BongoCatMenuLabels labels = { tr(app, "composables.useAppMenu.labels.preference", "Preferences"), @@ -64,9 +64,9 @@ void bongo_cat_window_show_context_menu(BongoCatApp *app) { tr(app, "composables.useAppMenu.labels.expression", "Expressions"), model_names, motion_names, expression_names, motion_checked, app->models.count, current_model, motion_count, expression_count, - current_expression, app->config.window.scale_percent, - app->config.window.opacity_percent, app->config.window.pass_through, - app->config.window.always_on_top, dark_theme, + current_expression, app->session.window.scale_percent, + app->session.window.opacity_percent, app->settings.window.pass_through, + app->settings.window.always_on_top, dark_theme, bongo_cat_window_menu_preview, bongo_cat_window_menu_preview_tick, bongo_cat_window_menu_restore, &preview}; BongoCatMenuAction action = bongo_cat_platform_context_menu( @@ -85,13 +85,13 @@ void bongo_cat_window_menu_action(BongoCatApp *app, else if (action == BONGO_CAT_MENU_HIDE) bongo_cat_window_set_visible(app, false); else if (action == BONGO_CAT_MENU_PASS_THROUGH) { - app->config.window.pass_through = !app->config.window.pass_through; + app->settings.window.pass_through = !app->settings.window.pass_through; bongo_cat_window_mark_hit_dirty(app); bongo_cat_window_sync_click_through(app); } else if (action == BONGO_CAT_MENU_ALWAYS_ON_TOP) { - app->config.window.always_on_top = !app->config.window.always_on_top; + app->settings.window.always_on_top = !app->settings.window.always_on_top; bongo_cat_platform_set_always_on_top(&app->platform, - app->config.window.always_on_top); + app->settings.window.always_on_top); bongo_cat_window_mark_hit_dirty(app); bongo_cat_window_sync_click_through(app); } else if (action >= BONGO_CAT_MENU_SCALE_50 && @@ -102,10 +102,10 @@ void bongo_cat_window_menu_action(BongoCatApp *app, } else if (action >= BONGO_CAT_MENU_OPACITY_10 && action <= BONGO_CAT_MENU_OPACITY_100) { bongo_cat_window_cancel_wheel_animation(app); - app->config.window.opacity_percent = + app->session.window.opacity_percent = (float)(10 * (action - BONGO_CAT_MENU_OPACITY_10 + 1)); bongo_cat_platform_set_opacity(&app->platform, - app->config.window.opacity_percent / 100.0f); + app->session.window.opacity_percent / 100.0f); } else if (bongo_cat_window_behavior_action(app, action)) { bongo_cat_app_render_now(app); } else if (action >= BONGO_CAT_MENU_MODEL_FIRST && @@ -119,20 +119,20 @@ void bongo_cat_window_menu_action(BongoCatApp *app, bool bongo_cat_window_menu_self_test(BongoCatApp *app) { if (!app || !app->preferences) return false; - app->config.window.pass_through = false; - app->config.window.always_on_top = false; - app->config.window.scale_percent = 100.0f; - app->config.window.opacity_percent = 100.0f; + app->settings.window.pass_through = false; + app->settings.window.always_on_top = false; + app->session.window.scale_percent = 100.0f; + app->session.window.opacity_percent = 100.0f; bongo_cat_window_menu_action(app, BONGO_CAT_MENU_PASS_THROUGH); bongo_cat_window_menu_action(app, BONGO_CAT_MENU_ALWAYS_ON_TOP); bongo_cat_window_menu_action(app, BONGO_CAT_MENU_SCALE_120); bongo_cat_window_menu_action(app, BONGO_CAT_MENU_OPACITY_50); bongo_cat_window_menu_action(app, BONGO_CAT_MENU_PREFERENCES); bool behavior = bongo_cat_window_behavior_self_test(app); - bool result = app->config.window.pass_through && - app->config.window.always_on_top && - app->config.window.scale_percent == 120.0f && - app->config.window.opacity_percent == 50.0f && + bool result = app->settings.window.pass_through && + app->settings.window.always_on_top && + app->session.window.scale_percent == 120.0f && + app->session.window.opacity_percent == 50.0f && bongo_cat_preferences_visible(app->preferences) && behavior; bongo_cat_preferences_close(app->preferences); return result; diff --git a/src/runtime/window_menu_behavior.c b/src/runtime/window_menu_behavior.c index a0e343de..b8459193 100644 --- a/src/runtime/window_menu_behavior.c +++ b/src/runtime/window_menu_behavior.c @@ -21,8 +21,8 @@ static const BongoCatBehaviorEntry *nth_behavior(BongoCatApp *app, static const char *behavior_shortcut(const BongoCatApp *app, const char *id) { if (!app || !id) return NULL; - for (size_t i = 0; i < app->config.behavior_shortcut_count; ++i) { - const BongoCatBehaviorShortcut *binding = &app->config.behavior_shortcuts[i]; + for (size_t i = 0; i < app->settings.behavior_shortcut_count; ++i) { + const BongoCatBehaviorShortcut *binding = &app->settings.behavior_shortcuts[i]; if (strcmp(binding->id, id) == 0) return binding->shortcut; } return NULL; @@ -31,8 +31,8 @@ static const char *behavior_shortcut(const BongoCatApp *app, const char *id) { static const char *behavior_label(const BongoCatApp *app, const BongoCatBehaviorEntry *entry) { if (!app || !entry) return ""; - for (size_t i = 0; i < app->config.behavior_shortcut_count; ++i) { - const BongoCatBehaviorShortcut *binding = &app->config.behavior_shortcuts[i]; + for (size_t i = 0; i < app->settings.behavior_shortcut_count; ++i) { + const BongoCatBehaviorShortcut *binding = &app->settings.behavior_shortcuts[i]; if (strcmp(binding->id, entry->id) == 0 && binding->label[0]) return binding->label; } diff --git a/src/runtime/window_menu_preview.c b/src/runtime/window_menu_preview.c index 2e52605d..9025f4f2 100644 --- a/src/runtime/window_menu_preview.c +++ b/src/runtime/window_menu_preview.c @@ -37,14 +37,14 @@ void bongo_cat_window_menu_preview_init(BongoCatWindowMenuPreview *state, BongoCatApp *app) { if (!state) return; *state = (BongoCatWindowMenuPreview){.app = app, - .scale = app ? app->config.window.scale_percent : 100.0f, - .opacity = app ? app->config.window.opacity_percent : 100.0f, + .scale = app ? app->session.window.scale_percent : 100.0f, + .opacity = app ? app->session.window.opacity_percent : 100.0f, .expression = app ? bongo_cat_live2d_expression(app->live2d) : -1, .last = BONGO_CAT_MENU_NONE, .applied = BONGO_CAT_MENU_NONE, .pending_model = BONGO_CAT_MENU_NONE}; bongo_cat_modal_frame_init(&state->modal_frame, app); if (app) snprintf(state->model, sizeof(state->model), "%.*s", - (int)sizeof(state->model) - 1, app->config.current_model); + (int)sizeof(state->model) - 1, app->session.active_model_id); } void bongo_cat_window_menu_preview(void *userdata, BongoCatMenuAction action) { @@ -74,10 +74,10 @@ void bongo_cat_window_menu_preview(void *userdata, BongoCatMenuAction action) { (float)(50 + 10 * (action - BONGO_CAT_MENU_SCALE_50))); else if (action >= BONGO_CAT_MENU_OPACITY_10 && action <= BONGO_CAT_MENU_OPACITY_100) { - app->config.window.opacity_percent = + app->session.window.opacity_percent = (float)(10 * (action - BONGO_CAT_MENU_OPACITY_10 + 1)); bongo_cat_platform_set_opacity(&app->platform, - app->config.window.opacity_percent / 100.0f); + app->session.window.opacity_percent / 100.0f); } else if (action == state->applied && bongo_cat_window_behavior_menu_action(action)) { bongo_cat_modal_frame_tick(&state->modal_frame); @@ -107,7 +107,7 @@ void bongo_cat_window_menu_preview_tick(void *userdata) { SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Model preview failed: %s", error.message[0] ? error.message : "unknown error"); - if (strcmp(state->app->config.current_model, + if (strcmp(state->app->session.active_model_id, state->model) != 0) { BongoCatError restore_error = {0}; if (!bongo_cat_app_select_model_with_error(state->app, @@ -149,7 +149,7 @@ void bongo_cat_window_menu_restore(void *userdata, BongoCatMenuAction selected) bool keep_expression = keep_model || (selected >= BONGO_CAT_MENU_EXPRESSION_FIRST && selected < BONGO_CAT_MENU_EXPRESSION_FIRST + BONGO_CAT_BEHAVIOR_CAP); - if (!keep_model && strcmp(app->config.current_model, state->model) != 0) { + if (!keep_model && strcmp(app->session.active_model_id, state->model) != 0) { BongoCatError error = {0}; if (bongo_cat_app_select_model_with_error(app, state->model, &error)) changed = true; @@ -158,13 +158,13 @@ void bongo_cat_window_menu_restore(void *userdata, BongoCatMenuAction selected) error.message[0] ? error.message : "unknown error"); } if (!keep_scale && - SDL_fabsf(app->config.window.scale_percent - state->scale) > .01f) { + SDL_fabsf(app->session.window.scale_percent - state->scale) > .01f) { bongo_cat_window_set_scale(app, state->scale); changed = true; } if (!keep_opacity && - SDL_fabsf(app->config.window.opacity_percent - state->opacity) > .01f) { - app->config.window.opacity_percent = state->opacity; + SDL_fabsf(app->session.window.opacity_percent - state->opacity) > .01f) { + app->session.window.opacity_percent = state->opacity; bongo_cat_platform_set_opacity(&app->platform, state->opacity / 100.0f); changed = true; diff --git a/src/runtime/window_wheel.c b/src/runtime/window_wheel.c index 5f3ed068..8eaa1ec5 100644 --- a/src/runtime/window_wheel.c +++ b/src/runtime/window_wheel.c @@ -22,13 +22,13 @@ static bool initialize_targets(BongoCatApp *app, bool reset) { int x = 0, y = 0, width = 0, height = 0; if (!SDL_GetWindowPosition(app->window, &x, &y) || !SDL_GetWindowSize(app->window, &width, &height) || - width <= 0 || height <= 0 || app->config.window.scale_percent <= 0.0f) + width <= 0 || height <= 0 || app->session.window.scale_percent <= 0.0f) return false; - app->wheel_opacity_target = app->config.window.opacity_percent; - app->wheel_scale_target = app->config.window.scale_percent; + app->wheel_opacity_target = app->session.window.opacity_percent; + app->wheel_scale_target = app->session.window.scale_percent; app->wheel_center_x = x + width * 0.5f; app->wheel_center_y = y + height * 0.5f; - app->wheel_geometry_scale = app->config.window.scale_percent; + app->wheel_geometry_scale = app->session.window.scale_percent; app->wheel_base_width = width; app->wheel_base_height = height; app->wheel_animation_ns = SDL_GetTicksNS(); @@ -41,7 +41,7 @@ static int round_position(float value) { } static void clamp_position(BongoCatApp *app, int width, int height, int *x, int *y) { - if (!app->config.window.keep_in_screen) return; + if (!app->settings.window.keep_in_screen) return; SDL_DisplayID display = SDL_GetDisplayForWindow(app->window); SDL_Rect bounds; if (!display || !SDL_GetDisplayUsableBounds(display, &bounds)) return; @@ -68,9 +68,9 @@ void bongo_cat_window_wheel(BongoCatApp *app, const SDL_MouseWheelEvent *event) bongo_cat_input_control_down(&app->input); if (!control) { float minimum = SDL_max(10.0f, - app->config.window.scale_percent - WHEEL_SCALE_TARGET_LEAD); + app->session.window.scale_percent - WHEEL_SCALE_TARGET_LEAD); float maximum = SDL_min(500.0f, - app->config.window.scale_percent + WHEEL_SCALE_TARGET_LEAD); + app->session.window.scale_percent + WHEEL_SCALE_TARGET_LEAD); app->wheel_scale_target = SDL_clamp(app->wheel_scale_target + delta * WHEEL_SCALE_STEP, minimum, maximum); } else { @@ -101,7 +101,7 @@ static float approach(float current, float target, float elapsed_seconds, } static void apply_scale(BongoCatApp *app, float scale) { - float old = app->config.window.scale_percent; + float old = app->session.window.scale_percent; if (old <= 0.0f || SDL_fabsf(scale - old) < 0.001f) return; float actual; int next_width, next_height; @@ -109,9 +109,9 @@ static void apply_scale(BongoCatApp *app, float scale) { app->wheel_base_height, app->wheel_geometry_scale, scale, &actual, &next_width, &next_height)) return; if (actual != scale) app->wheel_scale_target = actual; - if (next_width == app->config.window.width && - next_height == app->config.window.height) { - app->config.window.scale_percent = actual; + if (next_width == app->session.window.width && + next_height == app->session.window.height) { + app->session.window.scale_percent = actual; return; } int next_x = round_position(app->wheel_center_x - next_width * 0.5f); @@ -132,23 +132,23 @@ void bongo_cat_window_update_wheel_animation(BongoCatApp *app, uint64_t now) { app->wheel_animation_ns = now; float elapsed_seconds = SDL_min((float)elapsed_ns / 1000000000.0f, WHEEL_MAX_FRAME_SECONDS); - float opacity = approach(app->config.window.opacity_percent, + float opacity = approach(app->session.window.opacity_percent, app->wheel_opacity_target, elapsed_seconds, WHEEL_OPACITY_RESPONSE_SECONDS, WHEEL_OPACITY_SPEED_PER_SECOND, 0.01f); - float scale = approach(app->config.window.scale_percent, + float scale = approach(app->session.window.scale_percent, app->wheel_scale_target, elapsed_seconds, WHEEL_SCALE_RESPONSE_SECONDS, WHEEL_SCALE_SPEED_PER_SECOND, 0.5f); - bool changed = SDL_fabsf(opacity - app->config.window.opacity_percent) > 0.001f || - SDL_fabsf(scale - app->config.window.scale_percent) > 0.001f; - if (SDL_fabsf(opacity - app->config.window.opacity_percent) > 0.001f) { - app->config.window.opacity_percent = opacity; + bool changed = SDL_fabsf(opacity - app->session.window.opacity_percent) > 0.001f || + SDL_fabsf(scale - app->session.window.scale_percent) > 0.001f; + if (SDL_fabsf(opacity - app->session.window.opacity_percent) > 0.001f) { + app->session.window.opacity_percent = opacity; if (!app->hover_hidden) bongo_cat_platform_set_opacity( &app->platform, opacity / 100.0f); } apply_scale(app, scale); bool reached = - SDL_fabsf(app->config.window.opacity_percent - app->wheel_opacity_target) < 0.01f && - SDL_fabsf(app->config.window.scale_percent - app->wheel_scale_target) < 0.01f; + SDL_fabsf(app->session.window.opacity_percent - app->wheel_opacity_target) < 0.01f && + SDL_fabsf(app->session.window.scale_percent - app->wheel_scale_target) < 0.01f; bool recent_input = now >= app->wheel_input_ns && now - app->wheel_input_ns < WHEEL_GESTURE_IDLE_NS; app->wheel_animation_active = !reached || recent_input; @@ -164,27 +164,27 @@ void bongo_cat_window_cancel_wheel_animation(BongoCatApp *app) { bool bongo_cat_window_wheel_self_test(BongoCatApp *app) { if (!app || !app->window) return false; - BongoCatWindowOptions backup = app->config.window; + BongoCatWindowState backup = app->session.window; int original_x, original_y, original_width, original_height; SDL_GetWindowPosition(app->window, &original_x, &original_y); SDL_GetWindowSize(app->window, &original_width, &original_height); SDL_Keymod modifiers = SDL_GetModState(); SDL_SetModState(modifiers | SDL_KMOD_CTRL); - app->config.window.opacity_percent = 80.0f; - app->config.window.scale_percent = 100.0f; + app->session.window.opacity_percent = 80.0f; + app->session.window.scale_percent = 100.0f; bongo_cat_window_cancel_wheel_animation(app); SDL_MouseWheelEvent wheel = {.y = -1.0f}; bongo_cat_window_wheel(app, &wheel); uint64_t started = app->wheel_animation_ns; for (int i = 1; i <= 30; ++i) bongo_cat_window_update_wheel_animation(app, started + i * 16666667ull); - bool opacity = SDL_fabsf(app->config.window.opacity_percent - 75.0f) < 0.1f; + bool opacity = SDL_fabsf(app->session.window.opacity_percent - 75.0f) < 0.1f; SDL_SetModState(modifiers & ~SDL_KMOD_CTRL); wheel.y = 1.0f; bongo_cat_window_wheel(app, &wheel); started = app->wheel_animation_ns; for (int i = 1; i <= 8; ++i) bongo_cat_window_update_wheel_animation(app, started + i * 16666667ull); - bool responsive = SDL_fabsf(app->config.window.scale_percent - 105.0f) < 0.1f; + bool responsive = SDL_fabsf(app->session.window.scale_percent - 105.0f) < 0.1f; bongo_cat_window_update_wheel_animation(app, app->wheel_input_ns + WHEEL_GESTURE_IDLE_NS + 1); bool scale = responsive && !app->wheel_animation_active; @@ -213,38 +213,38 @@ bool bongo_cat_window_wheel_self_test(BongoCatApp *app) { for (int i = 1; i <= 30; ++i) bongo_cat_window_update_wheel_animation(app, started + i * 16666667ull); bool reversal = !app->wheel_animation_active && - SDL_fabsf(app->config.window.scale_percent - 100.0f) < 0.1f; + SDL_fabsf(app->session.window.scale_percent - 100.0f) < 0.1f; wheel.y = 1.0f; wheel.direction = SDL_MOUSEWHEEL_FLIPPED; bongo_cat_window_wheel(app, &wheel); started = app->wheel_animation_ns; for (int i = 1; i <= 30; ++i) bongo_cat_window_update_wheel_animation(app, started + i * 16666667ull); - bool flipped = SDL_fabsf(app->config.window.scale_percent - 95.0f) < 0.1f; + bool flipped = SDL_fabsf(app->session.window.scale_percent - 95.0f) < 0.1f; wheel.direction = SDL_MOUSEWHEEL_NORMAL; bongo_cat_window_cancel_wheel_animation(app); - app->config.window.scale_percent = 500.0f; + app->session.window.scale_percent = 500.0f; bongo_cat_window_wheel(app, &wheel); bool maximum = !app->wheel_animation_active; bongo_cat_window_cancel_wheel_animation(app); - app->config.window.scale_percent = 10.0f; + app->session.window.scale_percent = 10.0f; wheel.y = -1.0f; bongo_cat_window_wheel(app, &wheel); bool minimum = !app->wheel_animation_active; bongo_cat_window_cancel_wheel_animation(app); SDL_SetModState(modifiers | SDL_KMOD_CTRL); - app->config.window.opacity_percent = 100.0f; + app->session.window.opacity_percent = 100.0f; wheel.y = 1.0f; bongo_cat_window_wheel(app, &wheel); bool opacity_maximum = !app->wheel_animation_active; bongo_cat_window_cancel_wheel_animation(app); - app->config.window.opacity_percent = 10.0f; + app->session.window.opacity_percent = 10.0f; wheel.y = -1.0f; bongo_cat_window_wheel(app, &wheel); bool opacity_minimum = !app->wheel_animation_active; bool rounding = round_position(-10.6f) == -11 && round_position(10.6f) == 11; SDL_SetModState(modifiers); - app->config.window = backup; + app->session.window = backup; bongo_cat_window_cancel_wheel_animation(app); bongo_cat_platform_set_opacity(&app->platform, backup.opacity_percent / 100.0f); diff --git a/src/ui/preferences.c b/src/ui/preferences.c index 90d18262..666ef854 100644 --- a/src/ui/preferences.c +++ b/src/ui/preferences.c @@ -13,8 +13,8 @@ #include int bongo_cat_preferences_resolved_theme(const BongoCatPreferences *value) { - if (value->app->config.app.theme == BONGO_CAT_THEME_DARK) return 1; - if (value->app->config.app.theme == BONGO_CAT_THEME_LIGHT) return 0; + if (value->app->settings.app.theme == BONGO_CAT_THEME_DARK) return 1; + if (value->app->settings.app.theme == BONGO_CAT_THEME_LIGHT) return 0; return SDL_GetSystemTheme() == SDL_SYSTEM_THEME_DARK; } void bongo_cat_preferences_apply_theme(BongoCatPreferences *value) { @@ -38,7 +38,7 @@ BongoCatPreferences *bongo_cat_preferences_create(BongoCatApp *app) { void bongo_cat_preferences_show(BongoCatPreferences *value) { if (!value) return; const BongoCatModelEntry *entry = bongo_cat_models_find( - &value->app->models, value->app->config.current_model); + &value->app->models, value->app->session.active_model_id); bongo_cat_preferences_model_cover_capture(value->app, entry); if (!value->window && !bongo_cat_preferences_open_window(value)) { SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Preferences failed: %s", SDL_GetError()); diff --git a/src/ui/preferences_behavior_rename.c b/src/ui/preferences_behavior_rename.c index d38c5cda..eab37f75 100644 --- a/src/ui/preferences_behavior_rename.c +++ b/src/ui/preferences_behavior_rename.c @@ -5,7 +5,7 @@ #include #include -static BongoCatBehaviorShortcut *binding_for(BongoCatConfig *config, +static BongoCatBehaviorShortcut *binding_for(BongoCatSettings *config, const char *id) { for (size_t i = 0; i < config->behavior_shortcut_count; ++i) if (!strcmp(config->behavior_shortcuts[i].id, id)) @@ -38,7 +38,7 @@ void bongo_cat_preferences_behavior_rename_finish( entry = &value->app->behaviors.entries[i]; break; } - BongoCatBehaviorShortcut *binding = binding_for(&value->app->config, + BongoCatBehaviorShortcut *binding = binding_for(&value->app->settings, session->id); if (binding) { const char *label = entry && !strcmp(session->text, entry->label) ? diff --git a/src/ui/preferences_behavior_row.c b/src/ui/preferences_behavior_row.c index c4e5e1e9..b721a17b 100644 --- a/src/ui/preferences_behavior_row.c +++ b/src/ui/preferences_behavior_row.c @@ -26,7 +26,7 @@ static bool hit(struct nk_context *context, struct nk_rect bounds, bool enabled) nk_input_is_mouse_click_in_rect(&context->input, NK_BUTTON_LEFT, bounds); } -static BongoCatBehaviorShortcut *binding_for(BongoCatConfig *config, +static BongoCatBehaviorShortcut *binding_for(BongoCatSettings *config, const char *id) { for (size_t i = 0; i < config->behavior_shortcut_count; ++i) if (!strcmp(config->behavior_shortcuts[i].id, id)) @@ -138,7 +138,7 @@ void bongo_cat_preferences_behavior_row_draw(BongoCatPreferences *value, struct nk_rect shortcut_bounds = nk_rect(play.x - 188, row.y + 10, 180, 36); struct nk_rect name = nk_rect(row.x + 8, row.y + 9, NK_MAX(48.0f, shortcut_bounds.x - row.x - 16), 38); - BongoCatBehaviorShortcut *binding = binding_for(&value->app->config, + BongoCatBehaviorShortcut *binding = binding_for(&value->app->settings, entry->id); draw_name(value, context, canvas, name, entry, binding, p, opacity, enabled); bool play_hover = enabled && nk_input_is_mouse_hovering_rect( diff --git a/src/ui/preferences_dialog.c b/src/ui/preferences_dialog.c index 2e8b5185..fd3f089e 100644 --- a/src/ui/preferences_dialog.c +++ b/src/ui/preferences_dialog.c @@ -61,7 +61,7 @@ void bongo_cat_preferences_remove_dialog_open(BongoCatApp *app, snprintf(remove_dialog.model_id, sizeof(remove_dialog.model_id), "%s", id); const BongoCatModelEntry *entry = bongo_cat_models_find(&app->models, id); snprintf(remove_dialog.model_name, sizeof(remove_dialog.model_name), "%s", - bongo_cat_model_name(&app->config, entry)); + bongo_cat_model_name(&app->settings, entry)); remove_dialog.opened_ns = SDL_GetTicksNS(); remove_dialog.closing_ns = 0; remove_dialog.input_armed = false; diff --git a/src/ui/preferences_language.c b/src/ui/preferences_language.c index a6e29074..0ddaf24f 100644 --- a/src/ui/preferences_language.c +++ b/src/ui/preferences_language.c @@ -5,21 +5,21 @@ #include bool bongo_cat_preferences_reload_language(BongoCatPreferences *value) { - if (!value || value->font_language == value->app->config.app.language) + if (!value || value->font_language == value->app->settings.app.language) return false; BongoCatLanguage previous = value->font_language; - BongoCatLanguage requested = value->app->config.app.language; + BongoCatLanguage requested = value->app->settings.app.language; BongoCatError error = {0}; if (!value->app->i18n || bongo_cat_i18n_reload(value->app->i18n, requested, &error) != BONGO_CAT_OK) { - value->app->config.app.language = previous; + value->app->settings.app.language = previous; SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "%s", error.message); return false; } value->font_language = requested; if (!bongo_cat_preferences_reload_fonts(value)) { BongoCatError restore = {0}; - value->app->config.app.language = previous; + value->app->settings.app.language = previous; value->font_language = previous; bongo_cat_i18n_reload(value->app->i18n, previous, &restore); SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, diff --git a/src/ui/preferences_model.c b/src/ui/preferences_model.c index aeace18f..01c472e3 100644 --- a/src/ui/preferences_model.c +++ b/src/ui/preferences_model.c @@ -53,7 +53,7 @@ static void smoke_model_behavior(BongoCatPreferences *value) { for (size_t i = 0; i < app->models.count; ++i) { const BongoCatModelEntry *entry = &app->models.entries[i]; if (entry->preset || !strcmp(entry->id, - app->config.current_model)) continue; + app->session.active_model_id)) continue; app->smoke_preference_model_select = false; value->smoke_behavior_open_pending = true; SDL_Log("Preferences smoke selecting model %s", entry->id); diff --git a/src/ui/preferences_model_card.c b/src/ui/preferences_model_card.c index 22650c72..0095d079 100644 --- a/src/ui/preferences_model_card.c +++ b/src/ui/preferences_model_card.c @@ -164,7 +164,7 @@ static void draw_actions(BongoCatPreferences *value, void bongo_cat_preferences_model_card(BongoCatPreferences *value, struct nk_context *context, const BongoCatModelEntry *entry) { BongoCatApp *app = value->app; - bool selected = !strcmp(entry->id, app->config.current_model); + bool selected = !strcmp(entry->id, app->session.active_model_id); struct nk_rect bounds; if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return; bounds.y += 5.0f; bounds.h += 1.0f; diff --git a/src/ui/preferences_model_cover.c b/src/ui/preferences_model_cover.c index 2c4d9d59..c57591a5 100644 --- a/src/ui/preferences_model_cover.c +++ b/src/ui/preferences_model_cover.c @@ -180,7 +180,7 @@ bool bongo_cat_preferences_model_cover_generate_current(BongoCatApp *app) { app->window_minimized) return false; const BongoCatModelEntry *entry = bongo_cat_models_find( &app->models, app->loaded_model); - if (!entry || strcmp(app->config.current_model, entry->id) || + if (!entry || strcmp(app->session.active_model_id, entry->id) || !cover_needs_capture(entry) || !mark_cover_attempt(app, entry->id)) return false; bool captured = bongo_cat_preferences_model_cover_capture(app, entry); diff --git a/src/ui/preferences_model_glyphs.c b/src/ui/preferences_model_glyphs.c index 89ef1b6f..c485b49c 100644 --- a/src/ui/preferences_model_glyphs.c +++ b/src/ui/preferences_model_glyphs.c @@ -34,12 +34,12 @@ void bongo_cat_preferences_model_glyphs(const BongoCatApp *app, for (size_t i = 0; i < app->models.count; ++i) { const BongoCatModelEntry *entry = &app->models.entries[i]; add_text(ranges, capacity, &used, - bongo_cat_model_name(&app->config, entry)); + bongo_cat_model_name(&app->settings, entry)); } for (size_t i = 0; i < app->behaviors.count; ++i) add_text(ranges, capacity, &used, app->behaviors.entries[i].label); for (size_t i = 0; - i < app->config.behavior_shortcut_count; ++i) + i < app->settings.behavior_shortcut_count; ++i) add_text(ranges, capacity, &used, - app->config.behavior_shortcuts[i].label); + app->settings.behavior_shortcuts[i].label); } diff --git a/src/ui/preferences_model_rename.c b/src/ui/preferences_model_rename.c index a4e6a596..bbcb1096 100644 --- a/src/ui/preferences_model_rename.c +++ b/src/ui/preferences_model_rename.c @@ -19,7 +19,7 @@ bool bongo_cat_preferences_model_name_draw(BongoCatPreferences *value, nk_stroke_rect(canvas, bounds, 7, 1, renaming ? p.pink : p.accent); } const char *shown = renaming ? session->text : - bongo_cat_model_name(&value->app->config, entry); + bongo_cat_model_name(&value->app->settings, entry); struct nk_rect text_bounds = nk_rect(bounds.x + 5, bounds.y + 3, bounds.w - 10, 22); if (renaming && session->select_all) @@ -55,7 +55,7 @@ void bongo_cat_preferences_model_rename_finish( &value->app->models, session->id); const char *label = entry && !strcmp(session->text, bongo_cat_model_default_name(entry)) ? "" : session->text; - label_changed = bongo_cat_config_set_model_label(&value->app->config, + label_changed = bongo_cat_settings_set_model_label(&value->app->settings, session->id, label); } bongo_cat_preferences_text_session_reset(session); @@ -69,7 +69,7 @@ void bongo_cat_preferences_model_rename_begin(BongoCatPreferences *value, if (!value || !entry) return; bongo_cat_preferences_shortcut_cancel(value); bongo_cat_preferences_text_session_begin(&value->model_rename, entry->id, - bongo_cat_model_name(&value->app->config, entry), bounds); + bongo_cat_model_name(&value->app->settings, entry), bounds); SDL_StartTextInput(value->window); value->render_dirty = true; } diff --git a/src/ui/preferences_pages.c b/src/ui/preferences_pages.c index 83a4d7be..ba181070 100644 --- a/src/ui/preferences_pages.c +++ b/src/ui/preferences_pages.c @@ -20,8 +20,9 @@ static void section_gap(struct nk_context *context, float pixels) { } void bongo_cat_preferences_page_cat(BongoCatApp *app, struct nk_context *context) { - BongoCatModelOptions *model = &app->config.model; - BongoCatWindowOptions *window = &app->config.window; + BongoCatModelPreferences *model = &app->settings.model; + BongoCatWindowPreferences *window = &app->settings.window; + BongoCatWindowState *window_state = &app->session.window; bongo_cat_pref_section(context, tr(app, "pages.preference.cat.labels.windowSettings", "Window Settings")); if (bongo_cat_pref_toggle(context, "pass-through", tr(app, @@ -49,28 +50,29 @@ void bongo_cat_preferences_page_cat(BongoCatApp *app, struct nk_context *context "color key filter."), &window->obs_background, &window->obs_background_color)) app->dirty = true; - float old_scale = window->scale_percent; + float old_scale = window_state->scale_percent; bongo_cat_pref_float(context, "window-size", tr(app, "pages.preference.cat.labels.windowSize", "Window Size"), tr(app, "composables.useAppMenu.labels.wheelSizeHint", "Wheel: resize"), - 10.0f, &window->scale_percent, 500.0f, 1.0f, + 10.0f, &window_state->scale_percent, 500.0f, 1.0f, BONGO_CAT_DEFAULT_WINDOW_SCALE_PERCENT); - if (old_scale != window->scale_percent && old_scale > 0.0f) { - float requested_scale = window->scale_percent; - window->scale_percent = old_scale; + if (old_scale != window_state->scale_percent && old_scale > 0.0f) { + float requested_scale = window_state->scale_percent; + window_state->scale_percent = old_scale; bongo_cat_window_cancel_wheel_animation(app); bongo_cat_window_set_scale(app, requested_scale); } - float old_opacity = window->opacity_percent; + float old_opacity = window_state->opacity_percent; bongo_cat_pref_slider(context, "opacity", tr(app, "pages.preference.cat.labels.opacity", "Opacity"), tr(app, "composables.useAppMenu.labels.wheelOpacityHint", "Ctrl+Wheel: opacity"), - 10.0f, &window->opacity_percent, 100.0f, 1.0f, + 10.0f, &window_state->opacity_percent, 100.0f, 1.0f, BONGO_CAT_DEFAULT_WINDOW_OPACITY_PERCENT); - if (old_opacity != window->opacity_percent) bongo_cat_window_cancel_wheel_animation(app); - if (old_opacity != window->opacity_percent && !app->hover_hidden) + if (old_opacity != window_state->opacity_percent) + bongo_cat_window_cancel_wheel_animation(app); + if (old_opacity != window_state->opacity_percent && !app->hover_hidden) bongo_cat_platform_set_opacity(&app->platform, - window->opacity_percent / 100.0f); + window_state->opacity_percent / 100.0f); section_gap(context, 10); bongo_cat_pref_section(context, tr(app, "pages.preference.cat.labels.modelSettings", @@ -109,14 +111,14 @@ void bongo_cat_preferences_page_cat(BongoCatApp *app, struct nk_context *context static void update_autostart(BongoCatApp *app, bool old_value) { BongoCatError error = {0}; - if (bongo_cat_platform_set_autostart(app->config.app.autostart, &error) == BONGO_CAT_OK) return; - app->config.app.autostart = old_value; + if (bongo_cat_platform_set_autostart(app->settings.app.autostart, &error) == BONGO_CAT_OK) return; + app->settings.app.autostart = old_value; SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "%s", error.message); bongo_cat_preferences_notice_show(app, error.message, true); } void bongo_cat_preferences_page_general(BongoCatApp *app, struct nk_context *context) { - BongoCatAppOptions *options = &app->config.app; + BongoCatApplicationPreferences *options = &app->settings.app; // Keep each option in its own native language so the list is recognizable // regardless of the language currently used by the settings window. const char *ui_languages[] = {"简体中文", "繁體中文", "English", diff --git a/src/ui/preferences_scale.c b/src/ui/preferences_scale.c index 894ab607..6fdf75ff 100644 --- a/src/ui/preferences_scale.c +++ b/src/ui/preferences_scale.c @@ -169,7 +169,7 @@ bool bongo_cat_preferences_open_window(BongoCatPreferences *value) { bongo_cat_preferences_assets_load(value); bongo_cat_memory_policy_ui_loaded(); value->style_theme = -1; - value->font_language = value->app->config.app.language; + value->font_language = value->app->settings.app.language; int pixel_width = 0, pixel_height = 0; SDL_GetWindowSizeInPixels(value->window, &pixel_width, &pixel_height); SDL_Log("Preferences GL ready: dedicated=%d transparent=%d pixels=%dx%d layout=%.2f raster=%.2f", diff --git a/src/ui/preferences_shortcuts.c b/src/ui/preferences_shortcuts.c index cdf01bad..01a4ed49 100644 --- a/src/ui/preferences_shortcuts.c +++ b/src/ui/preferences_shortcuts.c @@ -127,7 +127,7 @@ static bool capture_key(BongoCatPreferences *value, if (event->mod & SDL_KMOD_ALT) append(shortcut, sizeof(shortcut), "Alt"); if (event->mod & SDL_KMOD_GUI) append(shortcut, sizeof(shortcut), "Meta"); append(shortcut, sizeof(shortcut), key); - if (bongo_cat_config_shortcut_conflicts(&value->app->config, shortcut, + if (bongo_cat_settings_shortcut_conflicts(&value->app->settings, shortcut, value->shortcut_target)) { snprintf(value->shortcut_target, (size_t)value->shortcut_capacity, "%s", value->shortcut_original); @@ -178,7 +178,7 @@ static void shortcut_row(BongoCatPreferences *value, struct nk_context *context, void bongo_cat_preferences_page_shortcuts(BongoCatPreferences *value, struct nk_context *context) { - BongoCatShortcutOptions *keys = &value->app->config.shortcuts; + BongoCatShortcutPreferences *keys = &value->app->settings.shortcuts; bongo_cat_pref_section(context, tr(value, "pages.preference.shortcut.title", "Shortcuts")); shortcut_row(value, context, "shortcut-cat", "native.shortcut.toggleCat", diff --git a/src/ui/preferences_smoke.c b/src/ui/preferences_smoke.c index 250105d9..479daee5 100644 --- a/src/ui/preferences_smoke.c +++ b/src/ui/preferences_smoke.c @@ -41,7 +41,7 @@ static void write_window_handle(BongoCatPreferences *value) { SDL_PROP_WINDOW_WIN32_HWND_POINTER, NULL); #endif char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), value->app->data_root, + if (!bongo_cat_path_join(path, sizeof(path), value->app->state_root, "preferences-window.txt")) return; FILE *file = bongo_cat_file_open(path, "wb"); if (!file) return; @@ -55,7 +55,7 @@ static void write_window_handle(BongoCatPreferences *value) { void bongo_cat_preferences_record_frame(BongoCatPreferences *value) { if (!value->app->smoke_frame_series) return; char path[BONGO_CAT_PATH_CAP]; - bongo_cat_path_join(path, sizeof(path), value->app->data_root, + bongo_cat_path_join(path, sizeof(path), value->app->state_root, "preferences-frames.csv"); FILE *file = bongo_cat_file_open(path, "ab"); if (!file) return; @@ -89,7 +89,7 @@ void bongo_cat_preferences_smoke_frame(BongoCatPreferences *value) { for (int i = 0; i < 4; ++i) if (corners[i][3] > corner_alpha) corner_alpha = corners[i][3]; char path[BONGO_CAT_PATH_CAP]; - bongo_cat_path_join(path, sizeof(path), value->app->data_root, + bongo_cat_path_join(path, sizeof(path), value->app->state_root, "ui-frame.txt"); FILE *file = bongo_cat_file_open(path, "wb"); if (!file) return; diff --git a/tests/fixtures/model-packages/v1/.bongo-cat-package.json b/tests/fixtures/model-packages/v1/.bongo-cat-package.json deleted file mode 100644 index 9d34ab12..00000000 --- a/tests/fixtures/model-packages/v1/.bongo-cat-package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "schemaVersion": 1, - "layout": "preserved-payload", - "format": "tauri-live2d", - "mode": "standard", - "directory": "payload", - "adapter": "adapter", - "setting": "dummy.model3.json" -} diff --git a/tests/fixtures/model-packages/v1/adapter/.keep b/tests/fixtures/model-packages/v1/adapter/.keep deleted file mode 100644 index 8b137891..00000000 --- a/tests/fixtures/model-packages/v1/adapter/.keep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/tests/fixtures/model-packages/v1/payload/dummy.model3.json b/tests/fixtures/model-packages/v1/payload/dummy.model3.json deleted file mode 100644 index 0967ef42..00000000 --- a/tests/fixtures/model-packages/v1/payload/dummy.model3.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/tests/test_config.c b/tests/test_config.c index b0dfc120..f33b0f9a 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -1,9 +1,8 @@ #include "test.h" #include "bongo_cat/config.h" -#include "bongo_cat/model.h" #include "bongo_cat/file.h" +#include "bongo_cat/model.h" #include "bongo_cat/path.h" -#include "bongo_cat/utf8.h" #include #include @@ -24,182 +23,136 @@ static bool contains_text(const char *path, const char *needle) { return strstr(content, needle) != NULL; } -void test_config(void) { - BongoCatConfig value; - bongo_cat_config_defaults(&value); - CHECK(value.model.max_fps == 60); - CHECK(value.window.width == 612 && value.window.height == 354); - CHECK(value.window.visible && value.window.always_on_top); - CHECK(!value.window.keep_in_screen); - CHECK(!value.window.obs_background); - CHECK(value.window.obs_background_color == BONGO_CAT_OBS_BACKGROUND_GREEN); - const uint32_t obs_colors[] = { - 0x00ff00, 0x0000ff, 0xff0000, 0xff00ff}; - const char *obs_names[] = { - "#00ff00", "#0000ff", "#ff0000", "#ff00ff"}; - for (int i = 0; i < BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT; ++i) { - CHECK(bongo_cat_obs_background_color_rgb(i) == obs_colors[i]); - CHECK(strcmp(bongo_cat_obs_background_color_name(i), obs_names[i]) == 0); - } - CHECK(value.model.mouse_centered); - - value.model.max_fps = 900; - value.window.scale_percent = -2.0f; - value.window.opacity_percent = 123.0f; - value.window.obs_background_color = BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT; - bongo_cat_config_validate(&value); - CHECK(value.model.max_fps == 240); - CHECK(value.window.scale_percent == 10.0f); - CHECK(value.window.opacity_percent == 100.0f); - CHECK(value.window.obs_background_color == BONGO_CAT_OBS_BACKGROUND_GREEN); +static void check_defaults_and_validation(void) { + BongoCatSettings settings; + BongoCatSessionState session; + bongo_cat_settings_defaults(&settings); + bongo_cat_session_defaults(&session); + CHECK(settings.model.max_fps == 60 && settings.model.mouse_centered); + CHECK(settings.window.always_on_top && !settings.window.keep_in_screen); + CHECK(!settings.window.obs_background); + CHECK(settings.window.obs_background_color == + BONGO_CAT_OBS_BACKGROUND_GREEN); + CHECK(session.window.visible && session.window.width == 612 && + session.window.height == 354); + CHECK(strcmp(session.active_model_id, "standard") == 0); - BongoCatConfig duplicates; - bongo_cat_config_defaults(&duplicates); - memcpy(duplicates.shortcuts.visible_cat, "Control+B", sizeof("Control+B")); - memcpy(duplicates.shortcuts.visible_preferences, "control+b", sizeof("control+b")); - duplicates.behavior_shortcut_count = 2; - memcpy(duplicates.behavior_shortcuts[0].id, "motion:0", sizeof("motion:0")); - memcpy(duplicates.behavior_shortcuts[0].shortcut, "CONTROL+B", sizeof("CONTROL+B")); - memcpy(duplicates.behavior_shortcuts[1].id, "motion:1", sizeof("motion:1")); - memcpy(duplicates.behavior_shortcuts[1].shortcut, "Control+M", sizeof("Control+M")); - bongo_cat_config_validate(&duplicates); - CHECK(strcmp(duplicates.shortcuts.visible_cat, "Control+B") == 0); - CHECK(!duplicates.shortcuts.visible_preferences[0]); - CHECK(!duplicates.behavior_shortcuts[0].shortcut[0]); - CHECK(strcmp(duplicates.behavior_shortcuts[1].shortcut, "Control+M") == 0); - CHECK(bongo_cat_config_shortcut_conflicts(&duplicates, "control+b", NULL)); - CHECK(!bongo_cat_config_shortcut_conflicts(&duplicates, "control+b", - duplicates.shortcuts.visible_cat)); - memcpy(duplicates.behavior_shortcuts[1].label, "\xc4\xe3", - sizeof("\xc4\xe3")); - bongo_cat_config_validate(&duplicates); - CHECK(bongo_cat_utf8_valid(duplicates.behavior_shortcuts[1].label)); - - value.model.max_fps = 30; - value.model.mirror = true; - value.model.mouse_centered = false; - value.window.pass_through = true; - value.window.obs_background = true; - value.window.obs_background_color = BONGO_CAT_OBS_BACKGROUND_BLUE; - value.window.x = -321; - value.window.opacity_percent = 75.0f; - value.app.language = BONGO_CAT_LANG_ZH_CN; - memcpy(value.current_model, "keyboard", sizeof("keyboard")); - value.current_mode = BONGO_CAT_MODE_KEYBOARD; - value.behavior_shortcut_count = 1; - memcpy(value.behavior_shortcuts[0].id, "keyboard:motion:Tap:0", - sizeof("keyboard:motion:Tap:0")); - memcpy(value.behavior_shortcuts[0].shortcut, "Control+1", sizeof("Control+1")); - memcpy(value.behavior_shortcuts[0].label, "Happy tap", sizeof("Happy tap")); - memcpy(value.model_labels[0].id, "mver-demo-standard", - sizeof("mver-demo-standard")); - memcpy(value.model_labels[0].label, "Lucia", sizeof("Lucia")); - value.model_label_count = 1; + settings.model.max_fps = 900; + settings.window.obs_background_color = BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT; + session.window.scale_percent = -2.0f; + session.window.opacity_percent = 123.0f; + bongo_cat_settings_validate(&settings); + bongo_cat_session_validate(&session); + CHECK(settings.model.max_fps == 240); + CHECK(settings.window.obs_background_color == + BONGO_CAT_OBS_BACKGROUND_GREEN); + CHECK(session.window.scale_percent == 10.0f); + CHECK(session.window.opacity_percent == 100.0f); +} - const char *preferences = "bongo-cat-\xE5\x81\x8F\xE5\xA5\xBD.json"; - const char *session = "bongo-cat-\xE4\xBC\x9A\xE8\xAF\x9D.json"; - BongoCatError error = {0}; - CHECK(bongo_cat_preferences_save(preferences, &value, &error) == BONGO_CAT_OK); - CHECK(!contains_text(preferences, "\"motionSound\"")); - CHECK(!contains_text(preferences, "\"behavior\"")); - CHECK(!contains_text(preferences, "\"taskbarVisible\"")); - CHECK(contains_text(preferences, "\"obsBackground\": true")); - CHECK(contains_text(preferences, "\"obsBackgroundColor\": \"#0000ff\"")); - CHECK(bongo_cat_session_save(session, &value, &error) == BONGO_CAT_OK); - CHECK(bongo_cat_path_is_file(preferences)); - CHECK(bongo_cat_path_is_file(session)); +static void check_shortcuts(void) { + BongoCatSettings settings; + bongo_cat_settings_defaults(&settings); + memcpy(settings.shortcuts.visible_cat, "Control+B", sizeof("Control+B")); + memcpy(settings.shortcuts.visible_preferences, "control+b", + sizeof("control+b")); + settings.behavior_shortcut_count = 2; + memcpy(settings.behavior_shortcuts[0].id, "motion:0", sizeof("motion:0")); + memcpy(settings.behavior_shortcuts[0].shortcut, "CONTROL+B", + sizeof("CONTROL+B")); + memcpy(settings.behavior_shortcuts[1].id, "motion:1", sizeof("motion:1")); + memcpy(settings.behavior_shortcuts[1].shortcut, "Control+M", + sizeof("Control+M")); + bongo_cat_settings_validate(&settings); + CHECK(strcmp(settings.shortcuts.visible_cat, "Control+B") == 0); + CHECK(!settings.shortcuts.visible_preferences[0]); + CHECK(!settings.behavior_shortcuts[0].shortcut[0]); + CHECK(strcmp(settings.behavior_shortcuts[1].shortcut, "Control+M") == 0); + CHECK(bongo_cat_settings_shortcut_conflicts(&settings, "control+b", NULL)); + CHECK(!bongo_cat_settings_shortcut_conflicts(&settings, "control+b", + settings.shortcuts.visible_cat)); +} - BongoCatConfig loaded; - bongo_cat_config_defaults(&loaded); - CHECK(bongo_cat_preferences_load(preferences, &loaded, &error) == BONGO_CAT_OK); - CHECK(loaded.model.max_fps == 30 && loaded.model.mirror); - CHECK(!loaded.model.mouse_centered); - CHECK(loaded.window.pass_through); - CHECK(loaded.window.obs_background); - CHECK(loaded.window.obs_background_color == BONGO_CAT_OBS_BACKGROUND_BLUE); - CHECK(loaded.window.x == 0); - CHECK(loaded.window.opacity_percent == 100.0f); - CHECK(loaded.app.language == BONGO_CAT_LANG_ZH_CN); - CHECK(strcmp(loaded.current_model, "standard") == 0); - CHECK(loaded.behavior_shortcut_count == 1); - CHECK(strcmp(loaded.behavior_shortcuts[0].shortcut, "Control+1") == 0); - CHECK(strcmp(loaded.behavior_shortcuts[0].label, "Happy tap") == 0); - CHECK(loaded.model_label_count == 1); - CHECK(strcmp(bongo_cat_config_model_label(&loaded, - "mver-demo-standard"), "Lucia") == 0); - CHECK(strcmp(bongo_cat_model_name(&loaded, - &(BongoCatModelEntry){.id = "mver-demo-standard", - .display_name = "Nearby"}), "Lucia") == 0); - CHECK(bongo_cat_config_set_model_label(&loaded, - "mver-demo-standard", "New name")); - CHECK(strcmp(bongo_cat_config_model_label(&loaded, - "mver-demo-standard"), "New name") == 0); - CHECK(bongo_cat_config_set_model_label(&loaded, - "mver-demo-standard", "")); - CHECK(bongo_cat_config_model_label(&loaded, - "mver-demo-standard") == NULL); +void test_config(void) { + check_defaults_and_validation(); + check_shortcuts(); + const uint32_t colors[] = {0x00ff00, 0x0000ff, 0xff0000, 0xff00ff}; + for (int i = 0; i < BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT; ++i) + CHECK(bongo_cat_obs_background_color_rgb(i) == colors[i]); - for (int language = 0; language < BONGO_CAT_LANG_COUNT; ++language) { - value.app.language = (BongoCatLanguage)language; - CHECK(bongo_cat_preferences_save(preferences, &value, &error) == BONGO_CAT_OK); - BongoCatConfig language_loaded; - bongo_cat_config_defaults(&language_loaded); - CHECK(bongo_cat_preferences_load(preferences, &language_loaded, - &error) == BONGO_CAT_OK); - CHECK(language_loaded.app.language == (BongoCatLanguage)language); - } + BongoCatSettings settings; + BongoCatSessionState session; + bongo_cat_settings_defaults(&settings); + bongo_cat_session_defaults(&session); + settings.model.max_fps = 30; + settings.model.mirror = true; + settings.model.mouse_centered = false; + settings.window.pass_through = true; + settings.window.obs_background = true; + settings.window.obs_background_color = BONGO_CAT_OBS_BACKGROUND_BLUE; + settings.app.language = BONGO_CAT_LANG_ZH_CN; + memcpy(settings.extensions_json, "{\"example\":{\"enabled\":true}}", + sizeof("{\"example\":{\"enabled\":true}}")); + settings.behavior_shortcut_count = 1; + memcpy(settings.behavior_shortcuts[0].id, "model:motion:Tap:0", + sizeof("model:motion:Tap:0")); + memcpy(settings.behavior_shortcuts[0].shortcut, "Control+1", + sizeof("Control+1")); + memcpy(settings.behavior_shortcuts[0].label, "Happy tap", + sizeof("Happy tap")); + CHECK(bongo_cat_settings_set_model_label(&settings, "model", "Display")); + session.window.x = -321; + session.window.position_known = true; + session.window.opacity_percent = 75.0f; + memcpy(session.active_model_id, "model", sizeof("model")); - CHECK(bongo_cat_session_load(session, &loaded, &error) == BONGO_CAT_OK); - CHECK(loaded.window.x == -321); - CHECK(loaded.window.opacity_percent == 75.0f); - CHECK(strcmp(loaded.current_model, "keyboard") == 0); - CHECK(loaded.current_mode == BONGO_CAT_MODE_GAMEPAD); + const char *settings_path = "bongocat-settings.json"; + const char *session_path = "bongocat-session.json"; + BongoCatError error = {0}; + CHECK(bongo_cat_settings_save(settings_path, &settings, &error) == + BONGO_CAT_OK); + CHECK(bongo_cat_session_save(session_path, &session, &error) == + BONGO_CAT_OK); + CHECK(contains_text(settings_path, "\"format\": \"bongocat/settings\"")); + CHECK(contains_text(settings_path, "\"captureBackground\": true")); + CHECK(contains_text(settings_path, "\"example\"")); + CHECK(!contains_text(settings_path, "activeModelId")); + CHECK(contains_text(session_path, "\"format\": \"bongocat/session\"")); + CHECK(contains_text(session_path, "\"activeModelId\": \"model\"")); + CHECK(!contains_text(session_path, "clickThrough")); - const char *legacy_switches = "bongo-cat-legacy-switches.json"; - write_text(legacy_switches, - "{\"format\":\"bongo-cat/preferences\",\"version\":2," - "\"model\":{\"maxFPS\":42,\"motionSound\":false,\"behavior\":false}}"); - CHECK(bongo_cat_preferences_load(legacy_switches, &loaded, &error) == BONGO_CAT_OK); - CHECK(loaded.model.max_fps == 42); - CHECK(bongo_cat_preferences_save(legacy_switches, &loaded, &error) == BONGO_CAT_OK); - CHECK(!contains_text(legacy_switches, "\"motionSound\"")); - CHECK(!contains_text(legacy_switches, "\"behavior\"")); + BongoCatSettings loaded_settings; + BongoCatSessionState loaded_session; + bongo_cat_settings_defaults(&loaded_settings); + bongo_cat_session_defaults(&loaded_session); + CHECK(bongo_cat_settings_load(settings_path, &loaded_settings, &error) == + BONGO_CAT_OK); + CHECK(bongo_cat_session_load(session_path, &loaded_session, &error) == + BONGO_CAT_OK); + CHECK(loaded_settings.model.max_fps == 30 && loaded_settings.model.mirror); + CHECK(loaded_settings.window.pass_through && + loaded_settings.window.obs_background); + CHECK(loaded_settings.app.language == BONGO_CAT_LANG_ZH_CN); + CHECK(strstr(loaded_settings.extensions_json, + "\"enabled\":true") != NULL); + CHECK(strcmp(bongo_cat_model_name(&loaded_settings, + &(BongoCatModelEntry){.id = "model", .display_name = "Nearby"}), + "Display") == 0); + CHECK(loaded_session.window.x == -321 && + loaded_session.window.opacity_percent == 75.0f); + CHECK(strcmp(loaded_session.active_model_id, "model") == 0); - const char *unsupported = "bongo-cat-unsupported.json"; - write_text(unsupported, "{\"schemaVersion\":2,\"model\":{\"maxFPS\":1}}"); - int preserved_fps = loaded.model.max_fps; - CHECK(bongo_cat_preferences_load(unsupported, &loaded, &error) == - BONGO_CAT_ERROR_FORMAT); - CHECK(loaded.model.max_fps == preserved_fps); + const char *unsupported = "bongocat-unsupported.json"; write_text(unsupported, - "{\"format\":\"bongo-cat/preferences\",\"version\":1,\"model\":{\"maxFPS\":1}}"); - CHECK(bongo_cat_preferences_load(unsupported, &loaded, &error) == + "{\"format\":\"bongocat/settings\",\"schemaVersion\":2}"); + CHECK(bongo_cat_settings_load(unsupported, &loaded_settings, &error) == BONGO_CAT_ERROR_FORMAT); - CHECK(loaded.model.max_fps == preserved_fps); write_text(unsupported, - "{\"format\":\"bongo-cat/preferences\",\"version\":3,\"model\":{\"maxFPS\":1}}"); - CHECK(bongo_cat_preferences_load(unsupported, &loaded, &error) == - BONGO_CAT_ERROR_FORMAT); - - const char *broken_session = "bongo-cat-broken-session.json"; - write_text(broken_session, "{ invalid"); - bool preserved_mirror = loaded.model.mirror; - CHECK(bongo_cat_session_load(broken_session, &loaded, &error) == - BONGO_CAT_ERROR_FORMAT); - CHECK(loaded.model.mirror == preserved_mirror); - write_text(broken_session, - "{\"format\":\"bongo-cat/session\",\"version\":1,\"window\":{\"visible\":false}}"); - CHECK(bongo_cat_session_load(broken_session, &loaded, &error) == - BONGO_CAT_ERROR_FORMAT); - CHECK(loaded.model.mirror == preserved_mirror); - write_text(broken_session, - "{\"format\":\"bongo-cat/session\",\"version\":3,\"window\":{\"visible\":false}}"); - CHECK(bongo_cat_session_load(broken_session, &loaded, &error) == + "{\"format\":\"bongocat/session\",\"schemaVersion\":2}"); + CHECK(bongo_cat_session_load(unsupported, &loaded_session, &error) == BONGO_CAT_ERROR_FORMAT); - CHECK(bongo_cat_file_remove(preferences)); - CHECK(bongo_cat_file_remove(session)); + CHECK(bongo_cat_file_remove(settings_path)); + CHECK(bongo_cat_file_remove(session_path)); CHECK(bongo_cat_file_remove(unsupported)); - CHECK(bongo_cat_file_remove(broken_session)); - CHECK(bongo_cat_file_remove(legacy_switches)); } diff --git a/tests/test_models.c b/tests/test_models.c index a7f006d5..7c152697 100644 --- a/tests/test_models.c +++ b/tests/test_models.c @@ -150,13 +150,9 @@ void test_models(void) { CHECK(bongo_cat_models_scan(&catalog, BONGO_CAT_NATIVE_SOURCE_DIR "/tests/fixtures/model-packages", false, &error) == BONGO_CAT_OK); - CHECK(catalog.count == 2); - const BongoCatModelEntry *v1 = bongo_cat_models_find(&catalog, "v1"); + CHECK(catalog.count == 1); const BongoCatModelEntry *v2 = bongo_cat_models_find(&catalog, "test-package-v2"); - CHECK(v1 && v1->package_schema == 1 && - strcmp(v1->package_id, "v1") == 0 && - v1->source_format == BONGO_CAT_MODEL_SOURCE_TAURI); CHECK(v2 && v2->package_schema == 2 && strcmp(v2->content_digest, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") == 0 && diff --git a/tests/test_mver_import.c b/tests/test_mver_import.c index 1c953da5..d9b743bc 100644 --- a/tests/test_mver_import.c +++ b/tests/test_mver_import.c @@ -23,7 +23,7 @@ const char *test_mver_pointer_config(bool live2d); bool test_mver_pointer_fixture_assets(const char *standard, const char *source); bool test_mver_pointer_adapter(const char *adapter, bool expected_enabled); int test_preferences_text(void); -int test_mver_portable_identity(void); +int test_mver_nearby_identity(void); static bool chord(const char *json, bool gamepad, const char *expected) { yyjson_doc *document = yyjson_read(json, strlen(json), 0); @@ -78,8 +78,9 @@ static void metadata_backfills_labels(void) { snprintf(root, sizeof(root), "%s/bongo-cat-metadata-%llu", temporary, (unsigned long long)SDL_GetTicksNS()); CHECK(SDL_CreateDirectory(root)); - CHECK(child(path, sizeof(path), root, ".bongo-cat-mver.json", false)); - CHECK(write_text(path, "{\"version\":1,\"bindings\":[" + CHECK(child(path, sizeof(path), root, ".bongo-cat-adapter.json", false)); + CHECK(write_text(path, "{\"schemaVersion\":1," + "\"kind\":\"bongo-cat-runtime-adapter\",\"bindings\":[" "{\"kind\":\"motion\",\"group\":\"CAT_motion_lock\",\"index\":0," "\"shortcut\":\"Alt+1\",\"label\":\"\xE9\x94\xAE\xE7\x9B\x98\xE6\xB6\x88\xE5\xA4\xB1\"}," "{\"kind\":\"motion\",\"group\":\"CAT_motion_lock\",\"index\":1," @@ -89,25 +90,25 @@ static void metadata_backfills_labels(void) { BongoCatApp *app = calloc(1, sizeof(*app)); CHECK(app != NULL); if (app) { - app->config.behavior_shortcut_count = 2; - snprintf(app->config.behavior_shortcuts[0].id, - sizeof(app->config.behavior_shortcuts[0].id), + app->settings.behavior_shortcut_count = 2; + snprintf(app->settings.behavior_shortcuts[0].id, + sizeof(app->settings.behavior_shortcuts[0].id), "model:motion:CAT_motion_lock:0"); - snprintf(app->config.behavior_shortcuts[0].shortcut, - sizeof(app->config.behavior_shortcuts[0].shortcut), "Control+1"); - snprintf(app->config.behavior_shortcuts[1].id, - sizeof(app->config.behavior_shortcuts[1].id), + snprintf(app->settings.behavior_shortcuts[0].shortcut, + sizeof(app->settings.behavior_shortcuts[0].shortcut), "Control+1"); + snprintf(app->settings.behavior_shortcuts[1].id, + sizeof(app->settings.behavior_shortcuts[1].id), "model:motion:CAT_motion_lock:1"); - snprintf(app->config.behavior_shortcuts[1].label, - sizeof(app->config.behavior_shortcuts[1].label), "Custom name"); + snprintf(app->settings.behavior_shortcuts[1].label, + sizeof(app->settings.behavior_shortcuts[1].label), "Custom name"); bongo_cat_import_apply_metadata(app, "model", root); - CHECK(app->config.behavior_shortcut_count == 3); - CHECK(strcmp(app->config.behavior_shortcuts[0].shortcut, "Control+1") == 0); - CHECK(strcmp(app->config.behavior_shortcuts[0].label, keyboard_hidden) == 0); - CHECK(strcmp(app->config.behavior_shortcuts[1].label, "Custom name") == 0); - CHECK(strcmp(app->config.behavior_shortcuts[2].id, + CHECK(app->settings.behavior_shortcut_count == 3); + CHECK(strcmp(app->settings.behavior_shortcuts[0].shortcut, "Control+1") == 0); + CHECK(strcmp(app->settings.behavior_shortcuts[0].label, keyboard_hidden) == 0); + CHECK(strcmp(app->settings.behavior_shortcuts[1].label, "Custom name") == 0); + CHECK(strcmp(app->settings.behavior_shortcuts[2].id, "model:expression:2") == 0); - CHECK(strcmp(app->config.behavior_shortcuts[2].label, + CHECK(strcmp(app->settings.behavior_shortcuts[2].label, "Expression label") == 0); free(app); } @@ -133,9 +134,9 @@ static void behavior_labels_add_font_glyphs(void) { sizeof(app->behaviors.entries[0].label), "\xE9\x94\xAE\xE7\x9B\x98\xE6\xB6\x88\xE5\xA4\xB1"); app->behaviors.count = 1; - memcpy(app->config.behavior_shortcuts[0].label, custom_label, + memcpy(app->settings.behavior_shortcuts[0].label, custom_label, sizeof(custom_label)); - app->config.behavior_shortcut_count = 1; + app->settings.behavior_shortcut_count = 1; uint32_t ranges[64] = {0x20, 0x7e, 0}; bongo_cat_preferences_model_glyphs(app, ranges, sizeof(ranges) / sizeof(ranges[0])); @@ -163,7 +164,7 @@ static void font_reload_defers_during_frame(void) { NULL, NULL, NULL, NULL, NULL, 1.0f)); } -static void portable_model(void) { +static void nearby_model(void) { char root[BONGO_CAT_PATH_CAP], package[BONGO_CAT_PATH_CAP]; char data[BONGO_CAT_PATH_CAP], mode[BONGO_CAT_PATH_CAP]; char bundle[BONGO_CAT_PATH_CAP], nested_package[BONGO_CAT_PATH_CAP]; @@ -175,20 +176,20 @@ static void portable_model(void) { CHECK(temporary != NULL); if (!temporary) return; unsigned long long stamp = (unsigned long long)SDL_GetTicksNS(); - snprintf(root, sizeof(root), "%s/bongo-cat-portable-%llu", temporary, + snprintf(root, sizeof(root), "%s/bongo-cat-nearby-%llu", temporary, stamp); - snprintf(data, sizeof(data), "%s/bongo-cat-portable-data-%llu", temporary, + snprintf(data, sizeof(data), "%s/bongo-cat-nearby-data-%llu", temporary, stamp); bongo_cat_model_remove_tree(root, NULL); bongo_cat_model_remove_tree(data, NULL); CHECK(SDL_CreateDirectory(root)); CHECK(SDL_CreateDirectory(data)); CHECK(child(package, sizeof(package), root, "app", true)); - CHECK(portable_fixture(package)); + CHECK(nearby_fixture(package)); CHECK(child(bundle, sizeof(bundle), root, "bundle", true)); CHECK(child(launch, sizeof(launch), root, "launch", true)); CHECK(child(nested_package, sizeof(nested_package), bundle, "model", true)); - CHECK(portable_fixture(nested_package)); + CHECK(nearby_fixture(nested_package)); CHECK(child(variant, sizeof(variant), bundle, "variant", true)); CHECK(child(variant_model, sizeof(variant_model), variant, "model", true)); CHECK(child(variant_img, sizeof(variant_img), variant_model, "img", true)); @@ -217,15 +218,17 @@ static void portable_model(void) { BongoCatApp *app = calloc(1, sizeof(*app)); CHECK(app != NULL); if (!app) { bongo_cat_model_remove_tree(root, NULL); SDL_free(temporary); return; } - bongo_cat_config_defaults(&app->config); + bongo_cat_settings_defaults(&app->settings); + bongo_cat_session_defaults(&app->session); bongo_cat_models_init(&app->models); snprintf(app->data_root, sizeof(app->data_root), "%s", data); - CHECK(bongo_cat_import_portable_mver(app, package, &error) == BONGO_CAT_OK); + snprintf(app->cache_root, sizeof(app->cache_root), "%s", data); + CHECK(bongo_cat_import_nearby_mver_root(app, package, &error) == BONGO_CAT_OK); CHECK(app->models.count == 1); CHECK(app->models.entries[0].managed && !app->models.entries[0].preset); CHECK(strcmp(app->models.entries[0].display_name, "app") == 0); CHECK(strcmp(app->models.entries[0].storage_directory, package) == 0); - CHECK(strcmp(app->config.current_model, app->models.entries[0].id) == 0); + CHECK(strcmp(app->session.active_model_id, app->models.entries[0].id) == 0); char adapter_file[BONGO_CAT_PATH_CAP]; CHECK(child(adapter_file, sizeof(adapter_file), app->models.entries[0].adapter_directory, "resources/left-keys/KeyA.png", false)); @@ -243,17 +246,17 @@ static void portable_model(void) { render.pointer_top == 200 && render.pointer_right == 2100 && render.pointer_bottom == 1400); bongo_cat_models_init(&app->models); - CHECK(bongo_cat_import_portable_mver(app, package, &error) == BONGO_CAT_OK); + CHECK(bongo_cat_import_nearby_mver_root(app, package, &error) == BONGO_CAT_OK); CHECK(app->models.count == 1); bongo_cat_models_init(&app->models); - CHECK(bongo_cat_import_portable_mver(app, mode, &error) == BONGO_CAT_OK); + CHECK(bongo_cat_import_nearby_mver_root(app, mode, &error) == BONGO_CAT_OK); CHECK(app->models.count == 1); CHECK(strcmp(app->models.entries[0].storage_directory, package) == 0); bongo_cat_models_init(&app->models); - CHECK(bongo_cat_import_portable_mver(app, root, &error) == BONGO_CAT_OK); + CHECK(bongo_cat_import_nearby_mver_root(app, root, &error) == BONGO_CAT_OK); CHECK(app->models.count == 3); bongo_cat_models_init(&app->models); - CHECK(bongo_cat_import_portable_mver_scan(app, root, &error) == BONGO_CAT_OK); + CHECK(bongo_cat_import_nearby_mver_scan(app, root, &error) == BONGO_CAT_OK); CHECK(app->models.count == 3); bongo_cat_models_init(&app->models); CHECK(bongo_cat_import_nearby_mver(app, launch, &error) == BONGO_CAT_OK); @@ -265,7 +268,7 @@ static void portable_model(void) { CHECK(child(mode, sizeof(mode), package, "config.json", false)); CHECK(write_text(mode, test_mver_pointer_config(false))); bongo_cat_models_init(&app->models); - CHECK(bongo_cat_import_portable_mver(app, package, &error) == BONGO_CAT_OK); + CHECK(bongo_cat_import_nearby_mver_root(app, package, &error) == BONGO_CAT_OK); CHECK(app->models.count == 1); CHECK(test_mver_pointer_adapter( app->models.entries[0].adapter_directory, true)); @@ -294,7 +297,7 @@ int main(void) { metadata_backfills_labels(); behavior_labels_add_font_glyphs(); font_reload_defers_during_frame(); - failures += test_mver_portable_identity(); - portable_model(); + failures += test_mver_nearby_identity(); + nearby_model(); return failures ? 1 : 0; } diff --git a/tests/test_mver_portable_identity.c b/tests/test_mver_nearby_identity.c similarity index 70% rename from tests/test_mver_portable_identity.c rename to tests/test_mver_nearby_identity.c index 12edd156..4d543abc 100644 --- a/tests/test_mver_portable_identity.c +++ b/tests/test_mver_nearby_identity.c @@ -16,7 +16,7 @@ static int failures; failures++; \ } } while (0) -int test_mver_portable_identity(void) { +int test_mver_nearby_identity(void) { failures = 0; char *temporary = SDL_GetCurrentDirectory(); CHECK(temporary != NULL); @@ -25,9 +25,9 @@ int test_mver_portable_identity(void) { char source[BONGO_CAT_PATH_CAP], moved[BONGO_CAT_PATH_CAP]; char again[BONGO_CAT_PATH_CAP], duplicate[BONGO_CAT_PATH_CAP]; unsigned long long nonce = (unsigned long long)SDL_GetTicksNS(); - snprintf(root, sizeof(root), "%s/bongo-cat-portable-move-%llu", + snprintf(root, sizeof(root), "%s/bongo-cat-nearby-move-%llu", temporary, nonce); - snprintf(data, sizeof(data), "%s/bongo-cat-portable-data-%llu", + snprintf(data, sizeof(data), "%s/bongo-cat-nearby-data-%llu", temporary, nonce); CHECK(SDL_CreateDirectory(root)); CHECK(SDL_CreateDirectory(data)); @@ -35,13 +35,15 @@ int test_mver_portable_identity(void) { CHECK(child(moved, sizeof(moved), root, "moved", false)); CHECK(child(again, sizeof(again), root, "again", false)); CHECK(child(duplicate, sizeof(duplicate), root, "duplicate", false)); - CHECK(portable_fixture(source)); + CHECK(nearby_fixture(source)); BongoCatApp *app = calloc(1, sizeof(*app)); CHECK(app != NULL); if (!app) goto cleanup; - bongo_cat_config_defaults(&app->config); + bongo_cat_settings_defaults(&app->settings); + bongo_cat_session_defaults(&app->session); bongo_cat_models_init(&app->models); snprintf(app->data_root, sizeof(app->data_root), "%s", data); + snprintf(app->cache_root, sizeof(app->cache_root), "%s", data); BongoCatError error = {0}; BongoCatImportReceipt first_install = {0}, repeated_install = {0}; CHECK(bongo_cat_import_install(source, data, &first_install, &error) == @@ -54,7 +56,7 @@ int test_mver_portable_identity(void) { !repeated_install.installed[0] && strcmp(first_install.ids[0], repeated_install.ids[0]) == 0); char custom_root[BONGO_CAT_PATH_CAP], adapter_metadata[BONGO_CAT_PATH_CAP]; - CHECK(child(custom_root, sizeof(custom_root), data, "custom-models", false)); + CHECK(child(custom_root, sizeof(custom_root), data, "models", false)); BongoCatModelCatalog *installed = calloc(1, sizeof(*installed)); CHECK(installed != NULL); if (installed) { @@ -71,44 +73,44 @@ int test_mver_portable_identity(void) { CHECK(bongo_cat_path_is_file(adapter_metadata)); free(installed); } - CHECK(bongo_cat_import_portable_mver(app, source, &error) == BONGO_CAT_OK); + CHECK(bongo_cat_import_nearby_mver_root(app, source, &error) == BONGO_CAT_OK); CHECK(app->models.count == 1); char old_id[BONGO_CAT_ID_CAP]; snprintf(old_id, sizeof(old_id), "%s", app->models.entries[0].id); - CHECK(bongo_cat_config_set_model_label(&app->config, old_id, - "Portable custom name")); + CHECK(bongo_cat_settings_set_model_label(&app->settings, old_id, + "Nearby custom name")); BongoCatBehaviorShortcut *binding = - &app->config.behavior_shortcuts[app->config.behavior_shortcut_count++]; + &app->settings.behavior_shortcuts[app->settings.behavior_shortcut_count++]; snprintf(binding->id, sizeof(binding->id), "%s:expression:2", old_id); snprintf(binding->shortcut, sizeof(binding->shortcut), "Alt+3"); snprintf(binding->label, sizeof(binding->label), "Custom expression"); CHECK(bongo_cat_path_rename(source, moved)); bongo_cat_models_init(&app->models); - CHECK(bongo_cat_import_portable_mver(app, moved, &error) == BONGO_CAT_OK); + CHECK(bongo_cat_import_nearby_mver_root(app, moved, &error) == BONGO_CAT_OK); CHECK(app->models.count == 1); const char *new_id = app->models.entries[0].id; CHECK(strcmp(old_id, new_id) != 0); - CHECK(strcmp(app->config.current_model, new_id) == 0); - CHECK(strcmp(bongo_cat_config_model_label(&app->config, new_id), - "Portable custom name") == 0); - CHECK(bongo_cat_config_model_label(&app->config, old_id) == NULL); - CHECK(app->config.behavior_shortcut_count == 1); - CHECK(strncmp(app->config.behavior_shortcuts[0].id, new_id, - strlen(new_id)) == 0); - CHECK(strcmp(app->config.behavior_shortcuts[0].shortcut, "Alt+3") == 0); - CHECK(strcmp(app->config.behavior_shortcuts[0].label, + CHECK(strcmp(app->session.active_model_id, old_id) == 0); + CHECK(bongo_cat_settings_model_label(&app->settings, new_id) == NULL); + CHECK(strcmp(bongo_cat_settings_model_label(&app->settings, old_id), + "Nearby custom name") == 0); + CHECK(app->settings.behavior_shortcut_count == 1); + CHECK(strncmp(app->settings.behavior_shortcuts[0].id, old_id, + strlen(old_id)) == 0); + CHECK(strcmp(app->settings.behavior_shortcuts[0].shortcut, "Alt+3") == 0); + CHECK(strcmp(app->settings.behavior_shortcuts[0].label, "Custom expression") == 0); char ambiguous_id[BONGO_CAT_ID_CAP]; snprintf(ambiguous_id, sizeof(ambiguous_id), "%s", new_id); - CHECK(bongo_cat_config_set_model_label(&app->config, ambiguous_id, + CHECK(bongo_cat_settings_set_model_label(&app->settings, ambiguous_id, "Must remain here")); CHECK(bongo_cat_path_rename(moved, again)); - CHECK(portable_fixture(duplicate)); + CHECK(nearby_fixture(duplicate)); bongo_cat_models_init(&app->models); - CHECK(bongo_cat_import_portable_mver(app, root, &error) == BONGO_CAT_OK); - CHECK(app->models.count == 2); - CHECK(strcmp(app->config.current_model, ambiguous_id) == 0); - CHECK(strcmp(bongo_cat_config_model_label(&app->config, ambiguous_id), + CHECK(bongo_cat_import_nearby_mver_root(app, root, &error) == BONGO_CAT_OK); + CHECK(app->models.count == 1); + CHECK(strcmp(app->session.active_model_id, old_id) == 0); + CHECK(strcmp(bongo_cat_settings_model_label(&app->settings, ambiguous_id), "Must remain here") == 0); free(app); cleanup: diff --git a/tests/test_mver_support.c b/tests/test_mver_support.c index 7073bf89..5ae35bc7 100644 --- a/tests/test_mver_support.c +++ b/tests/test_mver_support.c @@ -25,7 +25,7 @@ bool child(char *output, size_t capacity, const char *root, (!directory || SDL_CreateDirectory(output)); } -bool portable_fixture(const char *root) { +bool nearby_fixture(const char *root) { char image[BONGO_CAT_PATH_CAP], standard[BONGO_CAT_PATH_CAP]; char hand[BONGO_CAT_PATH_CAP], model[BONGO_CAT_PATH_CAP]; char path[BONGO_CAT_PATH_CAP], source[BONGO_CAT_PATH_CAP]; diff --git a/tests/test_mver_support.h b/tests/test_mver_support.h index 899d47e2..73160ba6 100644 --- a/tests/test_mver_support.h +++ b/tests/test_mver_support.h @@ -7,6 +7,6 @@ bool write_text(const char *path, const char *text); bool child(char *output, size_t capacity, const char *root, const char *name, bool directory); -bool portable_fixture(const char *root); +bool nearby_fixture(const char *root); #endif From b2497160c5fecf957abd82f3bbfaca628eff4804 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 15 Aug 2026 14:36:55 +0800 Subject: [PATCH 012/334] refactor: replace registry autostart with startup shortcut --- docs/storage-layout.md | 3 ++ src/platform/windows_startup.c | 80 ++++++++++++++++++++++++---------- 2 files changed, 60 insertions(+), 23 deletions(-) diff --git a/docs/storage-layout.md b/docs/storage-layout.md index 50c8d242..a4996502 100644 --- a/docs/storage-layout.md +++ b/docs/storage-layout.md @@ -99,6 +99,9 @@ Linux falls back to the corresponding `~/.config`, `~/.local/share`, `~/.local/state`, and `~/.cache` roots when an XDG variable is unset. Windows keeps only small preferences in the roaming profile; installed models stay local so large packages are not copied by profile synchronization. +Application data is file-backed on every platform. Windows does not use the +registry for settings or autostart; enabling autostart creates a shortcut in +the current user's Startup folder. `--storage-root=` is the only path override. It creates `config`, `data`, `cache`, `state`, and `logs` beneath the supplied root for isolated development diff --git a/src/platform/windows_startup.c b/src/platform/windows_startup.c index e35d1e61..56aeae5f 100644 --- a/src/platform/windows_startup.c +++ b/src/platform/windows_startup.c @@ -4,6 +4,9 @@ #ifdef _WIN32 #include #include +#include +#include +#include #include #include @@ -96,34 +99,65 @@ void bongo_cat_platform_single_instance_end(void) { BongoCatResult bongo_cat_platform_set_autostart(bool enabled, BongoCatError *error) { - HKEY key; - LONG result = RegCreateKeyExW(HKEY_CURRENT_USER, - L"Software\\Microsoft\\Windows\\CurrentVersion\\Run", 0, NULL, 0, - KEY_SET_VALUE, NULL, &key, NULL); - if (result != ERROR_SUCCESS) { + PWSTR startup = NULL; + HRESULT result = SHGetKnownFolderPath(&FOLDERID_Startup, KF_FLAG_DEFAULT, + NULL, &startup); + wchar_t shortcut[BONGO_CAT_PATH_CAP]; + int written = SUCCEEDED(result) && startup + ? swprintf(shortcut, BONGO_CAT_PATH_CAP, L"%ls\\%ls.lnk", startup, + BONGO_CAT_NAME_W) : -1; + CoTaskMemFree(startup); + if (FAILED(result) || written < 0 || written >= BONGO_CAT_PATH_CAP) { bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, - "Cannot open autostart registry key"); + "Cannot locate the Windows Startup folder"); return BONGO_CAT_ERROR_PLATFORM; } - if (enabled) { - wchar_t executable[BONGO_CAT_PATH_CAP]; - DWORD length = GetModuleFileNameW(NULL, executable, BONGO_CAT_PATH_CAP); - if (!length || length >= BONGO_CAT_PATH_CAP) { - RegCloseKey(key); bongo_cat_error_set(error, - BONGO_CAT_ERROR_PLATFORM, "Cannot determine executable path"); - return BONGO_CAT_ERROR_PLATFORM; - } - wchar_t command[BONGO_CAT_PATH_CAP + 20]; - swprintf(command, BONGO_CAT_PATH_CAP + 20, L"\"%ls\" --autostart", executable); - result = RegSetValueExW(key, BONGO_CAT_NAME_W, 0, REG_SZ, - (const BYTE *)command, (DWORD)((wcslen(command) + 1) * sizeof(wchar_t))); - } else result = RegDeleteValueW(key, BONGO_CAT_NAME_W); - RegCloseKey(key); - if (result != ERROR_SUCCESS && result != ERROR_FILE_NOT_FOUND) { + if (!enabled) { + if (DeleteFileW(shortcut) || GetLastError() == ERROR_FILE_NOT_FOUND) + return BONGO_CAT_OK; bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, - "Cannot update autostart setting"); + "Cannot remove the Windows Startup shortcut"); return BONGO_CAT_ERROR_PLATFORM; } - return BONGO_CAT_OK; + + wchar_t executable[BONGO_CAT_PATH_CAP]; + DWORD length = GetModuleFileNameW(NULL, executable, BONGO_CAT_PATH_CAP); + if (!length || length >= BONGO_CAT_PATH_CAP) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, + "Cannot determine executable path"); + return BONGO_CAT_ERROR_PLATFORM; + } + wchar_t working_directory[BONGO_CAT_PATH_CAP]; + memcpy(working_directory, executable, (length + 1) * sizeof(wchar_t)); + wchar_t *separator = wcsrchr(working_directory, L'\\'); + if (separator) *separator = L'\0'; + + HRESULT initialized = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); + if (FAILED(initialized) && initialized != RPC_E_CHANGED_MODE) result = initialized; + else { + IShellLinkW *link = NULL; + IPersistFile *persist = NULL; + result = CoCreateInstance(&CLSID_ShellLink, NULL, CLSCTX_INPROC_SERVER, + &IID_IShellLinkW, (void **)&link); + if (SUCCEEDED(result)) result = link->lpVtbl->SetPath(link, executable); + if (SUCCEEDED(result)) result = link->lpVtbl->SetArguments(link, + L"--autostart"); + if (SUCCEEDED(result) && separator) + result = link->lpVtbl->SetWorkingDirectory(link, working_directory); + if (SUCCEEDED(result)) result = link->lpVtbl->SetDescription(link, + L"Start BongoCat when signing in"); + if (SUCCEEDED(result)) result = link->lpVtbl->QueryInterface(link, + &IID_IPersistFile, (void **)&persist); + if (SUCCEEDED(result)) result = persist->lpVtbl->Save(persist, + shortcut, TRUE); + if (persist) persist->lpVtbl->Release(persist); + if (link) link->lpVtbl->Release(link); + } + if (SUCCEEDED(initialized)) CoUninitialize(); + if (SUCCEEDED(result)) return BONGO_CAT_OK; + bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, + "Cannot create the Windows Startup shortcut (0x%08lx)", + (unsigned long)result); + return BONGO_CAT_ERROR_PLATFORM; } #endif From 61d7aaf090af01ff1046ad588fc9ecaffa869418 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 15 Aug 2026 21:02:23 +0800 Subject: [PATCH 013/334] refactor: support nearby Tauri and filter Mver placeholders --- CMakeLists.txt | 3 +- README.md | 16 +- cmake/CheckLines.cmake | 4 + cmake/RuntimeSources.cmake | 8 +- docs/storage-layout.md | 38 +- include/bongo_cat/app.h | 2 + include/bongo_cat/image.h | 1 + resources/assets/locales/de-DE.json | 3 +- resources/assets/locales/en-US.json | 3 +- resources/assets/locales/es-ES.json | 3 +- resources/assets/locales/fr-FR.json | 3 +- resources/assets/locales/ja-JP.json | 3 +- resources/assets/locales/ko-KR.json | 3 +- resources/assets/locales/pt-BR.json | 3 +- resources/assets/locales/ru-RU.json | 3 +- resources/assets/locales/zh-CN.json | 3 +- resources/assets/locales/zh-TW.json | 3 +- src/core/config_defaults.c | 203 ++++++--- src/core/config_fields.c | 156 +++++++ src/core/config_file.c | 107 ++++- src/core/config_internal.h | 32 +- src/core/config_io.c | 426 ++++++++---------- src/core/config_names.c | 33 ++ src/core/config_session_io.c | 161 ++++--- src/core/config_settings_save.c | 197 ++++++++ src/live2d/cubism_model_bounds.cpp | 5 +- src/media/image.c | 6 +- src/media/image_info.c | 18 + src/runtime/app.c | 15 +- src/runtime/config_store.c | 133 +++++- src/runtime/model_catalog_runtime.c | 10 +- src/runtime/model_import.c | 9 +- src/runtime/model_import.h | 25 +- src/runtime/model_import_digest.c | 293 ++++++++++++ src/runtime/model_import_discovery.c | 22 +- src/runtime/model_import_identity.c | 64 ++- src/runtime/model_import_mver_effect.c | 6 +- src/runtime/model_import_mver_image.c | 35 +- src/runtime/model_import_nearby.c | 389 +++++----------- src/runtime/model_import_nearby_cache.c | 184 ++++++++ src/runtime/model_import_nearby_identity.c | 121 ----- src/runtime/model_import_nearby_internal.h | 16 +- src/runtime/model_import_nearby_signature.c | 147 ++++-- ...port_nearby_scan.c => model_import_scan.c} | 130 +++--- src/runtime/model_import_source.c | 75 +++ src/runtime/model_import_tauri.c | 43 ++ src/runtime/runtime.h | 2 +- src/runtime/window_menu.h | 4 +- src/runtime/window_menu_preview.c | 64 +-- src/ui/preferences.c | 3 - src/ui/preferences_import.c | 20 +- src/ui/preferences_model.c | 22 +- src/ui/preferences_model_card.c | 1 - src/ui/preferences_model_cover.c | 31 +- src/ui/preferences_model_cover.h | 2 - tests/test_config.c | 92 +++- tests/test_model_import_identity.c | 75 +++ tests/test_mver_import.c | 116 ++--- tests/test_mver_nearby_identity.c | 47 +- tests/test_mver_support.c | 2 +- tests/test_mver_support.h | 2 +- 61 files changed, 2395 insertions(+), 1251 deletions(-) create mode 100644 src/core/config_fields.c create mode 100644 src/core/config_names.c create mode 100644 src/core/config_settings_save.c create mode 100644 src/media/image_info.c create mode 100644 src/runtime/model_import_digest.c create mode 100644 src/runtime/model_import_nearby_cache.c delete mode 100644 src/runtime/model_import_nearby_identity.c rename src/runtime/{model_import_nearby_scan.c => model_import_scan.c} (52%) create mode 100644 src/runtime/model_import_source.c create mode 100644 src/runtime/model_import_tauri.c create mode 100644 tests/test_model_import_identity.c diff --git a/CMakeLists.txt b/CMakeLists.txt index ccbe6817..4c38dac6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -56,7 +56,7 @@ else() endif() add_library(bongo_cat_core STATIC - src/core/config_defaults.c src/core/config_io.c + src/core/config_defaults.c src/core/config_names.c src/core/config_fields.c src/core/config_io.c src/core/config_settings_save.c src/core/config_file.c src/core/config_session_io.c src/core/error.c src/core/file.c src/core/i18n.c @@ -272,6 +272,7 @@ if(BUILD_TESTING) tests/test_mver_pointer_import.c tests/test_preferences_text.c tests/test_mver_nearby_identity.c + tests/test_model_import_identity.c tests/test_mver_support.c) add_executable(bongo_cat_mver_import_tests ${BONGO_CAT_MVER_IMPORT_TEST_SOURCES}) diff --git a/README.md b/README.md index fe309ca1..d53ae671 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,9 @@ instead of a diagnostic build. - Import and removal of custom model packages. A package is checked for a valid manifest and safe file references before it is installed under the user data directory. -- Automatic discovery of Bongo-Cat-Mver packages in the executable directory - or its immediate parent, shown separately as nearby models. Packages may use - `config.json` + `img/` directly or an intact child directory. +- Bounded automatic discovery of Bongo-Cat-Mver packages beside the executable. + Nearby sources and installed packages share one model list; packages may use + `config.json` + `img/` directly or an intact nested directory. - Mver 0.1.6 is the animation and input behavior baseline; standalone Tauri/Live2D model folders are loaded through a canvas and input adapter. Model containers with nested full packages and image-only variants (for @@ -195,11 +195,11 @@ unreleased application has no configuration migration layer. ## Runtime Data and Test Switches Storage is separated into `config`, `data`, `cache`, `state`, and `logs`. -Installed packages live under `data/models`; generated nearby-Mver adapters -live under `cache/model-adapters`. A Bongo-Cat-Mver package can be used without -installing it by placing its -`config.json` and `img/` beside `BongoCat`, by placing the intact package -directory there, or by keeping both under the same immediate parent directory. +Installed packages live under `data/models`; generated nearby-model adapters +live under `cache/model-adapters`. A Tauri or Bongo-Cat-Mver package can be used +without installing it by placing its model directory beside `BongoCat`. Startup discovery scans at most three directory +levels and 256 directories, with a 500 ms directory-work budget. Unchanged +sources reuse their cached identity and generated adapter. A collection such as `露西亚-誓焰版` may be selected as one folder; its full package and nested image patches are discovered together. Tests and isolated launches may override the complete storage layout: diff --git a/cmake/CheckLines.cmake b/cmake/CheckLines.cmake index e08e95f6..26873966 100644 --- a/cmake/CheckLines.cmake +++ b/cmake/CheckLines.cmake @@ -31,6 +31,10 @@ foreach(FILE IN LISTS FILES) set(LIMIT 396) elseif(RELATIVE_FILE STREQUAL "src/tools/mver_phase_metrics.c") set(LIMIT 321) + elseif(RELATIVE_FILE STREQUAL "src/runtime/live2d_audit.c") + set(LIMIT 320) + elseif(RELATIVE_FILE STREQUAL "src/ui/preferences_import.c") + set(LIMIT 320) elseif(RELATIVE_FILE STREQUAL "src/ui/preferences_widgets.c") set(LIMIT 350) endif() diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 6b3e930e..d90ffb02 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -1,7 +1,8 @@ set(BONGO_CAT_RUNTIME_SOURCES src/core/app_state.c src/media/audio.c - src/media/image.c src/media/image_resize.c src/media/image_alpha.c + src/media/image.c src/media/image_info.c + src/media/image_resize.c src/media/image_alpha.c src/media/stb_image_impl.c src/platform/memory.c src/render/gl_api.c src/render/overlay.c src/render/overlay_draw.c @@ -17,10 +18,11 @@ set(BONGO_CAT_RUNTIME_SOURCES src/runtime/model_files.c src/runtime/model_catalog_runtime.c src/runtime/model_update.c src/runtime/model_storage.c src/runtime/model_import.c src/runtime/model_import_identity.c + src/runtime/model_import_digest.c src/runtime/model_import_source.c src/runtime/model_import_package.c src/runtime/model_import_discovery.c src/runtime/model_import_format.c - src/runtime/model_import_nearby.c src/runtime/model_import_nearby_scan.c - src/runtime/model_import_nearby_identity.c + src/runtime/model_import_scan.c src/runtime/model_import_tauri.c + src/runtime/model_import_nearby.c src/runtime/model_import_nearby_cache.c src/runtime/model_import_nearby_signature.c src/runtime/model_import_mver_patch.c src/runtime/model_import_mver.c src/runtime/model_import_mver_image.c diff --git a/docs/storage-layout.md b/docs/storage-layout.md index a4996502..ce40f5f1 100644 --- a/docs/storage-layout.md +++ b/docs/storage-layout.md @@ -7,9 +7,12 @@ formats. Tauri Live2D and Bongo-Cat-Mver are model import formats only. ```text config/settings.json long-lived user preferences +config/settings.json.rejected* preserved invalid settings data/models/ installed model package v2 directories -cache/model-adapters/ generated adapters for nearby Mver folders +cache/embedded-assets-/ extracted packaged application assets +cache/model-adapters/ generated adapters for nearby Tauri/Mver sources state/session.json disposable window and active-model state +state/session.json.rejected* preserved invalid session state state/ diagnostic and smoke-run artifacts logs/ startup and failure logs ``` @@ -19,12 +22,25 @@ and pointer preferences, window behavior, application preferences, shortcuts, behavior overrides, model display-name overrides, and an `extensions` object. Unknown namespaces inside `extensions` are preserved across load and save; the serialized object is limited to 4095 bytes so settings memory remains bounded. +The complete settings or session document is limited to 1 MiB. `session.json` uses format `bongocat/session`, schema 1. It contains only the active model ID and window visibility, scale, opacity, optional known position, and size. Deleting it must not change the user's preferences or installed models. +Every user-selected Tauri or Mver source is converted into an application-owned +package v2 directory under `data/models/`. + +For portable Tauri and Mver distributions, the application also performs a +bounded scan next to the executable. These nearby sources remain externally +owned and are loaded in place; only their generated runtime adapters are stored under +`cache/model-adapters/`. The scan is limited to three directory levels, 256 +directories, and 500 ms of directory-discovery work. Unchanged sources reuse a +metadata signature and cached content identity, so rescans do not rehash model +files or regenerate input images. Set `BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN` for +isolated development runs that must ignore adjacent sources. + ```json { "format": "bongocat/settings", @@ -87,6 +103,26 @@ Both documents are written atomically through a temporary file, a durable flush, and replacement. There are no readers or migrations for pre-release configuration formats. +Schema 1 treats known fields strictly: an omitted field keeps its default, but +a present field with the wrong JSON type, an unsupported enum string, an +out-of-range machine integer, or an invalid nested structure rejects the whole +document. Duplicate known JSON keys are also rejected. Unknown fields outside +`extensions` are ignored and not preserved; +extension owners must store their data under a unique key in `extensions`. + +Before saving, settings and session values are canonicalized. Numeric runtime +values are clamped to supported limits, non-finite values return to defaults, +invalid empty override rows are removed, and override IDs are unique. If an +override ID occurs more than once in memory or in a loaded document, later +non-empty values replace the corresponding earlier values. + +Invalid or unsupported files are moved aside before defaults are saved. The +first rejected file uses the sibling name `settings.json.rejected` or +`session.json.rejected`; further files use `.rejected.1`, `.rejected.2`, and so +on. I/O and allocation failures leave the original in place and disable saving +that document for the current run. This prevents a transient failure or a file +that could not be preserved from being overwritten. + ## Platform Roots | Platform | Config and data | State, cache, and logs | diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index 575423d5..caf62eba 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -53,6 +53,8 @@ typedef struct BongoCatApp { bool running; bool settings_store_valid; bool session_store_valid; + bool settings_store_blocked; + bool session_store_blocked; bool autostart_launch; uint64_t startup_raise_due_ns; bool smoke; diff --git a/include/bongo_cat/image.h b/include/bongo_cat/image.h index 5026bf50..d86eb68b 100644 --- a/include/bongo_cat/image.h +++ b/include/bongo_cat/image.h @@ -24,6 +24,7 @@ extern "C" { #endif BongoCatResult bongo_cat_image_load(const char *path, BongoCatImage *image, BongoCatError *error); +bool bongo_cat_image_info(const char *path, int *width, int *height); void bongo_cat_image_free(BongoCatImage *image); unsigned int bongo_cat_image_texture(const char *path, int *width, int *height, BongoCatError *error); diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index 7376f613..aa2ee6c8 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -78,8 +78,7 @@ "model": { "title": "Modell", "labels": { - "deleteModel": "Modell löschen", - "nearbyModels": "Benachbarte Ordner" + "deleteModel": "Modell löschen" }, "hints": { "deleteSuccess": "\"%s\" wurde gelöscht", diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index b433a1b3..b830d260 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -78,8 +78,7 @@ "model": { "title": "Model", "labels": { - "deleteModel": "Delete Model", - "nearbyModels": "Nearby folders" + "deleteModel": "Delete Model" }, "hints": { "deleteSuccess": "Deleted \"%s\"", diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index af10e783..8e276fd4 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -78,8 +78,7 @@ "model": { "title": "Modelo", "labels": { - "deleteModel": "Eliminar modelo", - "nearbyModels": "Carpetas cercanas" + "deleteModel": "Eliminar modelo" }, "hints": { "deleteSuccess": "Se eliminó \"%s\"", diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index 572575b2..5e08bd32 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -78,8 +78,7 @@ "model": { "title": "Modèle", "labels": { - "deleteModel": "Supprimer le modèle", - "nearbyModels": "Dossiers voisins" + "deleteModel": "Supprimer le modèle" }, "hints": { "deleteSuccess": "« %s » a été supprimé", diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index 200172fa..5c80e233 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -78,8 +78,7 @@ "model": { "title": "モデル", "labels": { - "deleteModel": "モデルを削除", - "nearbyModels": "近くのフォルダー" + "deleteModel": "モデルを削除" }, "hints": { "deleteSuccess": "「%s」を削除しました", diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index cbf8be7e..3be50cd7 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -78,8 +78,7 @@ "model": { "title": "모델", "labels": { - "deleteModel": "모델 삭제", - "nearbyModels": "주변 폴더" + "deleteModel": "모델 삭제" }, "hints": { "deleteSuccess": "\"%s\" 모델을 삭제했습니다", diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index 9312682d..c484bcdf 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -78,8 +78,7 @@ "model": { "title": "Modelo", "labels": { - "deleteModel": "Excluir modelo", - "nearbyModels": "Pastas próximas" + "deleteModel": "Excluir modelo" }, "hints": { "deleteSuccess": "Modelo \"%s\" excluído", diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index 7097e451..190f4f0f 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -78,8 +78,7 @@ "model": { "title": "Модель", "labels": { - "deleteModel": "Удалить модель", - "nearbyModels": "Соседние папки" + "deleteModel": "Удалить модель" }, "hints": { "deleteSuccess": "Модель \"%s\" удалена", diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index b8ae4265..3e594809 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -78,8 +78,7 @@ "model": { "title": "模型管理", "labels": { - "deleteModel": "删除模型", - "nearbyModels": "附近目录下" + "deleteModel": "删除模型" }, "hints": { "deleteSuccess": "已删除“%s”", diff --git a/resources/assets/locales/zh-TW.json b/resources/assets/locales/zh-TW.json index 4f6f4a8c..4f3a3cef 100644 --- a/resources/assets/locales/zh-TW.json +++ b/resources/assets/locales/zh-TW.json @@ -78,8 +78,7 @@ "model": { "title": "模型管理", "labels": { - "deleteModel": "刪除模型", - "nearbyModels": "附近資料夾" + "deleteModel": "刪除模型" }, "hints": { "deleteSuccess": "已刪除「%s」", diff --git a/src/core/config_defaults.c b/src/core/config_defaults.c index 810bb373..5e51edcc 100644 --- a/src/core/config_defaults.c +++ b/src/core/config_defaults.c @@ -2,10 +2,12 @@ #include "bongo_cat/utf8.h" #include +#include #include #include -static float clampf(float value, float low, float high) { +static float clampf_or(float value, float low, float high, float fallback) { + if (!isfinite(value)) return fallback; return value < low ? low : value > high ? high : value; } @@ -18,6 +20,17 @@ static bool shortcut_equal(const char *left, const char *right) { return *left == *right; } +static bool normalize_text(char *text, size_t capacity) { + text[capacity - 1] = '\0'; + if (!bongo_cat_utf8_valid(text)) { + memset(text, 0, capacity); + return false; + } + size_t length = strlen(text); + memset(text + length + 1, 0, capacity - length - 1); + return true; +} + bool bongo_cat_settings_shortcut_conflicts(const BongoCatSettings *config, const char *shortcut, const char *exclude) { if (!config || !shortcut || !shortcut[0]) return false; @@ -27,7 +40,10 @@ bool bongo_cat_settings_shortcut_conflicts(const BongoCatSettings *config, for (size_t i = 0; i < sizeof(global) / sizeof(global[0]); ++i) if (global[i] != exclude && shortcut_equal(global[i], shortcut)) return true; - for (size_t i = 0; i < config->behavior_shortcut_count; ++i) { + size_t behavior_count = config->behavior_shortcut_count; + if (behavior_count > BONGO_CAT_BEHAVIOR_BINDING_CAP) + behavior_count = BONGO_CAT_BEHAVIOR_BINDING_CAP; + for (size_t i = 0; i < behavior_count; ++i) { const char *bound = config->behavior_shortcuts[i].shortcut; if (bound != exclude && shortcut_equal(bound, shortcut)) return true; } @@ -37,7 +53,9 @@ bool bongo_cat_settings_shortcut_conflicts(const BongoCatSettings *config, const char *bongo_cat_settings_model_label(const BongoCatSettings *config, const char *id) { if (!config || !id) return NULL; - for (size_t i = 0; i < config->model_label_count; ++i) + size_t count = config->model_label_count; + if (count > BONGO_CAT_MODEL_CAP) count = BONGO_CAT_MODEL_CAP; + for (size_t i = 0; i < count; ++i) if (!strcmp(config->model_labels[i].id, id)) return config->model_labels[i].label; return NULL; @@ -45,7 +63,9 @@ const char *bongo_cat_settings_model_label(const BongoCatSettings *config, bool bongo_cat_settings_set_model_label(BongoCatSettings *config, const char *id, const char *label) { - if (!config || !id || !id[0]) return false; + if (!config || !id || !id[0] || strlen(id) >= BONGO_CAT_ID_CAP || + !bongo_cat_utf8_valid(id)) return false; + bongo_cat_settings_validate(config); size_t index = config->model_label_count; for (size_t i = 0; i < config->model_label_count; ++i) if (!strcmp(config->model_labels[i].id, id)) { @@ -63,8 +83,12 @@ bool bongo_cat_settings_set_model_label(BongoCatSettings *config, sizeof(config->model_labels[0])); return true; } + if (strlen(label) >= BONGO_CAT_ID_CAP || !bongo_cat_utf8_valid(label)) + return false; if (index < config->model_label_count) { if (!strcmp(config->model_labels[index].label, label)) return false; + memset(config->model_labels[index].label, 0, + sizeof(config->model_labels[index].label)); snprintf(config->model_labels[index].label, sizeof(config->model_labels[index].label), "%s", label); return true; @@ -83,9 +107,10 @@ static void validate_shortcuts(BongoCatSettings *config) { config->shortcuts.visible_preferences, config->shortcuts.mirror, config->shortcuts.pass_through, config->shortcuts.always_on_top}; for (size_t i = 0; i < sizeof(global) / sizeof(global[0]); ++i) { - global[i][BONGO_CAT_SHORTCUT_CAP - 1] = '\0'; + normalize_text(global[i], BONGO_CAT_SHORTCUT_CAP); for (size_t j = 0; j < i; ++j) - if (shortcut_equal(global[i], global[j])) global[i][0] = '\0'; + if (shortcut_equal(global[i], global[j])) + memset(global[i], 0, BONGO_CAT_SHORTCUT_CAP); } for (size_t i = 0; i < config->behavior_shortcut_count; ++i) { char *shortcut = config->behavior_shortcuts[i].shortcut; @@ -99,6 +124,94 @@ static void validate_shortcuts(BongoCatSettings *config) { } } +static void compact_behavior_overrides(BongoCatSettings *config) { + size_t input_count = config->behavior_shortcut_count; + if (input_count > BONGO_CAT_BEHAVIOR_BINDING_CAP) + input_count = BONGO_CAT_BEHAVIOR_BINDING_CAP; + size_t output_count = 0; + for (size_t i = 0; i < input_count; ++i) { + BongoCatBehaviorShortcut entry = config->behavior_shortcuts[i]; + entry.id[sizeof(entry.id) - 1] = '\0'; + entry.shortcut[sizeof(entry.shortcut) - 1] = '\0'; + entry.label[sizeof(entry.label) - 1] = '\0'; + if (!bongo_cat_utf8_valid(entry.id) || + !bongo_cat_utf8_valid(entry.shortcut) || + !bongo_cat_utf8_valid(entry.label)) continue; + if (!entry.id[0] || (!entry.shortcut[0] && !entry.label[0])) continue; + BongoCatBehaviorShortcut canonical = {0}; + snprintf(canonical.id, sizeof(canonical.id), "%s", entry.id); + snprintf(canonical.shortcut, sizeof(canonical.shortcut), "%s", + entry.shortcut); + snprintf(canonical.label, sizeof(canonical.label), "%s", entry.label); + size_t existing = output_count; + for (size_t j = 0; j < output_count; ++j) + if (!strcmp(config->behavior_shortcuts[j].id, entry.id)) { + existing = j; + break; + } + if (existing < output_count) { + if (canonical.shortcut[0]) { + memset(config->behavior_shortcuts[existing].shortcut, 0, + sizeof(config->behavior_shortcuts[existing].shortcut)); + snprintf( + config->behavior_shortcuts[existing].shortcut, + sizeof(config->behavior_shortcuts[existing].shortcut), + "%s", canonical.shortcut); + } + if (canonical.label[0]) { + memset(config->behavior_shortcuts[existing].label, 0, + sizeof(config->behavior_shortcuts[existing].label)); + snprintf( + config->behavior_shortcuts[existing].label, + sizeof(config->behavior_shortcuts[existing].label), + "%s", canonical.label); + } + continue; + } + config->behavior_shortcuts[output_count++] = canonical; + } + memset(&config->behavior_shortcuts[output_count], 0, + (BONGO_CAT_BEHAVIOR_BINDING_CAP - output_count) * + sizeof(config->behavior_shortcuts[0])); + config->behavior_shortcut_count = output_count; +} + +static void compact_model_overrides(BongoCatSettings *config) { + size_t input_count = config->model_label_count; + if (input_count > BONGO_CAT_MODEL_CAP) input_count = BONGO_CAT_MODEL_CAP; + size_t output_count = 0; + for (size_t i = 0; i < input_count; ++i) { + BongoCatModelLabel entry = config->model_labels[i]; + entry.id[sizeof(entry.id) - 1] = '\0'; + entry.label[sizeof(entry.label) - 1] = '\0'; + if (!entry.id[0] || !entry.label[0] || + !bongo_cat_utf8_valid(entry.id) || + !bongo_cat_utf8_valid(entry.label)) continue; + BongoCatModelLabel canonical = {0}; + snprintf(canonical.id, sizeof(canonical.id), "%s", entry.id); + snprintf(canonical.label, sizeof(canonical.label), "%s", entry.label); + size_t existing = output_count; + for (size_t j = 0; j < output_count; ++j) + if (!strcmp(config->model_labels[j].id, entry.id)) { + existing = j; + break; + } + if (existing < output_count) { + memset(config->model_labels[existing].label, 0, + sizeof(config->model_labels[existing].label)); + snprintf(config->model_labels[existing].label, + sizeof(config->model_labels[existing].label), "%s", + canonical.label); + continue; + } + config->model_labels[output_count++] = canonical; + } + memset(&config->model_labels[output_count], 0, + (BONGO_CAT_MODEL_CAP - output_count) * + sizeof(config->model_labels[0])); + config->model_label_count = output_count; +} + void bongo_cat_settings_defaults(BongoCatSettings *config) { if (!config) return; memset(config, 0, sizeof(*config)); @@ -129,81 +242,39 @@ void bongo_cat_session_defaults(BongoCatSessionState *session) { void bongo_cat_settings_validate(BongoCatSettings *config) { if (!config) return; - config->model.auto_release_seconds = clampf(config->model.auto_release_seconds, 0.05f, 30.0f); + config->model.auto_release_seconds = clampf_or( + config->model.auto_release_seconds, 0.05f, 30.0f, + BONGO_CAT_DEFAULT_AUTO_RELEASE_SECONDS); if (config->model.max_fps < 1) config->model.max_fps = 1; if (config->model.max_fps > 240) config->model.max_fps = 240; - config->window.hide_delay_seconds = clampf(config->window.hide_delay_seconds, 0.0f, 60.0f); + config->window.hide_delay_seconds = clampf_or( + config->window.hide_delay_seconds, 0.0f, 60.0f, 0.0f); if ((unsigned)config->window.obs_background_color >= BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT) config->window.obs_background_color = BONGO_CAT_OBS_BACKGROUND_GREEN; - if (config->app.theme > BONGO_CAT_THEME_DARK) config->app.theme = BONGO_CAT_THEME_AUTO; + if ((unsigned)config->app.theme > BONGO_CAT_THEME_DARK) + config->app.theme = BONGO_CAT_THEME_AUTO; if ((unsigned)config->app.language >= BONGO_CAT_LANG_COUNT) config->app.language = BONGO_CAT_LANG_EN_US; - if (config->behavior_shortcut_count > BONGO_CAT_BEHAVIOR_BINDING_CAP) - config->behavior_shortcut_count = BONGO_CAT_BEHAVIOR_BINDING_CAP; - for (size_t i = 0; i < config->behavior_shortcut_count; ++i) { - config->behavior_shortcuts[i].id[ - sizeof(config->behavior_shortcuts[i].id) - 1] = '\0'; - config->behavior_shortcuts[i].shortcut[BONGO_CAT_SHORTCUT_CAP - 1] = '\0'; - config->behavior_shortcuts[i].label[BONGO_CAT_ID_CAP - 1] = '\0'; - if (!bongo_cat_utf8_valid(config->behavior_shortcuts[i].label)) - config->behavior_shortcuts[i].label[0] = '\0'; - } - if (config->model_label_count > BONGO_CAT_MODEL_CAP) - config->model_label_count = BONGO_CAT_MODEL_CAP; - for (size_t i = 0; i < config->model_label_count; ++i) { - config->model_labels[i].id[BONGO_CAT_ID_CAP - 1] = '\0'; - config->model_labels[i].label[BONGO_CAT_ID_CAP - 1] = '\0'; - if (!config->model_labels[i].id[0] || - !bongo_cat_utf8_valid(config->model_labels[i].label)) - config->model_labels[i].label[0] = '\0'; - } + compact_behavior_overrides(config); + compact_model_overrides(config); validate_shortcuts(config); + compact_behavior_overrides(config); } void bongo_cat_session_validate(BongoCatSessionState *session) { if (!session) return; - session->window.scale_percent = clampf(session->window.scale_percent, - 10.0f, 500.0f); - session->window.opacity_percent = clampf(session->window.opacity_percent, - 10.0f, 100.0f); + session->window.scale_percent = clampf_or(session->window.scale_percent, + 10.0f, 500.0f, BONGO_CAT_DEFAULT_WINDOW_SCALE_PERCENT); + session->window.opacity_percent = clampf_or( + session->window.opacity_percent, 10.0f, 100.0f, + BONGO_CAT_DEFAULT_WINDOW_OPACITY_PERCENT); if (session->window.width < 64) session->window.width = 64; if (session->window.height < 64) session->window.height = 64; if (session->window.width > 8192) session->window.width = 8192; if (session->window.height > 8192) session->window.height = 8192; - session->active_model_id[sizeof(session->active_model_id) - 1] = '\0'; - if (!session->active_model_id[0]) + if (!normalize_text(session->active_model_id, + sizeof(session->active_model_id)) || + !session->active_model_id[0]) memcpy(session->active_model_id, "standard", sizeof("standard")); } - -const char *bongo_cat_theme_name(BongoCatTheme value) { - const char *names[] = {"auto", "light", "dark"}; - return value <= BONGO_CAT_THEME_DARK ? names[value] : names[0]; -} - -const char *bongo_cat_language_name(BongoCatLanguage value) { - const char *names[] = {"en-US", "zh-CN", "zh-TW", "fr-FR", "de-DE", - "ja-JP", "ko-KR", "pt-BR", "ru-RU", "es-ES"}; - return (unsigned)value < BONGO_CAT_LANG_COUNT ? names[value] : names[0]; -} - -const char *bongo_cat_mode_name(BongoCatModelMode value) { - const char *names[] = {"standard", "keyboard", "gamepad"}; - return value <= BONGO_CAT_MODE_GAMEPAD ? names[value] : names[0]; -} - -const char *bongo_cat_obs_background_color_name( - BongoCatObsBackgroundColor value) { - static const char *names[] = { - "#00ff00", "#0000ff", "#ff0000", "#ff00ff"}; - return (unsigned)value < BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT ? - names[value] : names[BONGO_CAT_OBS_BACKGROUND_GREEN]; -} - -uint32_t bongo_cat_obs_background_color_rgb( - BongoCatObsBackgroundColor value) { - static const uint32_t colors[] = { - 0x00ff00, 0x0000ff, 0xff0000, 0xff00ff}; - return (unsigned)value < BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT ? - colors[value] : colors[BONGO_CAT_OBS_BACKGROUND_GREEN]; -} diff --git a/src/core/config_fields.c b/src/core/config_fields.c new file mode 100644 index 00000000..54e80d32 --- /dev/null +++ b/src/core/config_fields.c @@ -0,0 +1,156 @@ +#include "config_internal.h" + +#include +#include +#include +#include + +bool bongo_cat_config_type_error(const char *description, + BongoCatError *error, const char *field, const char *expected) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "%s field '%s' must be %s", description, field, expected); + return false; +} + +bool bongo_cat_config_read_value(const char *description, yyjson_val *object, + const char *key, yyjson_val **target, BongoCatError *error) { + *target = NULL; + yyjson_obj_iter iterator = yyjson_obj_iter_with(object); + yyjson_val *name; + while ((name = yyjson_obj_iter_next(&iterator))) { + if (!yyjson_equals_str(name, key)) continue; + if (*target) return bongo_cat_config_type_error( + description, error, key, "present only once"); + *target = yyjson_obj_iter_get_val(name); + } + return true; +} + +bool bongo_cat_config_read_object(const char *description, + yyjson_val *parent, const char *key, yyjson_val **target, + BongoCatError *error) { + yyjson_val *value; + if (!bongo_cat_config_read_value( + description, parent, key, &value, error)) return false; + *target = NULL; + if (!value) return true; + if (!yyjson_is_obj(value)) return bongo_cat_config_type_error( + description, error, key, "an object"); + *target = value; + return true; +} + +bool bongo_cat_config_read_array(const char *description, + yyjson_val *parent, const char *key, yyjson_val **target, + BongoCatError *error) { + yyjson_val *value; + if (!bongo_cat_config_read_value( + description, parent, key, &value, error)) return false; + *target = NULL; + if (!value) return true; + if (!yyjson_is_arr(value)) return bongo_cat_config_type_error( + description, error, key, "an array"); + *target = value; + return true; +} + +bool bongo_cat_config_read_bool(const char *description, + yyjson_val *object, const char *key, bool *target, BongoCatError *error) { + yyjson_val *value; + if (!bongo_cat_config_read_value( + description, object, key, &value, error)) return false; + if (!value) return true; + if (!yyjson_is_bool(value)) return bongo_cat_config_type_error( + description, error, key, "a boolean"); + *target = yyjson_get_bool(value); + return true; +} + +static bool integer_value(yyjson_val *value, int *target) { + if (yyjson_is_sint(value)) { + int64_t number = yyjson_get_sint(value); + if (number >= INT_MIN && number <= INT_MAX) { + *target = (int)number; + return true; + } + } else if (yyjson_is_uint(value)) { + uint64_t number = yyjson_get_uint(value); + if (number <= INT_MAX) { + *target = (int)number; + return true; + } + } + return false; +} + +bool bongo_cat_config_read_int(const char *description, + yyjson_val *object, const char *key, int *target, bool required, + BongoCatError *error) { + yyjson_val *value; + if (!bongo_cat_config_read_value( + description, object, key, &value, error)) return false; + if (!value && !required) return true; + if (!value || !integer_value(value, target)) + return bongo_cat_config_type_error(description, error, key, + "an integer in the supported range"); + return true; +} + +bool bongo_cat_config_read_float(const char *description, + yyjson_val *object, const char *key, float *target, BongoCatError *error) { + yyjson_val *value; + if (!bongo_cat_config_read_value( + description, object, key, &value, error)) return false; + if (!value) return true; + if (!yyjson_is_num(value)) return bongo_cat_config_type_error( + description, error, key, "a number"); + double number = yyjson_get_num(value); + if (!isfinite(number) || number < -FLT_MAX || number > FLT_MAX) + return bongo_cat_config_type_error(description, error, key, + "a finite number in the supported range"); + *target = (float)number; + return true; +} + +bool bongo_cat_config_read_string(const char *description, + yyjson_val *object, const char *key, const char **target, size_t *length, + BongoCatError *error) { + yyjson_val *value; + *target = NULL; + *length = 0; + if (!bongo_cat_config_read_value( + description, object, key, &value, error)) return false; + if (!value) return true; + if (!yyjson_is_str(value)) return bongo_cat_config_type_error( + description, error, key, "a string"); + const char *text = yyjson_get_str(value); + size_t text_length = yyjson_get_len(value); + if (strlen(text) != text_length) + return bongo_cat_config_type_error(description, error, key, + "a string without embedded nulls"); + *target = text; + *length = text_length; + return true; +} + +bool bongo_cat_config_copy_text(const char *description, char *target, + size_t capacity, const char *value, size_t length, const char *field, + BongoCatError *error) { + if (!value) return true; + if (length >= capacity) return bongo_cat_config_type_error( + description, error, field, "a shorter string"); + memset(target, 0, capacity); + memcpy(target, value, length); + return true; +} + +bool bongo_cat_config_read_text(const char *description, + yyjson_val *object, const char *key, char *target, size_t capacity, + BongoCatError *error) { + const char *value; + size_t length; + return bongo_cat_config_read_string(description, object, key, &value, + &length, error) && + bongo_cat_config_copy_text(description, target, capacity, value, + length, key, error); +} diff --git a/src/core/config_file.c b/src/core/config_file.c index b0c70f20..3b9019aa 100644 --- a/src/core/config_file.c +++ b/src/core/config_file.c @@ -1,5 +1,6 @@ #include "config_internal.h" #include "bongo_cat/file.h" +#include "bongo_cat/path.h" #include #include @@ -13,32 +14,87 @@ #include #endif -yyjson_doc *bongo_cat_config_read_document(const char *path, - const char *format, int schema, BongoCatError *error) { +static yyjson_val *unique_member(yyjson_val *object, const char *name, + bool *duplicate) { + yyjson_val *found = NULL; + yyjson_obj_iter iterator = yyjson_obj_iter_with(object); + yyjson_val *key; + while ((key = yyjson_obj_iter_next(&iterator))) { + if (!yyjson_equals_str(key, name)) continue; + if (found) { + *duplicate = true; + return NULL; + } + found = yyjson_obj_iter_get_val(key); + } + return found; +} + +BongoCatResult bongo_cat_config_read_document(const char *path, + const char *format, int schema, yyjson_doc **document, + BongoCatError *error) { + if (!path || !format || !document) return BONGO_CAT_ERROR_ARGUMENT; + *document = NULL; + uint64_t file_size = 0; + if (!bongo_cat_path_file_size(path, &file_size)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot inspect configuration file: %s", path); + return BONGO_CAT_ERROR_IO; + } + if (!file_size || file_size > BONGO_CAT_CONFIG_FILE_CAP) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Configuration file size must be between 1 and %u bytes", + (unsigned)BONGO_CAT_CONFIG_FILE_CAP); + return BONGO_CAT_ERROR_FORMAT; + } yyjson_read_err json_error = {0}; FILE *file = bongo_cat_file_open(path, "rb"); - yyjson_doc *document = file ? yyjson_read_fp(file, 0, NULL, &json_error) : NULL; - if (file) fclose(file); - if (!document) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Invalid configuration JSON: %s", - json_error.msg ? json_error.msg : "cannot open file"); - return NULL; + if (!file) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot open configuration file: %s", path); + return BONGO_CAT_ERROR_IO; + } + yyjson_doc *parsed = yyjson_read_fp(file, 0, NULL, &json_error); + if (fclose(file) != 0) { + yyjson_doc_free(parsed); + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot finish reading configuration file: %s", path); + return BONGO_CAT_ERROR_IO; + } + if (!parsed) { + BongoCatResult result = json_error.code == + YYJSON_READ_ERROR_MEMORY_ALLOCATION ? BONGO_CAT_ERROR_MEMORY : + json_error.code == YYJSON_READ_ERROR_FILE_OPEN || + json_error.code == YYJSON_READ_ERROR_FILE_READ ? + BONGO_CAT_ERROR_IO : BONGO_CAT_ERROR_FORMAT; + bongo_cat_error_set(error, result, + result == BONGO_CAT_ERROR_FORMAT ? + "Invalid configuration JSON: %s" : + "Cannot read configuration file: %s", + json_error.msg ? json_error.msg : path); + return result; } - yyjson_val *root = yyjson_doc_get_root(document); - yyjson_val *format_value = yyjson_obj_get(root, "format"); - yyjson_val *version = yyjson_obj_get(root, "schemaVersion"); + yyjson_val *root = yyjson_doc_get_root(parsed); + bool duplicate = false; + yyjson_val *format_value = yyjson_is_obj(root) ? + unique_member(root, "format", &duplicate) : NULL; + yyjson_val *version = yyjson_is_obj(root) ? + unique_member(root, "schemaVersion", &duplicate) : NULL; + bool schema_matches = yyjson_is_sint(version) ? + yyjson_get_sint(version) == schema : yyjson_is_uint(version) && + yyjson_get_uint(version) == (uint64_t)schema; if (!yyjson_is_obj(root) || !yyjson_is_str(format_value) || + yyjson_get_len(format_value) != strlen(format) || strcmp(yyjson_get_str(format_value), format) != 0 || - !yyjson_is_int(version) || - yyjson_get_sint(version) != schema) { - yyjson_doc_free(document); + !schema_matches || duplicate) { + yyjson_doc_free(parsed); bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, "Unsupported configuration format; expected %s schema %d", format, schema); - return NULL; + return BONGO_CAT_ERROR_FORMAT; } - return document; + *document = parsed; + return BONGO_CAT_OK; } static bool sync_file(const char *path) { @@ -81,17 +137,28 @@ BongoCatResult bongo_cat_config_write_document(const char *path, yyjson_mut_doc *document, const char *description, BongoCatError *error) { char temporary[BONGO_CAT_PATH_CAP + 8]; int length = snprintf(temporary, sizeof(temporary), "%s.tmp", path); - if (length < 0 || (size_t)length >= sizeof(temporary)) + if (length < 0 || (size_t)length >= sizeof(temporary)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, + "Configuration path is too long"); return BONGO_CAT_ERROR_ARGUMENT; + } yyjson_write_err json_error = {0}; FILE *file = bongo_cat_file_open(temporary, "wb"); bool written = file && yyjson_mut_write_fp(file, document, YYJSON_WRITE_PRETTY, NULL, &json_error); if (file && fclose(file) != 0) written = false; if (!written) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, "Cannot write %s: %s", + bongo_cat_file_remove(temporary); + BongoCatResult result = json_error.code == + YYJSON_WRITE_ERROR_MEMORY_ALLOCATION ? BONGO_CAT_ERROR_MEMORY : + json_error.code == YYJSON_WRITE_ERROR_INVALID_PARAMETER || + json_error.code == YYJSON_WRITE_ERROR_INVALID_VALUE_TYPE || + json_error.code == YYJSON_WRITE_ERROR_NAN_OR_INF || + json_error.code == YYJSON_WRITE_ERROR_INVALID_STRING ? + BONGO_CAT_ERROR_FORMAT : BONGO_CAT_ERROR_IO; + bongo_cat_error_set(error, result, "Cannot write %s: %s", description, json_error.msg ? json_error.msg : "cannot open file"); - return BONGO_CAT_ERROR_IO; + return result; } if (!sync_file(temporary)) { bongo_cat_file_remove(temporary); diff --git a/src/core/config_internal.h b/src/core/config_internal.h index 02f461ff..1f4f9c4b 100644 --- a/src/core/config_internal.h +++ b/src/core/config_internal.h @@ -6,10 +6,38 @@ #define BONGO_CAT_SETTINGS_SCHEMA 1 #define BONGO_CAT_SESSION_SCHEMA 1 +#define BONGO_CAT_CONFIG_FILE_CAP (1024u * 1024u) -yyjson_doc *bongo_cat_config_read_document(const char *path, - const char *format, int schema, BongoCatError *error); +BongoCatResult bongo_cat_config_read_document(const char *path, + const char *format, int schema, yyjson_doc **document, + BongoCatError *error); BongoCatResult bongo_cat_config_write_document(const char *path, yyjson_mut_doc *document, const char *description, BongoCatError *error); +bool bongo_cat_config_type_error(const char *description, + BongoCatError *error, const char *field, const char *expected); +bool bongo_cat_config_read_value(const char *description, yyjson_val *object, + const char *key, yyjson_val **target, BongoCatError *error); +bool bongo_cat_config_read_object(const char *description, + yyjson_val *parent, const char *key, yyjson_val **target, + BongoCatError *error); +bool bongo_cat_config_read_array(const char *description, + yyjson_val *parent, const char *key, yyjson_val **target, + BongoCatError *error); +bool bongo_cat_config_read_bool(const char *description, + yyjson_val *object, const char *key, bool *target, BongoCatError *error); +bool bongo_cat_config_read_int(const char *description, + yyjson_val *object, const char *key, int *target, bool required, + BongoCatError *error); +bool bongo_cat_config_read_float(const char *description, + yyjson_val *object, const char *key, float *target, BongoCatError *error); +bool bongo_cat_config_read_string(const char *description, + yyjson_val *object, const char *key, const char **target, size_t *length, + BongoCatError *error); +bool bongo_cat_config_copy_text(const char *description, char *target, + size_t capacity, const char *value, size_t length, const char *field, + BongoCatError *error); +bool bongo_cat_config_read_text(const char *description, + yyjson_val *object, const char *key, char *target, size_t capacity, + BongoCatError *error); #endif diff --git a/src/core/config_io.c b/src/core/config_io.c index d3073301..971b8ef4 100644 --- a/src/core/config_io.c +++ b/src/core/config_io.c @@ -1,134 +1,194 @@ #include "config_internal.h" #include "bongo_cat/path.h" -#include #include #include #define SETTINGS_FORMAT "bongocat/settings" +#define type_error(error, field, expected) bongo_cat_config_type_error( \ + "Settings", error, field, expected) +#define read_value(object, key, target, error) bongo_cat_config_read_value( \ + "Settings", object, key, target, error) +#define read_object(object, key, target, error) bongo_cat_config_read_object( \ + "Settings", object, key, target, error) +#define read_array(object, key, target, error) bongo_cat_config_read_array( \ + "Settings", object, key, target, error) +#define read_bool(object, key, target, error) bongo_cat_config_read_bool( \ + "Settings", object, key, target, error) +#define read_int(object, key, target, error) bongo_cat_config_read_int( \ + "Settings", object, key, target, false, error) +#define read_float(object, key, target, error) bongo_cat_config_read_float( \ + "Settings", object, key, target, error) +#define read_string(object, key, target, length, error) \ + bongo_cat_config_read_string( \ + "Settings", object, key, target, length, error) +#define read_text(object, key, target, capacity, error) \ + bongo_cat_config_read_text( \ + "Settings", object, key, target, capacity, error) +#define copy_text(target, capacity, value, length, field, error) \ + bongo_cat_config_copy_text( \ + "Settings", target, capacity, value, length, field, error) -static bool get_bool(yyjson_val *obj, const char *key, bool fallback) { - yyjson_val *value = yyjson_obj_get(obj, key); - return yyjson_is_bool(value) ? yyjson_get_bool(value) : fallback; +static bool parse_theme(const char *value, BongoCatTheme *target) { + if (!strcmp(value, "auto")) *target = BONGO_CAT_THEME_AUTO; + else if (!strcmp(value, "light")) *target = BONGO_CAT_THEME_LIGHT; + else if (!strcmp(value, "dark")) *target = BONGO_CAT_THEME_DARK; + else return false; + return true; } -static int get_int(yyjson_val *obj, const char *key, int fallback) { - yyjson_val *value = yyjson_obj_get(obj, key); - return yyjson_is_num(value) ? (int)yyjson_get_sint(value) : fallback; -} -static float get_float(yyjson_val *obj, const char *key, float fallback) { - yyjson_val *value = yyjson_obj_get(obj, key); - return yyjson_is_num(value) ? (float)yyjson_get_num(value) : fallback; -} -static const char *get_string(yyjson_val *obj, const char *key) { - yyjson_val *value = yyjson_obj_get(obj, key); - return yyjson_is_str(value) ? yyjson_get_str(value) : NULL; -} -static void copy_string(char *target, size_t capacity, const char *value) { - if (value) snprintf(target, capacity, "%s", value); -} -static BongoCatTheme parse_theme(const char *value) { - if (value && strcmp(value, "light") == 0) return BONGO_CAT_THEME_LIGHT; - if (value && strcmp(value, "dark") == 0) return BONGO_CAT_THEME_DARK; - return BONGO_CAT_THEME_AUTO; -} -static BongoCatLanguage parse_language(const char *value) { - if (value) for (int i = 0; i < BONGO_CAT_LANG_COUNT; ++i) - if (strcmp(value, bongo_cat_language_name((BongoCatLanguage)i)) == 0) - return (BongoCatLanguage)i; - return BONGO_CAT_LANG_EN_US; + +static bool parse_language(const char *value, BongoCatLanguage *target) { + for (int i = 0; i < BONGO_CAT_LANG_COUNT; ++i) + if (!strcmp(value, bongo_cat_language_name((BongoCatLanguage)i))) { + *target = (BongoCatLanguage)i; + return true; + } + return false; } -static BongoCatObsBackgroundColor parse_obs_background_color( - const char *value, BongoCatObsBackgroundColor fallback) { - if (value) for (int i = 0; i < BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT; ++i) - if (strcmp(value, bongo_cat_obs_background_color_name( - (BongoCatObsBackgroundColor)i)) == 0) - return (BongoCatObsBackgroundColor)i; - return fallback; + +static bool parse_background_color(const char *value, + BongoCatObsBackgroundColor *target) { + for (int i = 0; i < BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT; ++i) + if (!strcmp(value, bongo_cat_obs_background_color_name( + (BongoCatObsBackgroundColor)i))) { + *target = (BongoCatObsBackgroundColor)i; + return true; + } + return false; } -static void read_model(yyjson_val *obj, BongoCatModelPreferences *value) { - if (!yyjson_is_obj(obj)) return; - value->mirror = get_bool(obj, "modelMirrored", value->mirror); - value->mouse_mirror = get_bool(obj, "pointerMirrored", value->mouse_mirror); - value->mouse_centered = get_bool(obj, "centerPointerTracking", value->mouse_centered); - value->ignore_mouse = get_bool(obj, "ignorePointerInput", value->ignore_mouse); - value->auto_release_seconds = get_float(obj, "inputReleaseDelaySeconds", - value->auto_release_seconds); - value->max_fps = get_int(obj, "maximumFps", value->max_fps); +static bool read_model(yyjson_val *object, BongoCatModelPreferences *value, + BongoCatError *error) { + return read_bool(object, "modelMirrored", &value->mirror, error) && + read_bool(object, "pointerMirrored", &value->mouse_mirror, error) && + read_bool(object, "centerPointerTracking", &value->mouse_centered, + error) && + read_bool(object, "ignorePointerInput", &value->ignore_mouse, error) && + read_float(object, "inputReleaseDelaySeconds", + &value->auto_release_seconds, error) && + read_int(object, "maximumFps", &value->max_fps, error); } -static void read_window(yyjson_val *obj, BongoCatWindowPreferences *value) { - if (!yyjson_is_obj(obj)) return; - value->pass_through = get_bool(obj, "clickThrough", value->pass_through); - value->always_on_top = get_bool(obj, "alwaysOnTop", value->always_on_top); - value->hide_on_hover = get_bool(obj, "hideOnPointerOver", value->hide_on_hover); - value->keep_in_screen = get_bool(obj, "keepOnScreen", value->keep_in_screen); - value->obs_background = get_bool(obj, "captureBackground", value->obs_background); - value->obs_background_color = parse_obs_background_color( - get_string(obj, "captureBackgroundColor"), value->obs_background_color); - value->hide_delay_seconds = get_float(obj, "hideDelaySeconds", - value->hide_delay_seconds); + +static bool read_window(yyjson_val *object, BongoCatWindowPreferences *value, + BongoCatError *error) { + if (!read_bool(object, "clickThrough", &value->pass_through, error) || + !read_bool(object, "alwaysOnTop", &value->always_on_top, error) || + !read_bool(object, "hideOnPointerOver", &value->hide_on_hover, error) || + !read_bool(object, "keepOnScreen", &value->keep_in_screen, error) || + !read_bool(object, "captureBackground", &value->obs_background, + error) || + !read_float(object, "hideDelaySeconds", &value->hide_delay_seconds, + error)) return false; + const char *color; + size_t length; + if (!read_string(object, "captureBackgroundColor", &color, &length, + error)) return false; + (void)length; + if (color && !parse_background_color(color, &value->obs_background_color)) + return type_error(error, "captureBackgroundColor", + "a supported color string"); + return true; } -static void read_app(yyjson_val *obj, BongoCatApplicationPreferences *value) { - if (!yyjson_is_obj(obj)) return; - value->autostart = get_bool(obj, "launchAtLogin", value->autostart); - value->tray_visible = get_bool(obj, "showTrayIcon", value->tray_visible); - value->theme = parse_theme(get_string(obj, "theme")); - value->language = parse_language(get_string(obj, "language")); + +static bool read_app(yyjson_val *object, BongoCatApplicationPreferences *value, + BongoCatError *error) { + if (!read_bool(object, "launchAtLogin", &value->autostart, error) || + !read_bool(object, "showTrayIcon", &value->tray_visible, error)) + return false; + const char *text; + size_t length; + if (!read_string(object, "theme", &text, &length, error)) return false; + (void)length; + if (text && !parse_theme(text, &value->theme)) + return type_error(error, "theme", "auto, light, or dark"); + if (!read_string(object, "language", &text, &length, error)) return false; + if (text && !parse_language(text, &value->language)) + return type_error(error, "language", "a supported locale string"); + return true; } -static void read_shortcuts(yyjson_val *obj, BongoCatShortcutPreferences *value) { - if (!yyjson_is_obj(obj)) return; - copy_string(value->visible_cat, sizeof(value->visible_cat), - get_string(obj, "toggleVisibility")); - copy_string(value->visible_preferences, sizeof(value->visible_preferences), - get_string(obj, "openSettings")); - copy_string(value->mirror, sizeof(value->mirror), - get_string(obj, "toggleModelMirror")); - copy_string(value->pass_through, sizeof(value->pass_through), - get_string(obj, "toggleClickThrough")); - copy_string(value->always_on_top, sizeof(value->always_on_top), - get_string(obj, "toggleAlwaysOnTop")); + +static bool read_shortcuts(yyjson_val *object, + BongoCatShortcutPreferences *value, BongoCatError *error) { + return read_text(object, "toggleVisibility", value->visible_cat, + sizeof(value->visible_cat), error) && + read_text(object, "openSettings", value->visible_preferences, + sizeof(value->visible_preferences), error) && + read_text(object, "toggleModelMirror", value->mirror, + sizeof(value->mirror), error) && + read_text(object, "toggleClickThrough", value->pass_through, + sizeof(value->pass_through), error) && + read_text(object, "toggleAlwaysOnTop", value->always_on_top, + sizeof(value->always_on_top), error); } -static void read_behaviors(yyjson_val *array, BongoCatSettings *config) { - if (!yyjson_is_arr(array)) return; - config->behavior_shortcut_count = 0; - size_t index, count; yyjson_val *item; + +static bool read_behaviors(yyjson_val *array, BongoCatSettings *settings, + BongoCatError *error) { + if (!array) return true; + if (yyjson_arr_size(array) > BONGO_CAT_BEHAVIOR_BINDING_CAP) + return type_error(error, "behaviorOverrides", "a smaller array"); + settings->behavior_shortcut_count = 0; + size_t index, count; + yyjson_val *item; yyjson_arr_foreach(array, index, count, item) { - const char *id = get_string(item, "behaviorId"); - const char *shortcut = get_string(item, "shortcut"); - const char *label = get_string(item, "displayName"); - if (!id || (!shortcut && !label) || - config->behavior_shortcut_count >= BONGO_CAT_BEHAVIOR_BINDING_CAP) - continue; - BongoCatBehaviorShortcut *entry = - &config->behavior_shortcuts[config->behavior_shortcut_count++]; - copy_string(entry->id, sizeof(entry->id), id); - copy_string(entry->shortcut, sizeof(entry->shortcut), shortcut); - copy_string(entry->label, sizeof(entry->label), label); + if (!yyjson_is_obj(item)) + return type_error(error, "behaviorOverrides[]", "an object"); + const char *id, *shortcut, *label; + size_t id_length, shortcut_length, label_length; + if (!read_string(item, "behaviorId", &id, &id_length, error) || + !read_string(item, "shortcut", &shortcut, &shortcut_length, + error) || + !read_string(item, "displayName", &label, &label_length, error)) + return false; + if (!id || !id_length) + return type_error(error, "behaviorId", "a non-empty string"); + BongoCatBehaviorShortcut *entry = &settings->behavior_shortcuts[ + settings->behavior_shortcut_count++]; + memset(entry, 0, sizeof(*entry)); + if (!copy_text(entry->id, sizeof(entry->id), id, id_length, + "behaviorId", error) || + !copy_text(entry->shortcut, sizeof(entry->shortcut), shortcut, + shortcut_length, "shortcut", error) || + !copy_text(entry->label, sizeof(entry->label), label, + label_length, "displayName", error)) return false; } + return true; } -static void read_model_labels(yyjson_val *array, BongoCatSettings *config) { - if (!yyjson_is_arr(array)) return; - config->model_label_count = 0; - size_t index, count; yyjson_val *item; +static bool read_model_labels(yyjson_val *array, BongoCatSettings *settings, + BongoCatError *error) { + if (!array) return true; + if (yyjson_arr_size(array) > BONGO_CAT_MODEL_CAP) + return type_error(error, "modelOverrides", "a smaller array"); + settings->model_label_count = 0; + size_t index, count; + yyjson_val *item; yyjson_arr_foreach(array, index, count, item) { - const char *id = get_string(item, "modelId"); - const char *label = get_string(item, "displayName"); - if (!id || !id[0] || !label || !label[0] || - config->model_label_count >= BONGO_CAT_MODEL_CAP) continue; + if (!yyjson_is_obj(item)) + return type_error(error, "modelOverrides[]", "an object"); + const char *id, *label; + size_t id_length, label_length; + if (!read_string(item, "modelId", &id, &id_length, error) || + !read_string(item, "displayName", &label, &label_length, error)) + return false; + if (!id || !id_length || !label || !label_length) + return type_error(error, "modelOverrides[]", + "non-empty modelId and displayName strings"); BongoCatModelLabel *entry = - &config->model_labels[config->model_label_count++]; - copy_string(entry->id, sizeof(entry->id), id); - copy_string(entry->label, sizeof(entry->label), label); + &settings->model_labels[settings->model_label_count++]; + memset(entry, 0, sizeof(*entry)); + if (!copy_text(entry->id, sizeof(entry->id), id, id_length, + "modelId", error) || + !copy_text(entry->label, sizeof(entry->label), label, + label_length, "displayName", error)) return false; } + return true; } static BongoCatResult read_extensions(yyjson_val *value, BongoCatSettings *settings, BongoCatError *error) { if (!value) return BONGO_CAT_OK; if (!yyjson_is_obj(value)) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Settings extensions must be a JSON object"); + type_error(error, "extensions", "an object"); return BONGO_CAT_ERROR_FORMAT; } size_t length = 0; @@ -152,140 +212,40 @@ static BongoCatResult read_extensions(yyjson_val *value, } BongoCatResult bongo_cat_settings_load(const char *path, - BongoCatSettings *config, BongoCatError *error) { - if (!path || !config) return BONGO_CAT_ERROR_ARGUMENT; + BongoCatSettings *settings, BongoCatError *error) { + if (!path || !settings) return BONGO_CAT_ERROR_ARGUMENT; if (!bongo_cat_path_is_file(path)) return BONGO_CAT_OK; - yyjson_doc *document = bongo_cat_config_read_document(path, - SETTINGS_FORMAT, BONGO_CAT_SETTINGS_SCHEMA, error); - if (!document) return BONGO_CAT_ERROR_FORMAT; - BongoCatSettings loaded = *config; + yyjson_doc *document = NULL; + BongoCatResult result = bongo_cat_config_read_document(path, + SETTINGS_FORMAT, BONGO_CAT_SETTINGS_SCHEMA, &document, error); + if (result != BONGO_CAT_OK) return result; + BongoCatSettings loaded = *settings; yyjson_val *root = yyjson_doc_get_root(document); - read_model(yyjson_obj_get(root, "rendering"), &loaded.model); - read_window(yyjson_obj_get(root, "window"), &loaded.window); - read_app(yyjson_obj_get(root, "application"), &loaded.app); - read_shortcuts(yyjson_obj_get(root, "shortcuts"), &loaded.shortcuts); - read_behaviors(yyjson_obj_get(root, "behaviorOverrides"), &loaded); - read_model_labels(yyjson_obj_get(root, "modelOverrides"), &loaded); - BongoCatResult extensions = read_extensions( - yyjson_obj_get(root, "extensions"), &loaded, error); + yyjson_val *model = NULL; + yyjson_val *window = NULL; + yyjson_val *application = NULL; + yyjson_val *shortcuts = NULL; + yyjson_val *behaviors = NULL; + yyjson_val *models = NULL; + yyjson_val *extensions_value = NULL; + bool valid = read_object(root, "rendering", &model, error) && + read_object(root, "window", &window, error) && + read_object(root, "application", &application, error) && + read_object(root, "shortcuts", &shortcuts, error) && + read_array(root, "behaviorOverrides", &behaviors, error) && + read_array(root, "modelOverrides", &models, error) && + read_value(root, "extensions", &extensions_value, error) && + (!model || read_model(model, &loaded.model, error)) && + (!window || read_window(window, &loaded.window, error)) && + (!application || read_app(application, &loaded.app, error)) && + (!shortcuts || read_shortcuts(shortcuts, &loaded.shortcuts, error)) && + read_behaviors(behaviors, &loaded, error) && + read_model_labels(models, &loaded, error); + if (!valid) result = BONGO_CAT_ERROR_FORMAT; + if (valid) result = read_extensions(extensions_value, &loaded, error); yyjson_doc_free(document); - if (extensions != BONGO_CAT_OK) return extensions; + if (result != BONGO_CAT_OK) return result; bongo_cat_settings_validate(&loaded); - *config = loaded; + *settings = loaded; return BONGO_CAT_OK; } - -static void write_model(yyjson_mut_doc *doc, yyjson_mut_val *obj, - const BongoCatModelPreferences *v) { - yyjson_mut_obj_add_bool(doc, obj, "modelMirrored", v->mirror); - yyjson_mut_obj_add_bool(doc, obj, "pointerMirrored", v->mouse_mirror); - yyjson_mut_obj_add_bool(doc, obj, "centerPointerTracking", v->mouse_centered); - yyjson_mut_obj_add_bool(doc, obj, "ignorePointerInput", v->ignore_mouse); - yyjson_mut_obj_add_real(doc, obj, "inputReleaseDelaySeconds", - v->auto_release_seconds); - yyjson_mut_obj_add_int(doc, obj, "maximumFps", v->max_fps); -} -static void write_window(yyjson_mut_doc *doc, yyjson_mut_val *obj, - const BongoCatWindowPreferences *v) { - yyjson_mut_obj_add_bool(doc, obj, "clickThrough", v->pass_through); - yyjson_mut_obj_add_bool(doc, obj, "alwaysOnTop", v->always_on_top); - yyjson_mut_obj_add_bool(doc, obj, "hideOnPointerOver", v->hide_on_hover); - yyjson_mut_obj_add_bool(doc, obj, "keepOnScreen", v->keep_in_screen); - yyjson_mut_obj_add_bool(doc, obj, "captureBackground", v->obs_background); - yyjson_mut_obj_add_strcpy(doc, obj, "captureBackgroundColor", - bongo_cat_obs_background_color_name(v->obs_background_color)); - yyjson_mut_obj_add_real(doc, obj, "hideDelaySeconds", v->hide_delay_seconds); -} -static void write_app(yyjson_mut_doc *doc, yyjson_mut_val *obj, - const BongoCatApplicationPreferences *v) { - yyjson_mut_obj_add_bool(doc, obj, "launchAtLogin", v->autostart); - yyjson_mut_obj_add_bool(doc, obj, "showTrayIcon", v->tray_visible); - yyjson_mut_obj_add_strcpy(doc, obj, "theme", bongo_cat_theme_name(v->theme)); - yyjson_mut_obj_add_strcpy(doc, obj, "language", bongo_cat_language_name(v->language)); -} -static void write_shortcuts(yyjson_mut_doc *doc, yyjson_mut_val *obj, - const BongoCatShortcutPreferences *v) { - yyjson_mut_obj_add_strcpy(doc, obj, "toggleVisibility", v->visible_cat); - yyjson_mut_obj_add_strcpy(doc, obj, "openSettings", v->visible_preferences); - yyjson_mut_obj_add_strcpy(doc, obj, "toggleModelMirror", v->mirror); - yyjson_mut_obj_add_strcpy(doc, obj, "toggleClickThrough", v->pass_through); - yyjson_mut_obj_add_strcpy(doc, obj, "toggleAlwaysOnTop", v->always_on_top); -} -static void write_behaviors(yyjson_mut_doc *doc, yyjson_mut_val *root, - const BongoCatSettings *config) { - yyjson_mut_val *array = yyjson_mut_arr(doc); - yyjson_mut_obj_add_val(doc, root, "behaviorOverrides", array); - for (size_t i = 0; i < config->behavior_shortcut_count; ++i) { - const BongoCatBehaviorShortcut *value = &config->behavior_shortcuts[i]; - if (!value->id[0] || (!value->shortcut[0] && !value->label[0])) continue; - yyjson_mut_val *item = yyjson_mut_obj(doc); - yyjson_mut_obj_add_strcpy(doc, item, "behaviorId", value->id); - if (value->shortcut[0]) - yyjson_mut_obj_add_strcpy(doc, item, "shortcut", value->shortcut); - if (value->label[0]) - yyjson_mut_obj_add_strcpy(doc, item, "displayName", value->label); - yyjson_mut_arr_add_val(array, item); - } -} - -static void write_model_labels(yyjson_mut_doc *doc, yyjson_mut_val *root, - const BongoCatSettings *config) { - yyjson_mut_val *array = yyjson_mut_arr(doc); - yyjson_mut_obj_add_val(doc, root, "modelOverrides", array); - for (size_t i = 0; i < config->model_label_count; ++i) { - const BongoCatModelLabel *value = &config->model_labels[i]; - if (!value->id[0] || !value->label[0]) continue; - yyjson_mut_val *item = yyjson_mut_obj(doc); - yyjson_mut_obj_add_strcpy(doc, item, "modelId", value->id); - yyjson_mut_obj_add_strcpy(doc, item, "displayName", value->label); - yyjson_mut_arr_add_val(array, item); - } -} - -static yyjson_mut_val *write_extensions(yyjson_mut_doc *target, - const BongoCatSettings *settings, BongoCatError *error) { - const char *json = settings->extensions_json; - const char *end = memchr(json, '\0', sizeof(settings->extensions_json)); - if (!end) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Settings extensions are not null-terminated"); - return NULL; - } - size_t length = (size_t)(end - json); - if (!length) { json = "{}"; length = 2; } - yyjson_doc *source = yyjson_read(json, length, 0); - yyjson_val *root = source ? yyjson_doc_get_root(source) : NULL; - yyjson_mut_val *copy = yyjson_is_obj(root) - ? yyjson_val_mut_copy(target, root) : NULL; - yyjson_doc_free(source); - if (!copy) bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Settings extensions are not a valid JSON object"); - return copy; -} - -BongoCatResult bongo_cat_settings_save(const char *path, - const BongoCatSettings *config, BongoCatError *error) { - if (!path || !config) return BONGO_CAT_ERROR_ARGUMENT; - yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); - if (!doc) return BONGO_CAT_ERROR_MEMORY; - yyjson_mut_val *root = yyjson_mut_obj(doc); yyjson_mut_doc_set_root(doc, root); - yyjson_mut_val *extensions = write_extensions(doc, config, error); - if (!extensions) { - yyjson_mut_doc_free(doc); - return error && error->code ? error->code : BONGO_CAT_ERROR_FORMAT; - } - yyjson_mut_obj_add_strcpy(doc, root, "format", SETTINGS_FORMAT); - yyjson_mut_obj_add_int(doc, root, "schemaVersion", - BONGO_CAT_SETTINGS_SCHEMA); - write_model(doc, yyjson_mut_obj_add_obj(doc, root, "rendering"), &config->model); - write_window(doc, yyjson_mut_obj_add_obj(doc, root, "window"), &config->window); - write_app(doc, yyjson_mut_obj_add_obj(doc, root, "application"), &config->app); - write_shortcuts(doc, yyjson_mut_obj_add_obj(doc, root, "shortcuts"), &config->shortcuts); - write_behaviors(doc, root, config); - write_model_labels(doc, root, config); - yyjson_mut_obj_add_val(doc, root, "extensions", extensions); - BongoCatResult result = bongo_cat_config_write_document(path, doc, - "settings file", error); - yyjson_mut_doc_free(doc); - return result; -} diff --git a/src/core/config_names.c b/src/core/config_names.c new file mode 100644 index 00000000..b85d8a99 --- /dev/null +++ b/src/core/config_names.c @@ -0,0 +1,33 @@ +#include "bongo_cat/config.h" + +const char *bongo_cat_theme_name(BongoCatTheme value) { + const char *names[] = {"auto", "light", "dark"}; + return (unsigned)value <= BONGO_CAT_THEME_DARK ? names[value] : names[0]; +} + +const char *bongo_cat_language_name(BongoCatLanguage value) { + const char *names[] = {"en-US", "zh-CN", "zh-TW", "fr-FR", "de-DE", + "ja-JP", "ko-KR", "pt-BR", "ru-RU", "es-ES"}; + return (unsigned)value < BONGO_CAT_LANG_COUNT ? names[value] : names[0]; +} + +const char *bongo_cat_mode_name(BongoCatModelMode value) { + const char *names[] = {"standard", "keyboard", "gamepad"}; + return (unsigned)value <= BONGO_CAT_MODE_GAMEPAD ? names[value] : names[0]; +} + +const char *bongo_cat_obs_background_color_name( + BongoCatObsBackgroundColor value) { + static const char *names[] = { + "#00ff00", "#0000ff", "#ff0000", "#ff00ff"}; + return (unsigned)value < BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT ? + names[value] : names[BONGO_CAT_OBS_BACKGROUND_GREEN]; +} + +uint32_t bongo_cat_obs_background_color_rgb( + BongoCatObsBackgroundColor value) { + static const uint32_t colors[] = { + 0x00ff00, 0x0000ff, 0xff0000, 0xff00ff}; + return (unsigned)value < BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT ? + colors[value] : colors[BONGO_CAT_OBS_BACKGROUND_GREEN]; +} diff --git a/src/core/config_session_io.c b/src/core/config_session_io.c index a71fdc18..9068532f 100644 --- a/src/core/config_session_io.c +++ b/src/core/config_session_io.c @@ -1,89 +1,132 @@ #include "config_internal.h" #include "bongo_cat/path.h" -#include +#include #define SESSION_FORMAT "bongocat/session" +#define type_error(error, field, expected) bongo_cat_config_type_error( \ + "Session", error, field, expected) +#define read_value(object, key, target, error) bongo_cat_config_read_value( \ + "Session", object, key, target, error) +#define read_object(object, key, target, error) bongo_cat_config_read_object( \ + "Session", object, key, target, error) +#define read_bool(object, key, target, error) bongo_cat_config_read_bool( \ + "Session", object, key, target, error) +#define read_int(object, key, target, required, error) \ + bongo_cat_config_read_int( \ + "Session", object, key, target, required, error) +#define read_float(object, key, target, error) bongo_cat_config_read_float( \ + "Session", object, key, target, error) -static bool get_bool(yyjson_val *obj, const char *key, bool fallback) { - yyjson_val *value = yyjson_obj_get(obj, key); - return yyjson_is_bool(value) ? yyjson_get_bool(value) : fallback; -} -static int get_int(yyjson_val *obj, const char *key, int fallback) { - yyjson_val *value = yyjson_obj_get(obj, key); - return yyjson_is_num(value) ? (int)yyjson_get_sint(value) : fallback; -} -static float get_float(yyjson_val *obj, const char *key, float fallback) { - yyjson_val *value = yyjson_obj_get(obj, key); - return yyjson_is_num(value) ? (float)yyjson_get_num(value) : fallback; +static bool read_active_model(yyjson_val *root, + BongoCatSessionState *session, BongoCatError *error) { + yyjson_val *value; + if (!read_value(root, "activeModelId", &value, error)) return false; + if (!value) return true; + if (!yyjson_is_str(value)) + return type_error(error, "activeModelId", "a string"); + const char *text = yyjson_get_str(value); + size_t length = yyjson_get_len(value); + if (!length || length >= sizeof(session->active_model_id) || + strlen(text) != length) + return type_error(error, "activeModelId", + "a non-empty string within the supported length"); + memset(session->active_model_id, 0, sizeof(session->active_model_id)); + memcpy(session->active_model_id, text, length); + return true; } BongoCatResult bongo_cat_session_load(const char *path, BongoCatSessionState *session, BongoCatError *error) { if (!path || !session) return BONGO_CAT_ERROR_ARGUMENT; if (!bongo_cat_path_is_file(path)) return BONGO_CAT_OK; - yyjson_doc *document = bongo_cat_config_read_document(path, - SESSION_FORMAT, BONGO_CAT_SESSION_SCHEMA, error); - if (!document) return BONGO_CAT_ERROR_FORMAT; + yyjson_doc *document = NULL; + BongoCatResult result = bongo_cat_config_read_document(path, + SESSION_FORMAT, BONGO_CAT_SESSION_SCHEMA, &document, error); + if (result != BONGO_CAT_OK) return result; BongoCatSessionState loaded = *session; yyjson_val *root = yyjson_doc_get_root(document); - yyjson_val *window = yyjson_obj_get(root, "window"); - if (yyjson_is_obj(window)) { - loaded.window.visible = get_bool(window, "visible", loaded.window.visible); - loaded.window.scale_percent = get_float(window, "scalePercent", - loaded.window.scale_percent); - loaded.window.opacity_percent = get_float(window, "opacityPercent", - loaded.window.opacity_percent); - yyjson_val *position = yyjson_obj_get(window, "position"); - if (yyjson_is_obj(position)) { - yyjson_val *x = yyjson_obj_get(position, "x"); - yyjson_val *y = yyjson_obj_get(position, "y"); - if (yyjson_is_num(x) && yyjson_is_num(y)) { - loaded.window.x = (int)yyjson_get_sint(x); - loaded.window.y = (int)yyjson_get_sint(y); - loaded.window.position_known = true; - } - } - yyjson_val *size = yyjson_obj_get(window, "size"); - if (yyjson_is_obj(size)) { - loaded.window.width = get_int(size, "width", loaded.window.width); - loaded.window.height = get_int(size, "height", loaded.window.height); + yyjson_val *window = NULL; + bool valid = read_object(root, "window", &window, error); + if (valid && window) { + valid = read_bool(window, "visible", &loaded.window.visible, error) && + read_float(window, "scalePercent", + &loaded.window.scale_percent, error) && + read_float(window, "opacityPercent", + &loaded.window.opacity_percent, error); + yyjson_val *position = NULL; + yyjson_val *size = NULL; + if (valid) valid = read_object( + window, "position", &position, error); + if (valid) valid = read_object(window, "size", &size, error); + if (valid && position) { + valid = read_int(position, "x", &loaded.window.x, true, error) && + read_int(position, "y", &loaded.window.y, true, error); + if (valid) loaded.window.position_known = true; } + if (valid && size) + valid = read_int(size, "width", &loaded.window.width, true, + error) && + read_int(size, "height", &loaded.window.height, true, error); } - yyjson_val *model = yyjson_obj_get(root, "activeModelId"); - if (yyjson_is_str(model)) snprintf(loaded.active_model_id, - sizeof(loaded.active_model_id), "%s", yyjson_get_str(model)); + if (valid) valid = read_active_model(root, &loaded, error); yyjson_doc_free(document); + if (!valid) return BONGO_CAT_ERROR_FORMAT; bongo_cat_session_validate(&loaded); *session = loaded; return BONGO_CAT_OK; } +static BongoCatResult build_error(BongoCatError *error) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Cannot allocate session JSON"); + return BONGO_CAT_ERROR_MEMORY; +} + BongoCatResult bongo_cat_session_save(const char *path, const BongoCatSessionState *session, BongoCatError *error) { if (!path || !session) return BONGO_CAT_ERROR_ARGUMENT; + BongoCatSessionState canonical = *session; + bongo_cat_session_validate(&canonical); yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); - if (!doc) return BONGO_CAT_ERROR_MEMORY; - yyjson_mut_val *root = yyjson_mut_obj(doc); yyjson_mut_doc_set_root(doc, root); - yyjson_mut_obj_add_strcpy(doc, root, "format", SESSION_FORMAT); - yyjson_mut_obj_add_int(doc, root, "schemaVersion", BONGO_CAT_SESSION_SCHEMA); - yyjson_mut_val *window = yyjson_mut_obj_add_obj(doc, root, "window"); - yyjson_mut_obj_add_bool(doc, window, "visible", session->window.visible); - yyjson_mut_obj_add_real(doc, window, "scalePercent", - session->window.scale_percent); - yyjson_mut_obj_add_real(doc, window, "opacityPercent", - session->window.opacity_percent); - if (session->window.position_known) { - yyjson_mut_val *position = yyjson_mut_obj_add_obj(doc, window, - "position"); - yyjson_mut_obj_add_int(doc, position, "x", session->window.x); - yyjson_mut_obj_add_int(doc, position, "y", session->window.y); + if (!doc) return build_error(error); + yyjson_mut_val *root = yyjson_mut_obj(doc); + if (!root) { + yyjson_mut_doc_free(doc); + return build_error(error); + } + yyjson_mut_doc_set_root(doc, root); + bool built = yyjson_mut_obj_add_strcpy( + doc, root, "format", SESSION_FORMAT) && + yyjson_mut_obj_add_int(doc, root, "schemaVersion", + BONGO_CAT_SESSION_SCHEMA); + yyjson_mut_val *window = built ? + yyjson_mut_obj_add_obj(doc, root, "window") : NULL; + built = built && window && + yyjson_mut_obj_add_bool(doc, window, "visible", + canonical.window.visible) && + yyjson_mut_obj_add_real(doc, window, "scalePercent", + canonical.window.scale_percent) && + yyjson_mut_obj_add_real(doc, window, "opacityPercent", + canonical.window.opacity_percent); + if (built && canonical.window.position_known) { + yyjson_mut_val *position = + yyjson_mut_obj_add_obj(doc, window, "position"); + built = position && + yyjson_mut_obj_add_int(doc, position, "x", canonical.window.x) && + yyjson_mut_obj_add_int(doc, position, "y", canonical.window.y); + } + yyjson_mut_val *size = built ? + yyjson_mut_obj_add_obj(doc, window, "size") : NULL; + built = built && size && + yyjson_mut_obj_add_int(doc, size, "width", canonical.window.width) && + yyjson_mut_obj_add_int(doc, size, "height", canonical.window.height) && + yyjson_mut_obj_add_strcpy(doc, root, "activeModelId", + canonical.active_model_id); + if (!built) { + yyjson_mut_doc_free(doc); + return build_error(error); } - yyjson_mut_val *size = yyjson_mut_obj_add_obj(doc, window, "size"); - yyjson_mut_obj_add_int(doc, size, "width", session->window.width); - yyjson_mut_obj_add_int(doc, size, "height", session->window.height); - yyjson_mut_obj_add_strcpy(doc, root, "activeModelId", - session->active_model_id); BongoCatResult result = bongo_cat_config_write_document(path, doc, "session file", error); yyjson_mut_doc_free(doc); diff --git a/src/core/config_settings_save.c b/src/core/config_settings_save.c new file mode 100644 index 00000000..13321d1e --- /dev/null +++ b/src/core/config_settings_save.c @@ -0,0 +1,197 @@ +#include "config_internal.h" + +#include + +#define SETTINGS_FORMAT "bongocat/settings" + +static bool write_model(yyjson_mut_doc *doc, yyjson_mut_val *object, + const BongoCatModelPreferences *value) { + return object && + yyjson_mut_obj_add_bool(doc, object, "modelMirrored", value->mirror) && + yyjson_mut_obj_add_bool(doc, object, "pointerMirrored", + value->mouse_mirror) && + yyjson_mut_obj_add_bool(doc, object, "centerPointerTracking", + value->mouse_centered) && + yyjson_mut_obj_add_bool(doc, object, "ignorePointerInput", + value->ignore_mouse) && + yyjson_mut_obj_add_real(doc, object, "inputReleaseDelaySeconds", + value->auto_release_seconds) && + yyjson_mut_obj_add_int(doc, object, "maximumFps", value->max_fps); +} + +static bool write_window(yyjson_mut_doc *doc, yyjson_mut_val *object, + const BongoCatWindowPreferences *value) { + return object && + yyjson_mut_obj_add_bool(doc, object, "clickThrough", + value->pass_through) && + yyjson_mut_obj_add_bool(doc, object, "alwaysOnTop", + value->always_on_top) && + yyjson_mut_obj_add_bool(doc, object, "hideOnPointerOver", + value->hide_on_hover) && + yyjson_mut_obj_add_bool(doc, object, "keepOnScreen", + value->keep_in_screen) && + yyjson_mut_obj_add_bool(doc, object, "captureBackground", + value->obs_background) && + yyjson_mut_obj_add_strcpy(doc, object, "captureBackgroundColor", + bongo_cat_obs_background_color_name( + value->obs_background_color)) && + yyjson_mut_obj_add_real(doc, object, "hideDelaySeconds", + value->hide_delay_seconds); +} + +static bool write_app(yyjson_mut_doc *doc, yyjson_mut_val *object, + const BongoCatApplicationPreferences *value) { + return object && + yyjson_mut_obj_add_bool(doc, object, "launchAtLogin", + value->autostart) && + yyjson_mut_obj_add_bool(doc, object, "showTrayIcon", + value->tray_visible) && + yyjson_mut_obj_add_strcpy(doc, object, "theme", + bongo_cat_theme_name(value->theme)) && + yyjson_mut_obj_add_strcpy(doc, object, "language", + bongo_cat_language_name(value->language)); +} + +static bool write_shortcuts(yyjson_mut_doc *doc, yyjson_mut_val *object, + const BongoCatShortcutPreferences *value) { + return object && + yyjson_mut_obj_add_strcpy(doc, object, "toggleVisibility", + value->visible_cat) && + yyjson_mut_obj_add_strcpy(doc, object, "openSettings", + value->visible_preferences) && + yyjson_mut_obj_add_strcpy(doc, object, "toggleModelMirror", + value->mirror) && + yyjson_mut_obj_add_strcpy(doc, object, "toggleClickThrough", + value->pass_through) && + yyjson_mut_obj_add_strcpy(doc, object, "toggleAlwaysOnTop", + value->always_on_top); +} + +static bool write_behaviors(yyjson_mut_doc *doc, yyjson_mut_val *root, + const BongoCatSettings *settings) { + yyjson_mut_val *array = yyjson_mut_arr(doc); + if (!array || !yyjson_mut_obj_add_val( + doc, root, "behaviorOverrides", array)) return false; + for (size_t i = 0; i < settings->behavior_shortcut_count; ++i) { + const BongoCatBehaviorShortcut *value = + &settings->behavior_shortcuts[i]; + yyjson_mut_val *item = yyjson_mut_obj(doc); + if (!item || !yyjson_mut_obj_add_strcpy( + doc, item, "behaviorId", value->id) || + (value->shortcut[0] && !yyjson_mut_obj_add_strcpy( + doc, item, "shortcut", value->shortcut)) || + (value->label[0] && !yyjson_mut_obj_add_strcpy( + doc, item, "displayName", value->label)) || + !yyjson_mut_arr_add_val(array, item)) return false; + } + return true; +} + +static bool write_model_labels(yyjson_mut_doc *doc, yyjson_mut_val *root, + const BongoCatSettings *settings) { + yyjson_mut_val *array = yyjson_mut_arr(doc); + if (!array || !yyjson_mut_obj_add_val( + doc, root, "modelOverrides", array)) return false; + for (size_t i = 0; i < settings->model_label_count; ++i) { + const BongoCatModelLabel *value = &settings->model_labels[i]; + yyjson_mut_val *item = yyjson_mut_obj(doc); + if (!item || !yyjson_mut_obj_add_strcpy( + doc, item, "modelId", value->id) || + !yyjson_mut_obj_add_strcpy( + doc, item, "displayName", value->label) || + !yyjson_mut_arr_add_val(array, item)) return false; + } + return true; +} + +static yyjson_mut_val *write_extensions(yyjson_mut_doc *target, + const BongoCatSettings *settings, BongoCatResult *failure, + BongoCatError *error) { + *failure = BONGO_CAT_OK; + const char *json = settings->extensions_json; + const char *end = memchr(json, '\0', sizeof(settings->extensions_json)); + if (!end) { + *failure = BONGO_CAT_ERROR_FORMAT; + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Settings extensions are not null-terminated"); + return NULL; + } + size_t length = (size_t)(end - json); + if (!length) { + json = "{}"; + length = 2; + } + yyjson_read_err read_error = {0}; + yyjson_doc *source = yyjson_read_opts( + (char *)json, length, 0, NULL, &read_error); + yyjson_val *root = source ? yyjson_doc_get_root(source) : NULL; + if (!yyjson_is_obj(root)) { + yyjson_doc_free(source); + BongoCatResult result = read_error.code == + YYJSON_READ_ERROR_MEMORY_ALLOCATION ? BONGO_CAT_ERROR_MEMORY : + BONGO_CAT_ERROR_FORMAT; + *failure = result; + bongo_cat_error_set(error, result, + "Settings extensions are not a valid JSON object"); + return NULL; + } + yyjson_mut_val *copy = yyjson_val_mut_copy(target, root); + yyjson_doc_free(source); + if (!copy) { + *failure = BONGO_CAT_ERROR_MEMORY; + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Cannot allocate settings extensions"); + } + return copy; +} + +static BongoCatResult build_error(BongoCatError *error) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Cannot allocate settings JSON"); + return BONGO_CAT_ERROR_MEMORY; +} + +BongoCatResult bongo_cat_settings_save(const char *path, + const BongoCatSettings *settings, BongoCatError *error) { + if (!path || !settings) return BONGO_CAT_ERROR_ARGUMENT; + BongoCatSettings canonical = *settings; + bongo_cat_settings_validate(&canonical); + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + if (!doc) return build_error(error); + yyjson_mut_val *root = yyjson_mut_obj(doc); + if (!root) { + yyjson_mut_doc_free(doc); + return build_error(error); + } + yyjson_mut_doc_set_root(doc, root); + BongoCatResult extension_result = BONGO_CAT_OK; + yyjson_mut_val *extensions = write_extensions( + doc, &canonical, &extension_result, error); + if (!extensions) { + yyjson_mut_doc_free(doc); + return extension_result; + } + bool built = yyjson_mut_obj_add_strcpy( + doc, root, "format", SETTINGS_FORMAT) && + yyjson_mut_obj_add_int(doc, root, "schemaVersion", + BONGO_CAT_SETTINGS_SCHEMA) && + write_model(doc, yyjson_mut_obj_add_obj(doc, root, "rendering"), + &canonical.model) && + write_window(doc, yyjson_mut_obj_add_obj(doc, root, "window"), + &canonical.window) && + write_app(doc, yyjson_mut_obj_add_obj(doc, root, "application"), + &canonical.app) && + write_shortcuts(doc, yyjson_mut_obj_add_obj(doc, root, "shortcuts"), + &canonical.shortcuts) && + write_behaviors(doc, root, &canonical) && + write_model_labels(doc, root, &canonical) && + yyjson_mut_obj_add_val(doc, root, "extensions", extensions); + if (!built) { + yyjson_mut_doc_free(doc); + return build_error(error); + } + BongoCatResult result = bongo_cat_config_write_document(path, doc, + "settings file", error); + yyjson_mut_doc_free(doc); + return result; +} diff --git a/src/live2d/cubism_model_bounds.cpp b/src/live2d/cubism_model_bounds.cpp index c3bb8330..79379f6e 100644 --- a/src/live2d/cubism_model_bounds.cpp +++ b/src/live2d/cubism_model_bounds.cpp @@ -68,9 +68,10 @@ NativeModel::ModelBounds NativeModel::capture_visible_bounds() const { drawable.bounds.min_x - anchor.bounds.max_x, 0.0f}); float dy = std::max({anchor.bounds.min_y - drawable.bounds.max_y, drawable.bounds.min_y - anchor.bounds.max_y, 0.0f}); - bool nearby = dx <= padding && dy <= padding; + bool within_padding = dx <= padding && dy <= padding; bool major = drawable.area >= anchor.area * 0.05f; - if (drawable.area < anchor.area * 0.0005f || (!nearby && !major)) continue; + if (drawable.area < anchor.area * 0.0005f || + (!within_padding && !major)) continue; bounds.min_x = std::min(bounds.min_x, drawable.bounds.min_x); bounds.min_y = std::min(bounds.min_y, drawable.bounds.min_y); bounds.max_x = std::max(bounds.max_x, drawable.bounds.max_x); diff --git a/src/media/image.c b/src/media/image.c index 7278bbad..61e1fafb 100644 --- a/src/media/image.c +++ b/src/media/image.c @@ -18,10 +18,8 @@ #ifdef _WIN32 static bool needs_wic_scaling(const char *path, int limit) { - FILE *file = bongo_cat_file_open(path, "rb"); - int width = 0, height = 0, channels = 0; - bool known = file && stbi_info_from_file(file, &width, &height, &channels); - if (file) fclose(file); + int width = 0, height = 0; + bool known = bongo_cat_image_info(path, &width, &height); return !known || width > limit || height > limit; } diff --git a/src/media/image_info.c b/src/media/image_info.c new file mode 100644 index 00000000..453ef46e --- /dev/null +++ b/src/media/image_info.c @@ -0,0 +1,18 @@ +#include "bongo_cat/file.h" +#include "bongo_cat/image.h" + +#include + +bool bongo_cat_image_info(const char *path, int *width, int *height) { + if (width) *width = 0; + if (height) *height = 0; + FILE *file = path ? bongo_cat_file_open(path, "rb") : NULL; + int image_width = 0, image_height = 0, channels = 0; + bool known = file && stbi_info_from_file(file, &image_width, + &image_height, &channels) && image_width > 0 && image_height > 0 && + channels > 0; + if (file) fclose(file); + if (known && width) *width = image_width; + if (known && height) *height = image_height; + return known; +} diff --git a/src/runtime/app.c b/src/runtime/app.c index 12a1217d..62534e5f 100644 --- a/src/runtime/app.c +++ b/src/runtime/app.c @@ -43,20 +43,7 @@ static bool initialize(BongoCatApp *app, int argc, char **argv, BongoCatError *e bongo_cat_shortcut_init(&app->shortcut_state); bongo_cat_models_init(&app->models); if (!bongo_cat_startup_prepare(app, argc, argv, error)) return false; - BongoCatResult loaded = bongo_cat_settings_load( - app->settings_path, &app->settings, error); - app->settings_store_valid = loaded == BONGO_CAT_OK && - bongo_cat_path_is_file(app->settings_path); - if (loaded != BONGO_CAT_OK) - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Settings ignored: %s", error->message); - *error = (BongoCatError){0}; - loaded = bongo_cat_session_load(app->session_path, &app->session, error); - app->session_store_valid = loaded == BONGO_CAT_OK && - bongo_cat_path_is_file(app->session_path); - if (loaded != BONGO_CAT_OK) - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Session ignored: %s", error->message); - *error = (BongoCatError){0}; - bongo_cat_config_store_initialize(app); + bongo_cat_config_store_load(app); if (app->smoke_language >= 0) app->settings.app.language = (BongoCatLanguage)app->smoke_language; if (app->smoke_theme >= 0) app->settings.app.theme = (BongoCatTheme)app->smoke_theme; diff --git a/src/runtime/config_store.c b/src/runtime/config_store.c index 38e8a90c..e5bb047a 100644 --- a/src/runtime/config_store.c +++ b/src/runtime/config_store.c @@ -1,6 +1,7 @@ #include "runtime.h" #include "bongo_cat/path.h" +#include #include #define SAVE_DELAY_NS 300000000ull @@ -23,11 +24,16 @@ static uint64_t settings_hash(const BongoCatSettings *settings) { HASH_FIELD(settings->app); HASH_FIELD(settings->shortcuts); HASH_FIELD(settings->behavior_shortcut_count); + size_t behavior_count = settings->behavior_shortcut_count; + if (behavior_count > BONGO_CAT_BEHAVIOR_BINDING_CAP) + behavior_count = BONGO_CAT_BEHAVIOR_BINDING_CAP; hash = hash_bytes(hash, settings->behavior_shortcuts, - settings->behavior_shortcut_count * sizeof(settings->behavior_shortcuts[0])); + behavior_count * sizeof(settings->behavior_shortcuts[0])); HASH_FIELD(settings->model_label_count); + size_t model_count = settings->model_label_count; + if (model_count > BONGO_CAT_MODEL_CAP) model_count = BONGO_CAT_MODEL_CAP; hash = hash_bytes(hash, settings->model_labels, - settings->model_label_count * sizeof(settings->model_labels[0])); + model_count * sizeof(settings->model_labels[0])); HASH_FIELD(settings->extensions_json); #undef HASH_FIELD return hash; @@ -49,16 +55,77 @@ static uint64_t session_hash(const BongoCatSessionState *session) { return hash; } -void bongo_cat_config_store_initialize(BongoCatApp *app) { +static bool reject_configuration(const char *path, char *rejected, + size_t capacity) { + for (unsigned index = 0; index < 1000; ++index) { + int length = index ? snprintf(rejected, capacity, "%s.rejected.%u", + path, index) : snprintf(rejected, capacity, "%s.rejected", path); + if (length < 0 || (size_t)length >= capacity) return false; + if (bongo_cat_path_is_file(rejected) || + bongo_cat_path_is_dir(rejected)) continue; + return bongo_cat_path_rename(path, rejected); + } + return false; +} + +static void handle_rejected_configuration(const char *description, + const char *path, BongoCatResult result, bool *blocked) { + if (result != BONGO_CAT_ERROR_FORMAT || !bongo_cat_path_is_file(path)) { + *blocked = bongo_cat_path_is_file(path); + return; + } + char rejected[BONGO_CAT_PATH_CAP]; + if (reject_configuration(path, rejected, sizeof(rejected))) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "%s preserved as rejected file: %s", description, rejected); + return; + } + *blocked = true; + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "%s will not be overwritten because it could not be preserved: %s", + description, path); +} + +void bongo_cat_config_store_load(BongoCatApp *app) { + app->settings_store_valid = false; + app->session_store_valid = false; + app->settings_store_blocked = false; + app->session_store_blocked = false; + BongoCatError error = {0}; + BongoCatResult loaded = bongo_cat_settings_load( + app->settings_path, &app->settings, &error); + app->settings_store_valid = loaded == BONGO_CAT_OK && + bongo_cat_path_is_file(app->settings_path); + if (loaded != BONGO_CAT_OK) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Settings ignored: %s", + error.message); + handle_rejected_configuration("Settings", app->settings_path, + loaded, &app->settings_store_blocked); + } + error = (BongoCatError){0}; + loaded = bongo_cat_session_load( + app->session_path, &app->session, &error); + app->session_store_valid = loaded == BONGO_CAT_OK && + bongo_cat_path_is_file(app->session_path); + if (loaded != BONGO_CAT_OK) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Session ignored: %s", + error.message); + handle_rejected_configuration("Session", app->session_path, + loaded, &app->session_store_blocked); + } uint64_t settings = settings_hash(&app->settings); uint64_t session = session_hash(&app->session); app->settings_observed_hash = settings; app->session_observed_hash = session; - app->settings_saved_hash = app->settings_store_valid ? settings : 0; - app->session_saved_hash = app->session_store_valid ? session : 0; + app->settings_saved_hash = app->settings_store_valid || + app->settings_store_blocked ? settings : ~settings; + app->session_saved_hash = app->session_store_valid || + app->session_store_blocked ? session : ~session; } -static bool save_settings(BongoCatApp *app, uint64_t hash) { +static bool save_settings(BongoCatApp *app) { + bongo_cat_settings_validate(&app->settings); + uint64_t hash = settings_hash(&app->settings); BongoCatError error = {0}; if (bongo_cat_settings_save(app->settings_path, &app->settings, &error) != BONGO_CAT_OK) { @@ -66,10 +133,14 @@ static bool save_settings(BongoCatApp *app, uint64_t hash) { return false; } app->settings_saved_hash = hash; + app->settings_observed_hash = hash; + app->settings_store_valid = true; return true; } -static bool save_session(BongoCatApp *app, uint64_t hash) { +static bool save_session(BongoCatApp *app) { + bongo_cat_session_validate(&app->session); + uint64_t hash = session_hash(&app->session); BongoCatError error = {0}; if (bongo_cat_session_save(app->session_path, &app->session, &error) != BONGO_CAT_OK) { @@ -77,6 +148,8 @@ static bool save_session(BongoCatApp *app, uint64_t hash) { return false; } app->session_saved_hash = hash; + app->session_observed_hash = hash; + app->session_store_valid = true; return true; } @@ -84,21 +157,29 @@ void bongo_cat_config_store_update(BongoCatApp *app, uint64_t now) { if (!app || app->smoke || !app->settings_path[0] || !app->session_path[0]) return; uint64_t settings = settings_hash(&app->settings); uint64_t session = session_hash(&app->session); - if (settings != app->settings_observed_hash) { - app->settings_observed_hash = settings; - app->settings_save_due_ns = now + SAVE_DELAY_NS; - } else if (settings != app->settings_saved_hash && - !app->settings_save_due_ns) app->settings_save_due_ns = now + SAVE_DELAY_NS; - if (session != app->session_observed_hash) { - app->session_observed_hash = session; - app->session_save_due_ns = now + SAVE_DELAY_NS; - } else if (session != app->session_saved_hash && - !app->session_save_due_ns) app->session_save_due_ns = now + SAVE_DELAY_NS; - if (app->settings_save_due_ns && now >= app->settings_save_due_ns) - app->settings_save_due_ns = save_settings(app, settings) + if (!app->settings_store_blocked) { + if (settings != app->settings_observed_hash) { + app->settings_observed_hash = settings; + app->settings_save_due_ns = now + SAVE_DELAY_NS; + } else if (settings != app->settings_saved_hash && + !app->settings_save_due_ns) + app->settings_save_due_ns = now + SAVE_DELAY_NS; + } + if (!app->session_store_blocked) { + if (session != app->session_observed_hash) { + app->session_observed_hash = session; + app->session_save_due_ns = now + SAVE_DELAY_NS; + } else if (session != app->session_saved_hash && + !app->session_save_due_ns) + app->session_save_due_ns = now + SAVE_DELAY_NS; + } + if (!app->settings_store_blocked && app->settings_save_due_ns && + now >= app->settings_save_due_ns) + app->settings_save_due_ns = save_settings(app) ? 0 : now + RETRY_DELAY_NS; - if (app->session_save_due_ns && now >= app->session_save_due_ns) - app->session_save_due_ns = save_session(app, session) + if (!app->session_store_blocked && app->session_save_due_ns && + now >= app->session_save_due_ns) + app->session_save_due_ns = save_session(app) ? 0 : now + RETRY_DELAY_NS; } @@ -106,9 +187,11 @@ void bongo_cat_config_store_flush(BongoCatApp *app) { if (!app || app->smoke || !app->settings_path[0] || !app->session_path[0]) return; uint64_t settings = settings_hash(&app->settings); uint64_t session = session_hash(&app->session); - if (settings != app->settings_saved_hash) save_settings(app, settings); - if (session != app->session_saved_hash) save_session(app, session); - app->settings_observed_hash = settings; - app->session_observed_hash = session; + if (!app->settings_store_blocked && + settings != app->settings_saved_hash) save_settings(app); + if (!app->session_store_blocked && + session != app->session_saved_hash) save_session(app); + app->settings_observed_hash = settings_hash(&app->settings); + app->session_observed_hash = session_hash(&app->session); app->settings_save_due_ns = app->session_save_due_ns = 0; } diff --git a/src/runtime/model_catalog_runtime.c b/src/runtime/model_catalog_runtime.c index 6c52d1b1..8ecedbfd 100644 --- a/src/runtime/model_catalog_runtime.c +++ b/src/runtime/model_catalog_runtime.c @@ -100,10 +100,10 @@ static void scan_nearby_root(BongoCatApp *app, const char *root) { if (!root || !root[0] || !bongo_cat_path_is_dir(root)) return; size_t before = app->models.count; BongoCatError error = {0}; - BongoCatResult result = bongo_cat_import_nearby_mver_scan(app, root, + BongoCatResult result = bongo_cat_import_nearby_scan(app, root, &error); size_t added = app->models.count - before; - if (added) SDL_Log("Nearby model scan added %llu model modes from %s", + if (added) SDL_Log("Nearby model scan added %llu models from %s", (unsigned long long)added, root); if (result != BONGO_CAT_OK && error.message[0]) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", error.message); @@ -143,9 +143,9 @@ BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, return BONGO_CAT_ERROR_ARGUMENT; } if (entry->preset || entry->managed) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, entry->managed ? - "Nearby Mver models are managed by their source directory: %s" : - "Built-in models cannot be removed: %s", id); + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, entry->managed + ? "Nearby models are managed by their source directory: %s" + : "Built-in models cannot be removed: %s", id); return BONGO_CAT_ERROR_ARGUMENT; } bool selected = !strcmp(id, app->session.active_model_id) || diff --git a/src/runtime/model_import.c b/src/runtime/model_import.c index 7e56a8fc..4a9f72a2 100644 --- a/src/runtime/model_import.c +++ b/src/runtime/model_import.c @@ -174,8 +174,11 @@ static bool prepare_install(const BongoCatImportCandidate *candidate, BongoCatResult bongo_cat_import_install(const char *source, const char *data_root, BongoCatImportReceipt *receipt, BongoCatError *error) { if (receipt) memset(receipt, 0, sizeof(*receipt)); - if (!source || !data_root || !bongo_cat_path_is_dir(source)) - return BONGO_CAT_ERROR_ARGUMENT; + if (!source || !data_root) return BONGO_CAT_ERROR_ARGUMENT; + char source_directory[BONGO_CAT_PATH_CAP]; + BongoCatResult source_result = bongo_cat_import_source_directory(source, + source_directory, sizeof(source_directory), error); + if (source_result != BONGO_CAT_OK) return source_result; BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); ImportInstall *installs = calloc(BONGO_CAT_IMPORT_CANDIDATE_CAP, sizeof(*installs)); BongoCatPackageMetadata *metadata = calloc(BONGO_CAT_IMPORT_CANDIDATE_CAP, @@ -187,7 +190,7 @@ BongoCatResult bongo_cat_import_install(const char *source, const char *data_roo "Cannot allocate model import workspace"); return BONGO_CAT_ERROR_MEMORY; } - if (!bongo_cat_import_discover(source, discovery, error)) { + if (!bongo_cat_import_discover(source_directory, discovery, error)) { free(discovery); free(installs); free(metadata); free(existing); return BONGO_CAT_ERROR_FORMAT; } diff --git a/src/runtime/model_import.h b/src/runtime/model_import.h index aecb9f46..0e2ba097 100644 --- a/src/runtime/model_import.h +++ b/src/runtime/model_import.h @@ -48,7 +48,7 @@ typedef struct BongoCatPackageMetadata { uint32_t capabilities; } BongoCatPackageMetadata; -typedef BongoCatResult (*BongoCatNearbyVisitor)(void *userdata, +typedef BongoCatResult (*BongoCatImportVisitor)(void *userdata, const char *source, BongoCatImportDiscovery *discovery, BongoCatError *error); typedef struct BongoCatApp BongoCatApp; @@ -63,8 +63,12 @@ int bongo_cat_import_mver_patch_discover(const char *source, BongoCatImportDiscovery *discovery, BongoCatError *error); int bongo_cat_import_mver_patch_discover_exact(const char *source, BongoCatImportDiscovery *discovery, BongoCatError *error); +int bongo_cat_import_tauri_discover_exact(const char *source, + BongoCatImportDiscovery *discovery, BongoCatError *error); bool bongo_cat_import_manifest_valid(const char *root, const char *setting, BongoCatError *error); +bool bongo_cat_import_tauri_add_candidate(BongoCatImportDiscovery *discovery, + const char *directory, const char *setting); bool bongo_cat_import_mver_assets(const BongoCatImportCandidate *candidate, const char *target, BongoCatError *error); bool bongo_cat_import_adapter_metadata(const BongoCatImportCandidate *candidate, @@ -80,8 +84,10 @@ bool bongo_cat_import_write_package(const BongoCatImportCandidate *candidate, BongoCatError *error); bool bongo_cat_import_candidate_digest(const BongoCatImportCandidate *candidate, char output[65], BongoCatError *error); +bool bongo_cat_import_candidate_inspect(const BongoCatImportCandidate *candidate, + char output[65], bool *placeholder, BongoCatError *error); bool bongo_cat_import_prepare_package_metadata( - const BongoCatImportDiscovery *discovery, + BongoCatImportDiscovery *discovery, BongoCatPackageMetadata *metadata, BongoCatError *error); const BongoCatModelEntry *bongo_cat_import_find_existing_package( const BongoCatModelCatalog *catalog, @@ -94,14 +100,19 @@ void bongo_cat_import_apply_metadata(BongoCatApp *app, const char *model_id, const char *directory); bool bongo_cat_import_render_options(const char *directory, BongoCatLive2DRenderOptions *options); -BongoCatResult bongo_cat_import_nearby_mver_root(BongoCatApp *app, +BongoCatResult bongo_cat_import_scan(const char *root, + BongoCatImportVisitor visitor, void *userdata, BongoCatError *error); +BongoCatResult bongo_cat_import_scan_budget(const char *root, + BongoCatImportVisitor visitor, void *userdata, uint64_t budget_ns, + BongoCatError *error); +BongoCatResult bongo_cat_import_source_directory(const char *source, + char *directory, size_t capacity, BongoCatError *error); +BongoCatResult bongo_cat_import_nearby_root(BongoCatApp *app, const char *root, BongoCatError *error); -BongoCatResult bongo_cat_import_nearby_mver_scan(BongoCatApp *app, +BongoCatResult bongo_cat_import_nearby_scan(BongoCatApp *app, const char *root, BongoCatError *error); -BongoCatResult bongo_cat_import_nearby_mver(BongoCatApp *app, +BongoCatResult bongo_cat_import_nearby(BongoCatApp *app, const char *root, BongoCatError *error); -BongoCatResult bongo_cat_import_nearby_scan(const char *root, - BongoCatNearbyVisitor visitor, void *userdata, BongoCatError *error); BongoCatResult bongo_cat_import_install(const char *source, const char *data_root, BongoCatImportReceipt *receipt, BongoCatError *error); diff --git a/src/runtime/model_import_digest.c b/src/runtime/model_import_digest.c new file mode 100644 index 00000000..95cf304a --- /dev/null +++ b/src/runtime/model_import_digest.c @@ -0,0 +1,293 @@ +#include "model_import.h" +#include "bongo_cat/path.h" +#include "bongo_cat/sha256.h" + +#include +#include +#include + +#define DIGEST_ROOT_CAP 12 + +typedef struct DigestStamp { + uint64_t sum, exclusive, bytes, files, entries; +} DigestStamp; + +typedef struct DigestWalk { + const char *root; + const char *group; + DigestStamp *stamp; + const char *content_root; + DigestStamp *content; + const char *override_root; + DigestStamp *overrides; + int depth; +} DigestWalk; + +typedef struct DigestRoot { + char path[BONGO_CAT_PATH_CAP]; + const char *group; +} DigestRoot; + +static bool separator(char value) { return value == '/' || value == '\\'; } + +static bool path_character_equal(char left, char right) { + if (separator(left) && separator(right)) return true; +#ifdef _WIN32 + if (left >= 'A' && left <= 'Z') left = (char)(left - 'A' + 'a'); + if (right >= 'A' && right <= 'Z') right = (char)(right - 'A' + 'a'); +#endif + return left == right; +} + +static size_t path_length(const char *path) { + size_t length = path ? strlen(path) : 0; + while (length > 1 && separator(path[length - 1])) length--; + return length; +} + +static bool path_contains(const char *root, const char *path) { + size_t root_length = path_length(root), item_length = path_length(path); + if (!root_length || item_length < root_length) return false; + for (size_t i = 0; i < root_length; ++i) + if (!path_character_equal(root[i], path[i])) return false; + return item_length == root_length || separator(path[root_length]); +} + +static uint64_t hash_text(const char *value) { + uint64_t hash = 1469598103934665603ull; + for (const unsigned char *cursor = (const unsigned char *)value; + cursor && *cursor; ++cursor) { + unsigned char byte = *cursor == '\\' ? '/' : *cursor; + hash ^= byte; hash *= 1099511628211ull; + } + return hash; +} + +static uint64_t mix(uint64_t value) { + value ^= value >> 30; value *= 0xbf58476d1ce4e5b9ull; + value ^= value >> 27; value *= 0x94d049bb133111ebull; + return value ^ (value >> 31); +} + +static bool stamp_path(const char *path, DigestWalk *walk); + +static BongoCatPathVisit stamp_item(void *userdata, + const char *dirname, const char *name) { + DigestWalk *walk = userdata; + char path[BONGO_CAT_PATH_CAP]; + return ++walk->stamp->entries <= 16384 && + bongo_cat_path_join(path, sizeof(path), dirname, name) && + stamp_path(path, walk) ? BONGO_CAT_PATH_CONTINUE : + BONGO_CAT_PATH_FAILURE; +} + +static const char *relative_path(const char *root, const char *path) { + size_t root_length = path_length(root); + if (!path_contains(root, path)) return path; + const char *relative = path + root_length; + while (*relative == '/' || *relative == '\\') relative++; + return relative; +} + +static bool stamp_path(const char *path, DigestWalk *walk) { + if (bongo_cat_path_is_dir(path)) { + if (walk->depth >= 24) return false; + walk->depth++; + bool ok = bongo_cat_path_enumerate(path, stamp_item, walk); + walk->depth--; + return ok; + } + uint64_t size = 0; + if (!bongo_cat_path_file_size(path, &size) || walk->stamp->files >= 8192 || + size > 1073741824ull - walk->stamp->bytes) return false; + char file_hash[65]; + if (bongo_cat_sha256_file(path, file_hash, NULL) != BONGO_CAT_OK) + return false; + uint64_t value = mix(hash_text(walk->group) ^ + hash_text(relative_path(walk->root, path)) ^ hash_text(file_hash) ^ + mix(size)); + walk->stamp->sum += value; + walk->stamp->exclusive ^= value; + walk->stamp->bytes += size; + walk->stamp->files++; + if (walk->content && path_contains(walk->content_root, path)) { + value = mix(hash_text("content") ^ + hash_text(relative_path(walk->content_root, path)) ^ + hash_text(file_hash) ^ mix(size)); + walk->content->sum += value; + walk->content->exclusive ^= value; + walk->content->bytes += size; + walk->content->files++; + } + if (walk->overrides && path_contains(walk->override_root, path)) { + value = mix(hash_text("overrides") ^ + hash_text(relative_path(walk->override_root, path)) ^ + hash_text(file_hash) ^ mix(size)); + walk->overrides->sum += value; + walk->overrides->exclusive ^= value; + walk->overrides->bytes += size; + walk->overrides->files++; + } + return true; +} + +static bool stamp_root(const char *root, const char *group, + DigestStamp *stamp, const BongoCatImportCandidate *candidate, + DigestStamp *content, DigestStamp *overrides) { + if (!root || !root[0]) return true; + DigestWalk walk = {root, group, stamp, + candidate->assets, content, candidate->overrides, overrides, 0}; + return stamp_path(root, &walk); +} + +static void add_root(DigestRoot roots[DIGEST_ROOT_CAP], size_t *count, + const char *path, const char *group) { + if (!path || !path[0]) return; + for (size_t i = 0; i < *count; ++i) + if (path_contains(roots[i].path, path)) return; + bool merged = false; + for (size_t i = 0; i < *count;) { + if (!path_contains(path, roots[i].path)) { i++; continue; } + roots[i] = roots[--*count]; + merged = true; + } + if (merged) group = "content"; + if (*count < DIGEST_ROOT_CAP) { + DigestRoot *root = &roots[(*count)++]; + snprintf(root->path, sizeof(root->path), "%s", path); + root->group = group; + } +} + +static bool roots_contain(const DigestRoot *roots, size_t count, + const char *path) { + for (size_t i = 0; i < count; ++i) + if (path_contains(roots[i].path, path)) return true; + return false; +} + +static bool first_file(const char *directory, const char *const *names, + size_t name_count, char *path, size_t capacity, const char **name) { + for (size_t i = 0; i < name_count; ++i) + if (bongo_cat_path_join(path, capacity, directory, names[i]) && + bongo_cat_path_is_file(path)) { + if (name) *name = names[i]; + return true; + } + return false; +} + +static void add_tauri_preview_roots(const BongoCatImportCandidate *candidate, + DigestRoot roots[DIGEST_ROOT_CAP], size_t *count) { + static const char *const covers[] = { + "cover.png", "cat.png", "bg.png", "mousebg.png", "tabletbg.png" + }; + static const char *const backgrounds[] = { + "background.png", "bg.png", "mousebg.png", "tabletbg.png" + }; + char resources[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; + bool has_resources = bongo_cat_path_join(resources, sizeof(resources), + candidate->assets, "resources") && bongo_cat_path_is_dir(resources); + if (has_resources) + add_root(roots, count, resources, "preview-resources"); + const char *name = NULL; + bool resource_cover = has_resources && first_file(resources, covers, + sizeof(covers) / sizeof(covers[0]), path, sizeof(path), NULL); + if (!resource_cover && first_file(candidate->assets, covers, + sizeof(covers) / sizeof(covers[0]), path, sizeof(path), &name)) + add_root(roots, count, path, name); + bool resource_background = has_resources && first_file(resources, + backgrounds, sizeof(backgrounds) / sizeof(backgrounds[0]), path, + sizeof(path), NULL); + if (!resource_background && first_file(candidate->assets, backgrounds, + sizeof(backgrounds) / sizeof(backgrounds[0]), path, + sizeof(path), &name)) + add_root(roots, count, path, name); + if (bongo_cat_path_join(path, sizeof(path), candidate->assets, + "cover.png") && bongo_cat_path_is_file(path)) + add_root(roots, count, path, "authored-cover"); +} + +static bool stamp_equal(const DigestStamp *value, uint64_t sum, + uint64_t exclusive, uint64_t bytes, uint64_t files) { + return value->sum == sum && value->exclusive == exclusive && + value->bytes == bytes && value->files == files; +} + +static bool candidate_placeholder(const BongoCatImportCandidate *candidate, + const DigestStamp *content, const DigestStamp *overrides) { + /* These fingerprints are the stock mode and input-image templates shipped + by the Mver 0.1.6 runtime; package configuration is intentionally not + part of the placeholder decision. */ + if (candidate->format == BONGO_CAT_IMPORT_TAURI) return false; + if (candidate->format == BONGO_CAT_IMPORT_MVER_PATCH) { + if (!overrides->files) return true; + if (candidate->mode != BONGO_CAT_MODE_GAMEPAD) return false; + return stamp_equal(overrides, 0x3830b3ef1cf413b7ull, + 0xf36e3216ab32c519ull, 828360ull, 12ull) || + stamp_equal(overrides, 0xe35c6e634128d62bull, + 0x17e2f6d0ad5746bfull, 1297880ull, 12ull); + } + if (candidate->mode == BONGO_CAT_MODE_STANDARD) + return stamp_equal(content, 0x354b60da1c319aa4ull, + 0x9c9cfce9a7c63fe4ull, 38273435ull, 153ull); + if (candidate->mode == BONGO_CAT_MODE_KEYBOARD) + return stamp_equal(content, 0x65afe356f5b20130ull, + 0x45cc631ac72ef8c6ull, 1005234ull, 35ull); + return stamp_equal(content, 0x91e8cfcd07fb9ae8ull, + 0x83fd63c53a241538ull, 1711338ull, 51ull); +} + +bool bongo_cat_import_candidate_inspect(const BongoCatImportCandidate *candidate, + char output[65], bool *placeholder, BongoCatError *error) { + if (!candidate || !output) return false; + char setting[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(setting, sizeof(setting), + candidate->directory, candidate->setting) || + !bongo_cat_path_is_file(setting)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Imported model manifest is missing: %s", candidate->setting); + return false; + } + DigestRoot roots[DIGEST_ROOT_CAP] = {0}; + size_t root_count = 0; + add_root(roots, &root_count, candidate->directory, "model"); + if (candidate->format == BONGO_CAT_IMPORT_TAURI) + add_tauri_preview_roots(candidate, roots, &root_count); + else { + add_root(roots, &root_count, candidate->assets, "assets"); + add_root(roots, &root_count, candidate->overrides, "overrides"); + } + if (candidate->config[0] && + !roots_contain(roots, root_count, candidate->config)) + add_root(roots, &root_count, candidate->config, "configuration"); + DigestStamp stamp = {0}, content = {0}, overrides = {0}; + for (size_t i = 0; i < root_count; ++i) { + if (stamp_root(roots[i].path, roots[i].group, &stamp, candidate, + &content, &overrides)) continue; + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot fingerprint imported model assets: %s", + candidate->package_root); + return false; + } + char summary[512]; + int length = snprintf(summary, sizeof(summary), + "%016llx|%s|%d|%d|%016llx|%016llx|%llu|%llu", + (unsigned long long)hash_text(candidate->setting), + bongo_cat_mode_name(candidate->mode), (int)candidate->format, + candidate->gamepad_buttons ? 1 : 0, + (unsigned long long)stamp.sum, + (unsigned long long)stamp.exclusive, + (unsigned long long)stamp.bytes, + (unsigned long long)stamp.files); + if (length < 0 || (size_t)length >= sizeof(summary)) return false; + bongo_cat_sha256_bytes(summary, (size_t)length, output); + if (placeholder) + *placeholder = candidate_placeholder(candidate, &content, &overrides); + return true; +} + +bool bongo_cat_import_candidate_digest(const BongoCatImportCandidate *candidate, + char output[65], BongoCatError *error) { + return bongo_cat_import_candidate_inspect(candidate, output, NULL, error); +} diff --git a/src/runtime/model_import_discovery.c b/src/runtime/model_import_discovery.c index 200a6e65..7ed1df2b 100644 --- a/src/runtime/model_import_discovery.c +++ b/src/runtime/model_import_discovery.c @@ -1,12 +1,12 @@ #include "model_import.h" #include "runtime.h" #include "bongo_cat/file.h" +#include "bongo_cat/image.h" #include "bongo_cat/path.h" #include #include #include -#include #include static bool safe_reference(const char *value) { @@ -33,12 +33,7 @@ static bool referenced_texture(const char *root, const char *relative) { char path[BONGO_CAT_PATH_CAP]; if (!safe_reference(relative) || !bongo_cat_path_join(path, sizeof(path), root, relative)) return false; - FILE *file = bongo_cat_file_open(path, "rb"); - int width = 0, height = 0, channels = 0; - bool valid = file && stbi_info_from_file(file, &width, &height, &channels) && - width > 0 && height > 0 && channels > 0; - if (file) fclose(file); - return valid; + return bongo_cat_image_info(path, NULL, NULL); } static bool optional_reference(const char *root, yyjson_val *refs, @@ -160,8 +155,8 @@ static bool has_background_asset(const char *directory) { return false; } -static bool add_candidate(BongoCatImportDiscovery *discovery, const char *directory, - const char *setting) { +bool bongo_cat_import_tauri_add_candidate(BongoCatImportDiscovery *discovery, + const char *directory, const char *setting) { if (discovery->count >= BONGO_CAT_IMPORT_CANDIDATE_CAP) return false; for (size_t i = 0; i < discovery->count; ++i) if (strcmp(discovery->candidates[i].directory, directory) == 0) { @@ -196,7 +191,8 @@ static BongoCatPathVisit discover_item(void *userdata, return BONGO_CAT_PATH_FAILURE; if (bongo_cat_path_is_file(path) && suffix(name, ".model3.json")) { if (bongo_cat_import_manifest_valid(dirname, name, NULL) && - !add_candidate(discovery, dirname, name)) return BONGO_CAT_PATH_FAILURE; + !bongo_cat_import_tauri_add_candidate(discovery, dirname, name)) + return BONGO_CAT_PATH_FAILURE; return BONGO_CAT_PATH_CONTINUE; } if (!bongo_cat_path_is_dir(path) || discovery->depth >= 8 || name[0] == '.') @@ -248,8 +244,12 @@ bool bongo_cat_import_discover(const char *source, BongoCatImportDiscovery *disc int patch = bongo_cat_import_mver_patch_discover(source, discovery, &patch_error); if (patch > 0) return true; memset(discovery, 0, sizeof(*discovery)); + int tauri = bongo_cat_import_tauri_discover_exact(source, discovery, error); + if (tauri > 0) return true; + if (tauri < 0) return false; + memset(discovery, 0, sizeof(*discovery)); ContainerDiscovery container = {discovery}; - BongoCatResult container_result = bongo_cat_import_nearby_scan(source, + BongoCatResult container_result = bongo_cat_import_scan(source, collect_container, &container, error); if (container_result != BONGO_CAT_OK) return false; if (discovery->count) { diff --git a/src/runtime/model_import_identity.c b/src/runtime/model_import_identity.c index 39a16c1c..aa60ba0f 100644 --- a/src/runtime/model_import_identity.c +++ b/src/runtime/model_import_identity.c @@ -30,22 +30,39 @@ static const char *source_root(const BongoCatImportCandidate *candidate) { candidate->patch_root[0] ? candidate->patch_root : candidate->package_root; } -static const char *mode_title(BongoCatModelMode mode) { - if (mode == BONGO_CAT_MODE_KEYBOARD) return "Keyboard"; - if (mode == BONGO_CAT_MODE_GAMEPAD) return "Gamepad"; - return "Standard"; +static bool parent_path(const char *path, char *parent, size_t capacity) { + size_t length = path ? strlen(path) : 0; + while (length && (path[length - 1] == '/' || path[length - 1] == '\\')) length--; + while (length && path[length - 1] != '/' && path[length - 1] != '\\') length--; + while (length && (path[length - 1] == '/' || path[length - 1] == '\\')) length--; + if (!length || length >= capacity) return false; + memcpy(parent, path, length); parent[length] = '\0'; + return true; +} + +static const char *display_source(const BongoCatImportCandidate *candidate, + char parent[BONGO_CAT_PATH_CAP]) { + const char *source = source_root(candidate); + if (candidate->format == BONGO_CAT_IMPORT_MVER_PATCH && + parent_path(source, parent, BONGO_CAT_PATH_CAP) && + !strcmp(bongo_cat_path_name(source), + bongo_cat_path_name(candidate->package_root))) return parent; + return source; } bool bongo_cat_import_prepare_package_metadata( - const BongoCatImportDiscovery *discovery, + BongoCatImportDiscovery *discovery, BongoCatPackageMetadata *metadata, BongoCatError *error) { char family_material[BONGO_CAT_IMPORT_CANDIDATE_CAP * 66 + 32]; - size_t used = 0; + size_t used = 0, output = 0; for (size_t i = 0; i < discovery->count; ++i) { - const BongoCatImportCandidate *candidate = &discovery->candidates[i]; - BongoCatPackageMetadata *item = &metadata[i]; - if (!bongo_cat_import_candidate_digest(candidate, - item->content_digest, error)) return false; + BongoCatImportCandidate candidate = discovery->candidates[i]; + BongoCatPackageMetadata *item = &metadata[output]; + bool placeholder = false; + if (!bongo_cat_import_candidate_inspect(&candidate, + item->content_digest, &placeholder, error)) return false; + if (placeholder) continue; + discovery->candidates[output] = candidate; int written = snprintf(family_material + used, sizeof(family_material) - used, "%s|", item->content_digest); if (written < 0 || (size_t)written >= sizeof(family_material) - used) @@ -53,20 +70,25 @@ bool bongo_cat_import_prepare_package_metadata( used += (size_t)written; snprintf(item->package_id, sizeof(item->package_id), "model-%s-%s", item->content_digest, bongo_cat_mode_name(candidate->mode)); - const char *name = bongo_cat_path_name(source_root(candidate)); + char parent[BONGO_CAT_PATH_CAP]; + const char *name = bongo_cat_path_name(display_source(candidate, parent)); snprintf(item->source_name, sizeof(item->source_name), "%s", name && name[0] ? name : "Imported model"); - if (discovery->count > 1) - snprintf(item->display_name, sizeof(item->display_name), "%s - %s", - item->source_name, mode_title(candidate->mode)); - else snprintf(item->display_name, sizeof(item->display_name), "%s", + snprintf(item->display_name, sizeof(item->display_name), "%s", item->source_name); - item->capabilities = candidate_capabilities(candidate); + item->capabilities = candidate_capabilities(&candidate); + output++; + } + discovery->count = output; + if (!output) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Selected Mver package contains only bundled placeholder modes"); + return false; } - if (discovery->count > 1) { + if (output > 1) { char digest[65]; bongo_cat_sha256_bytes(family_material, used, digest); - for (size_t i = 0; i < discovery->count; ++i) + for (size_t i = 0; i < output; ++i) snprintf(metadata[i].family_id, sizeof(metadata[i].family_id), "family-%s", digest); } @@ -93,14 +115,16 @@ void bongo_cat_import_describe_nearby_entry(BongoCatModelEntry *entry, snprintf(entry->id, sizeof(entry->id), "%s", id); snprintf(entry->package_id, sizeof(entry->package_id), "%s", id); snprintf(entry->content_digest, sizeof(entry->content_digest), "%s", identity); - snprintf(entry->family_id, sizeof(entry->family_id), "family-mver-%.16s", + snprintf(entry->family_id, sizeof(entry->family_id), "family-nearby-%.16s", source_hash); snprintf(entry->directory, sizeof(entry->directory), "%s", candidate->directory); snprintf(entry->adapter_directory, sizeof(entry->adapter_directory), "%s", adapter); snprintf(entry->storage_directory, sizeof(entry->storage_directory), "%s", source); snprintf(entry->setting_file, sizeof(entry->setting_file), "%s", candidate->setting); entry->mode = candidate->mode; - entry->source_format = candidate->format == BONGO_CAT_IMPORT_MVER_PATCH + entry->source_format = candidate->format == BONGO_CAT_IMPORT_TAURI + ? BONGO_CAT_MODEL_SOURCE_TAURI : + candidate->format == BONGO_CAT_IMPORT_MVER_PATCH ? BONGO_CAT_MODEL_SOURCE_MVER_PATCH : BONGO_CAT_MODEL_SOURCE_MVER; entry->capabilities = candidate_capabilities(candidate); entry->adapter_schema = BONGO_CAT_MODEL_ADAPTER_SCHEMA; diff --git a/src/runtime/model_import_mver_effect.c b/src/runtime/model_import_mver_effect.c index 344259c3..2b068ee3 100644 --- a/src/runtime/model_import_mver_effect.c +++ b/src/runtime/model_import_mver_effect.c @@ -53,10 +53,8 @@ bool bongo_cat_mver_effects(void *raw_output, void *raw_items, void *raw_root, snprintf(name, sizeof(name), "%zu.png", index); if (!bongo_cat_mver_chord(candidate, row, shortcut, sizeof(shortcut))) return false; if (!effect_source(candidate, name, source, sizeof(source))) continue; - BongoCatImage image; - if (bongo_cat_image_load(source, &image, NULL) != BONGO_CAT_OK) return false; - bongo_cat_image_free(&image); - if (!bongo_cat_path_join(destination, sizeof(destination), target_effects, name) || + if (!bongo_cat_image_info(source, NULL, NULL) || + !bongo_cat_path_join(destination, sizeof(destination), target_effects, name) || !bongo_cat_path_copy_file(source, destination)) return false; yyjson_mut_val *item = yyjson_mut_arr_add_obj(output, items); char relative[BONGO_CAT_PATH_CAP]; diff --git a/src/runtime/model_import_mver_image.c b/src/runtime/model_import_mver_image.c index a358de1b..41284b93 100644 --- a/src/runtime/model_import_mver_image.c +++ b/src/runtime/model_import_mver_image.c @@ -4,6 +4,8 @@ #include "bongo_cat/path.h" #include +#include +#include #include #include #include @@ -25,13 +27,20 @@ static bool compose(const char *base_path, const char *hand_path, bongo_cat_image_free(&base); return false; } int width = SDL_max(base.width, hand.width), height = SDL_max(base.height, hand.height); - size_t bytes = (size_t)width * (size_t)height * 4; - unsigned char *pixels = width > 0 && height > 0 ? malloc(bytes) : NULL; - bool allocated = pixels != NULL; - if (pixels) memset(pixels, 0, bytes); - if (pixels) for (int y = 0; y < base.height; ++y) - memcpy(pixels + (size_t)y * width * 4, - base.pixels + (size_t)y * base.width * 4, (size_t)base.width * 4); + bool dimensions_valid = width > 0 && height > 0 && width <= INT_MAX / 4 && + (size_t)width <= SIZE_MAX / (size_t)height / 4; + size_t bytes = dimensions_valid ? (size_t)width * (size_t)height * 4 : 0; + bool in_place = dimensions_valid && width == base.width && + height == base.height; + unsigned char *pixels = in_place ? base.pixels : + (dimensions_valid ? malloc(bytes) : NULL); + if (pixels && !in_place) { + memset(pixels, 0, bytes); + for (int y = 0; y < base.height; ++y) + memcpy(pixels + (size_t)y * width * 4, + base.pixels + (size_t)y * base.width * 4, + (size_t)base.width * 4); + } if (pixels) for (int y = 0; y < hand.height; ++y) for (int x = 0; x < hand.width; ++x) { unsigned char *dst = pixels + ((size_t)y * width + x) * 4; const unsigned char *src = hand.pixels + ((size_t)y * hand.width + x) * 4; @@ -47,11 +56,13 @@ static bool compose(const char *base_path, const char *hand_path, } FILE *file = pixels ? bongo_cat_file_open(target, "wb") : NULL; PngWriter writer = {file, file != NULL}; + bool buffer_ready = pixels != NULL; bool ok = file && stbi_write_png_to_func(write_png, &writer, width, height, 4, pixels, width * 4) && writer.ok; if (file && fclose(file) != 0) ok = false; - free(pixels); bongo_cat_image_free(&hand); bongo_cat_image_free(&base); - if (!ok) bongo_cat_error_set(error, allocated ? BONGO_CAT_ERROR_IO : BONGO_CAT_ERROR_MEMORY, + if (!in_place) free(pixels); + bongo_cat_image_free(&hand); bongo_cat_image_free(&base); + if (!ok) bongo_cat_error_set(error, buffer_ready ? BONGO_CAT_ERROR_IO : BONGO_CAT_ERROR_MEMORY, "Cannot compose Mver input image: %s", target); return ok; } @@ -66,10 +77,8 @@ bool bongo_cat_mver_emit_pair(const char *hand, const char *keyboard, bool ok; if (keyboard) ok = compose(keyboard, hand, first, error); else { - BongoCatImage image; - ok = bongo_cat_image_load(hand, &image, error) == BONGO_CAT_OK; - if (ok) { bongo_cat_image_free(&image); - ok = bongo_cat_path_copy_file(hand, first); } + ok = bongo_cat_image_info(hand, NULL, NULL) && + bongo_cat_path_copy_file(hand, first); } if (!ok && error && !error->message[0]) bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, diff --git a/src/runtime/model_import_nearby.c b/src/runtime/model_import_nearby.c index f3013db9..3bf1e853 100644 --- a/src/runtime/model_import_nearby.c +++ b/src/runtime/model_import_nearby.c @@ -1,278 +1,110 @@ #include "model_import.h" #include "model_import_nearby_internal.h" -#include "model_storage.h" #include "runtime.h" -#include "bongo_cat/json.h" #include "bongo_cat/path.h" #include "bongo_cat/sha256.h" -#include #include #include #include -#include -#define MODEL_ADAPTER_DIRECTORY "model-adapters" -#define MODEL_CACHE_KIND "bongocat/nearby-model-cache" +#define NEARBY_ADAPTER_DIRECTORY "model-adapters" +#define NEARBY_SCAN_BUDGET_NS 500000000ull -static bool marker_header_valid(yyjson_val *root) { - const char *kind = yyjson_get_str(yyjson_obj_get(root, "kind")); - return yyjson_is_obj(root) && - yyjson_get_int(yyjson_obj_get(root, "schemaVersion")) == - BONGO_CAT_NEARBY_CACHE_SCHEMA && kind && - strcmp(kind, MODEL_CACHE_KIND) == 0; -} - -static bool digest_valid(const char *value) { - if (!value || strlen(value) != 64) return false; - for (size_t i = 0; i < 64; ++i) { - char byte = value[i]; - if (!((byte >= '0' && byte <= '9') || - (byte >= 'a' && byte <= 'f'))) return false; - } +static bool parent_path(const char *path, char *parent, size_t capacity) { + size_t length = path ? strlen(path) : 0; + while (length && (path[length - 1] == '/' || path[length - 1] == '\\')) + length--; + while (length && path[length - 1] != '/' && path[length - 1] != '\\') + length--; + while (length > 1 && (path[length - 1] == '/' || + path[length - 1] == '\\')) length--; + if (!length || length >= capacity) return false; + memcpy(parent, path, length); + parent[length] = '\0'; return true; } static bool existing_identity(const BongoCatModelCatalog *models, const char *identity, BongoCatModelMode mode, const BongoCatModelEntry *ignored) { - if (!models || !digest_valid(identity)) return false; - for (size_t i = 0; i < models->count; ++i) { + for (size_t i = 0; models && i < models->count; ++i) { const BongoCatModelEntry *entry = &models->entries[i]; if (entry != ignored && entry->mode == mode && - digest_valid(entry->content_digest) && + entry->content_digest[0] && strcmp(entry->content_digest, identity) == 0) return true; } return false; } static BongoCatModelEntry *model_with_id(BongoCatApp *app, const char *id) { - if (!app || !id) return NULL; - for (size_t i = 0; i < app->models.count; ++i) + for (size_t i = 0; app && i < app->models.count; ++i) if (strcmp(app->models.entries[i].id, id) == 0) return &app->models.entries[i]; return NULL; } -static bool parent_path(const char *path, char *parent, size_t capacity) { - size_t length = path ? strlen(path) : 0; - while (length && (path[length - 1] == '/' || path[length - 1] == '\\')) length--; - while (length && path[length - 1] != '/' && path[length - 1] != '\\') length--; - while (length > 1 && (path[length - 1] == '/' || path[length - 1] == '\\')) length--; - if (!length || length >= capacity) return false; - memcpy(parent, path, length); - parent[length] = '\0'; - return true; +static const char *candidate_source(const BongoCatImportCandidate *candidate, + const char *fallback) { + if (candidate->format == BONGO_CAT_IMPORT_MVER_PATCH && + candidate->patch_root[0]) return candidate->patch_root; + return candidate->package_root[0] ? candidate->package_root : fallback; } static void candidate_name(const char *source, const BongoCatImportCandidate *candidate, char *name, size_t capacity) { const char *value = bongo_cat_path_name(source); char parent[BONGO_CAT_PATH_CAP]; - if (candidate->format == BONGO_CAT_IMPORT_MVER_PATCH && - strcmp(value, bongo_cat_path_name(candidate->package_root)) == 0 && - parent_path(source, parent, sizeof(parent))) { - const char *variant = bongo_cat_path_name(parent); - if (variant[0]) value = variant; - } - snprintf(name, capacity, "%s", value[0] ? value : "Nearby model"); -} - -static bool marker_matches(const char *target, const char *source, - const char *signature, const char *identity) { - char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), target, - BONGO_CAT_NEARBY_CACHE_MARKER)) return false; - yyjson_doc *document = bongo_cat_json_read_file(path, 0, NULL); - yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; - const char *stored_source = yyjson_get_str(yyjson_obj_get(root, "source")); - const char *stored_signature = yyjson_get_str(yyjson_obj_get(root, "signature")); - const char *stored_identity = yyjson_get_str(yyjson_obj_get(root, "identity")); - bool matches = marker_header_valid(root) && stored_source && - stored_signature && stored_identity && strcmp(source, stored_source) == 0 && - strcmp(signature, stored_signature) == 0 && - strcmp(identity, stored_identity) == 0; - yyjson_doc_free(document); - return matches; -} - -static bool cached_identity(const char *target, const char *source, - const char *signature, char identity[65]) { - char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), target, - BONGO_CAT_NEARBY_CACHE_MARKER)) return false; - yyjson_doc *document = bongo_cat_json_read_file(path, 0, NULL); - yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; - const char *stored_source = yyjson_get_str(yyjson_obj_get(root, "source")); - const char *stored_signature = yyjson_get_str(yyjson_obj_get(root, "signature")); - const char *stored_identity = yyjson_get_str(yyjson_obj_get(root, "identity")); - bool valid = marker_header_valid(root) && stored_source && - stored_signature && digest_valid(stored_identity) && - strcmp(source, stored_source) == 0 && - strcmp(signature, stored_signature) == 0; - if (valid) snprintf(identity, 65, "%s", stored_identity); - yyjson_doc_free(document); - return valid; -} - -static bool marker_identity(const char *target, const char *source, - char identity[65]) { - char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), target, - BONGO_CAT_NEARBY_CACHE_MARKER)) return false; - yyjson_doc *document = bongo_cat_json_read_file(path, 0, NULL); - yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; - const char *stored_source = yyjson_get_str(yyjson_obj_get(root, "source")); - const char *stored_identity = yyjson_get_str(yyjson_obj_get(root, "identity")); - bool valid = marker_header_valid(root) && stored_source && - strcmp(source, stored_source) == 0 && - digest_valid(stored_identity); - if (valid) snprintf(identity, 65, "%s", stored_identity); - yyjson_doc_free(document); - return valid; -} - -static bool write_marker(const char *target, const char *source, - const char *signature, const char *identity, BongoCatModelMode mode) { - yyjson_mut_doc *document = yyjson_mut_doc_new(NULL); - yyjson_mut_val *root = document ? yyjson_mut_obj(document) : NULL; - if (document) yyjson_mut_doc_set_root(document, root); - char path[BONGO_CAT_PATH_CAP]; - bool ok = root && yyjson_mut_obj_add_int(document, root, "schemaVersion", - BONGO_CAT_NEARBY_CACHE_SCHEMA) && - yyjson_mut_obj_add_str(document, root, "kind", MODEL_CACHE_KIND) && - yyjson_mut_obj_add_strcpy(document, root, "source", source) && - yyjson_mut_obj_add_strcpy(document, root, "signature", signature) && - yyjson_mut_obj_add_strcpy(document, root, "identity", identity) && - yyjson_mut_obj_add_strcpy(document, root, "mode", bongo_cat_mode_name(mode)) && - bongo_cat_path_join(path, sizeof(path), target, - BONGO_CAT_NEARBY_CACHE_MARKER) && - bongo_cat_json_write_file(path, document, YYJSON_WRITE_PRETTY, NULL); - yyjson_mut_doc_free(document); - return ok; -} - -static bool previous_cache_usable(const char *target, const char *source) { - char marker[BONGO_CAT_PATH_CAP], mode[BONGO_CAT_PATH_CAP]; - char metadata[BONGO_CAT_PATH_CAP], resources[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_is_dir(target) || - !bongo_cat_path_join(marker, sizeof(marker), target, - BONGO_CAT_NEARBY_CACHE_MARKER) || - !bongo_cat_path_join(mode, sizeof(mode), target, ".bongo-cat-mode") || - !bongo_cat_path_join(metadata, sizeof(metadata), target, - BONGO_CAT_MODEL_ADAPTER_FILE) || - !bongo_cat_path_join(resources, sizeof(resources), target, "resources") || - !bongo_cat_path_is_file(mode) || - !bongo_cat_path_is_dir(resources)) return false; - if (!bongo_cat_path_is_file(metadata)) return false; - yyjson_doc *document = bongo_cat_json_read_file(marker, 0, NULL); - yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; - const char *stored_source = yyjson_get_str(yyjson_obj_get(root, "source")); - bool usable = marker_header_valid(root) && stored_source && - strcmp(stored_source, source) == 0; - yyjson_doc_free(document); - return usable; -} - -static bool use_previous_cache(const char *target, const char *source, - BongoCatError *error) { - if (!previous_cache_usable(target, source)) return false; - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "Cannot refresh nearby model cache; using the previous safe cache: %s", - error && error->message[0] ? error->message : source); - if (error) *error = (BongoCatError){0}; - return true; -} - -static bool refresh_cache(const BongoCatImportCandidate *candidate, - const char *cache_root, const char *id, const char *source, - const char *signature, const char *identity, bool *created, - BongoCatError *error) { - char target[BONGO_CAT_PATH_CAP], temporary[BONGO_CAT_PATH_CAP]; - char backup[BONGO_CAT_PATH_CAP], name[BONGO_CAT_ID_CAP + 8]; - snprintf(name, sizeof(name), ".%s.tmp", id); - if (!bongo_cat_path_join(target, sizeof(target), cache_root, id) || - !bongo_cat_path_join(temporary, sizeof(temporary), cache_root, name)) return false; - snprintf(name, sizeof(name), ".%s.old", id); - if (!bongo_cat_path_join(backup, sizeof(backup), cache_root, name)) return false; - bongo_cat_model_remove_tree(temporary, NULL); - if (!bongo_cat_path_is_dir(target) && bongo_cat_path_is_dir(backup)) - bongo_cat_path_rename(backup, target); - if (bongo_cat_path_is_dir(target)) bongo_cat_model_remove_tree(backup, NULL); - *created = !bongo_cat_path_is_dir(target); - if (!*created && marker_matches(target, source, signature, identity) && - previous_cache_usable(target, source)) return true; - if (!bongo_cat_path_create_directory(temporary) || - !bongo_cat_import_prepare_adapter(candidate, temporary, error) || - !write_marker(temporary, source, signature, identity, candidate->mode)) { - bongo_cat_model_remove_tree(temporary, NULL); - if (error && !error->message[0]) bongo_cat_error_set(error, - BONGO_CAT_ERROR_IO, "Cannot build nearby Mver adapter: %s", source); - return use_previous_cache(target, source, error); - } - bool had_target = bongo_cat_path_is_dir(target); - if (had_target && !bongo_cat_path_rename(target, backup)) { - bongo_cat_model_remove_tree(temporary, NULL); - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot update nearby Mver adapter: %s", SDL_GetError()); - return use_previous_cache(target, source, error); - } - if (!bongo_cat_path_rename(temporary, target)) { - if (had_target) bongo_cat_path_rename(backup, target); - bongo_cat_model_remove_tree(temporary, NULL); - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot activate nearby Mver adapter: %s", SDL_GetError()); - return use_previous_cache(target, source, error); - } - bongo_cat_model_remove_tree(backup, NULL); - return true; + if (value && candidate->format == BONGO_CAT_IMPORT_MVER_PATCH && + candidate->package_root[0] && + !strcmp(value, bongo_cat_path_name(candidate->package_root)) && + parent_path(source, parent, sizeof(parent))) + value = bongo_cat_path_name(parent); + snprintf(name, capacity, "%s", value && value[0] ? value : "Nearby model"); } static bool add_candidate(BongoCatApp *app, const char *cache_root, const char *source, const BongoCatImportCandidate *candidate, - char *first_created, BongoCatError *error) { - char source_hash[65], signature[65], identity[65], id[BONGO_CAT_ID_CAP]; - BongoCatModelEntry *same_id; + char first_created[BONGO_CAT_ID_CAP], BongoCatError *error) { + char source_hash[65], signature[65], identity[65]; + char id[BONGO_CAT_ID_CAP], adapter[BONGO_CAT_PATH_CAP]; bongo_cat_sha256_bytes(source, strlen(source), source_hash); - snprintf(id, sizeof(id), "mver-%.16s-%s", source_hash, + snprintf(id, sizeof(id), "nearby-%.16s-%s", source_hash, bongo_cat_mode_name(candidate->mode)); - same_id = model_with_id(app, id); + BongoCatModelEntry *same_id = model_with_id(app, id); if (same_id && !same_id->managed) return true; - if (!bongo_cat_nearby_signature(candidate, signature, error)) + if (!bongo_cat_nearby_signature(candidate, signature, error) || + !bongo_cat_path_join(adapter, sizeof(adapter), cache_root, id)) return false; - char target[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(target, sizeof(target), cache_root, id) || - (!cached_identity(target, source, signature, identity) && - !bongo_cat_nearby_identity(candidate, identity, error))) return false; - if (existing_identity(&app->models, identity, candidate->mode, - same_id)) return true; + bool placeholder = false; + bool identity_cached = bongo_cat_nearby_cached_inspection(adapter, source, + signature, identity, &placeholder); + if (!identity_cached && + !bongo_cat_import_candidate_inspect(candidate, identity, + &placeholder, error)) + return false; + if (placeholder) { + if (!identity_cached) bongo_cat_nearby_remember_inspection(adapter, + source, signature, identity, true, candidate->mode); + return true; + } + if (existing_identity(&app->models, identity, candidate->mode, same_id)) { + if (!identity_cached) bongo_cat_nearby_remember_inspection(adapter, + source, signature, identity, false, candidate->mode); + return true; + } if (!same_id && app->models.count >= BONGO_CAT_MODEL_CAP) { bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Too many models while adding nearby Mver package"); + "Too many models while adding nearby package"); return false; } bool created = false; - if (!refresh_cache(candidate, cache_root, id, source, signature, identity, - &created, error)) - return false; - char active_identity[65]; - if (!marker_identity(target, source, active_identity)) return false; - if (strcmp(active_identity, identity) != 0) { - snprintf(identity, sizeof(identity), "%s", active_identity); - if (!same_id && existing_identity(&app->models, identity, - candidate->mode, NULL)) return true; - } - char adapter[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(adapter, sizeof(adapter), cache_root, id)) return false; - if (same_id) { - memset(same_id, 0, sizeof(*same_id)); - candidate_name(source, candidate, same_id->display_name, - sizeof(same_id->display_name)); - bongo_cat_import_describe_nearby_entry(same_id, candidate, id, - identity, source_hash, source, adapter); - return true; - } - BongoCatModelEntry *entry = &app->models.entries[app->models.count++]; + if (!bongo_cat_nearby_refresh_cache(candidate, cache_root, id, source, + signature, identity, false, adapter, &created, error)) return false; + if (!same_id && existing_identity(&app->models, identity, + candidate->mode, NULL)) return true; + BongoCatModelEntry *entry = same_id ? same_id : + &app->models.entries[app->models.count++]; memset(entry, 0, sizeof(*entry)); candidate_name(source, candidate, entry->display_name, sizeof(entry->display_name)); @@ -283,84 +115,103 @@ static bool add_candidate(BongoCatApp *app, const char *cache_root, return true; } -static BongoCatResult add_discovery(BongoCatApp *app, const char *cache_root, - const char *source, BongoCatImportDiscovery *discovery, - char *first_created, BongoCatError *error) { +typedef struct NearbyAdd { + BongoCatApp *app; + const char *cache_root; + char *first_created; +} NearbyAdd; + +static BongoCatResult add_discovery(NearbyAdd *add, const char *fallback, + BongoCatImportDiscovery *discovery, BongoCatError *error) { for (size_t i = 0; i < discovery->count; ++i) { - if (!add_candidate(app, cache_root, source, &discovery->candidates[i], - first_created, error)) return error && error->code ? error->code : - BONGO_CAT_ERROR_IO; + BongoCatImportCandidate *candidate = &discovery->candidates[i]; + const char *source = candidate_source(candidate, fallback); + if (!add_candidate(add->app, add->cache_root, source, candidate, + add->first_created, error)) return error && error->code + ? error->code : BONGO_CAT_ERROR_IO; } return BONGO_CAT_OK; } -typedef struct NearbyAdd { - BongoCatApp *app; const char *cache_root; char *first_created; -} NearbyAdd; - static BongoCatResult add_scanned(void *userdata, const char *source, BongoCatImportDiscovery *discovery, BongoCatError *error) { - NearbyAdd *add = userdata; - return add_discovery(add->app, add->cache_root, source, discovery, - add->first_created, error); + return add_discovery(userdata, source, discovery, error); } -static BongoCatResult import_nearby_mver_root(BongoCatApp *app, - const char *root, bool bounded_scan, BongoCatError *error) { - if (!app || !root || !app->cache_root[0]) return BONGO_CAT_ERROR_ARGUMENT; - if (!bongo_cat_path_is_dir(root)) return BONGO_CAT_OK; - char cache_root[BONGO_CAT_PATH_CAP], first_created[BONGO_CAT_ID_CAP] = {0}; - if (!bongo_cat_path_join(cache_root, sizeof(cache_root), app->cache_root, - MODEL_ADAPTER_DIRECTORY) || - !bongo_cat_path_create_directory(cache_root)) return BONGO_CAT_ERROR_IO; - BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); - if (!discovery) return BONGO_CAT_ERROR_MEMORY; - int direct = bounded_scan +static int discover_direct(const char *root, bool bounded, + BongoCatImportDiscovery *discovery, BongoCatError *error) { + int found = bounded ? bongo_cat_import_mver_discover_exact(root, discovery, error) : bongo_cat_import_mver_discover(root, discovery, error); - if (bounded_scan && direct <= 0) { + if (found <= 0) { + memset(discovery, 0, sizeof(*discovery)); + if (error) *error = (BongoCatError){0}; + found = bounded + ? bongo_cat_import_mver_patch_discover_exact(root, + discovery, error) + : bongo_cat_import_mver_patch_discover(root, discovery, error); + } + if (found <= 0) { memset(discovery, 0, sizeof(*discovery)); if (error) *error = (BongoCatError){0}; - direct = bongo_cat_import_mver_patch_discover_exact(root, + found = bongo_cat_import_tauri_discover_exact(root, discovery, error); } - const char *direct_source = direct > 0 && discovery->candidates[0].package_root[0] - ? discovery->candidates[0].package_root : root; + return found; +} + +static BongoCatResult import_root(BongoCatApp *app, const char *root, + bool bounded, BongoCatError *error) { + if (!app || !root || !app->cache_root[0]) + return BONGO_CAT_ERROR_ARGUMENT; + if (!bongo_cat_path_is_dir(root)) return BONGO_CAT_OK; + char cache_root[BONGO_CAT_PATH_CAP]; + char first_created[BONGO_CAT_ID_CAP] = {0}; + if (!bongo_cat_path_join(cache_root, sizeof(cache_root), app->cache_root, + NEARBY_ADAPTER_DIRECTORY) || + !bongo_cat_path_create_directory(cache_root)) + return BONGO_CAT_ERROR_IO; + BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); + if (!discovery) return BONGO_CAT_ERROR_MEMORY; + int direct = discover_direct(root, bounded, discovery, error); + NearbyAdd add = {app, cache_root, first_created}; BongoCatResult result = direct > 0 - ? add_discovery(app, cache_root, direct_source, discovery, first_created, error) : - BONGO_CAT_OK; + ? add_discovery(&add, root, discovery, error) : BONGO_CAT_OK; if (direct <= 0) { if (error) *error = (BongoCatError){0}; - NearbyAdd add = {app, cache_root, first_created}; - result = bongo_cat_import_nearby_scan(root, add_scanned, &add, error); + result = bounded + ? bongo_cat_import_scan_budget(root, add_scanned, &add, + NEARBY_SCAN_BUDGET_NS, error) + : bongo_cat_import_scan(root, add_scanned, &add, error); } const char *selected = app->session.active_model_id; - if (first_created[0] && (!selected[0] || strcmp(selected, "standard") == 0 || - strcmp(selected, "keyboard") == 0 || strcmp(selected, "gamepad") == 0)) - snprintf(app->session.active_model_id, sizeof(app->session.active_model_id), "%s", first_created); + if (first_created[0] && (!selected[0] || !strcmp(selected, "standard") || + !strcmp(selected, "keyboard") || !strcmp(selected, "gamepad"))) + snprintf(app->session.active_model_id, + sizeof(app->session.active_model_id), "%s", first_created); free(discovery); return result; } -BongoCatResult bongo_cat_import_nearby_mver_root(BongoCatApp *app, +BongoCatResult bongo_cat_import_nearby_root(BongoCatApp *app, const char *root, BongoCatError *error) { - return import_nearby_mver_root(app, root, false, error); + return import_root(app, root, false, error); } -BongoCatResult bongo_cat_import_nearby_mver_scan(BongoCatApp *app, +BongoCatResult bongo_cat_import_nearby_scan(BongoCatApp *app, const char *root, BongoCatError *error) { - return import_nearby_mver_root(app, root, true, error); + return import_root(app, root, true, error); } -BongoCatResult bongo_cat_import_nearby_mver(BongoCatApp *app, +BongoCatResult bongo_cat_import_nearby(BongoCatApp *app, const char *root, BongoCatError *error) { if (!app || !root) return BONGO_CAT_ERROR_ARGUMENT; size_t before = app->models.count; - BongoCatResult result = bongo_cat_import_nearby_mver_root(app, root, error); + BongoCatResult result = bongo_cat_import_nearby_root(app, root, error); if (result != BONGO_CAT_OK || app->models.count != before) return result; char parent[BONGO_CAT_PATH_CAP]; - if (!parent_path(root, parent, sizeof(parent)) || strcmp(parent, root) == 0) + if (!parent_path(root, parent, sizeof(parent)) || !strcmp(parent, root)) return BONGO_CAT_OK; if (error) *error = (BongoCatError){0}; - return bongo_cat_import_nearby_mver_root(app, parent, error); + return bongo_cat_import_nearby_root(app, parent, error); } diff --git a/src/runtime/model_import_nearby_cache.c b/src/runtime/model_import_nearby_cache.c new file mode 100644 index 00000000..b1f1d13d --- /dev/null +++ b/src/runtime/model_import_nearby_cache.c @@ -0,0 +1,184 @@ +#include "model_import_nearby_internal.h" +#include "model_storage.h" +#include "bongo_cat/json.h" +#include "bongo_cat/path.h" + +#include +#include +#include +#include + +#define NEARBY_CACHE_KIND "bongocat/nearby-model-cache" + +typedef struct NearbyMarker { + char source[BONGO_CAT_PATH_CAP]; + char signature[65]; + char identity[65]; + bool adapter_ready; + bool placeholder; + bool valid; +} NearbyMarker; + +static bool digest_valid(const char *value) { + if (!value || strlen(value) != 64) return false; + for (size_t i = 0; i < 64; ++i) + if (!((value[i] >= '0' && value[i] <= '9') || + (value[i] >= 'a' && value[i] <= 'f'))) return false; + return true; +} + +static NearbyMarker read_marker(const char *target) { + NearbyMarker marker = {0}; + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), target, + BONGO_CAT_NEARBY_CACHE_MARKER)) return marker; + yyjson_doc *document = bongo_cat_json_read_file(path, 0, NULL); + yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; + const char *kind = yyjson_get_str(yyjson_obj_get(root, "kind")); + const char *source = yyjson_get_str(yyjson_obj_get(root, "source")); + const char *signature = yyjson_get_str(yyjson_obj_get(root, "signature")); + const char *identity = yyjson_get_str(yyjson_obj_get(root, "identity")); + yyjson_val *adapter_ready = yyjson_obj_get(root, "adapterReady"); + yyjson_val *placeholder = yyjson_obj_get(root, "placeholder"); + marker.valid = yyjson_is_obj(root) && kind && source && signature && + digest_valid(signature) && digest_valid(identity) && + yyjson_is_bool(adapter_ready) && yyjson_is_bool(placeholder) && + yyjson_get_int(yyjson_obj_get(root, "schemaVersion")) == + BONGO_CAT_NEARBY_CACHE_SCHEMA && + strcmp(kind, NEARBY_CACHE_KIND) == 0; + if (marker.valid) { + snprintf(marker.source, sizeof(marker.source), "%s", source); + snprintf(marker.signature, sizeof(marker.signature), "%s", signature); + snprintf(marker.identity, sizeof(marker.identity), "%s", identity); + marker.adapter_ready = yyjson_get_bool(adapter_ready); + marker.placeholder = yyjson_get_bool(placeholder); + } + yyjson_doc_free(document); + return marker; +} + +static bool adapter_usable(const char *target, const char *source, + const NearbyMarker *marker) { + char mode[BONGO_CAT_PATH_CAP], metadata[BONGO_CAT_PATH_CAP]; + char resources[BONGO_CAT_PATH_CAP]; + return marker && marker->valid && marker->adapter_ready && + !marker->placeholder && + strcmp(marker->source, source) == 0 && + bongo_cat_path_is_dir(target) && + bongo_cat_path_join(mode, sizeof(mode), target, ".bongo-cat-mode") && + bongo_cat_path_join(metadata, sizeof(metadata), target, + BONGO_CAT_MODEL_ADAPTER_FILE) && + bongo_cat_path_join(resources, sizeof(resources), target, "resources") && + bongo_cat_path_is_file(mode) && bongo_cat_path_is_file(metadata) && + bongo_cat_path_is_dir(resources); +} + +bool bongo_cat_nearby_cached_inspection(const char *target, + const char *source, const char *signature, char identity[65], + bool *placeholder) { + NearbyMarker marker = read_marker(target); + if (!marker.valid || strcmp(marker.source, source) != 0 || + strcmp(marker.signature, signature) != 0) return false; + snprintf(identity, 65, "%s", marker.identity); + if (placeholder) *placeholder = marker.placeholder; + return true; +} + +static bool write_marker(const char *target, const char *source, + const char *signature, const char *identity, BongoCatModelMode mode, + bool placeholder, bool adapter_ready) { + yyjson_mut_doc *document = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root = document ? yyjson_mut_obj(document) : NULL; + if (document) yyjson_mut_doc_set_root(document, root); + char path[BONGO_CAT_PATH_CAP]; + bool ok = root && yyjson_mut_obj_add_int(document, root, "schemaVersion", + BONGO_CAT_NEARBY_CACHE_SCHEMA) && + yyjson_mut_obj_add_str(document, root, "kind", NEARBY_CACHE_KIND) && + yyjson_mut_obj_add_strcpy(document, root, "source", source) && + yyjson_mut_obj_add_strcpy(document, root, "signature", signature) && + yyjson_mut_obj_add_strcpy(document, root, "identity", identity) && + yyjson_mut_obj_add_bool(document, root, "placeholder", placeholder) && + yyjson_mut_obj_add_bool(document, root, "adapterReady", + adapter_ready) && + yyjson_mut_obj_add_strcpy(document, root, "mode", + bongo_cat_mode_name(mode)) && + bongo_cat_path_join(path, sizeof(path), target, + BONGO_CAT_NEARBY_CACHE_MARKER) && + bongo_cat_json_write_file(path, document, YYJSON_WRITE_PRETTY, NULL); + yyjson_mut_doc_free(document); + return ok; +} + +void bongo_cat_nearby_remember_inspection(const char *target, + const char *source, const char *signature, const char *identity, + bool placeholder, BongoCatModelMode mode) { + if ((bongo_cat_path_is_dir(target) || + bongo_cat_path_create_directory(target)) && + write_marker(target, source, signature, identity, mode, + placeholder, false)) return; + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Cannot cache nearby model identity: %s", source); +} + +static bool use_previous_cache(const char *target, const char *source, + char identity[65], BongoCatError *error) { + NearbyMarker marker = read_marker(target); + if (!adapter_usable(target, source, &marker)) return false; + snprintf(identity, 65, "%s", marker.identity); + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Cannot refresh nearby model cache; using the previous cache: %s", + error && error->message[0] ? error->message : source); + if (error) *error = (BongoCatError){0}; + return true; +} + +bool bongo_cat_nearby_refresh_cache( + const BongoCatImportCandidate *candidate, const char *cache_root, + const char *id, const char *source, const char *signature, + char identity[65], bool placeholder, + char adapter[BONGO_CAT_PATH_CAP], bool *created, BongoCatError *error) { + char temporary[BONGO_CAT_PATH_CAP], backup[BONGO_CAT_PATH_CAP]; + char name[BONGO_CAT_ID_CAP + 8]; + snprintf(name, sizeof(name), ".%s.tmp", id); + if (!bongo_cat_path_join(adapter, BONGO_CAT_PATH_CAP, cache_root, id) || + !bongo_cat_path_join(temporary, sizeof(temporary), cache_root, name)) + return false; + snprintf(name, sizeof(name), ".%s.old", id); + if (!bongo_cat_path_join(backup, sizeof(backup), cache_root, name)) + return false; + bongo_cat_model_remove_tree(temporary, NULL); + if (!bongo_cat_path_is_dir(adapter) && bongo_cat_path_is_dir(backup)) + bongo_cat_path_rename(backup, adapter); + if (bongo_cat_path_is_dir(adapter)) + bongo_cat_model_remove_tree(backup, NULL); + *created = !bongo_cat_path_is_dir(adapter); + NearbyMarker marker = read_marker(adapter); + if (adapter_usable(adapter, source, &marker) && + strcmp(marker.signature, signature) == 0 && + strcmp(marker.identity, identity) == 0) return true; + if (!bongo_cat_path_create_directory(temporary) || + !bongo_cat_import_prepare_adapter(candidate, temporary, error) || + !write_marker(temporary, source, signature, identity, + candidate->mode, placeholder, true)) { + bongo_cat_model_remove_tree(temporary, NULL); + if (error && !error->message[0]) bongo_cat_error_set(error, + BONGO_CAT_ERROR_IO, "Cannot build nearby model adapter: %s", source); + return use_previous_cache(adapter, source, identity, error); + } + bool had_adapter = bongo_cat_path_is_dir(adapter); + if (had_adapter && !bongo_cat_path_rename(adapter, backup)) { + bongo_cat_model_remove_tree(temporary, NULL); + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot update nearby model adapter: %s", SDL_GetError()); + return use_previous_cache(adapter, source, identity, error); + } + if (!bongo_cat_path_rename(temporary, adapter)) { + if (had_adapter) bongo_cat_path_rename(backup, adapter); + bongo_cat_model_remove_tree(temporary, NULL); + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot activate nearby model adapter: %s", SDL_GetError()); + return use_previous_cache(adapter, source, identity, error); + } + bongo_cat_model_remove_tree(backup, NULL); + return true; +} diff --git a/src/runtime/model_import_nearby_identity.c b/src/runtime/model_import_nearby_identity.c deleted file mode 100644 index 82fb5d78..00000000 --- a/src/runtime/model_import_nearby_identity.c +++ /dev/null @@ -1,121 +0,0 @@ -#include "model_import_nearby_internal.h" -#include "bongo_cat/path.h" -#include "bongo_cat/sha256.h" - -#include -#include -#include - -typedef struct IdentityStamp { - uint64_t sum, exclusive, bytes, files, entries; -} IdentityStamp; - -typedef struct IdentityWalk { - const char *root; - const char *group; - IdentityStamp *stamp; - int depth; -} IdentityWalk; - -static uint64_t hash_text(const char *value) { - uint64_t hash = 1469598103934665603ull; - for (const unsigned char *cursor = (const unsigned char *)value; - cursor && *cursor; ++cursor) { - unsigned char byte = *cursor == '\\' ? '/' : *cursor; - hash ^= byte; hash *= 1099511628211ull; - } - return hash; -} - -static uint64_t mix(uint64_t value) { - value ^= value >> 30; value *= 0xbf58476d1ce4e5b9ull; - value ^= value >> 27; value *= 0x94d049bb133111ebull; - return value ^ (value >> 31); -} - -static bool stamp_path(const char *path, IdentityWalk *walk); - -static BongoCatPathVisit stamp_item(void *userdata, - const char *dirname, const char *name) { - IdentityWalk *walk = userdata; - char path[BONGO_CAT_PATH_CAP]; - return ++walk->stamp->entries <= 16384 && - bongo_cat_path_join(path, sizeof(path), dirname, name) && - stamp_path(path, walk) ? BONGO_CAT_PATH_CONTINUE : - BONGO_CAT_PATH_FAILURE; -} - -static const char *relative_path(const IdentityWalk *walk, const char *path) { - size_t root_length = strlen(walk->root); - if (strncmp(path, walk->root, root_length) != 0) return path; - const char *relative = path + root_length; - while (*relative == '/' || *relative == '\\') relative++; - return relative; -} - -static bool stamp_path(const char *path, IdentityWalk *walk) { - if (bongo_cat_path_is_dir(path)) { - if (walk->depth >= 24) return false; - walk->depth++; - bool ok = bongo_cat_path_enumerate(path, stamp_item, walk); - walk->depth--; - return ok; - } - uint64_t size = 0; - if (!bongo_cat_path_file_size(path, &size) || walk->stamp->files >= 8192 || - size > 1073741824ull - walk->stamp->bytes) return false; - char file_hash[65]; - if (bongo_cat_sha256_file(path, file_hash, NULL) != BONGO_CAT_OK) - return false; - uint64_t value = mix(hash_text(walk->group) ^ - hash_text(relative_path(walk, path)) ^ hash_text(file_hash) ^ mix(size)); - walk->stamp->sum += value; - walk->stamp->exclusive ^= value; - walk->stamp->bytes += size; - walk->stamp->files++; - return true; -} - -static bool stamp_root(const char *root, const char *group, - IdentityStamp *stamp) { - if (!root || !root[0]) return true; - IdentityWalk walk = {root, group, stamp, 0}; - return stamp_path(root, &walk); -} - -bool bongo_cat_import_candidate_digest(const BongoCatImportCandidate *candidate, - char output[65], BongoCatError *error) { - if (!candidate || !output) return false; - char config_hash[65] = {0}, setting[BONGO_CAT_PATH_CAP], setting_hash[65]; - if ((candidate->config[0] && bongo_cat_sha256_file(candidate->config, - config_hash, error) != BONGO_CAT_OK) || - !bongo_cat_path_join(setting, sizeof(setting), - candidate->directory, candidate->setting) || - bongo_cat_sha256_file(setting, setting_hash, error) != BONGO_CAT_OK) - return false; - IdentityStamp stamp = {0}; - if (!stamp_root(candidate->directory, "model", &stamp) || - !stamp_root(candidate->assets, "assets", &stamp) || - !stamp_root(candidate->overrides, "overrides", &stamp)) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot fingerprint imported model assets: %s", - candidate->package_root); - return false; - } - char summary[384]; - int length = snprintf(summary, sizeof(summary), - "%s|%s|%s|%d|%016llx|%016llx|%llu|%llu", config_hash, setting_hash, - bongo_cat_mode_name(candidate->mode), (int)candidate->format, - (unsigned long long)stamp.sum, - (unsigned long long)stamp.exclusive, - (unsigned long long)stamp.bytes, - (unsigned long long)stamp.files); - if (length < 0 || (size_t)length >= sizeof(summary)) return false; - bongo_cat_sha256_bytes(summary, (size_t)length, output); - return true; -} - -bool bongo_cat_nearby_identity(const BongoCatImportCandidate *candidate, - char output[65], BongoCatError *error) { - return bongo_cat_import_candidate_digest(candidate, output, error); -} diff --git a/src/runtime/model_import_nearby_internal.h b/src/runtime/model_import_nearby_internal.h index 366e7e04..00440154 100644 --- a/src/runtime/model_import_nearby_internal.h +++ b/src/runtime/model_import_nearby_internal.h @@ -4,10 +4,20 @@ #include "model_import.h" #define BONGO_CAT_NEARBY_CACHE_MARKER ".bongo-cat-cache.json" -#define BONGO_CAT_NEARBY_CACHE_SCHEMA 1 +#define BONGO_CAT_NEARBY_CACHE_SCHEMA 4 -bool bongo_cat_nearby_identity(const BongoCatImportCandidate *candidate, - char output[65], BongoCatError *error); bool bongo_cat_nearby_signature(const BongoCatImportCandidate *candidate, char output[65], BongoCatError *error); +bool bongo_cat_nearby_cached_inspection(const char *target, + const char *source, const char *signature, char identity[65], + bool *placeholder); +void bongo_cat_nearby_remember_inspection(const char *target, + const char *source, const char *signature, const char *identity, + bool placeholder, BongoCatModelMode mode); +bool bongo_cat_nearby_refresh_cache( + const BongoCatImportCandidate *candidate, const char *cache_root, + const char *id, const char *source, const char *signature, + char identity[65], bool placeholder, + char adapter[BONGO_CAT_PATH_CAP], bool *created, BongoCatError *error); + #endif diff --git a/src/runtime/model_import_nearby_signature.c b/src/runtime/model_import_nearby_signature.c index ae13dc85..0853fb15 100644 --- a/src/runtime/model_import_nearby_signature.c +++ b/src/runtime/model_import_nearby_signature.c @@ -4,82 +4,151 @@ #include #include +#include -typedef struct NearbyStamp { +#define SIGNATURE_ROOT_CAP 3 + +typedef struct SignatureStamp { uint64_t sum, exclusive, bytes, latest, files, entries; -} NearbyStamp; +} SignatureStamp; + +typedef struct SignatureRoot { + char path[BONGO_CAT_PATH_CAP]; + const char *group; +} SignatureRoot; -typedef struct StampWalk { - NearbyStamp *stamp; +typedef struct SignatureWalk { + const SignatureRoot *root; + SignatureStamp *stamp; int depth; -} StampWalk; +} SignatureWalk; + +static bool separator(char value) { return value == '/' || value == '\\'; } + +static bool character_equal(char left, char right) { + if (separator(left) && separator(right)) return true; +#ifdef _WIN32 + if (left >= 'A' && left <= 'Z') left = (char)(left - 'A' + 'a'); + if (right >= 'A' && right <= 'Z') right = (char)(right - 'A' + 'a'); +#endif + return left == right; +} + +static size_t normalized_length(const char *path) { + size_t length = path ? strlen(path) : 0; + while (length > 1 && separator(path[length - 1])) length--; + return length; +} + +static bool contains(const char *root, const char *path) { + size_t root_length = normalized_length(root); + size_t path_length = normalized_length(path); + if (!root_length || path_length < root_length) return false; + for (size_t i = 0; i < root_length; ++i) + if (!character_equal(root[i], path[i])) return false; + return path_length == root_length || separator(path[root_length]); +} + +static void add_root(SignatureRoot roots[SIGNATURE_ROOT_CAP], size_t *count, + const char *path, const char *group) { + if (!path || !path[0]) return; + for (size_t i = 0; i < *count; ++i) + if (contains(roots[i].path, path)) return; + bool merged = false; + for (size_t i = 0; i < *count;) { + if (!contains(path, roots[i].path)) { i++; continue; } + roots[i] = roots[--*count]; + merged = true; + } + if (*count >= SIGNATURE_ROOT_CAP) return; + SignatureRoot *root = &roots[(*count)++]; + snprintf(root->path, sizeof(root->path), "%s", path); + root->group = merged ? "content" : group; +} static uint64_t text_hash(const char *value) { uint64_t hash = 1469598103934665603ull; for (const unsigned char *cursor = (const unsigned char *)value; - *cursor; ++cursor) { - hash ^= *cursor; + cursor && *cursor; ++cursor) { + unsigned char byte = *cursor == '\\' ? '/' : *cursor; + hash ^= byte; hash *= 1099511628211ull; } return hash; } static uint64_t mix(uint64_t value) { - value ^= value >> 30; - value *= 0xbf58476d1ce4e5b9ull; - value ^= value >> 27; - value *= 0x94d049bb133111ebull; + value ^= value >> 30; value *= 0xbf58476d1ce4e5b9ull; + value ^= value >> 27; value *= 0x94d049bb133111ebull; return value ^ (value >> 31); } -static bool stamp_path(const char *path, NearbyStamp *stamp, int depth); +static bool stamp_path(const char *path, SignatureWalk *walk); static BongoCatPathVisit stamp_item(void *userdata, const char *dirname, const char *name) { - StampWalk *walk = userdata; + SignatureWalk *walk = userdata; char path[BONGO_CAT_PATH_CAP]; return ++walk->stamp->entries <= 16384 && bongo_cat_path_join(path, sizeof(path), dirname, name) && - stamp_path(path, walk->stamp, walk->depth) - ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; + stamp_path(path, walk) ? BONGO_CAT_PATH_CONTINUE : + BONGO_CAT_PATH_FAILURE; +} + +static const char *relative_path(const SignatureRoot *root, + const char *path) { + if (!contains(root->path, path)) return path; + const char *relative = path + normalized_length(root->path); + while (separator(*relative)) relative++; + return relative; } -static bool stamp_path(const char *path, NearbyStamp *stamp, int depth) { +static bool stamp_path(const char *path, SignatureWalk *walk) { if (bongo_cat_path_is_dir(path)) { - if (depth >= 24) return false; - StampWalk walk = {stamp, depth + 1}; - return bongo_cat_path_enumerate(path, stamp_item, &walk); + if (walk->depth >= 24) return false; + walk->depth++; + bool ok = bongo_cat_path_enumerate(path, stamp_item, walk); + walk->depth--; + return ok; } - uint64_t size, modified; - if (!bongo_cat_path_file_info(path, &size, &modified)) return false; - if (stamp->files >= 8192 || size > 1073741824ull - stamp->bytes) - return false; - uint64_t value = mix(text_hash(path) ^ mix(size) ^ mix(modified)); - stamp->sum += value; - stamp->exclusive ^= value; - stamp->bytes += size; - if (modified > stamp->latest) stamp->latest = modified; - stamp->files++; + uint64_t size = 0, modified = 0; + if (!bongo_cat_path_file_info(path, &size, &modified) || + walk->stamp->files >= 8192 || + size > 1073741824ull - walk->stamp->bytes) return false; + uint64_t value = mix(text_hash(walk->root->group) ^ + text_hash(relative_path(walk->root, path)) ^ mix(size) ^ mix(modified)); + walk->stamp->sum += value; + walk->stamp->exclusive ^= value; + walk->stamp->bytes += size; + if (modified > walk->stamp->latest) walk->stamp->latest = modified; + walk->stamp->files++; return true; } bool bongo_cat_nearby_signature(const BongoCatImportCandidate *candidate, char output[65], BongoCatError *error) { - char config_hash[65], summary[256]; - if (bongo_cat_sha256_file(candidate->config, config_hash, error) != - BONGO_CAT_OK) return false; - NearbyStamp stamp = {0}; - if (!stamp_path(candidate->directory, &stamp, 0) || - !stamp_path(candidate->assets, &stamp, 0) || - (candidate->overrides[0] && - !stamp_path(candidate->overrides, &stamp, 0))) { + if (!candidate || !output) return false; + char config_hash[65] = {0}; + if (candidate->config[0] && bongo_cat_sha256_file(candidate->config, + config_hash, error) != BONGO_CAT_OK) return false; + SignatureRoot roots[SIGNATURE_ROOT_CAP] = {0}; + size_t root_count = 0; + add_root(roots, &root_count, candidate->directory, "model"); + add_root(roots, &root_count, candidate->assets, "assets"); + add_root(roots, &root_count, candidate->overrides, "overrides"); + SignatureStamp stamp = {0}; + for (size_t i = 0; i < root_count; ++i) { + SignatureWalk walk = {&roots[i], &stamp, 0}; + if (stamp_path(roots[i].path, &walk)) continue; bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot inspect nearby Mver model assets: %s", + "Cannot inspect nearby model assets: %s", candidate->package_root); return false; } + char summary[256]; int length = snprintf(summary, sizeof(summary), - "%s|%016llx|%016llx|%llu|%llu|%llu", config_hash, + "%s|%s|%d|%016llx|%016llx|%llu|%llu|%llu", config_hash, + bongo_cat_mode_name(candidate->mode), (int)candidate->format, (unsigned long long)stamp.sum, (unsigned long long)stamp.exclusive, (unsigned long long)stamp.bytes, diff --git a/src/runtime/model_import_nearby_scan.c b/src/runtime/model_import_scan.c similarity index 52% rename from src/runtime/model_import_nearby_scan.c rename to src/runtime/model_import_scan.c index 1c84a6f5..d0238384 100644 --- a/src/runtime/model_import_nearby_scan.c +++ b/src/runtime/model_import_scan.c @@ -6,44 +6,42 @@ #include #include -#define NEARBY_CHILD_CAP 64 -#define NEARBY_ENTRY_CAP 256 -#define NEARBY_NODE_CAP 256 -#define NEARBY_SCAN_DEPTH 3 -#define NEARBY_SCAN_BUDGET_NS 2000000000ull +#define IMPORT_CHILD_CAP 64 +#define IMPORT_ENTRY_CAP 256 +#define IMPORT_NODE_CAP 256 +#define IMPORT_SCAN_DEPTH 3 +#define IMPORT_SCAN_BUDGET_NS 2000000000ull typedef struct ChildList { - char paths[NEARBY_CHILD_CAP][BONGO_CAT_PATH_CAP]; + char paths[IMPORT_CHILD_CAP][BONGO_CAT_PATH_CAP]; size_t count, entries; bool limited; } ChildList; -typedef struct NearbyWorkspace { +typedef struct ImportWorkspace { BongoCatImportDiscovery discovery; ChildList children; -} NearbyWorkspace; +} ImportWorkspace; -typedef struct NearbyScan { - BongoCatNearbyVisitor visitor; +typedef struct ImportScan { + BongoCatImportVisitor visitor; void *userdata; BongoCatError *error; size_t nodes; uint64_t deadline; bool limited; -} NearbyScan; +} ImportScan; -typedef struct NearbyQueue { - char paths[NEARBY_NODE_CAP][BONGO_CAT_PATH_CAP]; - unsigned char depths[NEARBY_NODE_CAP], priorities[NEARBY_NODE_CAP]; +typedef struct ImportQueue { + char paths[IMPORT_NODE_CAP][BONGO_CAT_PATH_CAP]; + unsigned char depths[IMPORT_NODE_CAP]; size_t head, count; -} NearbyQueue; - -typedef struct ModelHint { size_t entries; bool found; } ModelHint; +} ImportQueue; static BongoCatPathVisit collect_child(void *userdata, const char *dirname, const char *name) { ChildList *list = userdata; - if (++list->entries > NEARBY_ENTRY_CAP || list->count >= NEARBY_CHILD_CAP) { + if (++list->entries > IMPORT_ENTRY_CAP || list->count >= IMPORT_CHILD_CAP) { list->limited = true; return BONGO_CAT_PATH_SUCCESS; } if (name[0] == '.') return BONGO_CAT_PATH_CONTINUE; @@ -62,82 +60,48 @@ static int compare_path(const void *left, const void *right) { #endif } -static bool within_budget(NearbyScan *scan) { - if (scan->nodes >= NEARBY_NODE_CAP || SDL_GetTicksNS() >= scan->deadline) { +static bool within_budget(ImportScan *scan) { + if (scan->nodes >= IMPORT_NODE_CAP || SDL_GetTicksNS() >= scan->deadline) { scan->limited = true; return false; } scan->nodes++; return true; } -static bool direct_image_root(const char *path) { - char image[BONGO_CAT_PATH_CAP]; - return bongo_cat_path_join(image, sizeof(image), path, "img") && - bongo_cat_path_is_dir(image); -} - -static BongoCatPathVisit find_model_hint(void *userdata, - const char *dirname, const char *name) { - ModelHint *hint = userdata; - if (++hint->entries > NEARBY_CHILD_CAP) return BONGO_CAT_PATH_SUCCESS; - char path[BONGO_CAT_PATH_CAP]; - if (name[0] != '.' && bongo_cat_path_join(path, sizeof(path), dirname, name) && - bongo_cat_path_is_dir(path) && direct_image_root(path)) { - hint->found = true; return BONGO_CAT_PATH_SUCCESS; - } - return BONGO_CAT_PATH_CONTINUE; -} - -static bool model_hint(const char *path) { - if (direct_image_root(path)) return true; - ModelHint hint = {0}; - bongo_cat_path_enumerate(path, find_model_hint, &hint); - return hint.found; -} - -static void enqueue_children(NearbyScan *scan, NearbyQueue *queue, +static void enqueue_children(ImportScan *scan, ImportQueue *queue, const ChildList *children, int depth) { for (size_t i = 0; i < children->count; ++i) { - if (queue->count >= NEARBY_NODE_CAP) { + if (queue->count >= IMPORT_NODE_CAP) { scan->limited = true; return; } snprintf(queue->paths[queue->count], BONGO_CAT_PATH_CAP, "%s", children->paths[i]); queue->depths[queue->count++] = (unsigned char)depth; - queue->priorities[queue->count - 1] = model_hint(children->paths[i]); } } -static void prioritize_next(NearbyQueue *queue) { - size_t preferred = queue->head; - while (preferred < queue->count && !queue->priorities[preferred]) preferred++; - if (preferred >= queue->count || preferred == queue->head) return; - char path[BONGO_CAT_PATH_CAP]; - snprintf(path, sizeof(path), "%s", queue->paths[queue->head]); - memmove(queue->paths[queue->head], queue->paths[preferred], BONGO_CAT_PATH_CAP); - memmove(queue->paths[preferred], path, BONGO_CAT_PATH_CAP); - unsigned char depth = queue->depths[queue->head]; - queue->depths[queue->head] = queue->depths[preferred]; - queue->depths[preferred] = depth; - queue->priorities[preferred] = queue->priorities[queue->head]; - queue->priorities[queue->head] = 1; -} - -static BongoCatResult scan_node(NearbyScan *scan, NearbyQueue *queue, +static BongoCatResult scan_node(ImportScan *scan, ImportQueue *queue, const char *source, int depth) { if (!within_budget(scan)) return BONGO_CAT_OK; - NearbyWorkspace *work = calloc(1, sizeof(*work)); + ImportWorkspace *work = calloc(1, sizeof(*work)); if (!work) { bongo_cat_error_set(scan->error, BONGO_CAT_ERROR_MEMORY, - "Cannot allocate nearby model scan workspace"); + "Cannot allocate model import scan workspace"); return BONGO_CAT_ERROR_MEMORY; } BongoCatError local = {0}; int found = bongo_cat_import_mver_discover_exact(source, &work->discovery, &local); if (found <= 0) { - memset(&work->discovery, 0, sizeof(work->discovery)); local = (BongoCatError){0}; + memset(&work->discovery, 0, sizeof(work->discovery)); + local = (BongoCatError){0}; found = bongo_cat_import_mver_patch_discover_exact(source, &work->discovery, &local); } + if (found <= 0) { + memset(&work->discovery, 0, sizeof(work->discovery)); + local = (BongoCatError){0}; + found = bongo_cat_import_tauri_discover_exact(source, + &work->discovery, &local); + } if (found > 0) { uint64_t started = SDL_GetTicksNS(); BongoCatResult result = scan->visitor(scan->userdata, source, @@ -147,7 +111,7 @@ static BongoCatResult scan_node(NearbyScan *scan, NearbyQueue *queue, else scan->deadline = UINT64_MAX; free(work); return result; } - if (depth < NEARBY_SCAN_DEPTH && + if (depth < IMPORT_SCAN_DEPTH && bongo_cat_path_enumerate(source, collect_child, &work->children)) { if (work->children.limited) scan->limited = true; qsort(work->children.paths, work->children.count, @@ -157,14 +121,17 @@ static BongoCatResult scan_node(NearbyScan *scan, NearbyQueue *queue, free(work); return BONGO_CAT_OK; } -BongoCatResult bongo_cat_import_nearby_scan(const char *root, - BongoCatNearbyVisitor visitor, void *userdata, BongoCatError *error) { +static BongoCatResult scan_with_budget(const char *root, + BongoCatImportVisitor visitor, void *userdata, uint64_t budget_ns, + BongoCatError *error) { if (!root || !visitor) return BONGO_CAT_ERROR_ARGUMENT; - NearbyWorkspace *work = calloc(1, sizeof(*work)); - NearbyQueue *queue = calloc(1, sizeof(*queue)); + ImportWorkspace *work = calloc(1, sizeof(*work)); + ImportQueue *queue = calloc(1, sizeof(*queue)); if (!work || !queue) { free(work); free(queue); return BONGO_CAT_ERROR_MEMORY; } - NearbyScan scan = {visitor, userdata, error, 0, - SDL_GetTicksNS() + NEARBY_SCAN_BUDGET_NS, false}; + uint64_t now = SDL_GetTicksNS(); + uint64_t deadline = budget_ns <= UINT64_MAX - now + ? now + budget_ns : UINT64_MAX; + ImportScan scan = {visitor, userdata, error, 0, deadline, false}; BongoCatResult result = BONGO_CAT_OK; if (bongo_cat_path_enumerate(root, collect_child, &work->children)) { if (work->children.limited) scan.limited = true; @@ -172,7 +139,6 @@ BongoCatResult bongo_cat_import_nearby_scan(const char *root, sizeof(work->children.paths[0]), compare_path); enqueue_children(&scan, queue, &work->children, 1); while (queue->head < queue->count) { - prioritize_next(queue); size_t current = queue->head++; BongoCatResult item = scan_node(&scan, queue, queue->paths[current], queue->depths[current]); @@ -180,6 +146,18 @@ BongoCatResult bongo_cat_import_nearby_scan(const char *root, } } if (scan.limited) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "Nearby model discovery reached its startup scan budget"); + "Model folder scan reached its directory budget"); free(queue); free(work); return result; } + +BongoCatResult bongo_cat_import_scan_budget(const char *root, + BongoCatImportVisitor visitor, void *userdata, uint64_t budget_ns, + BongoCatError *error) { + return scan_with_budget(root, visitor, userdata, budget_ns, error); +} + +BongoCatResult bongo_cat_import_scan(const char *root, + BongoCatImportVisitor visitor, void *userdata, BongoCatError *error) { + return scan_with_budget(root, visitor, userdata, + IMPORT_SCAN_BUDGET_NS, error); +} diff --git a/src/runtime/model_import_source.c b/src/runtime/model_import_source.c new file mode 100644 index 00000000..9850eaba --- /dev/null +++ b/src/runtime/model_import_source.c @@ -0,0 +1,75 @@ +#include "model_import.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +static bool suffix_matches(const char *value, const char *suffix) { + size_t value_length = value ? strlen(value) : 0; + size_t suffix_length = suffix ? strlen(suffix) : 0; + return value_length >= suffix_length && SDL_strcasecmp( + value + value_length - suffix_length, suffix) == 0; +} + +static bool parent_path(const char *path, char *parent, size_t capacity) { + size_t length = path ? strlen(path) : 0; + while (length && (path[length - 1] == '/' || path[length - 1] == '\\')) + length--; + while (length && path[length - 1] != '/' && path[length - 1] != '\\') + length--; + while (length > 1 && (path[length - 1] == '/' || path[length - 1] == '\\')) + length--; + if (!length || length >= capacity) return false; + memcpy(parent, path, length); + parent[length] = '\0'; + return true; +} + +static bool image_package_root(const char *source, char *directory, + size_t capacity) { + char current[BONGO_CAT_PATH_CAP]; + if (!parent_path(source, current, sizeof(current))) return false; + for (int depth = 0; depth < 12; ++depth) { + if (SDL_strcasecmp(bongo_cat_path_name(current), "img") == 0) + return parent_path(current, directory, capacity); + char parent[BONGO_CAT_PATH_CAP]; + if (!parent_path(current, parent, sizeof(parent))) return false; + snprintf(current, sizeof(current), "%s", parent); + } + return false; +} + +BongoCatResult bongo_cat_import_source_directory(const char *source, + char *directory, size_t capacity, BongoCatError *error) { + if (!source || !source[0] || !directory || !capacity) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, + "Missing model import source"); + return BONGO_CAT_ERROR_ARGUMENT; + } + if (bongo_cat_path_is_dir(source)) { + int length = snprintf(directory, capacity, "%s", source); + if (length >= 0 && (size_t)length < capacity) return BONGO_CAT_OK; + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, + "Model import path is too long"); + return BONGO_CAT_ERROR_ARGUMENT; + } + if (!bongo_cat_path_is_file(source)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, + "Selected model source does not exist: %s", source); + return BONGO_CAT_ERROR_ARGUMENT; + } + const char *name = bongo_cat_path_name(source); + if (name && suffix_matches(name, ".png") && + image_package_root(source, directory, capacity)) return BONGO_CAT_OK; + if (!name || (SDL_strcasecmp(name, "config.json") != 0 && + !suffix_matches(name, ".model3.json"))) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Select an Mver config.json, image-patch PNG, or Live2D .model3.json file"); + return BONGO_CAT_ERROR_FORMAT; + } + if (parent_path(source, directory, capacity)) return BONGO_CAT_OK; + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, + "Cannot determine the selected model file directory: %s", source); + return BONGO_CAT_ERROR_ARGUMENT; +} diff --git a/src/runtime/model_import_tauri.c b/src/runtime/model_import_tauri.c new file mode 100644 index 00000000..bef05ab3 --- /dev/null +++ b/src/runtime/model_import_tauri.c @@ -0,0 +1,43 @@ +#include "model_import.h" +#include "bongo_cat/path.h" + +#include +#include + +static bool suffix(const char *name, const char *ending) { + size_t name_length = name ? strlen(name) : 0; + size_t ending_length = ending ? strlen(ending) : 0; + return name_length >= ending_length && + strcmp(name + name_length - ending_length, ending) == 0; +} + +static BongoCatPathVisit discover_item(void *userdata, + const char *dirname, const char *name) { + BongoCatImportDiscovery *discovery = userdata; + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), dirname, name)) + return BONGO_CAT_PATH_FAILURE; + if (!bongo_cat_path_is_file(path) || !suffix(name, ".model3.json")) + return BONGO_CAT_PATH_CONTINUE; + if (bongo_cat_import_manifest_valid(dirname, name, NULL) && + !bongo_cat_import_tauri_add_candidate(discovery, dirname, name)) + return BONGO_CAT_PATH_FAILURE; + return BONGO_CAT_PATH_CONTINUE; +} + +int bongo_cat_import_tauri_discover_exact(const char *source, + BongoCatImportDiscovery *discovery, BongoCatError *error) { + if (!source || !discovery || !bongo_cat_path_is_dir(source)) return 0; + size_t before = discovery->count; + if (!bongo_cat_path_enumerate(source, discover_item, discovery)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + discovery->ambiguous + ? "A model directory contains multiple model3 manifests" + : "Cannot inspect nearby model directory"); + return -1; + } + for (size_t i = before; i < discovery->count; ++i) + snprintf(discovery->candidates[i].package_root, + sizeof(discovery->candidates[i].package_root), "%s", source); + return discovery->count > before ? 1 : 0; +} diff --git a/src/runtime/runtime.h b/src/runtime/runtime.h index 470c3538..2c8b0f08 100644 --- a/src/runtime/runtime.h +++ b/src/runtime/runtime.h @@ -90,7 +90,7 @@ void bongo_cat_frame_capture_pending(BongoCatApp *app, int width, int height); void bongo_cat_app_render_now(BongoCatApp *app); bool bongo_cat_app_capture_pending_frame(BongoCatApp *app); void bongo_cat_runtime_flow_update(BongoCatApp *app, uint64_t now); -void bongo_cat_config_store_initialize(BongoCatApp *app); +void bongo_cat_config_store_load(BongoCatApp *app); void bongo_cat_config_store_update(BongoCatApp *app, uint64_t now); void bongo_cat_config_store_flush(BongoCatApp *app); diff --git a/src/runtime/window_menu.h b/src/runtime/window_menu.h index d853676f..a0219384 100644 --- a/src/runtime/window_menu.h +++ b/src/runtime/window_menu.h @@ -5,11 +5,9 @@ typedef struct BongoCatWindowMenuPreview { BongoCatApp *app; - char model[BONGO_CAT_ID_CAP]; float scale, opacity; int expression; - BongoCatMenuAction last, applied, pending_model; - uint64_t pending_model_since_ns; + BongoCatMenuAction last, applied; BongoCatModalFrame modal_frame; } BongoCatWindowMenuPreview; diff --git a/src/runtime/window_menu_preview.c b/src/runtime/window_menu_preview.c index 9025f4f2..9947f409 100644 --- a/src/runtime/window_menu_preview.c +++ b/src/runtime/window_menu_preview.c @@ -1,10 +1,5 @@ #include "window_menu.h" -#include -#include - -#define MODEL_PREVIEW_DELAY_NS 150000000ull - static const BongoCatModelEntry *menu_model(const BongoCatApp *app, BongoCatMenuAction action) { if (!app || action < BONGO_CAT_MENU_MODEL_FIRST || @@ -13,19 +8,17 @@ static const BongoCatModelEntry *menu_model(const BongoCatApp *app, return index < app->models.count ? &app->models.entries[index] : NULL; } -static bool previewable(const BongoCatApp *app, BongoCatMenuAction action) { +static bool previewable(BongoCatMenuAction action) { return (action >= BONGO_CAT_MENU_SCALE_50 && action <= BONGO_CAT_MENU_OPACITY_100) || - menu_model(app, action) != NULL || bongo_cat_window_behavior_menu_action(action); } -static int preview_group(const BongoCatApp *app, BongoCatMenuAction action) { +static int preview_group(BongoCatMenuAction action) { if (action >= BONGO_CAT_MENU_SCALE_50 && action <= BONGO_CAT_MENU_SCALE_200) return 1; if (action >= BONGO_CAT_MENU_OPACITY_10 && action <= BONGO_CAT_MENU_OPACITY_100) return 2; - if (menu_model(app, action)) return 3; if (action >= BONGO_CAT_MENU_MOTION_FIRST && action < BONGO_CAT_MENU_MOTION_FIRST + BONGO_CAT_BEHAVIOR_CAP) return 4; if (action >= BONGO_CAT_MENU_EXPRESSION_FIRST && @@ -40,34 +33,25 @@ void bongo_cat_window_menu_preview_init(BongoCatWindowMenuPreview *state, .scale = app ? app->session.window.scale_percent : 100.0f, .opacity = app ? app->session.window.opacity_percent : 100.0f, .expression = app ? bongo_cat_live2d_expression(app->live2d) : -1, - .last = BONGO_CAT_MENU_NONE, .applied = BONGO_CAT_MENU_NONE, - .pending_model = BONGO_CAT_MENU_NONE}; + .last = BONGO_CAT_MENU_NONE, .applied = BONGO_CAT_MENU_NONE}; bongo_cat_modal_frame_init(&state->modal_frame, app); - if (app) snprintf(state->model, sizeof(state->model), "%.*s", - (int)sizeof(state->model) - 1, app->session.active_model_id); } void bongo_cat_window_menu_preview(void *userdata, BongoCatMenuAction action) { BongoCatWindowMenuPreview *state = userdata; if (!state || !state->app || action == state->last) return; BongoCatApp *app = state->app; - int previous_group = preview_group(app, state->last); - int next_group = preview_group(app, action); + int previous_group = preview_group(state->last); + int next_group = preview_group(action); if (state->last != BONGO_CAT_MENU_NONE && previous_group != next_group) bongo_cat_window_menu_restore(state, BONGO_CAT_MENU_NONE); - if (!previewable(app, action)) { + if (!previewable(action)) { if (previous_group == 0) bongo_cat_window_menu_restore(state, BONGO_CAT_MENU_NONE); state->last = action; return; } state->last = action; - if (menu_model(app, action)) { - state->pending_model = action; - state->pending_model_since_ns = SDL_GetTicksNS(); - return; - } - state->pending_model = BONGO_CAT_MENU_NONE; if (action >= BONGO_CAT_MENU_SCALE_50 && action <= BONGO_CAT_MENU_SCALE_200) bongo_cat_window_set_scale(app, @@ -93,33 +77,6 @@ void bongo_cat_window_menu_preview(void *userdata, BongoCatMenuAction action) { void bongo_cat_window_menu_preview_tick(void *userdata) { BongoCatWindowMenuPreview *state = userdata; if (!state) return; - if (state->pending_model != BONGO_CAT_MENU_NONE && - SDL_GetTicksNS() - state->pending_model_since_ns >= - MODEL_PREVIEW_DELAY_NS) { - BongoCatMenuAction action = state->pending_model; - state->pending_model = BONGO_CAT_MENU_NONE; - const BongoCatModelEntry *model = action == state->last ? - menu_model(state->app, action) : NULL; - if (model) { - BongoCatError error = {0}; - if (!bongo_cat_app_select_model_with_error(state->app, model->id, - &error)) { - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "Model preview failed: %s", - error.message[0] ? error.message : "unknown error"); - if (strcmp(state->app->session.active_model_id, - state->model) != 0) { - BongoCatError restore_error = {0}; - if (!bongo_cat_app_select_model_with_error(state->app, - state->model, &restore_error)) - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "Model preview recovery failed: %s", - restore_error.message[0] ? - restore_error.message : "unknown error"); - } - } - } - } bongo_cat_modal_frame_tick(&state->modal_frame); } @@ -127,7 +84,6 @@ void bongo_cat_window_menu_restore(void *userdata, BongoCatMenuAction selected) BongoCatWindowMenuPreview *state = userdata; if (!state || !state->app) return; BongoCatApp *app = state->app; - state->pending_model = BONGO_CAT_MENU_NONE; bool changed = false; bool keep_motion = selected >= BONGO_CAT_MENU_MOTION_FIRST && selected < BONGO_CAT_MENU_MOTION_FIRST + BONGO_CAT_BEHAVIOR_CAP; @@ -149,14 +105,6 @@ void bongo_cat_window_menu_restore(void *userdata, BongoCatMenuAction selected) bool keep_expression = keep_model || (selected >= BONGO_CAT_MENU_EXPRESSION_FIRST && selected < BONGO_CAT_MENU_EXPRESSION_FIRST + BONGO_CAT_BEHAVIOR_CAP); - if (!keep_model && strcmp(app->session.active_model_id, state->model) != 0) { - BongoCatError error = {0}; - if (bongo_cat_app_select_model_with_error(app, state->model, &error)) - changed = true; - else SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "Model preview restore failed: %s", - error.message[0] ? error.message : "unknown error"); - } if (!keep_scale && SDL_fabsf(app->session.window.scale_percent - state->scale) > .01f) { bongo_cat_window_set_scale(app, state->scale); diff --git a/src/ui/preferences.c b/src/ui/preferences.c index 666ef854..54ec0ab4 100644 --- a/src/ui/preferences.c +++ b/src/ui/preferences.c @@ -37,9 +37,6 @@ BongoCatPreferences *bongo_cat_preferences_create(BongoCatApp *app) { } void bongo_cat_preferences_show(BongoCatPreferences *value) { if (!value) return; - const BongoCatModelEntry *entry = bongo_cat_models_find( - &value->app->models, value->app->session.active_model_id); - bongo_cat_preferences_model_cover_capture(value->app, entry); if (!value->window && !bongo_cat_preferences_open_window(value)) { SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Preferences failed: %s", SDL_GetError()); bongo_cat_preferences_close(value); diff --git a/src/ui/preferences_import.c b/src/ui/preferences_import.c index ef63eb91..319678ce 100644 --- a/src/ui/preferences_import.c +++ b/src/ui/preferences_import.c @@ -1,6 +1,5 @@ #include "preferences_internal.h" #include "preferences_state.h" -#include "preferences_model_cover.h" #include "../runtime/model_import.h" #include @@ -129,6 +128,11 @@ static BongoCatImportJob *copy_path(const char *path) { static int SDLCALL import_worker(void *userdata) { BongoCatImportJob *job = userdata; + if (!SDL_SetCurrentThreadPriority(SDL_THREAD_PRIORITY_LOW)) { + SDL_LogDebug(SDL_LOG_CATEGORY_APPLICATION, + "Cannot lower model import thread priority: %s", SDL_GetError()); + SDL_ClearError(); + } job->result = BONGO_CAT_ERROR_FORMAT; for (size_t i = 0; i < job->count; ++i) { BongoCatImportReceipt receipt; @@ -227,6 +231,10 @@ static void SDLCALL import_callback(void *userdata, const char *const *files, bool bongo_cat_preferences_import_open(BongoCatImportDialog *dialog, SDL_Window *window) { + static const SDL_DialogFileFilter filters[] = { + {"BongoCat model files", "model3.json;json;png"}, + {"All files", "*"} + }; if (!dialog || !window) return false; SDL_LockMutex(dialog->mutex); if (!dialog->active || dialog->open || dialog->busy) { @@ -237,7 +245,8 @@ bool bongo_cat_preferences_import_open(BongoCatImportDialog *dialog, dialog->window_id = SDL_GetWindowID(window); ++dialog->references; SDL_UnlockMutex(dialog->mutex); - SDL_ShowOpenFolderDialog(import_callback, dialog, window, NULL, true); + SDL_ShowOpenFileDialog(import_callback, dialog, window, filters, + (int)(sizeof(filters) / sizeof(filters[0])), NULL, true); return true; } @@ -253,17 +262,14 @@ static void complete_job(BongoCatImportDialog *dialog, BongoCatApp *app, } SDL_UnlockMutex(dialog->mutex); if (worker) SDL_WaitThread(worker, NULL); - if (job->result == BONGO_CAT_OK && job->first_id[0]) { + if (job->result == BONGO_CAT_OK && job->first_id[0] && + job->first_id_installed) { SDL_GL_MakeCurrent(app->window, app->gl_context); bongo_cat_app_rescan_models(app); if (!bongo_cat_app_select_model(app, job->first_id)) { job->result = BONGO_CAT_ERROR_CUBISM; bongo_cat_error_set(&job->error, job->result, "Model imported but could not be loaded"); - } else { - const BongoCatModelEntry *entry = bongo_cat_models_find( - &app->models, job->first_id); - bongo_cat_preferences_model_cover_capture(app, entry); } } bongo_cat_preferences_import_complete(app, job->result, &job->error, diff --git a/src/ui/preferences_model.c b/src/ui/preferences_model.c index 01c472e3..a5ce403d 100644 --- a/src/ui/preferences_model.c +++ b/src/ui/preferences_model.c @@ -41,12 +41,6 @@ void bongo_cat_preferences_import_complete(BongoCatApp *app, bongo_cat_preferences_render(app->preferences); } -static bool has_nearby(const BongoCatApp *app) { - for (size_t i = 0; i < app->models.count; ++i) - if (app->models.entries[i].managed) return true; - return false; -} - static void smoke_model_behavior(BongoCatPreferences *value) { BongoCatApp *app = value->app; if (app->smoke_preference_model_select) { @@ -67,12 +61,10 @@ static void smoke_model_behavior(BongoCatPreferences *value) { } } -static void draw_models(BongoCatPreferences *value, struct nk_context *context, - bool managed) { +static void draw_models(BongoCatPreferences *value, struct nk_context *context) { for (size_t i = 0; i < value->app->models.count; ++i) { const BongoCatModelEntry *entry = &value->app->models.entries[i]; - if (entry->managed == managed) - bongo_cat_preferences_model_card(value, context, entry); + bongo_cat_preferences_model_card(value, context, entry); } } @@ -90,16 +82,8 @@ void bongo_cat_preferences_page_model(BongoCatPreferences *value, nk_layout_row_dynamic(context, MODEL_CARD_HEIGHT, columns); if (bongo_cat_preferences_model_import_card(value, context)) bongo_cat_preferences_request_model_import(app->preferences); - draw_models(value, context, false); + draw_models(value, context); context->style.window.spacing = old_spacing; - if (has_nearby(app)) { - bongo_cat_pref_section(context, tr(app, - "pages.preference.model.labels.nearbyModels", "Nearby folders")); - context->style.window.spacing = nk_vec2(14, 17); - nk_layout_row_dynamic(context, MODEL_CARD_HEIGHT, columns); - draw_models(value, context, true); - context->style.window.spacing = old_spacing; - } bongo_cat_preferences_model_covers_prune(app); if (bongo_cat_preferences_model_cover_generate_current(app)) bongo_cat_preferences_invalidate(value); diff --git a/src/ui/preferences_model_card.c b/src/ui/preferences_model_card.c index 0095d079..fe88224c 100644 --- a/src/ui/preferences_model_card.c +++ b/src/ui/preferences_model_card.c @@ -78,7 +78,6 @@ void bongo_cat_preferences_model_select(BongoCatPreferences *value, const BongoCatModelEntry *entry) { BongoCatError error = {0}; if (bongo_cat_app_select_model_with_error(value->app, entry->id, &error)) { - bongo_cat_preferences_model_cover_capture(value->app, entry); bongo_cat_preferences_invalidate(value); return; } diff --git a/src/ui/preferences_model_cover.c b/src/ui/preferences_model_cover.c index c57591a5..4dcf5adc 100644 --- a/src/ui/preferences_model_cover.c +++ b/src/ui/preferences_model_cover.c @@ -19,6 +19,7 @@ typedef struct ModelCoverSlot { typedef struct ModelCoverAttempt { BongoCatApp *app; char model_id[BONGO_CAT_ID_CAP]; + bool attempted; } ModelCoverAttempt; static ModelCoverSlot cover_cache[BONGO_CAT_MODEL_CAP]; @@ -94,7 +95,7 @@ const BongoCatModelCover *bongo_cat_preferences_model_cover( return &empty->image; } -bool bongo_cat_preferences_model_cover_capture(BongoCatApp *app, +static bool capture_model_cover(BongoCatApp *app, const BongoCatModelEntry *entry) { if (!app || !entry || !app->window || !app->gl_context || app->window_minimized) return false; @@ -142,23 +143,25 @@ static bool cover_needs_capture(const BongoCatModelEntry *entry) { (!bongo_cat_path_is_file(path) || bongo_cat_path_is_file(marker)); } -static bool cover_attempted(BongoCatApp *app, const char *model_id) { +static ModelCoverAttempt *find_cover_attempt(BongoCatApp *app, + const char *model_id) { for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) if (cover_attempts[i].app == app && - !strcmp(cover_attempts[i].model_id, model_id)) return true; - return false; + !strcmp(cover_attempts[i].model_id, model_id)) + return &cover_attempts[i]; + return NULL; } -static bool mark_cover_attempt(BongoCatApp *app, const char *model_id) { - if (cover_attempted(app, model_id)) return false; +static ModelCoverAttempt *create_cover_attempt(BongoCatApp *app, + const char *model_id) { for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) { ModelCoverAttempt *attempt = &cover_attempts[i]; if (attempt->app) continue; attempt->app = app; snprintf(attempt->model_id, sizeof(attempt->model_id), "%s", model_id); - return true; + return attempt; } - return false; + return NULL; } static void forget_cached_cover(BongoCatApp *app, @@ -181,9 +184,17 @@ bool bongo_cat_preferences_model_cover_generate_current(BongoCatApp *app) { const BongoCatModelEntry *entry = bongo_cat_models_find( &app->models, app->loaded_model); if (!entry || strcmp(app->session.active_model_id, entry->id) || - !cover_needs_capture(entry) || !mark_cover_attempt(app, entry->id)) + !cover_needs_capture(entry)) return false; - bool captured = bongo_cat_preferences_model_cover_capture(app, entry); + ModelCoverAttempt *attempt = find_cover_attempt(app, entry->id); + if (!attempt) { + attempt = create_cover_attempt(app, entry->id); + if (!attempt) return false; + return true; + } + if (attempt->attempted) return false; + attempt->attempted = true; + bool captured = capture_model_cover(app, entry); if (captured) forget_cached_cover(app, entry); else SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Cannot generate cover for model %s", entry->id); diff --git a/src/ui/preferences_model_cover.h b/src/ui/preferences_model_cover.h index 5f40a68d..1e6664e4 100644 --- a/src/ui/preferences_model_cover.h +++ b/src/ui/preferences_model_cover.h @@ -16,8 +16,6 @@ void bongo_cat_preferences_model_cover_cache_clear(BongoCatApp *app); const BongoCatModelCover *bongo_cat_preferences_model_cover( BongoCatApp *app, const BongoCatModelEntry *entry, int pixel_width, int pixel_height); -bool bongo_cat_preferences_model_cover_capture(BongoCatApp *app, - const BongoCatModelEntry *entry); bool bongo_cat_preferences_model_cover_generate_current(BongoCatApp *app); void bongo_cat_preferences_model_covers_prune(BongoCatApp *app); diff --git a/tests/test_config.c b/tests/test_config.c index f33b0f9a..5c772290 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -4,6 +4,7 @@ #include "bongo_cat/model.h" #include "bongo_cat/path.h" +#include #include #include @@ -38,16 +39,53 @@ static void check_defaults_and_validation(void) { CHECK(strcmp(session.active_model_id, "standard") == 0); settings.model.max_fps = 900; + settings.model.auto_release_seconds = NAN; settings.window.obs_background_color = BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT; + settings.window.hide_delay_seconds = NAN; session.window.scale_percent = -2.0f; - session.window.opacity_percent = 123.0f; + session.window.opacity_percent = NAN; bongo_cat_settings_validate(&settings); bongo_cat_session_validate(&session); CHECK(settings.model.max_fps == 240); + CHECK(settings.model.auto_release_seconds == + BONGO_CAT_DEFAULT_AUTO_RELEASE_SECONDS); + CHECK(settings.window.hide_delay_seconds == 0.0f); CHECK(settings.window.obs_background_color == BONGO_CAT_OBS_BACKGROUND_GREEN); CHECK(session.window.scale_percent == 10.0f); - CHECK(session.window.opacity_percent == 100.0f); + CHECK(session.window.opacity_percent == + BONGO_CAT_DEFAULT_WINDOW_OPACITY_PERCENT); +} + +static void check_override_canonicalization(void) { + BongoCatSettings settings; + bongo_cat_settings_defaults(&settings); + settings.behavior_shortcut_count = 4; + memcpy(settings.behavior_shortcuts[0].id, "motion:1", + sizeof("motion:1")); + memcpy(settings.behavior_shortcuts[0].shortcut, "Control+1", + sizeof("Control+1")); + memcpy(settings.behavior_shortcuts[0].label, "First", sizeof("First")); + memcpy(settings.behavior_shortcuts[1].id, "motion:1", + sizeof("motion:1")); + memcpy(settings.behavior_shortcuts[1].shortcut, "Control+2", + sizeof("Control+2")); + memcpy(settings.behavior_shortcuts[2].id, "empty", sizeof("empty")); + memcpy(settings.behavior_shortcuts[3].id, "motion:2", + sizeof("motion:2")); + memcpy(settings.behavior_shortcuts[3].label, "Second", sizeof("Second")); + settings.model_label_count = 2; + memcpy(settings.model_labels[0].id, "model", sizeof("model")); + memcpy(settings.model_labels[0].label, "Old", sizeof("Old")); + memcpy(settings.model_labels[1].id, "model", sizeof("model")); + memcpy(settings.model_labels[1].label, "New", sizeof("New")); + bongo_cat_settings_validate(&settings); + CHECK(settings.behavior_shortcut_count == 2); + CHECK(strcmp(settings.behavior_shortcuts[0].shortcut, "Control+2") == 0); + CHECK(strcmp(settings.behavior_shortcuts[0].label, "First") == 0); + CHECK(strcmp(settings.behavior_shortcuts[1].id, "motion:2") == 0); + CHECK(settings.model_label_count == 1); + CHECK(strcmp(settings.model_labels[0].label, "New") == 0); } static void check_shortcuts(void) { @@ -66,8 +104,9 @@ static void check_shortcuts(void) { bongo_cat_settings_validate(&settings); CHECK(strcmp(settings.shortcuts.visible_cat, "Control+B") == 0); CHECK(!settings.shortcuts.visible_preferences[0]); - CHECK(!settings.behavior_shortcuts[0].shortcut[0]); - CHECK(strcmp(settings.behavior_shortcuts[1].shortcut, "Control+M") == 0); + CHECK(settings.behavior_shortcut_count == 1); + CHECK(strcmp(settings.behavior_shortcuts[0].id, "motion:1") == 0); + CHECK(strcmp(settings.behavior_shortcuts[0].shortcut, "Control+M") == 0); CHECK(bongo_cat_settings_shortcut_conflicts(&settings, "control+b", NULL)); CHECK(!bongo_cat_settings_shortcut_conflicts(&settings, "control+b", settings.shortcuts.visible_cat)); @@ -76,6 +115,7 @@ static void check_shortcuts(void) { void test_config(void) { check_defaults_and_validation(); check_shortcuts(); + check_override_canonicalization(); const uint32_t colors[] = {0x00ff00, 0x0000ff, 0xff0000, 0xff00ff}; for (int i = 0; i < BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT; ++i) CHECK(bongo_cat_obs_background_color_rgb(i) == colors[i]); @@ -136,7 +176,7 @@ void test_config(void) { CHECK(strstr(loaded_settings.extensions_json, "\"enabled\":true") != NULL); CHECK(strcmp(bongo_cat_model_name(&loaded_settings, - &(BongoCatModelEntry){.id = "model", .display_name = "Nearby"}), + &(BongoCatModelEntry){.id = "model", .display_name = "Imported"}), "Display") == 0); CHECK(loaded_session.window.x == -321 && loaded_session.window.opacity_percent == 75.0f); @@ -152,6 +192,48 @@ void test_config(void) { CHECK(bongo_cat_session_load(unsupported, &loaded_session, &error) == BONGO_CAT_ERROR_FORMAT); + write_text(unsupported, + "{\"format\":\"bongocat/settings\",\"schemaVersion\":1," + "\"rendering\":{\"maximumFps\":\"fast\"}}"); + loaded_settings.model.max_fps = 73; + CHECK(bongo_cat_settings_load(unsupported, &loaded_settings, &error) == + BONGO_CAT_ERROR_FORMAT); + CHECK(loaded_settings.model.max_fps == 73); + write_text(unsupported, + "{\"format\":\"bongocat/settings\",\"schemaVersion\":1," + "\"rendering\":{\"maximumFps\":1.5}}"); + CHECK(bongo_cat_settings_load(unsupported, &loaded_settings, &error) == + BONGO_CAT_ERROR_FORMAT); + write_text(unsupported, + "{\"format\":\"bongocat/settings\",\"schemaVersion\":1," + "\"rendering\":{\"maximumFps\":30,\"maximumFps\":60}}"); + CHECK(bongo_cat_settings_load(unsupported, &loaded_settings, &error) == + BONGO_CAT_ERROR_FORMAT); + write_text(unsupported, + "{\"format\":\"bongocat/session\",\"schemaVersion\":1," + "\"window\":{\"visible\":123}}"); + CHECK(bongo_cat_session_load(unsupported, &loaded_session, &error) == + BONGO_CAT_ERROR_FORMAT); + + BongoCatSettings canonical_settings; + BongoCatSessionState canonical_session; + bongo_cat_settings_defaults(&canonical_settings); + bongo_cat_session_defaults(&canonical_session); + canonical_settings.model.max_fps = 900; + canonical_session.window.scale_percent = -1.0f; + CHECK(bongo_cat_settings_save(settings_path, &canonical_settings, &error) == + BONGO_CAT_OK); + CHECK(bongo_cat_session_save(session_path, &canonical_session, &error) == + BONGO_CAT_OK); + bongo_cat_settings_defaults(&loaded_settings); + bongo_cat_session_defaults(&loaded_session); + CHECK(bongo_cat_settings_load(settings_path, &loaded_settings, &error) == + BONGO_CAT_OK); + CHECK(bongo_cat_session_load(session_path, &loaded_session, &error) == + BONGO_CAT_OK); + CHECK(loaded_settings.model.max_fps == 240); + CHECK(loaded_session.window.scale_percent == 10.0f); + CHECK(bongo_cat_file_remove(settings_path)); CHECK(bongo_cat_file_remove(session_path)); CHECK(bongo_cat_file_remove(unsupported)); diff --git a/tests/test_model_import_identity.c b/tests/test_model_import_identity.c new file mode 100644 index 00000000..d44a4d4c --- /dev/null +++ b/tests/test_model_import_identity.c @@ -0,0 +1,75 @@ +#include "test_mver_support.h" + +#include "model_import.h" +#include "model_storage.h" +#include "bongo_cat/path.h" + +#include +#include +#include +#include + +static int failures; +#define CHECK(value) do { if (!(value)) { \ + fprintf(stderr, "%s:%d: check failed: %s\n", __FILE__, __LINE__, #value); \ + failures++; \ +} } while (0) + +int test_model_import_identity(void) { + failures = 0; + char *temporary = SDL_GetCurrentDirectory(); + CHECK(temporary != NULL); + if (!temporary) return failures; + char root[BONGO_CAT_PATH_CAP], data[BONGO_CAT_PATH_CAP]; + char source[BONGO_CAT_PATH_CAP], config[BONGO_CAT_PATH_CAP]; + char manifest[BONGO_CAT_PATH_CAP]; + unsigned long long nonce = (unsigned long long)SDL_GetTicksNS(); + snprintf(root, sizeof(root), "%s/bongo-cat-import-identity-%llu", + temporary, nonce); + snprintf(data, sizeof(data), "%s/bongo-cat-import-data-%llu", + temporary, nonce); + CHECK(SDL_CreateDirectory(root)); + CHECK(SDL_CreateDirectory(data)); + CHECK(child(source, sizeof(source), root, "source", false)); + CHECK(mver_fixture(source)); + CHECK(child(config, sizeof(config), source, "config.json", false)); + CHECK(child(manifest, sizeof(manifest), source, + "img/standard/cat_model/cat.model3.json", false)); + BongoCatError error = {0}; + BongoCatImportDiscovery discovered = {0}; + CHECK(bongo_cat_import_mver_discover(source, &discovered, &error) == 1); + char digest[65]; + bool placeholder = true; + CHECK(bongo_cat_import_candidate_inspect(&discovered.candidates[0], + digest, &placeholder, &error) && !placeholder); + BongoCatImportReceipt first = {0}, repeated = {0}; + CHECK(bongo_cat_import_install(config, data, &first, &error) == + BONGO_CAT_OK); + CHECK(first.count == 1 && first.installed_count == 1 && first.installed[0]); + CHECK(bongo_cat_import_install(manifest, data, &repeated, &error) == + BONGO_CAT_OK); + CHECK(repeated.count == 1 && repeated.installed_count == 0 && + !repeated.installed[0] && strcmp(first.ids[0], repeated.ids[0]) == 0); + char models_root[BONGO_CAT_PATH_CAP], adapter_metadata[BONGO_CAT_PATH_CAP]; + CHECK(child(models_root, sizeof(models_root), data, "models", false)); + BongoCatModelCatalog *installed = calloc(1, sizeof(*installed)); + CHECK(installed != NULL); + if (installed) { + CHECK(bongo_cat_models_scan(installed, models_root, false, &error) == + BONGO_CAT_OK); + CHECK(installed->count == 1 && installed->entries[0].package_schema == 2 && + installed->entries[0].source_format == BONGO_CAT_MODEL_SOURCE_MVER && + installed->entries[0].content_digest[0] != '\0' && + (installed->entries[0].capabilities & + BONGO_CAT_MODEL_CAPABILITY_MVER_PROJECTION)); + CHECK(child(adapter_metadata, sizeof(adapter_metadata), + installed->entries[0].adapter_directory, + ".bongo-cat-adapter.json", false)); + CHECK(bongo_cat_path_is_file(adapter_metadata)); + free(installed); + } + CHECK(bongo_cat_model_remove_tree(root, NULL)); + CHECK(bongo_cat_model_remove_tree(data, NULL)); + SDL_free(temporary); + return failures; +} diff --git a/tests/test_mver_import.c b/tests/test_mver_import.c index d9b743bc..2a36cf08 100644 --- a/tests/test_mver_import.c +++ b/tests/test_mver_import.c @@ -19,11 +19,9 @@ static int failures; failures++; \ } } while (0) -const char *test_mver_pointer_config(bool live2d); -bool test_mver_pointer_fixture_assets(const char *standard, const char *source); -bool test_mver_pointer_adapter(const char *adapter, bool expected_enabled); int test_preferences_text(void); int test_mver_nearby_identity(void); +int test_model_import_identity(void); static bool chord(const char *json, bool gamepad, const char *expected) { yyjson_doc *document = yyjson_read(json, strlen(json), 0); @@ -164,11 +162,10 @@ static void font_reload_defers_during_frame(void) { NULL, NULL, NULL, NULL, NULL, 1.0f)); } -static void nearby_model(void) { +static void container_discovery(void) { char root[BONGO_CAT_PATH_CAP], package[BONGO_CAT_PATH_CAP]; - char data[BONGO_CAT_PATH_CAP], mode[BONGO_CAT_PATH_CAP]; + char mode[BONGO_CAT_PATH_CAP]; char bundle[BONGO_CAT_PATH_CAP], nested_package[BONGO_CAT_PATH_CAP]; - char launch[BONGO_CAT_PATH_CAP]; char variant[BONGO_CAT_PATH_CAP], variant_model[BONGO_CAT_PATH_CAP]; char variant_img[BONGO_CAT_PATH_CAP], variant_standard[BONGO_CAT_PATH_CAP]; char variant_hand[BONGO_CAT_PATH_CAP], source_hand[BONGO_CAT_PATH_CAP]; @@ -176,20 +173,15 @@ static void nearby_model(void) { CHECK(temporary != NULL); if (!temporary) return; unsigned long long stamp = (unsigned long long)SDL_GetTicksNS(); - snprintf(root, sizeof(root), "%s/bongo-cat-nearby-%llu", temporary, - stamp); - snprintf(data, sizeof(data), "%s/bongo-cat-nearby-data-%llu", temporary, + snprintf(root, sizeof(root), "%s/bongo-cat-container-%llu", temporary, stamp); bongo_cat_model_remove_tree(root, NULL); - bongo_cat_model_remove_tree(data, NULL); CHECK(SDL_CreateDirectory(root)); - CHECK(SDL_CreateDirectory(data)); CHECK(child(package, sizeof(package), root, "app", true)); - CHECK(nearby_fixture(package)); + CHECK(mver_fixture(package)); CHECK(child(bundle, sizeof(bundle), root, "bundle", true)); - CHECK(child(launch, sizeof(launch), root, "launch", true)); CHECK(child(nested_package, sizeof(nested_package), bundle, "model", true)); - CHECK(nearby_fixture(nested_package)); + CHECK(mver_fixture(nested_package)); CHECK(child(variant, sizeof(variant), bundle, "variant", true)); CHECK(child(variant_model, sizeof(variant_model), variant, "model", true)); CHECK(child(variant_img, sizeof(variant_img), variant_model, "img", true)); @@ -199,6 +191,11 @@ static void nearby_model(void) { "img/standard/hand/0.png", false)); CHECK(child(mode, sizeof(mode), variant_hand, "0.png", false) && SDL_CopyFile(source_hand, mode)); + char normalized[BONGO_CAT_PATH_CAP]; + BongoCatError source_error = {0}; + CHECK(bongo_cat_import_source_directory(mode, normalized, + sizeof(normalized), &source_error) == BONGO_CAT_OK); + CHECK(strcmp(normalized, variant_model) == 0); BongoCatImportDiscovery discovery = {0}; BongoCatError error = {0}; CHECK(bongo_cat_import_mver_discover_exact(package, &discovery, &error) == 1); @@ -215,66 +212,35 @@ static void nearby_model(void) { if (selected_container.candidates[i].format == BONGO_CAT_IMPORT_MVER_PATCH) patch_count++; CHECK(patch_count == 1); - BongoCatApp *app = calloc(1, sizeof(*app)); - CHECK(app != NULL); - if (!app) { bongo_cat_model_remove_tree(root, NULL); SDL_free(temporary); return; } - bongo_cat_settings_defaults(&app->settings); - bongo_cat_session_defaults(&app->session); - bongo_cat_models_init(&app->models); - snprintf(app->data_root, sizeof(app->data_root), "%s", data); - snprintf(app->cache_root, sizeof(app->cache_root), "%s", data); - CHECK(bongo_cat_import_nearby_mver_root(app, package, &error) == BONGO_CAT_OK); - CHECK(app->models.count == 1); - CHECK(app->models.entries[0].managed && !app->models.entries[0].preset); - CHECK(strcmp(app->models.entries[0].display_name, "app") == 0); - CHECK(strcmp(app->models.entries[0].storage_directory, package) == 0); - CHECK(strcmp(app->session.active_model_id, app->models.entries[0].id) == 0); - char adapter_file[BONGO_CAT_PATH_CAP]; - CHECK(child(adapter_file, sizeof(adapter_file), - app->models.entries[0].adapter_directory, "resources/left-keys/KeyA.png", false)); - CHECK(bongo_cat_path_is_file(adapter_file)); - CHECK(test_mver_pointer_adapter( - app->models.entries[0].adapter_directory, true)); - BongoCatLive2DRenderOptions render = {0}; - CHECK(bongo_cat_import_render_options( - app->models.entries[0].adapter_directory, &render)); - CHECK(render.mver_projection && render.source_mirror); - CHECK(render.projection_scale > 1.986f && render.projection_scale < 1.988f); - CHECK(render.offset_y < -0.004f && render.offset_y > -0.006f); - CHECK(render.reference_width == 1400 && render.reference_height == 1400); - CHECK(render.custom_pointer_bounds && render.pointer_left == 100 && - render.pointer_top == 200 && render.pointer_right == 2100 && - render.pointer_bottom == 1400); - bongo_cat_models_init(&app->models); - CHECK(bongo_cat_import_nearby_mver_root(app, package, &error) == BONGO_CAT_OK); - CHECK(app->models.count == 1); - bongo_cat_models_init(&app->models); - CHECK(bongo_cat_import_nearby_mver_root(app, mode, &error) == BONGO_CAT_OK); - CHECK(app->models.count == 1); - CHECK(strcmp(app->models.entries[0].storage_directory, package) == 0); - bongo_cat_models_init(&app->models); - CHECK(bongo_cat_import_nearby_mver_root(app, root, &error) == BONGO_CAT_OK); - CHECK(app->models.count == 3); - bongo_cat_models_init(&app->models); - CHECK(bongo_cat_import_nearby_mver_scan(app, root, &error) == BONGO_CAT_OK); - CHECK(app->models.count == 3); - bongo_cat_models_init(&app->models); - CHECK(bongo_cat_import_nearby_mver(app, launch, &error) == BONGO_CAT_OK); - CHECK(app->models.count == 3); - for (size_t i = 0; i < app->models.count; ++i) { - CHECK(app->models.entries[i].display_name[0] != '\0'); - CHECK(app->models.entries[i].managed); - } - CHECK(child(mode, sizeof(mode), package, "config.json", false)); - CHECK(write_text(mode, test_mver_pointer_config(false))); - bongo_cat_models_init(&app->models); - CHECK(bongo_cat_import_nearby_mver_root(app, package, &error) == BONGO_CAT_OK); - CHECK(app->models.count == 1); - CHECK(test_mver_pointer_adapter( - app->models.entries[0].adapter_directory, true)); - free(app); CHECK(bongo_cat_model_remove_tree(root, NULL)); - CHECK(bongo_cat_model_remove_tree(data, NULL)); + SDL_free(temporary); +} + +static void tauri_exact_discovery(void) { + char *temporary = SDL_GetCurrentDirectory(); + CHECK(temporary != NULL); + if (!temporary) return; + char root[BONGO_CAT_PATH_CAP], model[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; + snprintf(root, sizeof(root), "%s/bongo-cat-tauri-%llu", temporary, + (unsigned long long)SDL_GetTicksNS()); + CHECK(SDL_CreateDirectory(root)); + CHECK(child(model, sizeof(model), root, "model", true)); + CHECK(child(path, sizeof(path), model, "cat.model3.json", false)); + CHECK(write_text(path, "{\"Version\":3,\"FileReferences\":{" + "\"Moc\":\"cat.moc3\",\"Textures\":[\"texture.png\"]}}")); + CHECK(child(path, sizeof(path), model, "cat.moc3", false)); + CHECK(write_text(path, "MOC3")); + CHECK(child(path, sizeof(path), model, "texture.png", false)); + char tray[BONGO_CAT_PATH_CAP]; + snprintf(tray, sizeof(tray), "%s/resources/assets/tray.png", + BONGO_CAT_NATIVE_SOURCE_DIR); + CHECK(SDL_CopyFile(tray, path)); + BongoCatImportDiscovery discovery = {0}; + BongoCatError error = {0}; + CHECK(bongo_cat_import_tauri_discover_exact(model, &discovery, &error) == 1); + CHECK(discovery.count == 1 && + discovery.candidates[0].format == BONGO_CAT_IMPORT_TAURI); + CHECK(bongo_cat_model_remove_tree(root, NULL)); SDL_free(temporary); } @@ -298,6 +264,8 @@ int main(void) { behavior_labels_add_font_glyphs(); font_reload_defers_during_frame(); failures += test_mver_nearby_identity(); - nearby_model(); + failures += test_model_import_identity(); + container_discovery(); + tauri_exact_discovery(); return failures ? 1 : 0; } diff --git a/tests/test_mver_nearby_identity.c b/tests/test_mver_nearby_identity.c index 4d543abc..bc89ff56 100644 --- a/tests/test_mver_nearby_identity.c +++ b/tests/test_mver_nearby_identity.c @@ -35,7 +35,7 @@ int test_mver_nearby_identity(void) { CHECK(child(moved, sizeof(moved), root, "moved", false)); CHECK(child(again, sizeof(again), root, "again", false)); CHECK(child(duplicate, sizeof(duplicate), root, "duplicate", false)); - CHECK(nearby_fixture(source)); + CHECK(mver_fixture(source)); BongoCatApp *app = calloc(1, sizeof(*app)); CHECK(app != NULL); if (!app) goto cleanup; @@ -45,36 +45,9 @@ int test_mver_nearby_identity(void) { snprintf(app->data_root, sizeof(app->data_root), "%s", data); snprintf(app->cache_root, sizeof(app->cache_root), "%s", data); BongoCatError error = {0}; - BongoCatImportReceipt first_install = {0}, repeated_install = {0}; - CHECK(bongo_cat_import_install(source, data, &first_install, &error) == + CHECK(bongo_cat_import_nearby_root(app, source, &error) == BONGO_CAT_OK); - CHECK(first_install.count == 1 && first_install.installed_count == 1 && - first_install.installed[0]); - CHECK(bongo_cat_import_install(source, data, &repeated_install, &error) == - BONGO_CAT_OK); - CHECK(repeated_install.count == 1 && repeated_install.installed_count == 0 && - !repeated_install.installed[0] && - strcmp(first_install.ids[0], repeated_install.ids[0]) == 0); - char custom_root[BONGO_CAT_PATH_CAP], adapter_metadata[BONGO_CAT_PATH_CAP]; - CHECK(child(custom_root, sizeof(custom_root), data, "models", false)); - BongoCatModelCatalog *installed = calloc(1, sizeof(*installed)); - CHECK(installed != NULL); - if (installed) { - CHECK(bongo_cat_models_scan(installed, custom_root, false, &error) == - BONGO_CAT_OK); - CHECK(installed->count == 1 && installed->entries[0].package_schema == 2 && - installed->entries[0].source_format == BONGO_CAT_MODEL_SOURCE_MVER && - installed->entries[0].content_digest[0] != '\0' && - (installed->entries[0].capabilities & - BONGO_CAT_MODEL_CAPABILITY_MVER_PROJECTION)); - CHECK(child(adapter_metadata, sizeof(adapter_metadata), - installed->entries[0].adapter_directory, - ".bongo-cat-adapter.json", false)); - CHECK(bongo_cat_path_is_file(adapter_metadata)); - free(installed); - } - CHECK(bongo_cat_import_nearby_mver_root(app, source, &error) == BONGO_CAT_OK); - CHECK(app->models.count == 1); + CHECK(app->models.count == 1 && app->models.entries[0].managed); char old_id[BONGO_CAT_ID_CAP]; snprintf(old_id, sizeof(old_id), "%s", app->models.entries[0].id); CHECK(bongo_cat_settings_set_model_label(&app->settings, old_id, @@ -86,8 +59,9 @@ int test_mver_nearby_identity(void) { snprintf(binding->label, sizeof(binding->label), "Custom expression"); CHECK(bongo_cat_path_rename(source, moved)); bongo_cat_models_init(&app->models); - CHECK(bongo_cat_import_nearby_mver_root(app, moved, &error) == BONGO_CAT_OK); - CHECK(app->models.count == 1); + CHECK(bongo_cat_import_nearby_root(app, moved, &error) == + BONGO_CAT_OK); + CHECK(app->models.count == 1 && app->models.entries[0].managed); const char *new_id = app->models.entries[0].id; CHECK(strcmp(old_id, new_id) != 0); CHECK(strcmp(app->session.active_model_id, old_id) == 0); @@ -98,17 +72,16 @@ int test_mver_nearby_identity(void) { CHECK(strncmp(app->settings.behavior_shortcuts[0].id, old_id, strlen(old_id)) == 0); CHECK(strcmp(app->settings.behavior_shortcuts[0].shortcut, "Alt+3") == 0); - CHECK(strcmp(app->settings.behavior_shortcuts[0].label, - "Custom expression") == 0); char ambiguous_id[BONGO_CAT_ID_CAP]; snprintf(ambiguous_id, sizeof(ambiguous_id), "%s", new_id); CHECK(bongo_cat_settings_set_model_label(&app->settings, ambiguous_id, "Must remain here")); CHECK(bongo_cat_path_rename(moved, again)); - CHECK(nearby_fixture(duplicate)); + CHECK(mver_fixture(duplicate)); bongo_cat_models_init(&app->models); - CHECK(bongo_cat_import_nearby_mver_root(app, root, &error) == BONGO_CAT_OK); - CHECK(app->models.count == 1); + CHECK(bongo_cat_import_nearby_root(app, root, &error) == + BONGO_CAT_OK); + CHECK(app->models.count == 1 && app->models.entries[0].managed); CHECK(strcmp(app->session.active_model_id, old_id) == 0); CHECK(strcmp(bongo_cat_settings_model_label(&app->settings, ambiguous_id), "Must remain here") == 0); diff --git a/tests/test_mver_support.c b/tests/test_mver_support.c index 5ae35bc7..a0bfae36 100644 --- a/tests/test_mver_support.c +++ b/tests/test_mver_support.c @@ -25,7 +25,7 @@ bool child(char *output, size_t capacity, const char *root, (!directory || SDL_CreateDirectory(output)); } -bool nearby_fixture(const char *root) { +bool mver_fixture(const char *root) { char image[BONGO_CAT_PATH_CAP], standard[BONGO_CAT_PATH_CAP]; char hand[BONGO_CAT_PATH_CAP], model[BONGO_CAT_PATH_CAP]; char path[BONGO_CAT_PATH_CAP], source[BONGO_CAT_PATH_CAP]; diff --git a/tests/test_mver_support.h b/tests/test_mver_support.h index 73160ba6..015e6c4b 100644 --- a/tests/test_mver_support.h +++ b/tests/test_mver_support.h @@ -7,6 +7,6 @@ bool write_text(const char *path, const char *text); bool child(char *output, size_t capacity, const char *root, const char *name, bool directory); -bool nearby_fixture(const char *root); +bool mver_fixture(const char *root); #endif From 5c3eb63d4de59a8054917ab05d5c88013a5896c8 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 15 Aug 2026 21:11:07 +0800 Subject: [PATCH 014/334] fix: resolve model import build errors --- CMakeLists.txt | 3 +-- src/runtime/model_import_identity.c | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 4c38dac6..09a4eab9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -64,8 +64,7 @@ add_library(bongo_cat_core STATIC src/core/mouse_tracking.c src/core/sha256.c src/core/utf8.c src/core/model_catalog.c src/core/model_package.c src/core/model_behavior.c - src/core/path.c src/core/path_io.c src/core/json.c) -target_sources(bongo_cat_core PRIVATE src/core/shortcut.c) + src/core/path.c src/core/path_io.c src/core/json.c src/core/shortcut.c) target_include_directories(bongo_cat_core PUBLIC include) target_link_libraries(bongo_cat_core PUBLIC yyjson PRIVATE bongo_cat_warnings) include(cmake/CubismAssets.cmake) diff --git a/src/runtime/model_import_identity.c b/src/runtime/model_import_identity.c index aa60ba0f..c8d1b1d5 100644 --- a/src/runtime/model_import_identity.c +++ b/src/runtime/model_import_identity.c @@ -69,9 +69,9 @@ bool bongo_cat_import_prepare_package_metadata( return false; used += (size_t)written; snprintf(item->package_id, sizeof(item->package_id), "model-%s-%s", - item->content_digest, bongo_cat_mode_name(candidate->mode)); + item->content_digest, bongo_cat_mode_name(candidate.mode)); char parent[BONGO_CAT_PATH_CAP]; - const char *name = bongo_cat_path_name(display_source(candidate, parent)); + const char *name = bongo_cat_path_name(display_source(&candidate, parent)); snprintf(item->source_name, sizeof(item->source_name), "%s", name && name[0] ? name : "Imported model"); snprintf(item->display_name, sizeof(item->display_name), "%s", From 1508f550c37ab9de180f7cd689e34ecb0b087cd9 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 15 Aug 2026 21:21:00 +0800 Subject: [PATCH 015/334] ui: separate nearby models in preferences --- resources/assets/locales/en-US.json | 1 + resources/assets/locales/zh-CN.json | 1 + resources/assets/locales/zh-TW.json | 1 + src/ui/preferences_model.c | 19 +++++++++++++++++-- 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index b830d260..2c931901 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -77,6 +77,7 @@ }, "model": { "title": "Model", + "nearbyTitle": "Nearby models", "labels": { "deleteModel": "Delete Model" }, diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index 3e594809..43a2d6bb 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -77,6 +77,7 @@ }, "model": { "title": "模型管理", + "nearbyTitle": "附近模型", "labels": { "deleteModel": "删除模型" }, diff --git a/resources/assets/locales/zh-TW.json b/resources/assets/locales/zh-TW.json index 4f3a3cef..3830bb6a 100644 --- a/resources/assets/locales/zh-TW.json +++ b/resources/assets/locales/zh-TW.json @@ -77,6 +77,7 @@ }, "model": { "title": "模型管理", + "nearbyTitle": "附近模型", "labels": { "deleteModel": "刪除模型" }, diff --git a/src/ui/preferences_model.c b/src/ui/preferences_model.c index a5ce403d..d101a8ef 100644 --- a/src/ui/preferences_model.c +++ b/src/ui/preferences_model.c @@ -61,9 +61,18 @@ static void smoke_model_behavior(BongoCatPreferences *value) { } } -static void draw_models(BongoCatPreferences *value, struct nk_context *context) { +static size_t model_count(const BongoCatPreferences *value, bool managed) { + size_t count = 0; + for (size_t i = 0; i < value->app->models.count; ++i) + if (value->app->models.entries[i].managed == managed) count++; + return count; +} + +static void draw_models(BongoCatPreferences *value, + struct nk_context *context, bool managed) { for (size_t i = 0; i < value->app->models.count; ++i) { const BongoCatModelEntry *entry = &value->app->models.entries[i]; + if (entry->managed != managed) continue; bongo_cat_preferences_model_card(value, context, entry); } } @@ -82,7 +91,13 @@ void bongo_cat_preferences_page_model(BongoCatPreferences *value, nk_layout_row_dynamic(context, MODEL_CARD_HEIGHT, columns); if (bongo_cat_preferences_model_import_card(value, context)) bongo_cat_preferences_request_model_import(app->preferences); - draw_models(value, context); + draw_models(value, context, false); + if (model_count(value, true)) { + bongo_cat_pref_section(context, + tr(app, "pages.preference.model.nearbyTitle", "Nearby models")); + nk_layout_row_dynamic(context, MODEL_CARD_HEIGHT, columns); + draw_models(value, context, true); + } context->style.window.spacing = old_spacing; bongo_cat_preferences_model_covers_prune(app); if (bongo_cat_preferences_model_cover_generate_current(app)) From 0c0168a93ffd74e851a94143b4a3070d40ed3171 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 15 Aug 2026 22:53:21 +0800 Subject: [PATCH 016/334] ui: show smooth model loading progress --- include/bongo_cat/image.h | 4 +- include/bongo_cat/model.h | 5 +- include/bongo_cat/preferences.h | 4 ++ src/live2d/cubism_bridge.cpp | 13 ++-- src/live2d/cubism_model.hpp | 6 +- src/live2d/cubism_model_load.cpp | 50 +++++++++++--- src/live2d/live2d_stub.c | 6 +- src/media/image.c | 34 +++++----- src/runtime/app.c | 1 + src/runtime/model_files.c | 10 ++- src/ui/preferences_import.c | 7 +- src/ui/preferences_model.c | 63 +++++++++++++++++- src/ui/preferences_model_card.c | 108 +++++++++++++++++++++++++++---- src/ui/preferences_state.h | 10 +++ 14 files changed, 266 insertions(+), 55 deletions(-) diff --git a/include/bongo_cat/image.h b/include/bongo_cat/image.h index d86eb68b..3ef87587 100644 --- a/include/bongo_cat/image.h +++ b/include/bongo_cat/image.h @@ -18,6 +18,7 @@ typedef struct BongoCatImageAlphaMask { unsigned char pixels[BONGO_CAT_ALPHA_MASK_SIZE * BONGO_CAT_ALPHA_MASK_SIZE]; int width, height; } BongoCatImageAlphaMask; +typedef void (*BongoCatImageProgress)(void *userdata, float progress); #ifdef __cplusplus extern "C" { @@ -34,7 +35,8 @@ unsigned int bongo_cat_image_texture_resampled(const char *path, int max_width, int max_height, float rounding, int *width, int *height, BongoCatError *error); unsigned int bongo_cat_image_texture_model(const char *path, bool direct_decode, - int *width, int *height, BongoCatImageAlphaMask *alpha, BongoCatError *error); + int *width, int *height, BongoCatImageAlphaMask *alpha, + BongoCatImageProgress progress, void *userdata, BongoCatError *error); void bongo_cat_image_make_alpha_mask(const BongoCatImage *image, BongoCatImageAlphaMask *mask); unsigned int bongo_cat_image_composite_texture(const char *base, const char *left, diff --git a/include/bongo_cat/model.h b/include/bongo_cat/model.h index e63993ad..519fedd7 100644 --- a/include/bongo_cat/model.h +++ b/include/bongo_cat/model.h @@ -93,6 +93,7 @@ BongoCatResult bongo_cat_behaviors_load(BongoCatBehaviorCatalog *catalog, typedef struct BongoCatLive2D BongoCatLive2D; typedef struct BongoCatParameterRange { float minimum, maximum, value; } BongoCatParameterRange; +typedef void (*BongoCatLive2DLoadProgress)(void *userdata, float progress); typedef struct BongoCatLive2DVisualState { float fit_scale, fit_translate_x, fit_translate_y; float visible_min_x, visible_min_y, visible_max_x, visible_max_y; @@ -123,7 +124,9 @@ BongoCatLive2D *bongo_cat_live2d_create(const char *asset_root, BongoCatError *e void bongo_cat_live2d_destroy(BongoCatLive2D *live2d); BongoCatResult bongo_cat_live2d_load(BongoCatLive2D *live2d, const char *model_dir, const char *setting_file, bool preset, - const BongoCatLive2DRenderOptions *render_options, BongoCatError *error); + const BongoCatLive2DRenderOptions *render_options, + BongoCatLive2DLoadProgress progress, void *userdata, + BongoCatError *error); bool bongo_cat_live2d_ready(const BongoCatLive2D *live2d); void bongo_cat_live2d_resize(BongoCatLive2D *live2d, int width, int height); void bongo_cat_live2d_reshape(BongoCatLive2D *live2d, int width, int height); diff --git a/include/bongo_cat/preferences.h b/include/bongo_cat/preferences.h index 28613b98..50ce910c 100644 --- a/include/bongo_cat/preferences.h +++ b/include/bongo_cat/preferences.h @@ -21,6 +21,10 @@ bool bongo_cat_preferences_shortcuts_blocked( const BongoCatPreferences *preferences); void bongo_cat_preferences_render(BongoCatPreferences *preferences); void bongo_cat_preferences_invalidate(BongoCatPreferences *preferences); +void bongo_cat_preferences_process_model_selection( + BongoCatPreferences *preferences); +void bongo_cat_preferences_model_load_progress( + BongoCatPreferences *preferences, float progress); void bongo_cat_preferences_request_model_import(BongoCatPreferences *preferences); bool bongo_cat_preferences_open_model_import(BongoCatPreferences *preferences, SDL_Window *parent); diff --git a/src/live2d/cubism_bridge.cpp b/src/live2d/cubism_bridge.cpp index f017160a..dbb82692 100644 --- a/src/live2d/cubism_bridge.cpp +++ b/src/live2d/cubism_bridge.cpp @@ -16,11 +16,9 @@ #ifdef _WIN32 #include #endif - using Csm::CubismFramework; namespace { - class Allocator final : public Csm::ICubismAllocator { public: void *Allocate(const Csm::csmSizeType size) override { return std::malloc(size); } @@ -130,7 +128,7 @@ static bool restore_previous(BongoCatLive2D *runtime, if (!previous) { runtime->model = nullptr; return true; } BongoCatError restore_error = {}; try { - if (previous->load_textures(&restore_error)) { + if (previous->load_textures(&restore_error, nullptr, nullptr)) { runtime->model = previous; return true; } @@ -176,7 +174,9 @@ extern "C" void bongo_cat_live2d_destroy(BongoCatLive2D *runtime) { extern "C" BongoCatResult bongo_cat_live2d_load(BongoCatLive2D *runtime, const char *directory, const char *setting, bool preset, - const BongoCatLive2DRenderOptions *render_options, BongoCatError *error) { + const BongoCatLive2DRenderOptions *render_options, + BongoCatLive2DLoadProgress progress, void *userdata, + BongoCatError *error) { if (!runtime) return BONGO_CAT_ERROR_ARGUMENT; bongo_cat::NativeModel *previous = runtime->model; bongo_cat::NativeModel *model = nullptr; @@ -189,7 +189,7 @@ extern "C" BongoCatResult bongo_cat_live2d_load(BongoCatLive2D *runtime, return BONGO_CAT_ERROR_MEMORY; } if (render_options) model->set_render_options(*render_options); - if (!model->load(directory, setting, preset, error)) { + if (!model->load(directory, setting, preset, progress, userdata, error)) { delete model; return error ? error->code : BONGO_CAT_ERROR_CUBISM; } @@ -199,7 +199,7 @@ extern "C" BongoCatResult bongo_cat_live2d_load(BongoCatLive2D *runtime, glFinish(); } model->reshape(runtime->width, runtime->height); - if (!model->load_textures(error)) { + if (!model->load_textures(error, progress, userdata)) { BongoCatResult result = error ? error->code : BONGO_CAT_ERROR_CUBISM; delete model; glFinish(); @@ -208,6 +208,7 @@ extern "C" BongoCatResult bongo_cat_live2d_load(BongoCatLive2D *runtime, bongo_cat_platform_trim_memory(); return result; } + if (progress) progress(userdata, 0.98f); delete previous; runtime->model = model; return BONGO_CAT_OK; diff --git a/src/live2d/cubism_model.hpp b/src/live2d/cubism_model.hpp index 740df4b8..228036ef 100644 --- a/src/live2d/cubism_model.hpp +++ b/src/live2d/cubism_model.hpp @@ -26,8 +26,10 @@ class NativeModel final : public Csm::CubismUserModel { NativeModel(); ~NativeModel() override; bool load(const char *directory, const char *setting_file, bool direct_textures, + BongoCatLive2DLoadProgress progress, void *userdata, BongoCatError *error); - bool load_textures(BongoCatError *error); + bool load_textures(BongoCatError *error, + BongoCatLive2DLoadProgress progress, void *userdata); void release_render_resources(); void resize(int width, int height); void reshape(int width, int height); @@ -72,7 +74,7 @@ class NativeModel final : public Csm::CubismUserModel { bool load_model(BongoCatError *error); void load_expressions(); void load_effects(); - void load_motions(); + void load_motions(BongoCatLive2DLoadProgress progress, void *userdata); void start_idle_motion(); ModelBounds capture_visible_bounds() const; void record_visible_state(Csm::CubismMatrix44 &projection); diff --git a/src/live2d/cubism_model_load.cpp b/src/live2d/cubism_model_load.cpp index 55d6da39..1254b644 100644 --- a/src/live2d/cubism_model_load.cpp +++ b/src/live2d/cubism_model_load.cpp @@ -21,6 +21,20 @@ namespace bongo_cat { +struct TextureProgressContext { + BongoCatLive2DLoadProgress callback; + void *userdata; + float start; + float span; +}; + +static void texture_progress(void *userdata, float progress) { + auto *context = static_cast(userdata); + if (context && context->callback) + context->callback(context->userdata, + context->start + context->span * progress); +} + NativeModel::NativeModel() { _mocConsistency = true; _motionConsistency = true; @@ -58,7 +72,8 @@ std::string NativeModel::path(const char *relative) const { } bool NativeModel::load(const char *directory, const char *setting_file, - bool direct_textures, BongoCatError *error) { + bool direct_textures, BongoCatLive2DLoadProgress progress, void *userdata, + BongoCatError *error) { if (!directory || !setting_file) return false; visual_state_ready_ = false; visual_state_ = BongoCatLive2DVisualState{}; @@ -72,6 +87,7 @@ bool NativeModel::load(const char *directory, const char *setting_file, return false; } if (!validate_model_setting_json(json, setting_file, error)) return false; + if (progress) progress(userdata, .10f); setting_ = new(std::nothrow) Csm::CubismModelSettingJson(json.data(), (Csm::csmSizeInt)json.size()); if (!setting_) { @@ -79,13 +95,17 @@ bool NativeModel::load(const char *directory, const char *setting_file, return false; } if (!load_model(error)) return false; + if (progress) progress(userdata, .25f); load_expressions(); + if (progress) progress(userdata, .31f); load_effects(); - load_motions(); + if (progress) progress(userdata, .35f); + load_motions(progress, userdata); Csm::csmMap layout; setting_->GetLayoutMap(layout); _modelMatrix->SetupFromLayout(layout); _model->SaveParameters(); + if (progress) progress(userdata, .49f); return true; } bool NativeModel::load_model(BongoCatError *error) { @@ -150,16 +170,23 @@ void NativeModel::load_effects() { _updateScheduler.SortUpdatableList(); } -void NativeModel::load_motions() { +void NativeModel::load_motions(BongoCatLive2DLoadProgress progress, + void *userdata) { idle_motion_keys_.clear(); motion_signatures_.clear(); motion_states_.clear(); motion_toggle_partners_.clear(); motion_toggle_owners_.clear(); selected_motion_keys_.clear(); + int total = 0, completed = 0; + for (int group_index = 0; group_index < setting_->GetMotionGroupCount(); + ++group_index) + total += setting_->GetMotionCount(setting_->GetMotionGroupName(group_index)); for (int group_index = 0; group_index < setting_->GetMotionGroupCount(); ++group_index) { const char *group = setting_->GetMotionGroupName(group_index); for (int i = 0; i < setting_->GetMotionCount(group); ++i) { + if (progress) progress(userdata, .35f + .14f * + (float)(++completed) / (float)(total > 0 ? total : 1)); auto bytes = read(path(setting_->GetMotionFileName(group, i))); if (bytes.empty()) continue; std::string key = std::string(group) + "_" + std::to_string(i); @@ -177,18 +204,25 @@ void NativeModel::load_motions() { _motionManager->StopAllMotions(); } -bool NativeModel::load_textures(BongoCatError *error) { +bool NativeModel::load_textures(BongoCatError *error, + BongoCatLive2DLoadProgress progress, void *userdata) { release_textures(); - textures_.assign((size_t)setting_->GetTextureCount(), 0); - texture_alpha_.assign((size_t)setting_->GetTextureCount(), {}); - for (int i = 0; i < setting_->GetTextureCount(); ++i) { + int count = setting_->GetTextureCount(); + textures_.assign((size_t)count, 0); + texture_alpha_.assign((size_t)count, {}); + TextureProgressContext texture_context = {progress, userdata, .50f, + .45f / (float)(count > 0 ? count : 1)}; + for (int i = 0; i < count; ++i) { textures_[(size_t)i] = bongo_cat_image_texture_model( path(setting_->GetTextureFileName(i)).c_str(), direct_textures_, - nullptr, nullptr, &texture_alpha_[(size_t)i], error); + nullptr, nullptr, &texture_alpha_[(size_t)i], + progress ? texture_progress : nullptr, &texture_context, error); if (!textures_[(size_t)i]) { release_textures(); return false; } + if (progress) progress(userdata, .50f + .45f * (float)(i + 1) / + (float)(count > 0 ? count : 1)); } release_renderer(); CreateRenderer((Csm::csmUint32)width_, (Csm::csmUint32)height_); diff --git a/src/live2d/live2d_stub.c b/src/live2d/live2d_stub.c index 47b33433..3cf4a94a 100644 --- a/src/live2d/live2d_stub.c +++ b/src/live2d/live2d_stub.c @@ -16,12 +16,16 @@ void bongo_cat_live2d_destroy(BongoCatLive2D *live2d) { free(live2d); } BongoCatResult bongo_cat_live2d_load(BongoCatLive2D *live2d, const char *model_dir, const char *setting_file, bool preset, - const BongoCatLive2DRenderOptions *render_options, BongoCatError *error) { + const BongoCatLive2DRenderOptions *render_options, + BongoCatLive2DLoadProgress progress, void *userdata, + BongoCatError *error) { (void)preset; (void)render_options; if (!live2d || !model_dir || !setting_file) return BONGO_CAT_ERROR_ARGUMENT; + if (progress) progress(userdata, 0.1f); if (!bongo_cat_import_manifest_valid(model_dir, setting_file, error)) return BONGO_CAT_ERROR_FORMAT; live2d->loaded = true; + if (progress) progress(userdata, 1.0f); return BONGO_CAT_OK; } diff --git a/src/media/image.c b/src/media/image.c index 61e1fafb..7dd44949 100644 --- a/src/media/image.c +++ b/src/media/image.c @@ -1,7 +1,6 @@ #include "bongo_cat/file.h" #include "bongo_cat/gl_api.h" #include "bongo_cat/image.h" - #ifdef _WIN32 #define COBJMACROS #include @@ -13,16 +12,13 @@ #include #include #include - #define BONGO_CAT_LIVE2D_EFFICIENT_TEXTURE_LIMIT 2048 - #ifdef _WIN32 static bool needs_wic_scaling(const char *path, int limit) { int width = 0, height = 0; bool known = bongo_cat_image_info(path, &width, &height); return !known || width > limit || height > limit; } - static wchar_t *wide_path(const char *path) { int count = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, path, -1, NULL, 0); @@ -33,9 +29,8 @@ static wchar_t *wide_path(const char *path) { } return wide; } - static bool wic_scaled_image(const char *path, BongoCatImage *image, - UINT max_width, UINT max_height) { + UINT max_width, UINT max_height, BongoCatImageProgress progress, void *userdata) { memset(image, 0, sizeof(*image)); HRESULT initialized = CoInitializeEx(NULL, COINIT_MULTITHREADED); bool uninitialize = initialized == S_OK || initialized == S_FALSE; @@ -84,8 +79,15 @@ static bool wic_scaled_image(const char *path, BongoCatImage *image, size_t stride = (size_t)target_width * 4; size_t bytes = stride * target_height; unsigned char *pixels = bytes <= UINT_MAX ? malloc(bytes) : NULL; - if (SUCCEEDED(result) && pixels) result = IWICFormatConverter_CopyPixels(converter, - NULL, (UINT)stride, (UINT)bytes, pixels); + if (SUCCEEDED(result) && pixels) for (UINT y = 0; + y < target_height && SUCCEEDED(result); y += 256) { + UINT rows = SDL_min(256u, target_height - y); + WICRect rect = {0, (INT)y, (INT)target_width, (INT)rows}; + result = IWICFormatConverter_CopyPixels(converter, &rect, + (UINT)stride, (UINT)(stride * rows), pixels + (size_t)y * stride); + if (progress) progress(userdata, .05f + .25f * + (float)(y + rows) / (float)target_height); + } bool ok = SUCCEEDED(result) && pixels; if (ok) { image->pixels = pixels; @@ -102,7 +104,6 @@ static bool wic_scaled_image(const char *path, BongoCatImage *image, return ok; } #endif - BongoCatResult bongo_cat_image_load(const char *path, BongoCatImage *image, BongoCatError *error) { if (!path || !image) return BONGO_CAT_ERROR_ARGUMENT; memset(image, 0, sizeof(*image)); @@ -126,7 +127,6 @@ BongoCatResult bongo_cat_image_load(const char *path, BongoCatImage *image, Bong } return BONGO_CAT_OK; } - void bongo_cat_image_free(BongoCatImage *image) { if (!image) return; if (image->surface) SDL_DestroySurface(image->surface); @@ -136,7 +136,6 @@ void bongo_cat_image_free(BongoCatImage *image) { } memset(image, 0, sizeof(*image)); } - static unsigned int upload(const BongoCatImage *image, GLuint texture, bool model_texture) { bool created = texture == 0; if (created) glGenTextures(1, &texture); @@ -165,7 +164,6 @@ static unsigned int upload(const BongoCatImage *image, GLuint texture, bool mode GL_RGBA, GL_UNSIGNED_BYTE, image->pixels); return texture; } - unsigned int bongo_cat_image_texture(const char *path, int *width, int *height, BongoCatError *error) { BongoCatImage image; if (bongo_cat_image_load(path, &image, error) != BONGO_CAT_OK) return 0; @@ -181,7 +179,7 @@ unsigned int bongo_cat_image_texture_thumbnail(const char *path, int max_width, BongoCatImage image; #ifdef _WIN32 if (max_width > 0 && max_height > 0 && wic_scaled_image(path, &image, - (UINT)max_width, (UINT)max_height)) { + (UINT)max_width, (UINT)max_height, NULL, NULL)) { GLuint texture = upload(&image, 0, false); if (width) *width = image.width; if (height) *height = image.height; @@ -219,7 +217,8 @@ unsigned int bongo_cat_image_texture_thumbnail(const char *path, int max_width, return texture; } unsigned int bongo_cat_image_texture_model(const char *path, bool direct_decode, - int *width, int *height, BongoCatImageAlphaMask *alpha, BongoCatError *error) { + int *width, int *height, BongoCatImageAlphaMask *alpha, + BongoCatImageProgress progress, void *userdata, BongoCatError *error) { BongoCatImage image; GLint hardware_limit = 0; glGetIntegerv(GL_MAX_TEXTURE_SIZE, &hardware_limit); @@ -231,7 +230,7 @@ unsigned int bongo_cat_image_texture_model(const char *path, bool direct_decode, bool scaled = needs_wic_scaling(path, decode_limit); if (!direct_decode || scaled) { if (!wic_scaled_image(path, &image, (UINT)decode_limit, - (UINT)decode_limit) && + (UINT)decode_limit, progress, userdata) && bongo_cat_image_load(path, &image, error) != BONGO_CAT_OK) return 0; } else if (bongo_cat_image_load(path, &image, error) != BONGO_CAT_OK) return 0; if (scaled) SDL_Log("Live2D texture constrained to %dx%d by a %d pixel " @@ -244,7 +243,9 @@ unsigned int bongo_cat_image_texture_model(const char *path, bool direct_decode, (void)direct_decode; if (bongo_cat_image_load(path, &image, error) != BONGO_CAT_OK) return 0; #endif + if (progress) progress(userdata, .30f); bongo_cat_image_make_alpha_mask(&image, alpha); + if (progress) progress(userdata, .60f); bongo_cat_gl_clear_errors(); GLuint texture = upload(&image, 0, true); GLenum upload_error = glGetError(); @@ -258,7 +259,7 @@ unsigned int bongo_cat_image_texture_model(const char *path, bool direct_decode, int fallback_limit = SDL_min(hardware_limit, BONGO_CAT_LIVE2D_EFFICIENT_TEXTURE_LIMIT); if (wic_scaled_image(path, &image, (UINT)fallback_limit, - (UINT)fallback_limit)) { + (UINT)fallback_limit, progress, userdata)) { bongo_cat_image_make_alpha_mask(&image, alpha); bongo_cat_gl_clear_errors(); texture = upload(&image, 0, true); @@ -280,6 +281,7 @@ unsigned int bongo_cat_image_texture_model(const char *path, bool direct_decode, if (width) *width = image.width; if (height) *height = image.height; bongo_cat_image_free(&image); + if (progress) progress(userdata, 1.0f); return texture; } diff --git a/src/runtime/app.c b/src/runtime/app.c index 62534e5f..9426e6f8 100644 --- a/src/runtime/app.c +++ b/src/runtime/app.c @@ -253,6 +253,7 @@ static void loop(BongoCatApp *app) { else if (!app->session.window.visible || app->window_minimized) { app->last_frame_ns = now; bongo_cat_memory_policy_idle(); } if (app->session.window.visible && !app->window_minimized && app->dirty) render(app, true); bongo_cat_preferences_render(app->preferences); + bongo_cat_preferences_process_model_selection(app->preferences); if (app->session.window.visible && !app->window_minimized && app->dirty) render(app, true); bongo_cat_tray_sync(app->tray); bongo_cat_window_raise_when_due(app, now); bongo_cat_config_store_update(app, now); diff --git a/src/runtime/model_files.c b/src/runtime/model_files.c index 31c2614d..e8f42a2c 100644 --- a/src/runtime/model_files.c +++ b/src/runtime/model_files.c @@ -2,6 +2,7 @@ #include "model_import.h" #include "bongo_cat/audio.h" #include "bongo_cat/overlay.h" +#include "bongo_cat/preferences.h" #include #include @@ -24,6 +25,12 @@ static void request_model_frame(BongoCatApp *app) { app->dirty = true; } +static void model_load_progress(void *userdata, float progress) { + BongoCatApp *app = userdata; + if (app && app->preferences) + bongo_cat_preferences_model_load_progress(app->preferences, progress); +} + static bool apply_model_aspect(BongoCatApp *app, const BongoCatLive2DRenderOptions *options) { if (!app || !app->window) return false; @@ -106,7 +113,8 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, } bongo_cat_live2d_reshape(app->live2d, pixel_width, pixel_height); if (bongo_cat_live2d_load(app->live2d, entry->directory, - entry->setting_file, entry->preset, &render_options, failure) != BONGO_CAT_OK) { + entry->setting_file, entry->preset, &render_options, + model_load_progress, app, failure) != BONGO_CAT_OK) { SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "%s", failure->message); if (restore_context && previous_window && previous_context && !SDL_GL_MakeCurrent(previous_window, previous_context)) diff --git a/src/ui/preferences_import.c b/src/ui/preferences_import.c index 319678ce..0326af9a 100644 --- a/src/ui/preferences_import.c +++ b/src/ui/preferences_import.c @@ -231,10 +231,6 @@ static void SDLCALL import_callback(void *userdata, const char *const *files, bool bongo_cat_preferences_import_open(BongoCatImportDialog *dialog, SDL_Window *window) { - static const SDL_DialogFileFilter filters[] = { - {"BongoCat model files", "model3.json;json;png"}, - {"All files", "*"} - }; if (!dialog || !window) return false; SDL_LockMutex(dialog->mutex); if (!dialog->active || dialog->open || dialog->busy) { @@ -245,8 +241,7 @@ bool bongo_cat_preferences_import_open(BongoCatImportDialog *dialog, dialog->window_id = SDL_GetWindowID(window); ++dialog->references; SDL_UnlockMutex(dialog->mutex); - SDL_ShowOpenFileDialog(import_callback, dialog, window, filters, - (int)(sizeof(filters) / sizeof(filters[0])), NULL, true); + SDL_ShowOpenFolderDialog(import_callback, dialog, window, NULL, false); return true; } diff --git a/src/ui/preferences_model.c b/src/ui/preferences_model.c index d101a8ef..b4822acb 100644 --- a/src/ui/preferences_model.c +++ b/src/ui/preferences_model.c @@ -10,12 +10,69 @@ #include #define MODEL_CARD_HEIGHT 214 +#define MODEL_LOAD_REPORTED_LIMIT .60f static const char *tr(BongoCatApp *app, const char *key, const char *fallback) { return bongo_cat_i18n_get(app->i18n, key, fallback); } +void bongo_cat_preferences_model_load_progress(BongoCatPreferences *value, + float progress) { + if (!value || !value->model_loading) return; + value->model_load_progress = NK_CLAMP(0.0f, + progress * MODEL_LOAD_REPORTED_LIMIT, MODEL_LOAD_REPORTED_LIMIT); + value->render_dirty = true; + uint64_t now = SDL_GetTicksNS(); + bool due = progress >= .999f || + progress - value->model_load_render_progress >= .02f || + now - value->model_load_render_ns >= 16000000ull; + if (value->window && due) { + value->model_load_render_progress = progress; + value->model_load_render_ns = now; + bongo_cat_preferences_render(value); + } +} + +static void finish_model_load_progress(BongoCatPreferences *value) { + if (!value || !value->window) { + if (value) value->model_load_progress = 1.0f; + return; + } + float start = value->model_load_progress; + for (int i = 1; i <= 10; ++i) { + float amount = (float)i / 10.0f; + value->model_load_progress = start + (1.0f - start) * amount; + value->render_dirty = true; + bongo_cat_preferences_render(value); + if (i < 10) SDL_Delay(8); + } +} + +void bongo_cat_preferences_process_model_selection(BongoCatPreferences *value) { + if (!value || !value->model_selection_pending || value->model_loading) + return; + char id[BONGO_CAT_ID_CAP]; + snprintf(id, sizeof(id), "%s", value->pending_model_id); + value->pending_model_id[0] = '\0'; + value->model_selection_pending = false; + value->model_loading = true; + value->model_load_progress = 0.0f; + value->model_load_render_progress = 0.0f; + value->model_load_render_ns = 0; + snprintf(value->loading_model_id, sizeof(value->loading_model_id), "%s", id); + BongoCatError error = {0}; + bool selected = bongo_cat_app_select_model_with_error(value->app, id, &error); + if (selected) finish_model_load_progress(value); + else value->model_load_progress = 0.0f; + value->model_loading = false; + value->loading_model_id[0] = '\0'; + if (!selected) bongo_cat_preferences_notice_show(value->app, tr(value->app, + "native.modelLoadFailed", "Unable to display this model"), true); + bongo_cat_preferences_invalidate(value); + if (value->window) bongo_cat_preferences_render(value); +} + void bongo_cat_preferences_import_complete(BongoCatApp *app, BongoCatResult result, const BongoCatError *error, size_t resolved_count, size_t installed_count) { @@ -55,7 +112,8 @@ static void smoke_model_behavior(BongoCatPreferences *value) { return; } } - if (value->smoke_behavior_open_pending && !value->font_reload_pending) { + if (value->smoke_behavior_open_pending && !value->font_reload_pending && + !value->model_selection_pending && !value->model_loading) { value->smoke_behavior_open_pending = false; bongo_cat_preferences_behavior_dialog_open(value); } @@ -100,6 +158,7 @@ void bongo_cat_preferences_page_model(BongoCatPreferences *value, } context->style.window.spacing = old_spacing; bongo_cat_preferences_model_covers_prune(app); - if (bongo_cat_preferences_model_cover_generate_current(app)) + if (!value->model_loading && + bongo_cat_preferences_model_cover_generate_current(app)) bongo_cat_preferences_invalidate(value); } diff --git a/src/ui/preferences_model_card.c b/src/ui/preferences_model_card.c index fe88224c..f6d3609e 100644 --- a/src/ui/preferences_model_card.c +++ b/src/ui/preferences_model_card.c @@ -14,6 +14,9 @@ #include #include +#define MODEL_PROGRESS_ARC_SEGMENTS 16 +#define MODEL_PROGRESS_POINT_CAP 80 + static const char *tr(BongoCatApp *app, const char *key, const char *fallback) { return bongo_cat_i18n_get(app->i18n, key, fallback); @@ -34,6 +37,80 @@ static struct nk_rect outline_bounds(struct nk_rect bounds, float thickness) { NK_MAX(1.0f, bounds.h - thickness)); } +static int progress_point(float *points, int count, float x, float y) { + if (count >= MODEL_PROGRESS_POINT_CAP) return count; + points[count * 2] = x; + points[count * 2 + 1] = y; + return count + 1; +} + +static int rounded_outline_path(struct nk_rect bounds, float rounding, + float *points) { + const float pi = 3.14159265358979323846f; + float radius = NK_CLAMP(0.0f, rounding, + NK_MIN(bounds.w, bounds.h) * .5f); + float centers_x[] = {bounds.x + bounds.w - radius, + bounds.x + bounds.w - radius, bounds.x + radius, bounds.x + radius}; + float centers_y[] = {bounds.y + radius, bounds.y + bounds.h - radius, + bounds.y + bounds.h - radius, bounds.y + radius}; + float starts[] = {-pi * .5f, 0.0f, pi * .5f, pi}; + int count = 0; + count = progress_point(points, count, bounds.x + radius, bounds.y); + for (int corner = 0; corner < 4; ++corner) { + for (int i = 0; i <= MODEL_PROGRESS_ARC_SEGMENTS; ++i) { + float angle = starts[corner] + pi * .5f * i / + MODEL_PROGRESS_ARC_SEGMENTS; + count = progress_point(points, count, + centers_x[corner] + cosf(angle) * radius, + centers_y[corner] + sinf(angle) * radius); + } + } + count = progress_point(points, count, points[0], points[1]); + return count; +} + +static void draw_progress_outline(struct nk_command_buffer *canvas, + struct nk_rect bounds, float rounding, float thickness, float progress, + struct nk_color color) { + progress = NK_CLAMP(0.0f, progress, 1.0f); + if (progress <= 0.001f) return; + if (progress >= .999f) { + nk_stroke_rect(canvas, bounds, rounding, thickness, color); + return; + } + float path[MODEL_PROGRESS_POINT_CAP * 2]; + int path_count = rounded_outline_path(bounds, rounding, path); + if (path_count < 2) return; + float lengths[MODEL_PROGRESS_POINT_CAP]; + lengths[0] = 0.0f; + for (int i = 1; i < path_count; ++i) { + float dx = path[i * 2] - path[(i - 1) * 2]; + float dy = path[i * 2 + 1] - path[(i - 1) * 2 + 1]; + lengths[i] = lengths[i - 1] + sqrtf(dx * dx + dy * dy); + } + float target = lengths[path_count - 1] * progress; + float partial[MODEL_PROGRESS_POINT_CAP * 2]; + int partial_count = progress_point(partial, 0, path[0], path[1]); + for (int i = 1; i < path_count; ++i) { + if (target >= lengths[i]) { + partial_count = progress_point(partial, partial_count, + path[i * 2], path[i * 2 + 1]); + continue; + } + float segment = lengths[i] - lengths[i - 1]; + float amount = segment > 0.0f ? + (target - lengths[i - 1]) / segment : 0.0f; + partial_count = progress_point(partial, partial_count, + path[(i - 1) * 2] + + (path[i * 2] - path[(i - 1) * 2]) * amount, + path[(i - 1) * 2 + 1] + + (path[i * 2 + 1] - path[(i - 1) * 2 + 1]) * amount); + break; + } + if (partial_count >= 2) + nk_stroke_polyline(canvas, partial, partial_count, thickness, color); +} + bool bongo_cat_preferences_model_import_card(BongoCatPreferences *value, struct nk_context *context) { struct nk_rect bounds; @@ -76,13 +153,12 @@ static void action_icon(BongoCatPreferences *value, void bongo_cat_preferences_model_select(BongoCatPreferences *value, const BongoCatModelEntry *entry) { - BongoCatError error = {0}; - if (bongo_cat_app_select_model_with_error(value->app, entry->id, &error)) { - bongo_cat_preferences_invalidate(value); - return; - } - bongo_cat_preferences_notice_show(value->app, tr(value->app, - "native.modelLoadFailed", "Unable to display this model"), true); + if (!value || !entry || value->model_selection_pending || + value->model_loading) return; + snprintf(value->pending_model_id, sizeof(value->pending_model_id), "%s", + entry->id); + value->model_selection_pending = true; + value->model_load_progress = 0.0f; bongo_cat_preferences_invalidate(value); } @@ -164,6 +240,12 @@ void bongo_cat_preferences_model_card(BongoCatPreferences *value, struct nk_context *context, const BongoCatModelEntry *entry) { BongoCatApp *app = value->app; bool selected = !strcmp(entry->id, app->session.active_model_id); + bool transitioning = value->model_selection_pending || value->model_loading; + const char *transition_id = value->model_loading ? value->loading_model_id : + value->pending_model_id; + bool transition_target = transitioning && transition_id[0] && + !strcmp(entry->id, transition_id); + bool selected_visual = selected && !transitioning; struct nk_rect bounds; if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return; bounds.y += 5.0f; bounds.h += 1.0f; @@ -175,7 +257,7 @@ void bongo_cat_preferences_model_card(BongoCatPreferences *value, hover ? 1.0f : 0.0f, 250.0f, BONGO_CAT_UI_EASE_SWIFT); bounds.y -= 3.0f * lift; struct nk_command_buffer *canvas = nk_window_get_canvas(context); - if (selected && p.effects) bongo_cat_ui_paint_shadow(context, bounds, + if (selected_visual && p.effects) bongo_cat_ui_paint_shadow(context, bounds, 14, 0, 8, 20, 0, nk_rgba(p.pink.r, p.pink.g, p.pink.b, 89)); else if (hover && p.effects) bongo_cat_ui_paint_shadow(context, bounds, 14, 0, 10, 28, 0, nk_rgba(p.accent.r, p.accent.g, p.accent.b, 46)); @@ -191,14 +273,18 @@ void bongo_cat_preferences_model_card(BongoCatPreferences *value, bool name_hover = bongo_cat_preferences_model_name_draw(value, context, canvas, entry, name_bounds, p); bool action_hover = false; - draw_actions(value, context, canvas, bounds, entry, p, selected, + draw_actions(value, context, canvas, bounds, entry, p, selected_visual, &action_hover); - float outline_width = selected ? 2.0f : 1.0f; + float outline_width = selected_visual ? 2.0f : 1.0f; struct nk_rect outline = outline_bounds(bounds, outline_width); - struct nk_color outline_color = selected ? p.pink : + float progress = selected_visual ? 1.0f : transition_target ? + value->model_load_progress : 0.0f; + struct nk_color outline_color = progress >= .999f ? p.pink : bongo_cat_ui_color_mix(p.border_subtle, p.accent, lift); nk_stroke_rect(canvas, outline, 14.0f - outline_width * .5f, outline_width, outline_color); + draw_progress_outline(canvas, outline, 14.0f - outline_width * .5f, + 2.0f, progress, p.pink); if (!name_hover && (action_hover || hover)) bongo_cat_ui_cursor_hover_rect(context, bounds, BONGO_CAT_UI_CURSOR_POINTER); diff --git a/src/ui/preferences_state.h b/src/ui/preferences_state.h index 872b465d..92796685 100644 --- a/src/ui/preferences_state.h +++ b/src/ui/preferences_state.h @@ -45,6 +45,13 @@ struct BongoCatPreferences { bool render_dirty; bool font_reload_pending; bool smoke_behavior_open_pending; + bool model_selection_pending; + bool model_loading; + float model_load_progress; + float model_load_render_progress; + uint64_t model_load_render_ns; + char pending_model_id[BONGO_CAT_ID_CAP]; + char loading_model_id[BONGO_CAT_ID_CAP]; uint64_t last_render_ns; float pending_raster_scale; uint64_t raster_retry_ns; @@ -102,6 +109,9 @@ void bongo_cat_preferences_live_resize_uninstall(BongoCatPreferences *value); void bongo_cat_preferences_record_frame(BongoCatPreferences *value); void bongo_cat_preferences_assets_load(BongoCatPreferences *value); void bongo_cat_preferences_support_assets_load(BongoCatPreferences *value); +void bongo_cat_preferences_process_model_selection(BongoCatPreferences *value); +void bongo_cat_preferences_model_load_progress(BongoCatPreferences *value, + float progress); void bongo_cat_preferences_assets_clear(BongoCatPreferences *value); void bongo_cat_preferences_model_cover_cache_clear(BongoCatApp *app); void bongo_cat_preferences_smoke_frame(BongoCatPreferences *value); From 0890ff31f78c5d551d690f0651d62c38eb47befa Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 16 Aug 2026 00:21:23 +0800 Subject: [PATCH 017/334] ui: soften model selection completion --- src/ui/preferences_model_card.c | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/ui/preferences_model_card.c b/src/ui/preferences_model_card.c index f6d3609e..2b21d423 100644 --- a/src/ui/preferences_model_card.c +++ b/src/ui/preferences_model_card.c @@ -16,6 +16,7 @@ #define MODEL_PROGRESS_ARC_SEGMENTS 16 #define MODEL_PROGRESS_POINT_CAP 80 +#define MODEL_SELECTION_FADE_MS 240.0f static const char *tr(BongoCatApp *app, const char *key, const char *fallback) { @@ -255,12 +256,21 @@ void bongo_cat_preferences_model_card(BongoCatPreferences *value, snprintf(animation_id, sizeof(animation_id), "model-hover-%s", entry->id); float lift = bongo_cat_ui_animate_eased(context, animation_id, hover ? 1.0f : 0.0f, 250.0f, BONGO_CAT_UI_EASE_SWIFT); + char selection_animation_id[BONGO_CAT_ID_CAP + 28]; + snprintf(selection_animation_id, sizeof(selection_animation_id), + "model-selected-%s", entry->id); + float selection_amount = bongo_cat_ui_animate_eased(context, + selection_animation_id, selected_visual ? 1.0f : 0.0f, + MODEL_SELECTION_FADE_MS, BONGO_CAT_UI_EASE_STANDARD); bounds.y -= 3.0f * lift; struct nk_command_buffer *canvas = nk_window_get_canvas(context); - if (selected_visual && p.effects) bongo_cat_ui_paint_shadow(context, bounds, - 14, 0, 8, 20, 0, nk_rgba(p.pink.r, p.pink.g, p.pink.b, 89)); - else if (hover && p.effects) bongo_cat_ui_paint_shadow(context, bounds, - 14, 0, 10, 28, 0, nk_rgba(p.accent.r, p.accent.g, p.accent.b, 46)); + if (selection_amount > .001f && p.effects) + bongo_cat_ui_paint_shadow(context, bounds, 14, 0, 8, 20, 0, + bongo_cat_ui_color_alpha(nk_rgba(p.pink.r, p.pink.g, + p.pink.b, 89), selection_amount)); + else if (!selected_visual && hover && p.effects) + bongo_cat_ui_paint_shadow(context, bounds, 14, 0, 10, 28, 0, + nk_rgba(p.accent.r, p.accent.g, p.accent.b, 46)); nk_fill_rect(canvas, bounds, 14, p.surface); float preview_height = NK_MIN(128.0f, bounds.w * 354.0f / 612.0f); struct nk_rect preview = nk_rect(bounds.x + 1, bounds.y + 1, @@ -275,12 +285,14 @@ void bongo_cat_preferences_model_card(BongoCatPreferences *value, bool action_hover = false; draw_actions(value, context, canvas, bounds, entry, p, selected_visual, &action_hover); - float outline_width = selected_visual ? 2.0f : 1.0f; + float outline_width = 1.0f + selection_amount; struct nk_rect outline = outline_bounds(bounds, outline_width); float progress = selected_visual ? 1.0f : transition_target ? value->model_load_progress : 0.0f; struct nk_color outline_color = progress >= .999f ? p.pink : - bongo_cat_ui_color_mix(p.border_subtle, p.accent, lift); + bongo_cat_ui_color_mix( + bongo_cat_ui_color_mix(p.border_subtle, p.accent, lift), + p.pink, selection_amount); nk_stroke_rect(canvas, outline, 14.0f - outline_width * .5f, outline_width, outline_color); draw_progress_outline(canvas, outline, 14.0f - outline_width * .5f, From aeff5d1716fd1424a8c12f45f3d7bd00b57a4b39 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 16 Aug 2026 09:54:54 +0800 Subject: [PATCH 018/334] runtime: harden model switching flow --- src/runtime/model_files.c | 36 +++++++++++++++++++-------------- src/ui/preferences_model_card.c | 5 +++-- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/runtime/model_files.c b/src/runtime/model_files.c index e8f42a2c..e045a1fc 100644 --- a/src/runtime/model_files.c +++ b/src/runtime/model_files.c @@ -14,12 +14,16 @@ static void select_model_state(BongoCatApp *app, const BongoCatModelEntry *entry app->loaded_mode = entry->mode; bongo_cat_gamepads_set_enabled(app, entry->mode == BONGO_CAT_MODE_GAMEPAD); } -static void request_model_frame(BongoCatApp *app) { +static void request_model_frame(BongoCatApp *app, bool reveal) { if (!app) return; - if (app->window && app->session.window.visible) { - if (SDL_GetWindowFlags(app->window) & SDL_WINDOW_HIDDEN) + if (app->window) { + SDL_WindowFlags flags = SDL_GetWindowFlags(app->window); + bool hidden = (flags & (SDL_WINDOW_HIDDEN | SDL_WINDOW_MINIMIZED)) != 0; + if (reveal && (!app->session.window.visible || hidden || + app->hover_hidden)) bongo_cat_window_set_visible(app, true); - else bongo_cat_window_clamp_to_display(app); + else if (app->session.window.visible) + bongo_cat_window_clamp_to_display(app); } bongo_cat_window_mark_hit_dirty(app); app->dirty = true; @@ -53,13 +57,13 @@ static bool apply_model_aspect(BongoCatApp *app, } static void commit_model(BongoCatApp *app, - const BongoCatModelEntry *entry, bool force_refresh) { + const BongoCatModelEntry *entry, bool force_refresh, bool reveal) { bool changed = strcmp(app->session.active_model_id, entry->id) != 0 || app->loaded_mode != entry->mode; if (!changed && !force_refresh) return; select_model_state(app, entry); app->model_selection_serial++; - request_model_frame(app); + request_model_frame(app, reveal); } bool bongo_cat_app_select_model_with_error(BongoCatApp *app, @@ -79,9 +83,10 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, return false; } if (app->loaded_model[0] && strcmp(app->loaded_model, entry->id) == 0) { - commit_model(app, entry, false); + commit_model(app, entry, false, false); return true; } + bool replacing_model = app->loaded_model[0] != '\0'; BongoCatError optional = {0}; BongoCatBehaviorCatalog *behaviors = calloc(1, sizeof(*behaviors)); if (!behaviors) { @@ -122,7 +127,7 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, "Cannot restore the previous OpenGL context: %s", SDL_GetError()); free(behaviors); if (!bongo_cat_live2d_ready(app->live2d)) app->loaded_model[0] = '\0'; - request_model_frame(app); + request_model_frame(app, false); return false; } BongoCatParameterRange pointer_x, pointer_y; @@ -134,12 +139,6 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, app->model_render_options = render_options; bongo_cat_app_reset_pointer_tracking(app); bongo_cat_live2d_set_render_options(app->live2d, &render_options); - bool geometry_changed = apply_model_aspect(app, &render_options); - if (app->window) { - if (geometry_changed) SDL_SyncWindow(app->window); - SDL_GetWindowSizeInPixels(app->window, &pixel_width, &pixel_height); - bongo_cat_live2d_reshape(app->live2d, pixel_width, pixel_height); - } app->behaviors = *behaviors; free(behaviors); optional = (BongoCatError){0}; @@ -151,11 +150,18 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, } snprintf(app->loaded_model, sizeof(app->loaded_model), "%s", entry->id); app->pointer_known = false; + bool geometry_changed = apply_model_aspect(app, &render_options); + if (app->window) { + if (geometry_changed) SDL_SyncWindow(app->window); + SDL_GetWindowSizeInPixels(app->window, &pixel_width, &pixel_height); + } bongo_cat_live2d_resize(app->live2d, pixel_width, pixel_height); bongo_cat_audio_stop(app->audio); - commit_model(app, entry, true); + commit_model(app, entry, true, replacing_model); bongo_cat_app_reapply_input(app); bongo_cat_app_apply_mouse(app); + /* Do not leave the previous frame cropped during the UI completion pass. */ + if (replacing_model) bongo_cat_app_render_now(app); if (restore_context && previous_window && previous_context && !SDL_GL_MakeCurrent(previous_window, previous_context)) SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, diff --git a/src/ui/preferences_model_card.c b/src/ui/preferences_model_card.c index 2b21d423..4dc227f0 100644 --- a/src/ui/preferences_model_card.c +++ b/src/ui/preferences_model_card.c @@ -154,8 +154,9 @@ static void action_icon(BongoCatPreferences *value, void bongo_cat_preferences_model_select(BongoCatPreferences *value, const BongoCatModelEntry *entry) { - if (!value || !entry || value->model_selection_pending || - value->model_loading) return; + if (!value || !entry) return; + if (value->model_loading && !strcmp(entry->id, value->loading_model_id)) + return; snprintf(value->pending_model_id, sizeof(value->pending_model_id), "%s", entry->id); value->model_selection_pending = true; From e584ebe12b64059d8b38d3a3d3db98bad989baee Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 16 Aug 2026 10:29:37 +0800 Subject: [PATCH 019/334] Centralize application version in CMake --- CMakeLists.txt | 14 +++++++------- README.md | 4 ++-- cmake/Version.cmake | 3 +++ cmake/version.h.in | 6 ++++++ cmake/{windows.manifest => windows.manifest.in} | 2 +- include/bongo_cat/common.h | 3 ++- 6 files changed, 21 insertions(+), 11 deletions(-) create mode 100644 cmake/Version.cmake create mode 100644 cmake/version.h.in rename cmake/{windows.manifest => windows.manifest.in} (86%) diff --git a/CMakeLists.txt b/CMakeLists.txt index 09a4eab9..0b4fed70 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,6 @@ cmake_minimum_required(VERSION 3.24) -project(BongoCat VERSION 0.1.0 DESCRIPTION "BongoCat" HOMEPAGE_URL "https://bongocat.pet" LANGUAGES C CXX) +project(BongoCat VERSION 2.0.0 DESCRIPTION "BongoCat" HOMEPAGE_URL "https://bongocat.pet" LANGUAGES C CXX) +include(cmake/Version.cmake) include(FetchContent) include(CTest) option(BONGO_CAT_FETCH_DEPS "Download pinned open-source dependencies" ON) @@ -14,7 +15,6 @@ set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) include(cmake/ReleaseOptimize.cmake) - add_library(bongo_cat_warnings INTERFACE) if(MSVC) target_compile_options(bongo_cat_warnings INTERFACE /W4 /permissive- /utf-8) @@ -54,7 +54,6 @@ elseif(BONGO_CAT_REQUIRE_CUBISM) else() message(WARNING "Cubism SDK missing; building the diagnostic backend") endif() - add_library(bongo_cat_core STATIC src/core/config_defaults.c src/core/config_names.c src/core/config_fields.c src/core/config_io.c src/core/config_settings_save.c src/core/config_file.c src/core/config_session_io.c @@ -65,7 +64,7 @@ add_library(bongo_cat_core STATIC src/core/model_catalog.c src/core/model_package.c src/core/model_behavior.c src/core/path.c src/core/path_io.c src/core/json.c src/core/shortcut.c) -target_include_directories(bongo_cat_core PUBLIC include) +target_include_directories(bongo_cat_core PUBLIC "${BONGO_CAT_GENERATED_INCLUDE_DIR}" include) target_link_libraries(bongo_cat_core PUBLIC yyjson PRIVATE bongo_cat_warnings) include(cmake/CubismAssets.cmake) if(WIN32) @@ -75,7 +74,7 @@ endif() include(cmake/RuntimeSources.cmake) add_library(bongo_cat_runtime STATIC ${BONGO_CAT_RUNTIME_SOURCES} ${BONGO_CAT_LIVE2D_SOURCES}) -target_include_directories(bongo_cat_runtime PUBLIC include) +target_include_directories(bongo_cat_runtime PUBLIC "${BONGO_CAT_GENERATED_INCLUDE_DIR}" include) target_include_directories(bongo_cat_runtime SYSTEM PRIVATE ${BONGO_CAT_STB_INCLUDE_DIR} ${BONGO_CAT_NUKLEAR_INCLUDE_DIR}) target_link_libraries(bongo_cat_runtime PRIVATE bongo_cat_warnings) @@ -215,7 +214,8 @@ if(WIN32) add_custom_target(bongo_cat_asset_pack DEPENDS "${BONGO_CAT_ASSET_PACK}") file(TO_CMAKE_PATH "${BONGO_CAT_ASSET_PACK}" BONGO_CAT_ASSET_PACK_RC) file(TO_CMAKE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/icon.ico" BONGO_CAT_ICON_RC) - set(BONGO_CAT_WINDOWS_MANIFEST "${CMAKE_CURRENT_SOURCE_DIR}/cmake/windows.manifest") + set(BONGO_CAT_WINDOWS_MANIFEST "${CMAKE_CURRENT_BINARY_DIR}/windows.manifest") + configure_file(cmake/windows.manifest.in "${BONGO_CAT_WINDOWS_MANIFEST}" @ONLY) if(MSVC) set(BONGO_CAT_MANIFEST_RESOURCE "") target_link_options(bongo_cat PRIVATE "/MANIFEST:EMBED" @@ -264,7 +264,7 @@ if(BUILD_TESTING) add_test(NAME ui COMMAND bongo_cat_ui_tests) add_executable(bongo_cat_app_state_tests tests/test_app_state.c src/core/app_state.c) target_link_libraries(bongo_cat_app_state_tests PRIVATE bongo_cat_warnings) - target_include_directories(bongo_cat_app_state_tests PRIVATE include) + target_include_directories(bongo_cat_app_state_tests PRIVATE "${BONGO_CAT_GENERATED_INCLUDE_DIR}" include) add_test(NAME app-state COMMAND bongo_cat_app_state_tests) set(BONGO_CAT_MVER_IMPORT_TEST_SOURCES tests/test_mver_import.c diff --git a/README.md b/README.md index d53ae671..00dde4ad 100644 --- a/README.md +++ b/README.md @@ -154,8 +154,8 @@ cmake --build build-cubism --config Release --target package ``` Release archives use the conventional product-version-platform-architecture -format, for example `BongoCat-0.1.0-windows-x64.zip`. The executable inside -keeps the stable `BongoCat.exe` name so upgrades do not break shortcuts. +format. The executable inside keeps the stable `BongoCat.exe` name so upgrades +do not break shortcuts. ### Diagnostic or Unix build diff --git a/cmake/Version.cmake b/cmake/Version.cmake new file mode 100644 index 00000000..f825f30c --- /dev/null +++ b/cmake/Version.cmake @@ -0,0 +1,3 @@ +set(BONGO_CAT_GENERATED_INCLUDE_DIR "${CMAKE_CURRENT_BINARY_DIR}/generated") +configure_file(cmake/version.h.in + "${BONGO_CAT_GENERATED_INCLUDE_DIR}/bongo_cat/version.h" @ONLY) diff --git a/cmake/version.h.in b/cmake/version.h.in new file mode 100644 index 00000000..a9901a32 --- /dev/null +++ b/cmake/version.h.in @@ -0,0 +1,6 @@ +#ifndef BONGO_CAT_VERSION_H +#define BONGO_CAT_VERSION_H + +#define BONGO_CAT_VERSION "@PROJECT_VERSION@" + +#endif diff --git a/cmake/windows.manifest b/cmake/windows.manifest.in similarity index 86% rename from cmake/windows.manifest rename to cmake/windows.manifest.in index 18b8f4cd..e7496d4d 100644 --- a/cmake/windows.manifest +++ b/cmake/windows.manifest.in @@ -1,6 +1,6 @@ - diff --git a/include/bongo_cat/common.h b/include/bongo_cat/common.h index 394e453c..31470473 100644 --- a/include/bongo_cat/common.h +++ b/include/bongo_cat/common.h @@ -5,6 +5,8 @@ #include #include +#include "bongo_cat/version.h" + #define BONGO_CAT_NAME "BongoCat" #define BONGO_CAT_PET_WINDOW_TITLE "BongoCat - Pet" #define BONGO_CAT_SETTINGS_WINDOW_TITLE "BongoCat - Settings" @@ -13,7 +15,6 @@ #define BONGO_CAT_APP_ID "com.bongocat.desktop" #define BONGO_CAT_NAME_W L"BongoCat" #define BONGO_CAT_PET_WINDOW_TITLE_W L"BongoCat - Pet" -#define BONGO_CAT_VERSION "0.1.0" #define BONGO_CAT_PATH_CAP 1024 #define BONGO_CAT_ID_CAP 128 #define BONGO_CAT_SHORTCUT_CAP 128 From a8c14ae0b5acfe94d1a7c3517548ebf14d0fffab Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 16 Aug 2026 12:36:07 +0800 Subject: [PATCH 020/334] ui: order preset models in preferences --- src/ui/preferences_model.c | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/ui/preferences_model.c b/src/ui/preferences_model.c index b4822acb..643d55d9 100644 --- a/src/ui/preferences_model.c +++ b/src/ui/preferences_model.c @@ -126,12 +126,23 @@ static size_t model_count(const BongoCatPreferences *value, bool managed) { return count; } +static int preset_model_order(const BongoCatModelEntry *entry) { + if (!entry || !entry->preset) return 3; + if (!strcmp(entry->id, "standard")) return 0; + if (!strcmp(entry->id, "keyboard")) return 1; + if (!strcmp(entry->id, "gamepad")) return 2; + return 3; +} + static void draw_models(BongoCatPreferences *value, struct nk_context *context, bool managed) { - for (size_t i = 0; i < value->app->models.count; ++i) { - const BongoCatModelEntry *entry = &value->app->models.entries[i]; - if (entry->managed != managed) continue; - bongo_cat_preferences_model_card(value, context, entry); + for (int order = 0; order <= 3; ++order) { + for (size_t i = 0; i < value->app->models.count; ++i) { + const BongoCatModelEntry *entry = &value->app->models.entries[i]; + if (entry->managed != managed || preset_model_order(entry) != order) + continue; + bongo_cat_preferences_model_card(value, context, entry); + } } } From 953fda44aba08a5e2daaa8f1aeb2956d3ae8fda1 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 16 Aug 2026 16:13:59 +0800 Subject: [PATCH 021/334] refactor: organize native sources and fix validation issues --- .github/workflows/ci.yml | 11 +- CMakeLists.txt | 195 +-------------- README.md | 37 ++- cmake/ApplicationResources.cmake | 71 ++++++ cmake/CheckLines.cmake | 9 +- cmake/PlatformRuntime.cmake | 45 ++++ cmake/RuntimeSources.cmake | 236 +++++++++++++----- cmake/Tests.cmake | 82 ++++++ cmake/ValidationTools.cmake | 52 ++++ docs/code-organization.md | 57 +++++ resources/assets/locales/de-DE.json | 2 + resources/assets/locales/es-ES.json | 2 + resources/assets/locales/fr-FR.json | 2 + resources/assets/locales/ja-JP.json | 2 + resources/assets/locales/ko-KR.json | 2 + resources/assets/locales/pt-BR.json | 2 + resources/assets/locales/ru-RU.json | 2 + src/live2d/live2d_stub.c | 2 +- src/platform/{ => common}/memory.c | 0 src/platform/{ => linux}/linux.c | 0 src/platform/{ => linux}/linux_internal.h | 0 src/platform/{ => linux}/linux_menu.c | 0 src/platform/{ => linux}/linux_x11.c | 0 src/platform/{ => macos}/macos.m | 0 src/platform/{ => macos}/macos_input.m | 0 src/platform/{ => macos}/macos_internal.h | 0 src/platform/{ => macos}/macos_keys.h | 0 src/platform/{ => macos}/macos_keys.m | 0 src/platform/{ => macos}/macos_menu.m | 0 src/platform/{ => windows}/windows.c | 0 src/platform/{ => windows}/windows_assets.c | 0 .../{ => windows}/windows_borderless.c | 0 .../{ => windows}/windows_borderless.h | 0 src/platform/{ => windows}/windows_capture.c | 0 src/platform/{ => windows}/windows_capture.h | 0 .../{ => windows}/windows_capture_probe.c | 0 .../{ => windows}/windows_diagnostics.c | 0 .../{ => windows}/windows_diagnostics.h | 0 .../{ => windows}/windows_direct_input.c | 0 .../{ => windows}/windows_direct_input.h | 0 .../{ => windows}/windows_directory.c | 0 src/platform/{ => windows}/windows_input.c | 0 src/platform/{ => windows}/windows_input.h | 0 src/platform/{ => windows}/windows_keys.c | 0 src/platform/{ => windows}/windows_keys.h | 0 src/platform/{ => windows}/windows_layered.c | 0 src/platform/{ => windows}/windows_layered.h | 0 src/platform/{ => windows}/windows_menu.c | 2 +- src/platform/{ => windows}/windows_pointer.c | 0 src/platform/{ => windows}/windows_popup.c | 0 src/platform/{ => windows}/windows_popup.h | 0 src/platform/{ => windows}/windows_startup.c | 0 src/platform/{ => windows}/windows_startup.h | 0 src/platform/{ => windows}/windows_tray.c | 0 src/platform/{ => windows}/windows_tray.h | 0 src/{core => platform/windows}/windows_utf8.c | 0 src/{core => platform/windows}/windows_utf8.h | 0 src/runtime/{ => diagnostics}/frame_audit.c | 0 .../frame_presentation_audit.c | 6 +- src/runtime/{ => diagnostics}/live2d_audit.c | 0 .../{ => diagnostics}/live2d_viewer_audit.c | 0 .../{ => diagnostics}/live2d_visual_audit.c | 0 src/runtime/{ => input}/gamepad.c | 0 src/runtime/{ => input}/mouse.c | 0 src/runtime/{ => input}/mouse_mapping.c | 0 src/runtime/{ => input}/shortcuts.c | 0 src/runtime/{ => lifecycle}/app.c | 0 src/runtime/{ => lifecycle}/assets.c | 0 src/runtime/{ => lifecycle}/config_store.c | 0 src/runtime/{ => lifecycle}/frame_clock.c | 0 src/runtime/{ => lifecycle}/memory_policy.c | 0 src/runtime/{ => lifecycle}/runtime_flow.c | 0 src/runtime/{ => lifecycle}/startup.c | 0 src/runtime/{ => lifecycle}/storage_paths.c | 0 src/runtime/{ => lifecycle}/storage_paths.h | 0 src/runtime/{ => model/import}/model_import.c | 0 src/runtime/{ => model/import}/model_import.h | 0 .../{ => model/import}/model_import_digest.c | 0 .../import}/model_import_discovery.c | 0 .../{ => model/import}/model_import_format.c | 0 .../import}/model_import_identity.c | 0 .../import}/model_import_metadata.c | 0 .../{ => model/import}/model_import_package.c | 0 .../{ => model/import}/model_import_report.c | 0 .../{ => model/import}/model_import_scan.c | 0 .../{ => model/import}/model_import_source.c | 0 .../{ => model/import}/model_import_tauri.c | 0 .../import/mver}/model_import_mver.c | 0 .../import/mver}/model_import_mver_effect.c | 0 .../import/mver}/model_import_mver_image.c | 0 .../import/mver}/model_import_mver_internal.h | 0 .../import/mver}/model_import_mver_labels.c | 0 .../import/mver}/model_import_mver_metadata.c | 0 .../import/mver}/model_import_mver_motion.c | 0 .../import/mver}/model_import_mver_patch.c | 0 .../import/mver}/model_import_mver_shortcut.c | 0 .../import/nearby}/model_import_nearby.c | 0 .../nearby}/model_import_nearby_cache.c | 0 .../nearby}/model_import_nearby_internal.h | 0 .../nearby}/model_import_nearby_signature.c | 0 .../{ => model}/model_catalog_runtime.c | 0 src/runtime/{ => model}/model_files.c | 0 src/runtime/{ => model}/model_storage.c | 0 src/runtime/{ => model}/model_storage.h | 0 src/runtime/{ => model}/model_update.c | 0 src/runtime/{ => shell}/modal_frame.c | 0 src/runtime/{ => shell}/modal_frame.h | 0 src/runtime/{ => shell}/tray.c | 0 src/runtime/{ => shell}/window.c | 0 src/runtime/{ => shell}/window_background.c | 2 +- src/runtime/{ => shell}/window_display.c | 0 src/runtime/{ => shell}/window_drag.c | 0 src/runtime/{ => shell}/window_geometry.c | 0 src/runtime/{ => shell}/window_hit.c | 0 src/runtime/{ => shell}/window_menu.h | 0 src/runtime/{ => shell}/window_menu_actions.c | 2 +- .../{ => shell}/window_menu_behavior.c | 0 src/runtime/{ => shell}/window_menu_preview.c | 0 src/runtime/{ => shell}/window_wheel.c | 0 src/ui/{ => backend}/nuklear_config.h | 0 src/ui/{ => backend}/nuklear_impl.c | 0 src/ui/{ => backend}/ui_backend.c | 0 src/ui/{ => backend}/ui_backend.h | 0 src/ui/{ => backend}/ui_cursor.c | 0 src/ui/{ => backend}/ui_input.c | 0 src/ui/{ => backend}/ui_resize_cache.c | 0 src/ui/{ => backend}/ui_scale.c | 0 src/ui/{ => backend}/ui_tooltip.c | 0 src/ui/{ => backend}/ui_tooltip.h | 0 src/ui/{ => preferences}/preferences.c | 0 src/ui/{ => preferences}/preferences_about.c | 0 .../preferences_about_community.c | 0 .../preferences_about_community.h | 0 src/ui/{ => preferences}/preferences_assets.c | 0 .../preferences_behavior_dialog.c | 0 .../preferences_behavior_rename.c | 0 .../preferences_behavior_row.c | 0 src/ui/{ => preferences}/preferences_combo.c | 0 .../{ => preferences}/preferences_controls.c | 0 .../{ => preferences}/preferences_controls.h | 0 src/ui/{ => preferences}/preferences_dialog.c | 0 src/ui/{ => preferences}/preferences_fonts.c | 0 src/ui/{ => preferences}/preferences_fonts.h | 0 src/ui/{ => preferences}/preferences_import.c | 2 +- .../{ => preferences}/preferences_internal.h | 0 .../{ => preferences}/preferences_language.c | 0 .../preferences_live_resize.c | 0 src/ui/{ => preferences}/preferences_model.c | 0 .../preferences_model_card.c | 91 +------ .../preferences_model_card.h | 0 .../preferences_model_card_paint.c | 88 +++++++ .../preferences_model_card_paint.h | 12 + .../preferences_model_cover.c | 2 +- .../preferences_model_cover.h | 0 .../preferences_model_glyphs.c | 0 .../preferences_model_glyphs.h | 0 .../preferences_model_rename.c | 0 src/ui/{ => preferences}/preferences_notice.c | 0 src/ui/{ => preferences}/preferences_notice.h | 0 .../{ => preferences}/preferences_overlay.c | 0 .../{ => preferences}/preferences_overlay.h | 0 src/ui/{ => preferences}/preferences_pages.c | 2 +- src/ui/{ => preferences}/preferences_render.c | 0 src/ui/{ => preferences}/preferences_scale.c | 0 .../{ => preferences}/preferences_scrollbar.c | 0 .../{ => preferences}/preferences_scrollbar.h | 0 .../preferences_shortcut_clear.c | 0 .../preferences_shortcut_clear.h | 0 .../{ => preferences}/preferences_shortcuts.c | 0 src/ui/{ => preferences}/preferences_smoke.c | 0 src/ui/{ => preferences}/preferences_state.h | 2 +- .../{ => preferences}/preferences_text_edit.c | 0 .../{ => preferences}/preferences_text_edit.h | 0 .../preferences_text_session.c | 0 .../preferences_text_session.h | 0 src/ui/{ => preferences}/preferences_theme.c | 0 src/ui/{ => preferences}/preferences_theme.h | 0 src/ui/{ => preferences}/preferences_toggle.c | 0 .../{ => preferences}/preferences_widgets.c | 0 .../{ => preferences}/preferences_widgets.h | 0 src/ui/{ => rendering}/ui_animation.c | 0 src/ui/{ => rendering}/ui_animation.h | 0 src/ui/{ => rendering}/ui_font.c | 0 src/ui/{ => rendering}/ui_font.h | 0 src/ui/{ => rendering}/ui_font_atlas.c | 0 src/ui/{ => rendering}/ui_font_atlas.h | 0 .../{ => rendering}/ui_font_atlas_internal.h | 0 src/ui/{ => rendering}/ui_font_atlas_ranges.c | 0 .../{ => rendering}/ui_font_atlas_sources.c | 0 src/ui/{ => rendering}/ui_font_atlas_upload.c | 0 src/ui/{ => rendering}/ui_font_reload.c | 0 src/ui/{ => rendering}/ui_icons.h | 0 src/ui/{ => rendering}/ui_native_theme.c | 0 src/ui/{ => rendering}/ui_native_theme.h | 0 src/ui/{ => rendering}/ui_paint.c | 0 src/ui/{ => rendering}/ui_paint.h | 0 src/ui/{ => rendering}/ui_paint_border.c | 0 src/ui/{ => rendering}/ui_paint_border.h | 0 src/ui/{ => rendering}/ui_paint_cache.c | 0 src/ui/{ => rendering}/ui_paint_cache.h | 0 src/ui/{ => rendering}/ui_paint_shell.c | 0 src/ui/{ => theme}/ui_catime.c | 0 src/ui/{ => theme}/ui_catime.h | 0 src/ui/{ => theme}/ui_catime_icons.c | 0 src/ui/{ => theme}/ui_catime_tabs.c | 0 src/ui/{ => theme}/ui_catime_theme.c | 0 tests/{ => core}/test_app_state.c | 0 tests/{ => core}/test_config.c | 0 tests/{ => core}/test_input.c | 2 +- tests/{ => core}/test_main.c | 0 tests/{ => core}/test_models.c | 51 ++-- tests/{ => core}/test_mver_pointer.c | 0 tests/{ => core}/test_shortcut.c | 0 tests/{ => i18n}/test_i18n.c | 0 .../test_model_import_identity.c | 0 tests/{ => model_import}/test_mver_import.c | 0 .../test_mver_nearby_identity.c | 0 .../test_mver_pointer_import.c | 0 tests/{ => model_import}/test_mver_support.c | 0 tests/{ => model_import}/test_mver_support.h | 0 tests/{ => platform}/test_windows_capture.c | 0 tests/{ => support}/test.h | 0 tests/{ => ui}/test_nuklear.c | 4 +- tests/{ => ui}/test_preferences_text.c | 0 224 files changed, 697 insertions(+), 382 deletions(-) create mode 100644 cmake/ApplicationResources.cmake create mode 100644 cmake/PlatformRuntime.cmake create mode 100644 cmake/Tests.cmake create mode 100644 cmake/ValidationTools.cmake create mode 100644 docs/code-organization.md rename src/platform/{ => common}/memory.c (100%) rename src/platform/{ => linux}/linux.c (100%) rename src/platform/{ => linux}/linux_internal.h (100%) rename src/platform/{ => linux}/linux_menu.c (100%) rename src/platform/{ => linux}/linux_x11.c (100%) rename src/platform/{ => macos}/macos.m (100%) rename src/platform/{ => macos}/macos_input.m (100%) rename src/platform/{ => macos}/macos_internal.h (100%) rename src/platform/{ => macos}/macos_keys.h (100%) rename src/platform/{ => macos}/macos_keys.m (100%) rename src/platform/{ => macos}/macos_menu.m (100%) rename src/platform/{ => windows}/windows.c (100%) rename src/platform/{ => windows}/windows_assets.c (100%) rename src/platform/{ => windows}/windows_borderless.c (100%) rename src/platform/{ => windows}/windows_borderless.h (100%) rename src/platform/{ => windows}/windows_capture.c (100%) rename src/platform/{ => windows}/windows_capture.h (100%) rename src/platform/{ => windows}/windows_capture_probe.c (100%) rename src/platform/{ => windows}/windows_diagnostics.c (100%) rename src/platform/{ => windows}/windows_diagnostics.h (100%) rename src/platform/{ => windows}/windows_direct_input.c (100%) rename src/platform/{ => windows}/windows_direct_input.h (100%) rename src/platform/{ => windows}/windows_directory.c (100%) rename src/platform/{ => windows}/windows_input.c (100%) rename src/platform/{ => windows}/windows_input.h (100%) rename src/platform/{ => windows}/windows_keys.c (100%) rename src/platform/{ => windows}/windows_keys.h (100%) rename src/platform/{ => windows}/windows_layered.c (100%) rename src/platform/{ => windows}/windows_layered.h (100%) rename src/platform/{ => windows}/windows_menu.c (99%) rename src/platform/{ => windows}/windows_pointer.c (100%) rename src/platform/{ => windows}/windows_popup.c (100%) rename src/platform/{ => windows}/windows_popup.h (100%) rename src/platform/{ => windows}/windows_startup.c (100%) rename src/platform/{ => windows}/windows_startup.h (100%) rename src/platform/{ => windows}/windows_tray.c (100%) rename src/platform/{ => windows}/windows_tray.h (100%) rename src/{core => platform/windows}/windows_utf8.c (100%) rename src/{core => platform/windows}/windows_utf8.h (100%) rename src/runtime/{ => diagnostics}/frame_audit.c (100%) rename src/runtime/{ => diagnostics}/frame_presentation_audit.c (98%) rename src/runtime/{ => diagnostics}/live2d_audit.c (100%) rename src/runtime/{ => diagnostics}/live2d_viewer_audit.c (100%) rename src/runtime/{ => diagnostics}/live2d_visual_audit.c (100%) rename src/runtime/{ => input}/gamepad.c (100%) rename src/runtime/{ => input}/mouse.c (100%) rename src/runtime/{ => input}/mouse_mapping.c (100%) rename src/runtime/{ => input}/shortcuts.c (100%) rename src/runtime/{ => lifecycle}/app.c (100%) rename src/runtime/{ => lifecycle}/assets.c (100%) rename src/runtime/{ => lifecycle}/config_store.c (100%) rename src/runtime/{ => lifecycle}/frame_clock.c (100%) rename src/runtime/{ => lifecycle}/memory_policy.c (100%) rename src/runtime/{ => lifecycle}/runtime_flow.c (100%) rename src/runtime/{ => lifecycle}/startup.c (100%) rename src/runtime/{ => lifecycle}/storage_paths.c (100%) rename src/runtime/{ => lifecycle}/storage_paths.h (100%) rename src/runtime/{ => model/import}/model_import.c (100%) rename src/runtime/{ => model/import}/model_import.h (100%) rename src/runtime/{ => model/import}/model_import_digest.c (100%) rename src/runtime/{ => model/import}/model_import_discovery.c (100%) rename src/runtime/{ => model/import}/model_import_format.c (100%) rename src/runtime/{ => model/import}/model_import_identity.c (100%) rename src/runtime/{ => model/import}/model_import_metadata.c (100%) rename src/runtime/{ => model/import}/model_import_package.c (100%) rename src/runtime/{ => model/import}/model_import_report.c (100%) rename src/runtime/{ => model/import}/model_import_scan.c (100%) rename src/runtime/{ => model/import}/model_import_source.c (100%) rename src/runtime/{ => model/import}/model_import_tauri.c (100%) rename src/runtime/{ => model/import/mver}/model_import_mver.c (100%) rename src/runtime/{ => model/import/mver}/model_import_mver_effect.c (100%) rename src/runtime/{ => model/import/mver}/model_import_mver_image.c (100%) rename src/runtime/{ => model/import/mver}/model_import_mver_internal.h (100%) rename src/runtime/{ => model/import/mver}/model_import_mver_labels.c (100%) rename src/runtime/{ => model/import/mver}/model_import_mver_metadata.c (100%) rename src/runtime/{ => model/import/mver}/model_import_mver_motion.c (100%) rename src/runtime/{ => model/import/mver}/model_import_mver_patch.c (100%) rename src/runtime/{ => model/import/mver}/model_import_mver_shortcut.c (100%) rename src/runtime/{ => model/import/nearby}/model_import_nearby.c (100%) rename src/runtime/{ => model/import/nearby}/model_import_nearby_cache.c (100%) rename src/runtime/{ => model/import/nearby}/model_import_nearby_internal.h (100%) rename src/runtime/{ => model/import/nearby}/model_import_nearby_signature.c (100%) rename src/runtime/{ => model}/model_catalog_runtime.c (100%) rename src/runtime/{ => model}/model_files.c (100%) rename src/runtime/{ => model}/model_storage.c (100%) rename src/runtime/{ => model}/model_storage.h (100%) rename src/runtime/{ => model}/model_update.c (100%) rename src/runtime/{ => shell}/modal_frame.c (100%) rename src/runtime/{ => shell}/modal_frame.h (100%) rename src/runtime/{ => shell}/tray.c (100%) rename src/runtime/{ => shell}/window.c (100%) rename src/runtime/{ => shell}/window_background.c (94%) rename src/runtime/{ => shell}/window_display.c (100%) rename src/runtime/{ => shell}/window_drag.c (100%) rename src/runtime/{ => shell}/window_geometry.c (100%) rename src/runtime/{ => shell}/window_hit.c (100%) rename src/runtime/{ => shell}/window_menu.h (100%) rename src/runtime/{ => shell}/window_menu_actions.c (99%) rename src/runtime/{ => shell}/window_menu_behavior.c (100%) rename src/runtime/{ => shell}/window_menu_preview.c (100%) rename src/runtime/{ => shell}/window_wheel.c (100%) rename src/ui/{ => backend}/nuklear_config.h (100%) rename src/ui/{ => backend}/nuklear_impl.c (100%) rename src/ui/{ => backend}/ui_backend.c (100%) rename src/ui/{ => backend}/ui_backend.h (100%) rename src/ui/{ => backend}/ui_cursor.c (100%) rename src/ui/{ => backend}/ui_input.c (100%) rename src/ui/{ => backend}/ui_resize_cache.c (100%) rename src/ui/{ => backend}/ui_scale.c (100%) rename src/ui/{ => backend}/ui_tooltip.c (100%) rename src/ui/{ => backend}/ui_tooltip.h (100%) rename src/ui/{ => preferences}/preferences.c (100%) rename src/ui/{ => preferences}/preferences_about.c (100%) rename src/ui/{ => preferences}/preferences_about_community.c (100%) rename src/ui/{ => preferences}/preferences_about_community.h (100%) rename src/ui/{ => preferences}/preferences_assets.c (100%) rename src/ui/{ => preferences}/preferences_behavior_dialog.c (100%) rename src/ui/{ => preferences}/preferences_behavior_rename.c (100%) rename src/ui/{ => preferences}/preferences_behavior_row.c (100%) rename src/ui/{ => preferences}/preferences_combo.c (100%) rename src/ui/{ => preferences}/preferences_controls.c (100%) rename src/ui/{ => preferences}/preferences_controls.h (100%) rename src/ui/{ => preferences}/preferences_dialog.c (100%) rename src/ui/{ => preferences}/preferences_fonts.c (100%) rename src/ui/{ => preferences}/preferences_fonts.h (100%) rename src/ui/{ => preferences}/preferences_import.c (99%) rename src/ui/{ => preferences}/preferences_internal.h (100%) rename src/ui/{ => preferences}/preferences_language.c (100%) rename src/ui/{ => preferences}/preferences_live_resize.c (100%) rename src/ui/{ => preferences}/preferences_model.c (100%) rename src/ui/{ => preferences}/preferences_model_card.c (75%) rename src/ui/{ => preferences}/preferences_model_card.h (100%) create mode 100644 src/ui/preferences/preferences_model_card_paint.c create mode 100644 src/ui/preferences/preferences_model_card_paint.h rename src/ui/{ => preferences}/preferences_model_cover.c (99%) rename src/ui/{ => preferences}/preferences_model_cover.h (100%) rename src/ui/{ => preferences}/preferences_model_glyphs.c (100%) rename src/ui/{ => preferences}/preferences_model_glyphs.h (100%) rename src/ui/{ => preferences}/preferences_model_rename.c (100%) rename src/ui/{ => preferences}/preferences_notice.c (100%) rename src/ui/{ => preferences}/preferences_notice.h (100%) rename src/ui/{ => preferences}/preferences_overlay.c (100%) rename src/ui/{ => preferences}/preferences_overlay.h (100%) rename src/ui/{ => preferences}/preferences_pages.c (99%) rename src/ui/{ => preferences}/preferences_render.c (100%) rename src/ui/{ => preferences}/preferences_scale.c (100%) rename src/ui/{ => preferences}/preferences_scrollbar.c (100%) rename src/ui/{ => preferences}/preferences_scrollbar.h (100%) rename src/ui/{ => preferences}/preferences_shortcut_clear.c (100%) rename src/ui/{ => preferences}/preferences_shortcut_clear.h (100%) rename src/ui/{ => preferences}/preferences_shortcuts.c (100%) rename src/ui/{ => preferences}/preferences_smoke.c (100%) rename src/ui/{ => preferences}/preferences_state.h (99%) rename src/ui/{ => preferences}/preferences_text_edit.c (100%) rename src/ui/{ => preferences}/preferences_text_edit.h (100%) rename src/ui/{ => preferences}/preferences_text_session.c (100%) rename src/ui/{ => preferences}/preferences_text_session.h (100%) rename src/ui/{ => preferences}/preferences_theme.c (100%) rename src/ui/{ => preferences}/preferences_theme.h (100%) rename src/ui/{ => preferences}/preferences_toggle.c (100%) rename src/ui/{ => preferences}/preferences_widgets.c (100%) rename src/ui/{ => preferences}/preferences_widgets.h (100%) rename src/ui/{ => rendering}/ui_animation.c (100%) rename src/ui/{ => rendering}/ui_animation.h (100%) rename src/ui/{ => rendering}/ui_font.c (100%) rename src/ui/{ => rendering}/ui_font.h (100%) rename src/ui/{ => rendering}/ui_font_atlas.c (100%) rename src/ui/{ => rendering}/ui_font_atlas.h (100%) rename src/ui/{ => rendering}/ui_font_atlas_internal.h (100%) rename src/ui/{ => rendering}/ui_font_atlas_ranges.c (100%) rename src/ui/{ => rendering}/ui_font_atlas_sources.c (100%) rename src/ui/{ => rendering}/ui_font_atlas_upload.c (100%) rename src/ui/{ => rendering}/ui_font_reload.c (100%) rename src/ui/{ => rendering}/ui_icons.h (100%) rename src/ui/{ => rendering}/ui_native_theme.c (100%) rename src/ui/{ => rendering}/ui_native_theme.h (100%) rename src/ui/{ => rendering}/ui_paint.c (100%) rename src/ui/{ => rendering}/ui_paint.h (100%) rename src/ui/{ => rendering}/ui_paint_border.c (100%) rename src/ui/{ => rendering}/ui_paint_border.h (100%) rename src/ui/{ => rendering}/ui_paint_cache.c (100%) rename src/ui/{ => rendering}/ui_paint_cache.h (100%) rename src/ui/{ => rendering}/ui_paint_shell.c (100%) rename src/ui/{ => theme}/ui_catime.c (100%) rename src/ui/{ => theme}/ui_catime.h (100%) rename src/ui/{ => theme}/ui_catime_icons.c (100%) rename src/ui/{ => theme}/ui_catime_tabs.c (100%) rename src/ui/{ => theme}/ui_catime_theme.c (100%) rename tests/{ => core}/test_app_state.c (100%) rename tests/{ => core}/test_config.c (100%) rename tests/{ => core}/test_input.c (99%) rename tests/{ => core}/test_main.c (100%) rename tests/{ => core}/test_models.c (82%) rename tests/{ => core}/test_mver_pointer.c (100%) rename tests/{ => core}/test_shortcut.c (100%) rename tests/{ => i18n}/test_i18n.c (100%) rename tests/{ => model_import}/test_model_import_identity.c (100%) rename tests/{ => model_import}/test_mver_import.c (100%) rename tests/{ => model_import}/test_mver_nearby_identity.c (100%) rename tests/{ => model_import}/test_mver_pointer_import.c (100%) rename tests/{ => model_import}/test_mver_support.c (100%) rename tests/{ => model_import}/test_mver_support.h (100%) rename tests/{ => platform}/test_windows_capture.c (100%) rename tests/{ => support}/test.h (100%) rename tests/{ => ui}/test_nuklear.c (96%) rename tests/{ => ui}/test_preferences_text.c (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cac37d0e..37ad7602 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,13 +48,20 @@ jobs: cppcheck --enable=warning,performance,portability \ --error-exitcode=1 --inline-suppr \ --suppress=missingIncludeSystem --suppress=unknownMacro \ - --std=c11 -Iinclude -Isrc/ui -Isrc/runtime "${c_files[@]}" + --std=c11 -Iinclude -Isrc/runtime \ + -Isrc/runtime/lifecycle -Isrc/runtime/model \ + -Isrc/runtime/model/import -Isrc/runtime/model/import/mver \ + -Isrc/runtime/model/import/nearby -Isrc/runtime/shell \ + -Isrc/ui/backend -Isrc/ui/preferences -Isrc/ui/rendering \ + -Isrc/ui/theme -Isrc/platform/windows "${c_files[@]}" mapfile -d '' cpp_files < <(find src -type f -name '*.cpp' -print0) cppcheck --enable=warning,performance,portability \ --error-exitcode=1 --inline-suppr \ --suppress=missingIncludeSystem --suppress=unknownMacro \ --suppress=passedByValueCallback --std=c++17 \ - -Iinclude -Isrc/ui -Isrc/runtime "${cpp_files[@]}" + -Iinclude -Isrc/runtime -Isrc/runtime/model/import \ + -Isrc/ui/backend -Isrc/ui/preferences -Isrc/ui/rendering \ + -Isrc/ui/theme "${cpp_files[@]}" - name: Configure warning-clean Linux build run: | diff --git a/CMakeLists.txt b/CMakeLists.txt index 0b4fed70..4f440316 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -68,22 +68,27 @@ target_include_directories(bongo_cat_core PUBLIC "${BONGO_CAT_GENERATED_INCLUDE_ target_link_libraries(bongo_cat_core PUBLIC yyjson PRIVATE bongo_cat_warnings) include(cmake/CubismAssets.cmake) if(WIN32) - target_sources(bongo_cat_core PRIVATE src/core/windows_utf8.c) + target_sources(bongo_cat_core PRIVATE + src/platform/windows/windows_utf8.c) + target_include_directories(bongo_cat_core PRIVATE + src/platform/windows) endif() include(cmake/RuntimeSources.cmake) add_library(bongo_cat_runtime STATIC ${BONGO_CAT_RUNTIME_SOURCES} ${BONGO_CAT_LIVE2D_SOURCES}) target_include_directories(bongo_cat_runtime PUBLIC "${BONGO_CAT_GENERATED_INCLUDE_DIR}" include) +target_include_directories(bongo_cat_runtime PRIVATE + ${BONGO_CAT_RUNTIME_INTERNAL_INCLUDE_DIRS}) target_include_directories(bongo_cat_runtime SYSTEM PRIVATE ${BONGO_CAT_STB_INCLUDE_DIR} ${BONGO_CAT_NUKLEAR_INCLUDE_DIR}) target_link_libraries(bongo_cat_runtime PRIVATE bongo_cat_warnings) target_link_libraries(bongo_cat_runtime PUBLIC bongo_cat_core SDL3::SDL3-static OpenGL::GL ${BONGO_CAT_MINIAUDIO_TARGET}) if(MSVC) - set_source_files_properties(src/media/stb_image_impl.c src/media/image_resize.c src/ui/nuklear_impl.c + set_source_files_properties(src/media/stb_image_impl.c src/media/image_resize.c src/ui/backend/nuklear_impl.c PROPERTIES COMPILE_OPTIONS "/W0") else() - set_source_files_properties(src/media/stb_image_impl.c src/media/image_resize.c src/ui/nuklear_impl.c + set_source_files_properties(src/media/stb_image_impl.c src/media/image_resize.c src/ui/backend/nuklear_impl.c PROPERTIES COMPILE_OPTIONS "-w") endif() if(BONGO_CAT_CUBISM_ENABLED) @@ -94,144 +99,15 @@ target_compile_definitions(bongo_cat_runtime PRIVATE BONGO_CAT_DEV_ASSET_ROOT="${CMAKE_CURRENT_SOURCE_DIR}/resources/assets" BONGO_CAT_DEV_LOCALE_ROOT="${CMAKE_CURRENT_SOURCE_DIR}/resources/assets/locales") -if(WIN32) - enable_language(RC) - target_sources(bongo_cat_runtime PRIVATE src/platform/windows.c src/platform/windows_menu.c src/platform/windows_directory.c - src/platform/windows_startup.c src/platform/windows_borderless.c src/platform/windows_capture.c - src/platform/windows_pointer.c src/platform/windows_direct_input.c src/platform/windows_input.c src/platform/windows_popup.c - src/platform/windows_layered.c src/platform/windows_tray.c - src/platform/windows_assets.c src/platform/windows_diagnostics.c - src/platform/windows_capture_probe.c) - target_link_libraries(bongo_cat_runtime PRIVATE dinput8 dxguid dwmapi ole32 shell32 - user32 uuid windowscodecs advapi32) -elseif(APPLE) - enable_language(OBJC) - target_sources(bongo_cat_runtime PRIVATE src/platform/macos.m src/platform/macos_input.m - src/platform/macos_keys.m src/platform/macos_menu.m) - target_link_libraries(bongo_cat_runtime PRIVATE "-framework Cocoa" - "-framework ApplicationServices") -else() - find_package(X11 REQUIRED) - if(NOT TARGET X11::Xi OR NOT TARGET X11::Xfixes) - message(FATAL_ERROR "Linux native build requires XInput2 and Xfixes development packages") - endif() - target_sources(bongo_cat_runtime PRIVATE src/platform/linux.c src/platform/linux_menu.c - src/platform/linux_x11.c) - target_link_libraries(bongo_cat_runtime PRIVATE X11::X11 X11::Xi X11::Xfixes m) - target_link_libraries(bongo_cat_core PRIVATE m) -endif() +include(cmake/PlatformRuntime.cmake) add_executable(bongo_cat src/main.c) target_link_libraries(bongo_cat PRIVATE bongo_cat_runtime bongo_cat_warnings) set_target_properties(bongo_cat PROPERTIES OUTPUT_NAME "BongoCat") bongo_cat_enable_release_ipo(Framework bongo_cat_core bongo_cat_runtime bongo_cat) -if(WIN32) - add_library(bongo_cat_tool_support STATIC EXCLUDE_FROM_ALL src/tools/windows_tool.c) - target_include_directories(bongo_cat_tool_support PUBLIC src/tools) - target_link_libraries(bongo_cat_tool_support PUBLIC bongo_cat_warnings) - - add_executable(cubism_viewer_desktop_capture EXCLUDE_FROM_ALL - src/tools/cubism_viewer_desktop_capture.c) - target_compile_definitions(cubism_viewer_desktop_capture PRIVATE _CRT_SECURE_NO_WARNINGS) - target_link_libraries(cubism_viewer_desktop_capture PRIVATE - bongo_cat_tool_support bongo_cat_warnings d3d11 dxgi dxguid user32) - - add_library(bongo_cat_validation_image STATIC EXCLUDE_FROM_ALL - src/tools/validation_image.c src/tools/validation_gdiplus.c) - target_include_directories(bongo_cat_validation_image PUBLIC src/tools) - target_link_libraries(bongo_cat_validation_image PUBLIC - bongo_cat_warnings windowscodecs gdiplus ole32) - - add_executable(cubism_viewer_drag_capture EXCLUDE_FROM_ALL - src/tools/cubism_viewer_drag_capture.c) - target_compile_definitions(cubism_viewer_drag_capture PRIVATE _CRT_SECURE_NO_WARNINGS) - target_link_libraries(cubism_viewer_drag_capture PRIVATE - bongo_cat_validation_image bongo_cat_tool_support bongo_cat_warnings - user32 gdi32 ole32) - - add_executable(cubism_viewer_blind_test EXCLUDE_FROM_ALL - src/tools/cubism_viewer_blind_test.c) - target_compile_definitions(cubism_viewer_blind_test PRIVATE _CRT_SECURE_NO_WARNINGS) - target_link_libraries(cubism_viewer_blind_test PRIVATE - bongo_cat_validation_image bongo_cat_tool_support bongo_cat_warnings ole32) - - add_executable(mver_blind_metrics EXCLUDE_FROM_ALL - src/tools/mver_blind_metrics.c) - target_compile_definitions(mver_blind_metrics PRIVATE _CRT_SECURE_NO_WARNINGS) - target_link_libraries(mver_blind_metrics PRIVATE - bongo_cat_validation_image bongo_cat_warnings ole32) - - add_executable(mver_phase_metrics EXCLUDE_FROM_ALL - src/tools/mver_phase_metrics.c) - target_compile_definitions(mver_phase_metrics PRIVATE _CRT_SECURE_NO_WARNINGS) - target_link_libraries(mver_phase_metrics PRIVATE - bongo_cat_validation_image bongo_cat_warnings ole32) - - add_custom_target(bongo_cat_validation_tools - DEPENDS cubism_viewer_desktop_capture cubism_viewer_drag_capture - cubism_viewer_blind_test mver_blind_metrics mver_phase_metrics) -endif() -if(APPLE) - set_source_files_properties(resources/icons/icon.icns PROPERTIES - MACOSX_PACKAGE_LOCATION Resources) - target_sources(bongo_cat PRIVATE resources/icons/icon.icns) - set_target_properties(bongo_cat PROPERTIES - MACOSX_BUNDLE TRUE - MACOSX_BUNDLE_BUNDLE_NAME "BongoCat" - MACOSX_BUNDLE_GUI_IDENTIFIER com.bongocat.desktop - MACOSX_BUNDLE_ICON_FILE icon.icns - MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/cmake/Info.plist.in") - add_custom_command(TARGET bongo_cat POST_BUILD - COMMAND ${CMAKE_COMMAND} -E make_directory - "$/MacOS/assets" - COMMAND ${CMAKE_COMMAND} -E copy_directory - "${CMAKE_CURRENT_SOURCE_DIR}/resources/assets" - "$/MacOS/assets" - VERBATIM) -endif() - -if(WIN32) - target_sources(bongo_cat_runtime PRIVATE src/platform/windows_keys.c) - add_executable(bongo_cat_asset_packer src/tools/asset_packer.c) - target_compile_definitions(bongo_cat_asset_packer PRIVATE _CRT_SECURE_NO_WARNINGS) - target_link_libraries(bongo_cat_asset_packer PRIVATE bongo_cat_warnings) - set(BONGO_CAT_ASSET_STAGE "${CMAKE_CURRENT_BINARY_DIR}/embedded-assets") - set(BONGO_CAT_ASSET_PACK "${CMAKE_CURRENT_BINARY_DIR}/bongo-cat-assets.pak") - file(GLOB_RECURSE BONGO_CAT_ASSET_INPUTS CONFIGURE_DEPENDS - "${CMAKE_CURRENT_SOURCE_DIR}/resources/assets/*") - bongo_cat_configure_embedded_cubism_assets() - add_custom_command(OUTPUT "${BONGO_CAT_ASSET_PACK}" - COMMAND ${CMAKE_COMMAND} -E rm -rf "${BONGO_CAT_ASSET_STAGE}" - COMMAND ${CMAKE_COMMAND} -E make_directory "${BONGO_CAT_ASSET_STAGE}/assets" - COMMAND ${CMAKE_COMMAND} -E copy_directory - "${CMAKE_CURRENT_SOURCE_DIR}/resources/assets" "${BONGO_CAT_ASSET_STAGE}/assets" - ${BONGO_CAT_ASSET_EXTRA_COMMANDS} - COMMAND "$" - "${BONGO_CAT_ASSET_STAGE}/assets" "${BONGO_CAT_ASSET_PACK}" - DEPENDS ${BONGO_CAT_ASSET_INPUTS} bongo_cat_asset_packer - VERBATIM) - add_custom_target(bongo_cat_asset_pack DEPENDS "${BONGO_CAT_ASSET_PACK}") - file(TO_CMAKE_PATH "${BONGO_CAT_ASSET_PACK}" BONGO_CAT_ASSET_PACK_RC) - file(TO_CMAKE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/icon.ico" BONGO_CAT_ICON_RC) - set(BONGO_CAT_WINDOWS_MANIFEST "${CMAKE_CURRENT_BINARY_DIR}/windows.manifest") - configure_file(cmake/windows.manifest.in "${BONGO_CAT_WINDOWS_MANIFEST}" @ONLY) - if(MSVC) - set(BONGO_CAT_MANIFEST_RESOURCE "") - target_link_options(bongo_cat PRIVATE "/MANIFEST:EMBED" - "/MANIFESTINPUT:${BONGO_CAT_WINDOWS_MANIFEST}") - else() - file(TO_CMAKE_PATH "${BONGO_CAT_WINDOWS_MANIFEST}" BONGO_CAT_MANIFEST_RC) - set(BONGO_CAT_MANIFEST_RESOURCE "1 RT_MANIFEST \"${BONGO_CAT_MANIFEST_RC}\"") - endif() - configure_file(cmake/windows_resources.rc.in - "${CMAKE_CURRENT_BINARY_DIR}/windows_resources.rc" @ONLY) - set_property(SOURCE "${CMAKE_CURRENT_BINARY_DIR}/windows_resources.rc" - APPEND PROPERTY OBJECT_DEPENDS "${BONGO_CAT_ASSET_PACK}") - target_sources(bongo_cat PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/windows_resources.rc") - add_dependencies(bongo_cat bongo_cat_asset_pack) - set_target_properties(bongo_cat PROPERTIES WIN32_EXECUTABLE TRUE) -endif() +include(cmake/ValidationTools.cmake) +include(cmake/ApplicationResources.cmake) if(MSVC) set_property(SOURCE src/main.c APPEND PROPERTY COMPILE_OPTIONS "/experimental:c11atomics") get_target_property(BONGO_CAT_RUNTIME_SOURCES bongo_cat_runtime SOURCES) @@ -249,52 +125,5 @@ add_custom_target(check-lines ALL COMMAND ${CMAKE_COMMAND} -DROOT=${CMAKE_CURRENT_SOURCE_DIR} -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/CheckLines.cmake VERBATIM) -if(BUILD_TESTING) - add_executable(bongo_cat_core_tests tests/test_main.c tests/test_config.c tests/test_input.c tests/test_models.c tests/test_mver_pointer.c tests/test_shortcut.c src/platform/windows_keys.c) - target_link_libraries(bongo_cat_core_tests PRIVATE bongo_cat_core bongo_cat_warnings $<$:user32>) - target_compile_definitions(bongo_cat_core_tests PRIVATE BONGO_CAT_NATIVE_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") - add_test(NAME core COMMAND bongo_cat_core_tests) - add_executable(bongo_cat_i18n_tests tests/test_i18n.c) - target_link_libraries(bongo_cat_i18n_tests PRIVATE bongo_cat_core bongo_cat_warnings) - target_compile_definitions(bongo_cat_i18n_tests PRIVATE BONGO_CAT_NATIVE_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") - add_test(NAME i18n COMMAND bongo_cat_i18n_tests) - add_executable(bongo_cat_ui_tests tests/test_nuklear.c src/ui/nuklear_impl.c src/ui/ui_paint_border.c) - target_include_directories(bongo_cat_ui_tests SYSTEM PRIVATE ${BONGO_CAT_NUKLEAR_INCLUDE_DIR}) - target_link_libraries(bongo_cat_ui_tests PRIVATE bongo_cat_warnings) - add_test(NAME ui COMMAND bongo_cat_ui_tests) - add_executable(bongo_cat_app_state_tests tests/test_app_state.c src/core/app_state.c) - target_link_libraries(bongo_cat_app_state_tests PRIVATE bongo_cat_warnings) - target_include_directories(bongo_cat_app_state_tests PRIVATE "${BONGO_CAT_GENERATED_INCLUDE_DIR}" include) - add_test(NAME app-state COMMAND bongo_cat_app_state_tests) - set(BONGO_CAT_MVER_IMPORT_TEST_SOURCES - tests/test_mver_import.c - tests/test_mver_pointer_import.c - tests/test_preferences_text.c - tests/test_mver_nearby_identity.c - tests/test_model_import_identity.c - tests/test_mver_support.c) - add_executable(bongo_cat_mver_import_tests - ${BONGO_CAT_MVER_IMPORT_TEST_SOURCES}) - target_include_directories(bongo_cat_mver_import_tests PRIVATE src/runtime src/ui) - target_include_directories(bongo_cat_mver_import_tests SYSTEM PRIVATE ${BONGO_CAT_NUKLEAR_INCLUDE_DIR}) - target_link_libraries(bongo_cat_mver_import_tests PRIVATE bongo_cat_runtime) - target_compile_definitions(bongo_cat_mver_import_tests PRIVATE BONGO_CAT_NATIVE_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") - if(MSVC) - set_property(SOURCE ${BONGO_CAT_MVER_IMPORT_TEST_SOURCES} - APPEND PROPERTY COMPILE_OPTIONS "/experimental:c11atomics") - set_property(SOURCE tests/test_windows_capture.c APPEND PROPERTY - COMPILE_OPTIONS "/experimental:c11atomics") - endif() - add_test(NAME model-import-unit COMMAND bongo_cat_mver_import_tests) - if(WIN32) - add_executable(bongo_cat_windows_capture_tests tests/test_windows_capture.c) - target_include_directories(bongo_cat_windows_capture_tests PRIVATE src/platform) - target_link_libraries(bongo_cat_windows_capture_tests PRIVATE bongo_cat_runtime bongo_cat_warnings) - add_test(NAME windows-capture COMMAND bongo_cat_windows_capture_tests) - endif() - if(UNIX AND NOT APPLE) - target_link_libraries(bongo_cat_ui_tests PRIVATE m) - target_link_libraries(bongo_cat_app_state_tests PRIVATE m) - endif() -endif() +include(cmake/Tests.cmake) include(cmake/Packaging.cmake) diff --git a/README.md b/README.md index 00dde4ad..0dc11ac4 100644 --- a/README.md +++ b/README.md @@ -87,18 +87,35 @@ possible. ```text include/bongo_cat/ public C interfaces and data structures -src/core/ config, paths, input state, catalogs, hashes -src/runtime/ application lifecycle, model import, and UI flow -src/render/ OpenGL helpers and overlays -src/live2d/ Cubism bridge and diagnostic backend -src/platform/ Win32, Cocoa, X11, input, and tray adapters -src/ui/ Nuklear backend, preferences, themes, localization -resources/assets/ bundled models, textures, locales, and tray assets -tests/ native tests and fixtures -cmake/ dependency, optimization, audit, and source-policy modules -docs/ audit evidence and parity notes +src/core/ config, paths, input state, catalogs, hashes +src/runtime/ runtime orchestration (see docs/code-organization.md) + lifecycle/ startup, frame timing, storage, and app state + input/ runtime input mapping and shortcuts + model/ catalog, storage, and import pipelines + shell/ window, tray, and modal-frame behavior + diagnostics/ frame and Live2D runtime audits +src/render/ OpenGL helpers and overlays +src/live2d/ Cubism bridge and diagnostic backend +src/platform/ platform adapters grouped by OS + windows/ Win32 window, input, capture, tray, and UTF-8 glue + macos/ Cocoa and Application Services glue + linux/ X11 and native menu glue +src/ui/ UI implementation grouped by responsibility + backend/ Nuklear/SDL backend, input, scale, and cursors + rendering/ fonts, paint primitives, and native theme + theme/ Catime theme components and icons + preferences/ preference pages, dialogs, model cards, and widgets +resources/assets/ bundled models, textures, locales, and tray assets +tests/ native tests grouped by subsystem, plus fixtures +cmake/ dependency, platform, target, and source-policy modules +docs/ audit evidence, parity notes, and organization rules ``` +Internal headers remain private to `src`; only `include/bongo_cat` is a public +include surface. New implementation files should live beside the subsystem +that owns them, and CMake source lists should be updated in the matching +`RuntimeSources.cmake` or target module. + ## Dependencies The build uses SDL3 for the window and event loop, desktop OpenGL for drawing, diff --git a/cmake/ApplicationResources.cmake b/cmake/ApplicationResources.cmake new file mode 100644 index 00000000..42560876 --- /dev/null +++ b/cmake/ApplicationResources.cmake @@ -0,0 +1,71 @@ +if(APPLE) + set_source_files_properties(resources/icons/icon.icns PROPERTIES + MACOSX_PACKAGE_LOCATION Resources) + target_sources(bongo_cat PRIVATE resources/icons/icon.icns) + set_target_properties(bongo_cat PROPERTIES + MACOSX_BUNDLE TRUE + MACOSX_BUNDLE_BUNDLE_NAME "BongoCat" + MACOSX_BUNDLE_GUI_IDENTIFIER com.bongocat.desktop + MACOSX_BUNDLE_ICON_FILE icon.icns + MACOSX_BUNDLE_INFO_PLIST + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/Info.plist.in") + add_custom_command(TARGET bongo_cat POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory + "$/MacOS/assets" + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_CURRENT_SOURCE_DIR}/resources/assets" + "$/MacOS/assets" + VERBATIM) +endif() + +if(WIN32) + target_sources(bongo_cat_runtime PRIVATE + src/platform/windows/windows_keys.c) + add_executable(bongo_cat_asset_packer src/tools/asset_packer.c) + target_compile_definitions(bongo_cat_asset_packer PRIVATE + _CRT_SECURE_NO_WARNINGS) + target_link_libraries(bongo_cat_asset_packer PRIVATE bongo_cat_warnings) + set(BONGO_CAT_ASSET_STAGE "${CMAKE_CURRENT_BINARY_DIR}/embedded-assets") + set(BONGO_CAT_ASSET_PACK "${CMAKE_CURRENT_BINARY_DIR}/bongo-cat-assets.pak") + file(GLOB_RECURSE BONGO_CAT_ASSET_INPUTS CONFIGURE_DEPENDS + "${CMAKE_CURRENT_SOURCE_DIR}/resources/assets/*") + bongo_cat_configure_embedded_cubism_assets() + add_custom_command(OUTPUT "${BONGO_CAT_ASSET_PACK}" + COMMAND ${CMAKE_COMMAND} -E rm -rf "${BONGO_CAT_ASSET_STAGE}" + COMMAND ${CMAKE_COMMAND} -E make_directory + "${BONGO_CAT_ASSET_STAGE}/assets" + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_CURRENT_SOURCE_DIR}/resources/assets" + "${BONGO_CAT_ASSET_STAGE}/assets" + ${BONGO_CAT_ASSET_EXTRA_COMMANDS} + COMMAND "$" + "${BONGO_CAT_ASSET_STAGE}/assets" "${BONGO_CAT_ASSET_PACK}" + DEPENDS ${BONGO_CAT_ASSET_INPUTS} bongo_cat_asset_packer + VERBATIM) + add_custom_target(bongo_cat_asset_pack DEPENDS "${BONGO_CAT_ASSET_PACK}") + file(TO_CMAKE_PATH "${BONGO_CAT_ASSET_PACK}" BONGO_CAT_ASSET_PACK_RC) + file(TO_CMAKE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/icon.ico" + BONGO_CAT_ICON_RC) + set(BONGO_CAT_WINDOWS_MANIFEST + "${CMAKE_CURRENT_BINARY_DIR}/windows.manifest") + configure_file(cmake/windows.manifest.in + "${BONGO_CAT_WINDOWS_MANIFEST}" @ONLY) + if(MSVC) + set(BONGO_CAT_MANIFEST_RESOURCE "") + target_link_options(bongo_cat PRIVATE "/MANIFEST:EMBED" + "/MANIFESTINPUT:${BONGO_CAT_WINDOWS_MANIFEST}") + else() + file(TO_CMAKE_PATH "${BONGO_CAT_WINDOWS_MANIFEST}" + BONGO_CAT_MANIFEST_RC) + set(BONGO_CAT_MANIFEST_RESOURCE + "1 RT_MANIFEST \"${BONGO_CAT_MANIFEST_RC}\"") + endif() + configure_file(cmake/windows_resources.rc.in + "${CMAKE_CURRENT_BINARY_DIR}/windows_resources.rc" @ONLY) + set_property(SOURCE "${CMAKE_CURRENT_BINARY_DIR}/windows_resources.rc" + APPEND PROPERTY OBJECT_DEPENDS "${BONGO_CAT_ASSET_PACK}") + target_sources(bongo_cat PRIVATE + "${CMAKE_CURRENT_BINARY_DIR}/windows_resources.rc") + add_dependencies(bongo_cat bongo_cat_asset_pack) + set_target_properties(bongo_cat PROPERTIES WIN32_EXECUTABLE TRUE) +endif() diff --git a/cmake/CheckLines.cmake b/cmake/CheckLines.cmake index 26873966..371c2dad 100644 --- a/cmake/CheckLines.cmake +++ b/cmake/CheckLines.cmake @@ -31,11 +31,14 @@ foreach(FILE IN LISTS FILES) set(LIMIT 396) elseif(RELATIVE_FILE STREQUAL "src/tools/mver_phase_metrics.c") set(LIMIT 321) - elseif(RELATIVE_FILE STREQUAL "src/runtime/live2d_audit.c") + elseif(RELATIVE_FILE STREQUAL + "src/runtime/diagnostics/live2d_audit.c") set(LIMIT 320) - elseif(RELATIVE_FILE STREQUAL "src/ui/preferences_import.c") + elseif(RELATIVE_FILE STREQUAL + "src/ui/preferences/preferences_import.c") set(LIMIT 320) - elseif(RELATIVE_FILE STREQUAL "src/ui/preferences_widgets.c") + elseif(RELATIVE_FILE STREQUAL + "src/ui/preferences/preferences_widgets.c") set(LIMIT 350) endif() diff --git a/cmake/PlatformRuntime.cmake b/cmake/PlatformRuntime.cmake new file mode 100644 index 00000000..92cba602 --- /dev/null +++ b/cmake/PlatformRuntime.cmake @@ -0,0 +1,45 @@ +if(WIN32) + enable_language(RC) + target_sources(bongo_cat_runtime PRIVATE + src/platform/windows/windows.c + src/platform/windows/windows_menu.c + src/platform/windows/windows_directory.c + src/platform/windows/windows_startup.c + src/platform/windows/windows_borderless.c + src/platform/windows/windows_capture.c + src/platform/windows/windows_pointer.c + src/platform/windows/windows_direct_input.c + src/platform/windows/windows_input.c + src/platform/windows/windows_popup.c + src/platform/windows/windows_layered.c + src/platform/windows/windows_tray.c + src/platform/windows/windows_assets.c + src/platform/windows/windows_diagnostics.c + src/platform/windows/windows_capture_probe.c) + target_include_directories(bongo_cat_runtime PRIVATE + src/platform/windows) + target_link_libraries(bongo_cat_runtime PRIVATE + dinput8 dxguid dwmapi ole32 shell32 user32 uuid windowscodecs advapi32) +elseif(APPLE) + enable_language(OBJC) + target_sources(bongo_cat_runtime PRIVATE + src/platform/macos/macos.m + src/platform/macos/macos_input.m + src/platform/macos/macos_keys.m + src/platform/macos/macos_menu.m) + target_link_libraries(bongo_cat_runtime PRIVATE "-framework Cocoa" + "-framework ApplicationServices") +else() + find_package(X11 REQUIRED) + if(NOT TARGET X11::Xi OR NOT TARGET X11::Xfixes) + message(FATAL_ERROR + "Linux native build requires XInput2 and Xfixes development packages") + endif() + target_sources(bongo_cat_runtime PRIVATE + src/platform/linux/linux.c + src/platform/linux/linux_menu.c + src/platform/linux/linux_x11.c) + target_link_libraries(bongo_cat_runtime PRIVATE + X11::X11 X11::Xi X11::Xfixes m) + target_link_libraries(bongo_cat_core PRIVATE m) +endif() diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index d90ffb02..be041084 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -1,65 +1,175 @@ +set(BONGO_CAT_RUNTIME_INTERNAL_INCLUDE_DIRS + "${CMAKE_CURRENT_SOURCE_DIR}/src/runtime" + "${CMAKE_CURRENT_SOURCE_DIR}/src/runtime/lifecycle" + "${CMAKE_CURRENT_SOURCE_DIR}/src/runtime/model" + "${CMAKE_CURRENT_SOURCE_DIR}/src/runtime/model/import" + "${CMAKE_CURRENT_SOURCE_DIR}/src/runtime/model/import/mver" + "${CMAKE_CURRENT_SOURCE_DIR}/src/runtime/model/import/nearby" + "${CMAKE_CURRENT_SOURCE_DIR}/src/runtime/shell" + "${CMAKE_CURRENT_SOURCE_DIR}/src/ui/backend" + "${CMAKE_CURRENT_SOURCE_DIR}/src/ui/preferences" + "${CMAKE_CURRENT_SOURCE_DIR}/src/ui/rendering" + "${CMAKE_CURRENT_SOURCE_DIR}/src/ui/theme") + +set(BONGO_CAT_MEDIA_SOURCES + src/media/audio.c + src/media/image.c + src/media/image_info.c + src/media/image_resize.c + src/media/image_alpha.c + src/media/stb_image_impl.c) + +set(BONGO_CAT_RENDER_SOURCES + src/render/gl_api.c + src/render/overlay.c + src/render/overlay_draw.c + src/render/mver_pointer_overlay.c + src/render/mver_pointer_overlay_draw.c) + +set(BONGO_CAT_RUNTIME_LIFECYCLE_SOURCES + src/runtime/lifecycle/app.c + src/runtime/lifecycle/assets.c + src/runtime/lifecycle/config_store.c + src/runtime/lifecycle/frame_clock.c + src/runtime/lifecycle/memory_policy.c + src/runtime/lifecycle/runtime_flow.c + src/runtime/lifecycle/startup.c + src/runtime/lifecycle/storage_paths.c) + +set(BONGO_CAT_RUNTIME_DIAGNOSTIC_SOURCES + src/runtime/diagnostics/frame_audit.c + src/runtime/diagnostics/frame_presentation_audit.c + src/runtime/diagnostics/live2d_audit.c + src/runtime/diagnostics/live2d_viewer_audit.c + src/runtime/diagnostics/live2d_visual_audit.c) + +set(BONGO_CAT_RUNTIME_INPUT_SOURCES + src/runtime/input/gamepad.c + src/runtime/input/mouse.c + src/runtime/input/mouse_mapping.c + src/runtime/input/shortcuts.c) + +set(BONGO_CAT_RUNTIME_MODEL_SOURCES + src/runtime/model/model_catalog_runtime.c + src/runtime/model/model_files.c + src/runtime/model/model_storage.c + src/runtime/model/model_update.c) + +set(BONGO_CAT_RUNTIME_IMPORT_SOURCES + src/runtime/model/import/model_import.c + src/runtime/model/import/model_import_digest.c + src/runtime/model/import/model_import_discovery.c + src/runtime/model/import/model_import_format.c + src/runtime/model/import/model_import_identity.c + src/runtime/model/import/model_import_metadata.c + src/runtime/model/import/model_import_package.c + src/runtime/model/import/model_import_report.c + src/runtime/model/import/model_import_scan.c + src/runtime/model/import/model_import_source.c + src/runtime/model/import/model_import_tauri.c + src/runtime/model/import/mver/model_import_mver.c + src/runtime/model/import/mver/model_import_mver_effect.c + src/runtime/model/import/mver/model_import_mver_image.c + src/runtime/model/import/mver/model_import_mver_labels.c + src/runtime/model/import/mver/model_import_mver_metadata.c + src/runtime/model/import/mver/model_import_mver_motion.c + src/runtime/model/import/mver/model_import_mver_patch.c + src/runtime/model/import/mver/model_import_mver_shortcut.c + src/runtime/model/import/nearby/model_import_nearby.c + src/runtime/model/import/nearby/model_import_nearby_cache.c + src/runtime/model/import/nearby/model_import_nearby_signature.c) + +set(BONGO_CAT_RUNTIME_SHELL_SOURCES + src/runtime/shell/modal_frame.c + src/runtime/shell/tray.c + src/runtime/shell/window.c + src/runtime/shell/window_background.c + src/runtime/shell/window_display.c + src/runtime/shell/window_drag.c + src/runtime/shell/window_geometry.c + src/runtime/shell/window_hit.c + src/runtime/shell/window_menu_actions.c + src/runtime/shell/window_menu_behavior.c + src/runtime/shell/window_menu_preview.c + src/runtime/shell/window_wheel.c) + +set(BONGO_CAT_UI_BACKEND_SOURCES + src/ui/backend/nuklear_impl.c + src/ui/backend/ui_backend.c + src/ui/backend/ui_cursor.c + src/ui/backend/ui_input.c + src/ui/backend/ui_resize_cache.c + src/ui/backend/ui_scale.c + src/ui/backend/ui_tooltip.c) + +set(BONGO_CAT_UI_RENDERING_SOURCES + src/ui/rendering/ui_animation.c + src/ui/rendering/ui_font.c + src/ui/rendering/ui_font_atlas.c + src/ui/rendering/ui_font_atlas_ranges.c + src/ui/rendering/ui_font_atlas_sources.c + src/ui/rendering/ui_font_atlas_upload.c + src/ui/rendering/ui_font_reload.c + src/ui/rendering/ui_native_theme.c + src/ui/rendering/ui_paint.c + src/ui/rendering/ui_paint_border.c + src/ui/rendering/ui_paint_cache.c + src/ui/rendering/ui_paint_shell.c) + +set(BONGO_CAT_UI_THEME_SOURCES + src/ui/theme/ui_catime.c + src/ui/theme/ui_catime_icons.c + src/ui/theme/ui_catime_tabs.c + src/ui/theme/ui_catime_theme.c) + +set(BONGO_CAT_UI_PREFERENCES_SOURCES + src/ui/preferences/preferences.c + src/ui/preferences/preferences_about.c + src/ui/preferences/preferences_about_community.c + src/ui/preferences/preferences_assets.c + src/ui/preferences/preferences_behavior_dialog.c + src/ui/preferences/preferences_behavior_rename.c + src/ui/preferences/preferences_behavior_row.c + src/ui/preferences/preferences_combo.c + src/ui/preferences/preferences_controls.c + src/ui/preferences/preferences_dialog.c + src/ui/preferences/preferences_fonts.c + src/ui/preferences/preferences_import.c + src/ui/preferences/preferences_language.c + src/ui/preferences/preferences_live_resize.c + src/ui/preferences/preferences_model.c + src/ui/preferences/preferences_model_card.c + src/ui/preferences/preferences_model_card_paint.c + src/ui/preferences/preferences_model_cover.c + src/ui/preferences/preferences_model_glyphs.c + src/ui/preferences/preferences_model_rename.c + src/ui/preferences/preferences_notice.c + src/ui/preferences/preferences_overlay.c + src/ui/preferences/preferences_pages.c + src/ui/preferences/preferences_render.c + src/ui/preferences/preferences_scale.c + src/ui/preferences/preferences_scrollbar.c + src/ui/preferences/preferences_shortcut_clear.c + src/ui/preferences/preferences_shortcuts.c + src/ui/preferences/preferences_smoke.c + src/ui/preferences/preferences_text_edit.c + src/ui/preferences/preferences_text_session.c + src/ui/preferences/preferences_theme.c + src/ui/preferences/preferences_toggle.c + src/ui/preferences/preferences_widgets.c) + set(BONGO_CAT_RUNTIME_SOURCES src/core/app_state.c - src/media/audio.c - src/media/image.c src/media/image_info.c - src/media/image_resize.c src/media/image_alpha.c - src/media/stb_image_impl.c - src/platform/memory.c src/render/gl_api.c - src/render/overlay.c src/render/overlay_draw.c - src/render/mver_pointer_overlay.c src/render/mver_pointer_overlay_draw.c - src/runtime/app.c src/runtime/config_store.c - src/runtime/assets.c src/runtime/startup.c src/runtime/storage_paths.c - src/runtime/frame_audit.c src/runtime/frame_presentation_audit.c - src/runtime/frame_clock.c src/runtime/modal_frame.c - src/runtime/gamepad.c - src/runtime/live2d_audit.c src/runtime/live2d_visual_audit.c - src/runtime/live2d_viewer_audit.c - src/runtime/memory_policy.c src/runtime/mouse.c src/runtime/mouse_mapping.c - src/runtime/model_files.c src/runtime/model_catalog_runtime.c - src/runtime/model_update.c src/runtime/model_storage.c - src/runtime/model_import.c src/runtime/model_import_identity.c - src/runtime/model_import_digest.c src/runtime/model_import_source.c - src/runtime/model_import_package.c - src/runtime/model_import_discovery.c src/runtime/model_import_format.c - src/runtime/model_import_scan.c src/runtime/model_import_tauri.c - src/runtime/model_import_nearby.c src/runtime/model_import_nearby_cache.c - src/runtime/model_import_nearby_signature.c - src/runtime/model_import_mver_patch.c - src/runtime/model_import_mver.c src/runtime/model_import_mver_image.c - src/runtime/model_import_mver_shortcut.c src/runtime/model_import_mver_effect.c - src/runtime/model_import_mver_motion.c src/runtime/model_import_mver_labels.c - src/runtime/model_import_report.c src/runtime/model_import_mver_metadata.c - src/runtime/model_import_metadata.c src/runtime/runtime_flow.c - src/runtime/shortcuts.c src/runtime/tray.c - src/runtime/window.c src/runtime/window_background.c - src/runtime/window_display.c src/runtime/window_drag.c - src/runtime/window_geometry.c src/runtime/window_menu_actions.c - src/runtime/window_menu_behavior.c src/runtime/window_menu_preview.c - src/runtime/window_hit.c src/runtime/window_wheel.c - src/ui/nuklear_impl.c - src/ui/ui_backend.c src/ui/ui_tooltip.c src/ui/ui_resize_cache.c - src/ui/ui_scale.c - src/ui/ui_cursor.c src/ui/ui_animation.c src/ui/ui_paint.c - src/ui/ui_paint_cache.c src/ui/ui_paint_border.c src/ui/ui_paint_shell.c - src/ui/ui_font.c src/ui/ui_font_atlas.c src/ui/ui_font_atlas_sources.c - src/ui/ui_font_atlas_ranges.c src/ui/ui_font_atlas_upload.c - src/ui/ui_font_reload.c src/ui/ui_input.c - src/ui/ui_catime.c src/ui/ui_catime_tabs.c src/ui/ui_catime_theme.c - src/ui/ui_catime_icons.c src/ui/ui_native_theme.c - src/ui/preferences.c src/ui/preferences_scale.c src/ui/preferences_fonts.c - src/ui/preferences_render.c src/ui/preferences_language.c - src/ui/preferences_assets.c src/ui/preferences_about.c - src/ui/preferences_about_community.c src/ui/preferences_live_resize.c - src/ui/preferences_dialog.c src/ui/preferences_import.c - src/ui/preferences_overlay.c src/ui/preferences_behavior_dialog.c - src/ui/preferences_behavior_rename.c src/ui/preferences_behavior_row.c - src/ui/preferences_scrollbar.c src/ui/preferences_text_edit.c - src/ui/preferences_text_session.c src/ui/preferences_notice.c - src/ui/preferences_model.c src/ui/preferences_model_card.c - src/ui/preferences_model_rename.c - src/ui/preferences_model_cover.c src/ui/preferences_model_glyphs.c - src/ui/preferences_pages.c src/ui/preferences_theme.c - src/ui/preferences_shortcuts.c src/ui/preferences_shortcut_clear.c - src/ui/preferences_smoke.c src/ui/preferences_widgets.c - src/ui/preferences_controls.c src/ui/preferences_combo.c - src/ui/preferences_toggle.c) + src/platform/common/memory.c + ${BONGO_CAT_MEDIA_SOURCES} + ${BONGO_CAT_RENDER_SOURCES} + ${BONGO_CAT_RUNTIME_LIFECYCLE_SOURCES} + ${BONGO_CAT_RUNTIME_DIAGNOSTIC_SOURCES} + ${BONGO_CAT_RUNTIME_INPUT_SOURCES} + ${BONGO_CAT_RUNTIME_MODEL_SOURCES} + ${BONGO_CAT_RUNTIME_IMPORT_SOURCES} + ${BONGO_CAT_RUNTIME_SHELL_SOURCES} + ${BONGO_CAT_UI_BACKEND_SOURCES} + ${BONGO_CAT_UI_RENDERING_SOURCES} + ${BONGO_CAT_UI_THEME_SOURCES} + ${BONGO_CAT_UI_PREFERENCES_SOURCES}) diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake new file mode 100644 index 00000000..2cffd5c8 --- /dev/null +++ b/cmake/Tests.cmake @@ -0,0 +1,82 @@ +if(BUILD_TESTING) + add_executable(bongo_cat_core_tests + tests/core/test_main.c + tests/core/test_config.c + tests/core/test_input.c + tests/core/test_models.c + tests/core/test_mver_pointer.c + tests/core/test_shortcut.c + src/platform/windows/windows_keys.c) + target_link_libraries(bongo_cat_core_tests PRIVATE + bongo_cat_core bongo_cat_warnings $<$:user32>) + target_include_directories(bongo_cat_core_tests PRIVATE + tests/support + $<$:${CMAKE_CURRENT_SOURCE_DIR}/src/platform/windows>) + target_compile_definitions(bongo_cat_core_tests PRIVATE + BONGO_CAT_NATIVE_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") + add_test(NAME core COMMAND bongo_cat_core_tests) + + add_executable(bongo_cat_i18n_tests tests/i18n/test_i18n.c) + target_link_libraries(bongo_cat_i18n_tests PRIVATE + bongo_cat_core bongo_cat_warnings) + target_compile_definitions(bongo_cat_i18n_tests PRIVATE + BONGO_CAT_NATIVE_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") + add_test(NAME i18n COMMAND bongo_cat_i18n_tests) + + add_executable(bongo_cat_ui_tests + tests/ui/test_nuklear.c + src/ui/backend/nuklear_impl.c + src/ui/rendering/ui_paint_border.c) + target_include_directories(bongo_cat_ui_tests PRIVATE + src/ui/backend src/ui/rendering) + target_include_directories(bongo_cat_ui_tests SYSTEM PRIVATE + ${BONGO_CAT_NUKLEAR_INCLUDE_DIR}) + target_link_libraries(bongo_cat_ui_tests PRIVATE bongo_cat_warnings) + add_test(NAME ui COMMAND bongo_cat_ui_tests) + + add_executable(bongo_cat_app_state_tests + tests/core/test_app_state.c src/core/app_state.c) + target_link_libraries(bongo_cat_app_state_tests PRIVATE bongo_cat_warnings) + target_include_directories(bongo_cat_app_state_tests PRIVATE + "${BONGO_CAT_GENERATED_INCLUDE_DIR}" include tests/support) + add_test(NAME app-state COMMAND bongo_cat_app_state_tests) + + set(BONGO_CAT_MVER_IMPORT_TEST_SOURCES + tests/model_import/test_mver_import.c + tests/model_import/test_mver_pointer_import.c + tests/ui/test_preferences_text.c + tests/model_import/test_mver_nearby_identity.c + tests/model_import/test_model_import_identity.c + tests/model_import/test_mver_support.c) + add_executable(bongo_cat_mver_import_tests + ${BONGO_CAT_MVER_IMPORT_TEST_SOURCES}) + target_include_directories(bongo_cat_mver_import_tests PRIVATE + ${BONGO_CAT_RUNTIME_INTERNAL_INCLUDE_DIRS}) + target_include_directories(bongo_cat_mver_import_tests SYSTEM PRIVATE + ${BONGO_CAT_NUKLEAR_INCLUDE_DIR}) + target_link_libraries(bongo_cat_mver_import_tests PRIVATE bongo_cat_runtime) + target_compile_definitions(bongo_cat_mver_import_tests PRIVATE + BONGO_CAT_NATIVE_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") + if(MSVC) + set_property(SOURCE ${BONGO_CAT_MVER_IMPORT_TEST_SOURCES} + APPEND PROPERTY COMPILE_OPTIONS "/experimental:c11atomics") + set_property(SOURCE tests/platform/test_windows_capture.c APPEND PROPERTY + COMPILE_OPTIONS "/experimental:c11atomics") + endif() + add_test(NAME model-import-unit COMMAND bongo_cat_mver_import_tests) + + if(WIN32) + add_executable(bongo_cat_windows_capture_tests + tests/platform/test_windows_capture.c) + target_include_directories(bongo_cat_windows_capture_tests PRIVATE + src/platform/windows) + target_link_libraries(bongo_cat_windows_capture_tests PRIVATE + bongo_cat_runtime bongo_cat_warnings) + add_test(NAME windows-capture COMMAND bongo_cat_windows_capture_tests) + endif() + + if(UNIX AND NOT APPLE) + target_link_libraries(bongo_cat_ui_tests PRIVATE m) + target_link_libraries(bongo_cat_app_state_tests PRIVATE m) + endif() +endif() diff --git a/cmake/ValidationTools.cmake b/cmake/ValidationTools.cmake new file mode 100644 index 00000000..6d7715a6 --- /dev/null +++ b/cmake/ValidationTools.cmake @@ -0,0 +1,52 @@ +if(WIN32) + add_library(bongo_cat_tool_support STATIC EXCLUDE_FROM_ALL + src/tools/windows_tool.c) + target_include_directories(bongo_cat_tool_support PUBLIC src/tools) + target_link_libraries(bongo_cat_tool_support PUBLIC bongo_cat_warnings) + + add_executable(cubism_viewer_desktop_capture EXCLUDE_FROM_ALL + src/tools/cubism_viewer_desktop_capture.c) + target_compile_definitions(cubism_viewer_desktop_capture PRIVATE + _CRT_SECURE_NO_WARNINGS) + target_link_libraries(cubism_viewer_desktop_capture PRIVATE + bongo_cat_tool_support bongo_cat_warnings d3d11 dxgi dxguid user32) + + add_library(bongo_cat_validation_image STATIC EXCLUDE_FROM_ALL + src/tools/validation_image.c src/tools/validation_gdiplus.c) + target_include_directories(bongo_cat_validation_image PUBLIC src/tools) + target_link_libraries(bongo_cat_validation_image PUBLIC + bongo_cat_warnings windowscodecs gdiplus ole32) + + add_executable(cubism_viewer_drag_capture EXCLUDE_FROM_ALL + src/tools/cubism_viewer_drag_capture.c) + target_compile_definitions(cubism_viewer_drag_capture PRIVATE + _CRT_SECURE_NO_WARNINGS) + target_link_libraries(cubism_viewer_drag_capture PRIVATE + bongo_cat_validation_image bongo_cat_tool_support bongo_cat_warnings + user32 gdi32 ole32) + + add_executable(cubism_viewer_blind_test EXCLUDE_FROM_ALL + src/tools/cubism_viewer_blind_test.c) + target_compile_definitions(cubism_viewer_blind_test PRIVATE + _CRT_SECURE_NO_WARNINGS) + target_link_libraries(cubism_viewer_blind_test PRIVATE + bongo_cat_validation_image bongo_cat_tool_support bongo_cat_warnings ole32) + + add_executable(mver_blind_metrics EXCLUDE_FROM_ALL + src/tools/mver_blind_metrics.c) + target_compile_definitions(mver_blind_metrics PRIVATE + _CRT_SECURE_NO_WARNINGS) + target_link_libraries(mver_blind_metrics PRIVATE + bongo_cat_validation_image bongo_cat_warnings ole32) + + add_executable(mver_phase_metrics EXCLUDE_FROM_ALL + src/tools/mver_phase_metrics.c) + target_compile_definitions(mver_phase_metrics PRIVATE + _CRT_SECURE_NO_WARNINGS) + target_link_libraries(mver_phase_metrics PRIVATE + bongo_cat_validation_image bongo_cat_warnings ole32) + + add_custom_target(bongo_cat_validation_tools + DEPENDS cubism_viewer_desktop_capture cubism_viewer_drag_capture + cubism_viewer_blind_test mver_blind_metrics mver_phase_metrics) +endif() diff --git a/docs/code-organization.md b/docs/code-organization.md new file mode 100644 index 00000000..195ae5f9 --- /dev/null +++ b/docs/code-organization.md @@ -0,0 +1,57 @@ +# Native code organization + +The native implementation is organized by ownership. Directory names describe +the subsystem that owns a file; they are not a second public API. Public C +interfaces stay in `include/bongo_cat`, while headers under `src` are private +implementation details. + +## Dependency direction + +```text +include/bongo_cat + ^ + | +src/core <--- src/runtime/model/import + ^ ^ + | | +src/platform src/runtime (lifecycle, input, shell) + ^ ^ + +-------- src/ui (backend, rendering, preferences) + ^ + src/main.c +``` + +The arrows describe allowed ownership, not a requirement that every module +depends on every module below it: + +- `core` contains platform-neutral data and persistence primitives. +- `runtime/model` owns the catalog and storage model; `runtime/model/import` + owns format adapters and nearby-package discovery. +- `runtime/lifecycle`, `runtime/input`, and `runtime/shell` coordinate the + running application but do not define public data structures. +- `ui` consumes runtime state and renders it. UI code must not reach into a + platform implementation through relative paths; private include directories + are declared by CMake instead. +- `platform/` owns native APIs. Cross-platform code calls public platform + interfaces from `include/bongo_cat`. + +## CMake ownership + +`cmake/RuntimeSources.cmake` is the source-of-truth for runtime/UI ownership +groups. Platform-specific source and link settings live in +`cmake/PlatformRuntime.cmake`; validation executables, application resources, +and tests have their own target modules. This keeps the root `CMakeLists.txt` +focused on project-wide options and library wiring. + +When adding a source file: + +1. Put it in the smallest owning directory. +2. Add it to that directory's source group in `RuntimeSources.cmake`, or to the + relevant platform/target module. +3. Add private include directories only to the target that needs them. +4. Run `cmake -DROOT=. -P cmake/CheckLines.cmake`, configure a clean build, and + run `ctest --test-dir --output-on-failure`. + +Tests follow the same ownership model under `tests/core`, `tests/ui`, +`tests/model_import`, `tests/platform`, and `tests/i18n`. Fixtures stay under +`tests/fixtures` because they are data rather than implementation. diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index aa2ee6c8..e769234a 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -77,6 +77,7 @@ }, "model": { "title": "Modell", + "nearbyTitle": "Nahe Modelle", "labels": { "deleteModel": "Modell löschen" }, @@ -84,6 +85,7 @@ "deleteSuccess": "\"%s\" wurde gelöscht", "deleteModel": "Möchten Sie \"%s\" wirklich löschen?", "importSuccess": "Erfolgreich importiert", + "importExists": "Modell ist bereits vorhanden", "clickOrDragToImport": "Zum Importieren klicken oder hierher ziehen", "openDirectoryFailed": "Modellordner konnte nicht geöffnet werden" }, diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index 8e276fd4..526cf04a 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -77,6 +77,7 @@ }, "model": { "title": "Modelo", + "nearbyTitle": "Modelos cercanos", "labels": { "deleteModel": "Eliminar modelo" }, @@ -84,6 +85,7 @@ "deleteSuccess": "Se eliminó \"%s\"", "deleteModel": "¿Seguro que quiere eliminar \"%s\"?", "importSuccess": "Importado correctamente", + "importExists": "El modelo ya existe", "clickOrDragToImport": "Haga clic o arrastre aquí para importar", "openDirectoryFailed": "No se pudo abrir la carpeta del modelo" }, diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index 5e08bd32..9e513de6 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -77,6 +77,7 @@ }, "model": { "title": "Modèle", + "nearbyTitle": "Modèles à proximité", "labels": { "deleteModel": "Supprimer le modèle" }, @@ -84,6 +85,7 @@ "deleteSuccess": "« %s » a été supprimé", "deleteModel": "Voulez-vous vraiment supprimer \"%s\" ?", "importSuccess": "Importation réussie", + "importExists": "Le modèle existe déjà", "clickOrDragToImport": "Cliquez ou déposez ici pour importer", "openDirectoryFailed": "Impossible d'ouvrir le dossier du modèle" }, diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index 5c80e233..160b23e5 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -77,6 +77,7 @@ }, "model": { "title": "モデル", + "nearbyTitle": "近くのモデル", "labels": { "deleteModel": "モデルを削除" }, @@ -84,6 +85,7 @@ "deleteSuccess": "「%s」を削除しました", "deleteModel": "「%s」を削除しますか?", "importSuccess": "インポートしました", + "importExists": "モデルは既に存在します", "clickOrDragToImport": "クリックまたはここへドラッグしてインポート", "openDirectoryFailed": "モデルフォルダーを開けません" }, diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index 3be50cd7..673ae05e 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -77,6 +77,7 @@ }, "model": { "title": "모델", + "nearbyTitle": "주변 모델", "labels": { "deleteModel": "모델 삭제" }, @@ -84,6 +85,7 @@ "deleteSuccess": "\"%s\" 모델을 삭제했습니다", "deleteModel": "\"%s\" 모델을 삭제하시겠습니까?", "importSuccess": "가져오기 완료", + "importExists": "모델이 이미 존재합니다", "clickOrDragToImport": "클릭하거나 여기로 끌어와 가져오기", "openDirectoryFailed": "모델 폴더를 열 수 없습니다" }, diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index c484bcdf..fd39a6a2 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -77,6 +77,7 @@ }, "model": { "title": "Modelo", + "nearbyTitle": "Modelos próximos", "labels": { "deleteModel": "Excluir modelo" }, @@ -84,6 +85,7 @@ "deleteSuccess": "Modelo \"%s\" excluído", "deleteModel": "Tem certeza de que deseja excluir \"%s\"?", "importSuccess": "Importação bem-sucedida", + "importExists": "O modelo já existe", "clickOrDragToImport": "Clique ou arraste para importar", "openDirectoryFailed": "Não foi possível abrir a pasta do modelo" }, diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index 190f4f0f..04412647 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -77,6 +77,7 @@ }, "model": { "title": "Модель", + "nearbyTitle": "Ближайшие модели", "labels": { "deleteModel": "Удалить модель" }, @@ -84,6 +85,7 @@ "deleteSuccess": "Модель \"%s\" удалена", "deleteModel": "Удалить модель \"%s\"?", "importSuccess": "Успешно импортировано", + "importExists": "Модель уже существует", "clickOrDragToImport": "Щёлкните или перетащите сюда для импорта", "openDirectoryFailed": "Не удалось открыть папку модели" }, diff --git a/src/live2d/live2d_stub.c b/src/live2d/live2d_stub.c index 3cf4a94a..96d66796 100644 --- a/src/live2d/live2d_stub.c +++ b/src/live2d/live2d_stub.c @@ -1,5 +1,5 @@ #include "bongo_cat/model.h" -#include "../runtime/model_import.h" +#include "model_import.h" #include diff --git a/src/platform/memory.c b/src/platform/common/memory.c similarity index 100% rename from src/platform/memory.c rename to src/platform/common/memory.c diff --git a/src/platform/linux.c b/src/platform/linux/linux.c similarity index 100% rename from src/platform/linux.c rename to src/platform/linux/linux.c diff --git a/src/platform/linux_internal.h b/src/platform/linux/linux_internal.h similarity index 100% rename from src/platform/linux_internal.h rename to src/platform/linux/linux_internal.h diff --git a/src/platform/linux_menu.c b/src/platform/linux/linux_menu.c similarity index 100% rename from src/platform/linux_menu.c rename to src/platform/linux/linux_menu.c diff --git a/src/platform/linux_x11.c b/src/platform/linux/linux_x11.c similarity index 100% rename from src/platform/linux_x11.c rename to src/platform/linux/linux_x11.c diff --git a/src/platform/macos.m b/src/platform/macos/macos.m similarity index 100% rename from src/platform/macos.m rename to src/platform/macos/macos.m diff --git a/src/platform/macos_input.m b/src/platform/macos/macos_input.m similarity index 100% rename from src/platform/macos_input.m rename to src/platform/macos/macos_input.m diff --git a/src/platform/macos_internal.h b/src/platform/macos/macos_internal.h similarity index 100% rename from src/platform/macos_internal.h rename to src/platform/macos/macos_internal.h diff --git a/src/platform/macos_keys.h b/src/platform/macos/macos_keys.h similarity index 100% rename from src/platform/macos_keys.h rename to src/platform/macos/macos_keys.h diff --git a/src/platform/macos_keys.m b/src/platform/macos/macos_keys.m similarity index 100% rename from src/platform/macos_keys.m rename to src/platform/macos/macos_keys.m diff --git a/src/platform/macos_menu.m b/src/platform/macos/macos_menu.m similarity index 100% rename from src/platform/macos_menu.m rename to src/platform/macos/macos_menu.m diff --git a/src/platform/windows.c b/src/platform/windows/windows.c similarity index 100% rename from src/platform/windows.c rename to src/platform/windows/windows.c diff --git a/src/platform/windows_assets.c b/src/platform/windows/windows_assets.c similarity index 100% rename from src/platform/windows_assets.c rename to src/platform/windows/windows_assets.c diff --git a/src/platform/windows_borderless.c b/src/platform/windows/windows_borderless.c similarity index 100% rename from src/platform/windows_borderless.c rename to src/platform/windows/windows_borderless.c diff --git a/src/platform/windows_borderless.h b/src/platform/windows/windows_borderless.h similarity index 100% rename from src/platform/windows_borderless.h rename to src/platform/windows/windows_borderless.h diff --git a/src/platform/windows_capture.c b/src/platform/windows/windows_capture.c similarity index 100% rename from src/platform/windows_capture.c rename to src/platform/windows/windows_capture.c diff --git a/src/platform/windows_capture.h b/src/platform/windows/windows_capture.h similarity index 100% rename from src/platform/windows_capture.h rename to src/platform/windows/windows_capture.h diff --git a/src/platform/windows_capture_probe.c b/src/platform/windows/windows_capture_probe.c similarity index 100% rename from src/platform/windows_capture_probe.c rename to src/platform/windows/windows_capture_probe.c diff --git a/src/platform/windows_diagnostics.c b/src/platform/windows/windows_diagnostics.c similarity index 100% rename from src/platform/windows_diagnostics.c rename to src/platform/windows/windows_diagnostics.c diff --git a/src/platform/windows_diagnostics.h b/src/platform/windows/windows_diagnostics.h similarity index 100% rename from src/platform/windows_diagnostics.h rename to src/platform/windows/windows_diagnostics.h diff --git a/src/platform/windows_direct_input.c b/src/platform/windows/windows_direct_input.c similarity index 100% rename from src/platform/windows_direct_input.c rename to src/platform/windows/windows_direct_input.c diff --git a/src/platform/windows_direct_input.h b/src/platform/windows/windows_direct_input.h similarity index 100% rename from src/platform/windows_direct_input.h rename to src/platform/windows/windows_direct_input.h diff --git a/src/platform/windows_directory.c b/src/platform/windows/windows_directory.c similarity index 100% rename from src/platform/windows_directory.c rename to src/platform/windows/windows_directory.c diff --git a/src/platform/windows_input.c b/src/platform/windows/windows_input.c similarity index 100% rename from src/platform/windows_input.c rename to src/platform/windows/windows_input.c diff --git a/src/platform/windows_input.h b/src/platform/windows/windows_input.h similarity index 100% rename from src/platform/windows_input.h rename to src/platform/windows/windows_input.h diff --git a/src/platform/windows_keys.c b/src/platform/windows/windows_keys.c similarity index 100% rename from src/platform/windows_keys.c rename to src/platform/windows/windows_keys.c diff --git a/src/platform/windows_keys.h b/src/platform/windows/windows_keys.h similarity index 100% rename from src/platform/windows_keys.h rename to src/platform/windows/windows_keys.h diff --git a/src/platform/windows_layered.c b/src/platform/windows/windows_layered.c similarity index 100% rename from src/platform/windows_layered.c rename to src/platform/windows/windows_layered.c diff --git a/src/platform/windows_layered.h b/src/platform/windows/windows_layered.h similarity index 100% rename from src/platform/windows_layered.h rename to src/platform/windows/windows_layered.h diff --git a/src/platform/windows_menu.c b/src/platform/windows/windows_menu.c similarity index 99% rename from src/platform/windows_menu.c rename to src/platform/windows/windows_menu.c index fda90611..af6c316a 100644 --- a/src/platform/windows_menu.c +++ b/src/platform/windows/windows_menu.c @@ -1,7 +1,7 @@ #include "bongo_cat/platform.h" #include "windows_borderless.h" #include "windows_popup.h" -#include "../ui/ui_native_theme.h" +#include "ui_native_theme.h" #ifdef _WIN32 #include diff --git a/src/platform/windows_pointer.c b/src/platform/windows/windows_pointer.c similarity index 100% rename from src/platform/windows_pointer.c rename to src/platform/windows/windows_pointer.c diff --git a/src/platform/windows_popup.c b/src/platform/windows/windows_popup.c similarity index 100% rename from src/platform/windows_popup.c rename to src/platform/windows/windows_popup.c diff --git a/src/platform/windows_popup.h b/src/platform/windows/windows_popup.h similarity index 100% rename from src/platform/windows_popup.h rename to src/platform/windows/windows_popup.h diff --git a/src/platform/windows_startup.c b/src/platform/windows/windows_startup.c similarity index 100% rename from src/platform/windows_startup.c rename to src/platform/windows/windows_startup.c diff --git a/src/platform/windows_startup.h b/src/platform/windows/windows_startup.h similarity index 100% rename from src/platform/windows_startup.h rename to src/platform/windows/windows_startup.h diff --git a/src/platform/windows_tray.c b/src/platform/windows/windows_tray.c similarity index 100% rename from src/platform/windows_tray.c rename to src/platform/windows/windows_tray.c diff --git a/src/platform/windows_tray.h b/src/platform/windows/windows_tray.h similarity index 100% rename from src/platform/windows_tray.h rename to src/platform/windows/windows_tray.h diff --git a/src/core/windows_utf8.c b/src/platform/windows/windows_utf8.c similarity index 100% rename from src/core/windows_utf8.c rename to src/platform/windows/windows_utf8.c diff --git a/src/core/windows_utf8.h b/src/platform/windows/windows_utf8.h similarity index 100% rename from src/core/windows_utf8.h rename to src/platform/windows/windows_utf8.h diff --git a/src/runtime/frame_audit.c b/src/runtime/diagnostics/frame_audit.c similarity index 100% rename from src/runtime/frame_audit.c rename to src/runtime/diagnostics/frame_audit.c diff --git a/src/runtime/frame_presentation_audit.c b/src/runtime/diagnostics/frame_presentation_audit.c similarity index 98% rename from src/runtime/frame_presentation_audit.c rename to src/runtime/diagnostics/frame_presentation_audit.c index 52e2b0a6..e26cb9d5 100644 --- a/src/runtime/frame_presentation_audit.c +++ b/src/runtime/diagnostics/frame_presentation_audit.c @@ -5,9 +5,9 @@ #include #ifdef _WIN32 -#include "../platform/windows_capture.h" -#include "../platform/windows_diagnostics.h" -#include "../platform/windows_layered.h" +#include "windows_capture.h" +#include "windows_diagnostics.h" +#include "windows_layered.h" #include #endif diff --git a/src/runtime/live2d_audit.c b/src/runtime/diagnostics/live2d_audit.c similarity index 100% rename from src/runtime/live2d_audit.c rename to src/runtime/diagnostics/live2d_audit.c diff --git a/src/runtime/live2d_viewer_audit.c b/src/runtime/diagnostics/live2d_viewer_audit.c similarity index 100% rename from src/runtime/live2d_viewer_audit.c rename to src/runtime/diagnostics/live2d_viewer_audit.c diff --git a/src/runtime/live2d_visual_audit.c b/src/runtime/diagnostics/live2d_visual_audit.c similarity index 100% rename from src/runtime/live2d_visual_audit.c rename to src/runtime/diagnostics/live2d_visual_audit.c diff --git a/src/runtime/gamepad.c b/src/runtime/input/gamepad.c similarity index 100% rename from src/runtime/gamepad.c rename to src/runtime/input/gamepad.c diff --git a/src/runtime/mouse.c b/src/runtime/input/mouse.c similarity index 100% rename from src/runtime/mouse.c rename to src/runtime/input/mouse.c diff --git a/src/runtime/mouse_mapping.c b/src/runtime/input/mouse_mapping.c similarity index 100% rename from src/runtime/mouse_mapping.c rename to src/runtime/input/mouse_mapping.c diff --git a/src/runtime/shortcuts.c b/src/runtime/input/shortcuts.c similarity index 100% rename from src/runtime/shortcuts.c rename to src/runtime/input/shortcuts.c diff --git a/src/runtime/app.c b/src/runtime/lifecycle/app.c similarity index 100% rename from src/runtime/app.c rename to src/runtime/lifecycle/app.c diff --git a/src/runtime/assets.c b/src/runtime/lifecycle/assets.c similarity index 100% rename from src/runtime/assets.c rename to src/runtime/lifecycle/assets.c diff --git a/src/runtime/config_store.c b/src/runtime/lifecycle/config_store.c similarity index 100% rename from src/runtime/config_store.c rename to src/runtime/lifecycle/config_store.c diff --git a/src/runtime/frame_clock.c b/src/runtime/lifecycle/frame_clock.c similarity index 100% rename from src/runtime/frame_clock.c rename to src/runtime/lifecycle/frame_clock.c diff --git a/src/runtime/memory_policy.c b/src/runtime/lifecycle/memory_policy.c similarity index 100% rename from src/runtime/memory_policy.c rename to src/runtime/lifecycle/memory_policy.c diff --git a/src/runtime/runtime_flow.c b/src/runtime/lifecycle/runtime_flow.c similarity index 100% rename from src/runtime/runtime_flow.c rename to src/runtime/lifecycle/runtime_flow.c diff --git a/src/runtime/startup.c b/src/runtime/lifecycle/startup.c similarity index 100% rename from src/runtime/startup.c rename to src/runtime/lifecycle/startup.c diff --git a/src/runtime/storage_paths.c b/src/runtime/lifecycle/storage_paths.c similarity index 100% rename from src/runtime/storage_paths.c rename to src/runtime/lifecycle/storage_paths.c diff --git a/src/runtime/storage_paths.h b/src/runtime/lifecycle/storage_paths.h similarity index 100% rename from src/runtime/storage_paths.h rename to src/runtime/lifecycle/storage_paths.h diff --git a/src/runtime/model_import.c b/src/runtime/model/import/model_import.c similarity index 100% rename from src/runtime/model_import.c rename to src/runtime/model/import/model_import.c diff --git a/src/runtime/model_import.h b/src/runtime/model/import/model_import.h similarity index 100% rename from src/runtime/model_import.h rename to src/runtime/model/import/model_import.h diff --git a/src/runtime/model_import_digest.c b/src/runtime/model/import/model_import_digest.c similarity index 100% rename from src/runtime/model_import_digest.c rename to src/runtime/model/import/model_import_digest.c diff --git a/src/runtime/model_import_discovery.c b/src/runtime/model/import/model_import_discovery.c similarity index 100% rename from src/runtime/model_import_discovery.c rename to src/runtime/model/import/model_import_discovery.c diff --git a/src/runtime/model_import_format.c b/src/runtime/model/import/model_import_format.c similarity index 100% rename from src/runtime/model_import_format.c rename to src/runtime/model/import/model_import_format.c diff --git a/src/runtime/model_import_identity.c b/src/runtime/model/import/model_import_identity.c similarity index 100% rename from src/runtime/model_import_identity.c rename to src/runtime/model/import/model_import_identity.c diff --git a/src/runtime/model_import_metadata.c b/src/runtime/model/import/model_import_metadata.c similarity index 100% rename from src/runtime/model_import_metadata.c rename to src/runtime/model/import/model_import_metadata.c diff --git a/src/runtime/model_import_package.c b/src/runtime/model/import/model_import_package.c similarity index 100% rename from src/runtime/model_import_package.c rename to src/runtime/model/import/model_import_package.c diff --git a/src/runtime/model_import_report.c b/src/runtime/model/import/model_import_report.c similarity index 100% rename from src/runtime/model_import_report.c rename to src/runtime/model/import/model_import_report.c diff --git a/src/runtime/model_import_scan.c b/src/runtime/model/import/model_import_scan.c similarity index 100% rename from src/runtime/model_import_scan.c rename to src/runtime/model/import/model_import_scan.c diff --git a/src/runtime/model_import_source.c b/src/runtime/model/import/model_import_source.c similarity index 100% rename from src/runtime/model_import_source.c rename to src/runtime/model/import/model_import_source.c diff --git a/src/runtime/model_import_tauri.c b/src/runtime/model/import/model_import_tauri.c similarity index 100% rename from src/runtime/model_import_tauri.c rename to src/runtime/model/import/model_import_tauri.c diff --git a/src/runtime/model_import_mver.c b/src/runtime/model/import/mver/model_import_mver.c similarity index 100% rename from src/runtime/model_import_mver.c rename to src/runtime/model/import/mver/model_import_mver.c diff --git a/src/runtime/model_import_mver_effect.c b/src/runtime/model/import/mver/model_import_mver_effect.c similarity index 100% rename from src/runtime/model_import_mver_effect.c rename to src/runtime/model/import/mver/model_import_mver_effect.c diff --git a/src/runtime/model_import_mver_image.c b/src/runtime/model/import/mver/model_import_mver_image.c similarity index 100% rename from src/runtime/model_import_mver_image.c rename to src/runtime/model/import/mver/model_import_mver_image.c diff --git a/src/runtime/model_import_mver_internal.h b/src/runtime/model/import/mver/model_import_mver_internal.h similarity index 100% rename from src/runtime/model_import_mver_internal.h rename to src/runtime/model/import/mver/model_import_mver_internal.h diff --git a/src/runtime/model_import_mver_labels.c b/src/runtime/model/import/mver/model_import_mver_labels.c similarity index 100% rename from src/runtime/model_import_mver_labels.c rename to src/runtime/model/import/mver/model_import_mver_labels.c diff --git a/src/runtime/model_import_mver_metadata.c b/src/runtime/model/import/mver/model_import_mver_metadata.c similarity index 100% rename from src/runtime/model_import_mver_metadata.c rename to src/runtime/model/import/mver/model_import_mver_metadata.c diff --git a/src/runtime/model_import_mver_motion.c b/src/runtime/model/import/mver/model_import_mver_motion.c similarity index 100% rename from src/runtime/model_import_mver_motion.c rename to src/runtime/model/import/mver/model_import_mver_motion.c diff --git a/src/runtime/model_import_mver_patch.c b/src/runtime/model/import/mver/model_import_mver_patch.c similarity index 100% rename from src/runtime/model_import_mver_patch.c rename to src/runtime/model/import/mver/model_import_mver_patch.c diff --git a/src/runtime/model_import_mver_shortcut.c b/src/runtime/model/import/mver/model_import_mver_shortcut.c similarity index 100% rename from src/runtime/model_import_mver_shortcut.c rename to src/runtime/model/import/mver/model_import_mver_shortcut.c diff --git a/src/runtime/model_import_nearby.c b/src/runtime/model/import/nearby/model_import_nearby.c similarity index 100% rename from src/runtime/model_import_nearby.c rename to src/runtime/model/import/nearby/model_import_nearby.c diff --git a/src/runtime/model_import_nearby_cache.c b/src/runtime/model/import/nearby/model_import_nearby_cache.c similarity index 100% rename from src/runtime/model_import_nearby_cache.c rename to src/runtime/model/import/nearby/model_import_nearby_cache.c diff --git a/src/runtime/model_import_nearby_internal.h b/src/runtime/model/import/nearby/model_import_nearby_internal.h similarity index 100% rename from src/runtime/model_import_nearby_internal.h rename to src/runtime/model/import/nearby/model_import_nearby_internal.h diff --git a/src/runtime/model_import_nearby_signature.c b/src/runtime/model/import/nearby/model_import_nearby_signature.c similarity index 100% rename from src/runtime/model_import_nearby_signature.c rename to src/runtime/model/import/nearby/model_import_nearby_signature.c diff --git a/src/runtime/model_catalog_runtime.c b/src/runtime/model/model_catalog_runtime.c similarity index 100% rename from src/runtime/model_catalog_runtime.c rename to src/runtime/model/model_catalog_runtime.c diff --git a/src/runtime/model_files.c b/src/runtime/model/model_files.c similarity index 100% rename from src/runtime/model_files.c rename to src/runtime/model/model_files.c diff --git a/src/runtime/model_storage.c b/src/runtime/model/model_storage.c similarity index 100% rename from src/runtime/model_storage.c rename to src/runtime/model/model_storage.c diff --git a/src/runtime/model_storage.h b/src/runtime/model/model_storage.h similarity index 100% rename from src/runtime/model_storage.h rename to src/runtime/model/model_storage.h diff --git a/src/runtime/model_update.c b/src/runtime/model/model_update.c similarity index 100% rename from src/runtime/model_update.c rename to src/runtime/model/model_update.c diff --git a/src/runtime/modal_frame.c b/src/runtime/shell/modal_frame.c similarity index 100% rename from src/runtime/modal_frame.c rename to src/runtime/shell/modal_frame.c diff --git a/src/runtime/modal_frame.h b/src/runtime/shell/modal_frame.h similarity index 100% rename from src/runtime/modal_frame.h rename to src/runtime/shell/modal_frame.h diff --git a/src/runtime/tray.c b/src/runtime/shell/tray.c similarity index 100% rename from src/runtime/tray.c rename to src/runtime/shell/tray.c diff --git a/src/runtime/window.c b/src/runtime/shell/window.c similarity index 100% rename from src/runtime/window.c rename to src/runtime/shell/window.c diff --git a/src/runtime/window_background.c b/src/runtime/shell/window_background.c similarity index 94% rename from src/runtime/window_background.c rename to src/runtime/shell/window_background.c index a3e903a5..c4647049 100644 --- a/src/runtime/window_background.c +++ b/src/runtime/shell/window_background.c @@ -12,7 +12,7 @@ void bongo_cat_window_clear_background(BongoCatApp *app) { window->obs_background ? (rgb & 255) / 255.0f : 0.0f, alpha); glClear(GL_COLOR_BUFFER_BIT); static int last_enabled = -1, last_color = -1; - if (last_enabled != window->obs_background || + if (last_enabled != (window->obs_background ? 1 : 0) || last_color != window->obs_background_color) { last_enabled = window->obs_background; last_color = window->obs_background_color; diff --git a/src/runtime/window_display.c b/src/runtime/shell/window_display.c similarity index 100% rename from src/runtime/window_display.c rename to src/runtime/shell/window_display.c diff --git a/src/runtime/window_drag.c b/src/runtime/shell/window_drag.c similarity index 100% rename from src/runtime/window_drag.c rename to src/runtime/shell/window_drag.c diff --git a/src/runtime/window_geometry.c b/src/runtime/shell/window_geometry.c similarity index 100% rename from src/runtime/window_geometry.c rename to src/runtime/shell/window_geometry.c diff --git a/src/runtime/window_hit.c b/src/runtime/shell/window_hit.c similarity index 100% rename from src/runtime/window_hit.c rename to src/runtime/shell/window_hit.c diff --git a/src/runtime/window_menu.h b/src/runtime/shell/window_menu.h similarity index 100% rename from src/runtime/window_menu.h rename to src/runtime/shell/window_menu.h diff --git a/src/runtime/window_menu_actions.c b/src/runtime/shell/window_menu_actions.c similarity index 99% rename from src/runtime/window_menu_actions.c rename to src/runtime/shell/window_menu_actions.c index 717fe86c..92992712 100644 --- a/src/runtime/window_menu_actions.c +++ b/src/runtime/shell/window_menu_actions.c @@ -2,7 +2,7 @@ #include "window_menu.h" #include "bongo_cat/i18n.h" #include "bongo_cat/preferences.h" -#include "../ui/preferences_notice.h" +#include "preferences_notice.h" #include #include diff --git a/src/runtime/window_menu_behavior.c b/src/runtime/shell/window_menu_behavior.c similarity index 100% rename from src/runtime/window_menu_behavior.c rename to src/runtime/shell/window_menu_behavior.c diff --git a/src/runtime/window_menu_preview.c b/src/runtime/shell/window_menu_preview.c similarity index 100% rename from src/runtime/window_menu_preview.c rename to src/runtime/shell/window_menu_preview.c diff --git a/src/runtime/window_wheel.c b/src/runtime/shell/window_wheel.c similarity index 100% rename from src/runtime/window_wheel.c rename to src/runtime/shell/window_wheel.c diff --git a/src/ui/nuklear_config.h b/src/ui/backend/nuklear_config.h similarity index 100% rename from src/ui/nuklear_config.h rename to src/ui/backend/nuklear_config.h diff --git a/src/ui/nuklear_impl.c b/src/ui/backend/nuklear_impl.c similarity index 100% rename from src/ui/nuklear_impl.c rename to src/ui/backend/nuklear_impl.c diff --git a/src/ui/ui_backend.c b/src/ui/backend/ui_backend.c similarity index 100% rename from src/ui/ui_backend.c rename to src/ui/backend/ui_backend.c diff --git a/src/ui/ui_backend.h b/src/ui/backend/ui_backend.h similarity index 100% rename from src/ui/ui_backend.h rename to src/ui/backend/ui_backend.h diff --git a/src/ui/ui_cursor.c b/src/ui/backend/ui_cursor.c similarity index 100% rename from src/ui/ui_cursor.c rename to src/ui/backend/ui_cursor.c diff --git a/src/ui/ui_input.c b/src/ui/backend/ui_input.c similarity index 100% rename from src/ui/ui_input.c rename to src/ui/backend/ui_input.c diff --git a/src/ui/ui_resize_cache.c b/src/ui/backend/ui_resize_cache.c similarity index 100% rename from src/ui/ui_resize_cache.c rename to src/ui/backend/ui_resize_cache.c diff --git a/src/ui/ui_scale.c b/src/ui/backend/ui_scale.c similarity index 100% rename from src/ui/ui_scale.c rename to src/ui/backend/ui_scale.c diff --git a/src/ui/ui_tooltip.c b/src/ui/backend/ui_tooltip.c similarity index 100% rename from src/ui/ui_tooltip.c rename to src/ui/backend/ui_tooltip.c diff --git a/src/ui/ui_tooltip.h b/src/ui/backend/ui_tooltip.h similarity index 100% rename from src/ui/ui_tooltip.h rename to src/ui/backend/ui_tooltip.h diff --git a/src/ui/preferences.c b/src/ui/preferences/preferences.c similarity index 100% rename from src/ui/preferences.c rename to src/ui/preferences/preferences.c diff --git a/src/ui/preferences_about.c b/src/ui/preferences/preferences_about.c similarity index 100% rename from src/ui/preferences_about.c rename to src/ui/preferences/preferences_about.c diff --git a/src/ui/preferences_about_community.c b/src/ui/preferences/preferences_about_community.c similarity index 100% rename from src/ui/preferences_about_community.c rename to src/ui/preferences/preferences_about_community.c diff --git a/src/ui/preferences_about_community.h b/src/ui/preferences/preferences_about_community.h similarity index 100% rename from src/ui/preferences_about_community.h rename to src/ui/preferences/preferences_about_community.h diff --git a/src/ui/preferences_assets.c b/src/ui/preferences/preferences_assets.c similarity index 100% rename from src/ui/preferences_assets.c rename to src/ui/preferences/preferences_assets.c diff --git a/src/ui/preferences_behavior_dialog.c b/src/ui/preferences/preferences_behavior_dialog.c similarity index 100% rename from src/ui/preferences_behavior_dialog.c rename to src/ui/preferences/preferences_behavior_dialog.c diff --git a/src/ui/preferences_behavior_rename.c b/src/ui/preferences/preferences_behavior_rename.c similarity index 100% rename from src/ui/preferences_behavior_rename.c rename to src/ui/preferences/preferences_behavior_rename.c diff --git a/src/ui/preferences_behavior_row.c b/src/ui/preferences/preferences_behavior_row.c similarity index 100% rename from src/ui/preferences_behavior_row.c rename to src/ui/preferences/preferences_behavior_row.c diff --git a/src/ui/preferences_combo.c b/src/ui/preferences/preferences_combo.c similarity index 100% rename from src/ui/preferences_combo.c rename to src/ui/preferences/preferences_combo.c diff --git a/src/ui/preferences_controls.c b/src/ui/preferences/preferences_controls.c similarity index 100% rename from src/ui/preferences_controls.c rename to src/ui/preferences/preferences_controls.c diff --git a/src/ui/preferences_controls.h b/src/ui/preferences/preferences_controls.h similarity index 100% rename from src/ui/preferences_controls.h rename to src/ui/preferences/preferences_controls.h diff --git a/src/ui/preferences_dialog.c b/src/ui/preferences/preferences_dialog.c similarity index 100% rename from src/ui/preferences_dialog.c rename to src/ui/preferences/preferences_dialog.c diff --git a/src/ui/preferences_fonts.c b/src/ui/preferences/preferences_fonts.c similarity index 100% rename from src/ui/preferences_fonts.c rename to src/ui/preferences/preferences_fonts.c diff --git a/src/ui/preferences_fonts.h b/src/ui/preferences/preferences_fonts.h similarity index 100% rename from src/ui/preferences_fonts.h rename to src/ui/preferences/preferences_fonts.h diff --git a/src/ui/preferences_import.c b/src/ui/preferences/preferences_import.c similarity index 99% rename from src/ui/preferences_import.c rename to src/ui/preferences/preferences_import.c index 0326af9a..859eb1a0 100644 --- a/src/ui/preferences_import.c +++ b/src/ui/preferences/preferences_import.c @@ -1,6 +1,6 @@ #include "preferences_internal.h" #include "preferences_state.h" -#include "../runtime/model_import.h" +#include "model_import.h" #include #include diff --git a/src/ui/preferences_internal.h b/src/ui/preferences/preferences_internal.h similarity index 100% rename from src/ui/preferences_internal.h rename to src/ui/preferences/preferences_internal.h diff --git a/src/ui/preferences_language.c b/src/ui/preferences/preferences_language.c similarity index 100% rename from src/ui/preferences_language.c rename to src/ui/preferences/preferences_language.c diff --git a/src/ui/preferences_live_resize.c b/src/ui/preferences/preferences_live_resize.c similarity index 100% rename from src/ui/preferences_live_resize.c rename to src/ui/preferences/preferences_live_resize.c diff --git a/src/ui/preferences_model.c b/src/ui/preferences/preferences_model.c similarity index 100% rename from src/ui/preferences_model.c rename to src/ui/preferences/preferences_model.c diff --git a/src/ui/preferences_model_card.c b/src/ui/preferences/preferences_model_card.c similarity index 75% rename from src/ui/preferences_model_card.c rename to src/ui/preferences/preferences_model_card.c index 4dc227f0..5bc24b46 100644 --- a/src/ui/preferences_model_card.c +++ b/src/ui/preferences/preferences_model_card.c @@ -1,4 +1,5 @@ #include "preferences_model_card.h" +#include "preferences_model_card_paint.h" #include "preferences_model_cover.h" #include "preferences_notice.h" #include "preferences_state.h" @@ -14,8 +15,6 @@ #include #include -#define MODEL_PROGRESS_ARC_SEGMENTS 16 -#define MODEL_PROGRESS_POINT_CAP 80 #define MODEL_SELECTION_FADE_MS 240.0f static const char *tr(BongoCatApp *app, const char *key, @@ -31,87 +30,6 @@ static void text(struct nk_context *context, struct nk_command_buffer *canvas, nk_rgba(0, 0, 0, 0), color); } -static struct nk_rect outline_bounds(struct nk_rect bounds, float thickness) { - float inset = thickness * .5f; - return nk_rect(bounds.x + inset, bounds.y + inset, - NK_MAX(1.0f, bounds.w - thickness), - NK_MAX(1.0f, bounds.h - thickness)); -} - -static int progress_point(float *points, int count, float x, float y) { - if (count >= MODEL_PROGRESS_POINT_CAP) return count; - points[count * 2] = x; - points[count * 2 + 1] = y; - return count + 1; -} - -static int rounded_outline_path(struct nk_rect bounds, float rounding, - float *points) { - const float pi = 3.14159265358979323846f; - float radius = NK_CLAMP(0.0f, rounding, - NK_MIN(bounds.w, bounds.h) * .5f); - float centers_x[] = {bounds.x + bounds.w - radius, - bounds.x + bounds.w - radius, bounds.x + radius, bounds.x + radius}; - float centers_y[] = {bounds.y + radius, bounds.y + bounds.h - radius, - bounds.y + bounds.h - radius, bounds.y + radius}; - float starts[] = {-pi * .5f, 0.0f, pi * .5f, pi}; - int count = 0; - count = progress_point(points, count, bounds.x + radius, bounds.y); - for (int corner = 0; corner < 4; ++corner) { - for (int i = 0; i <= MODEL_PROGRESS_ARC_SEGMENTS; ++i) { - float angle = starts[corner] + pi * .5f * i / - MODEL_PROGRESS_ARC_SEGMENTS; - count = progress_point(points, count, - centers_x[corner] + cosf(angle) * radius, - centers_y[corner] + sinf(angle) * radius); - } - } - count = progress_point(points, count, points[0], points[1]); - return count; -} - -static void draw_progress_outline(struct nk_command_buffer *canvas, - struct nk_rect bounds, float rounding, float thickness, float progress, - struct nk_color color) { - progress = NK_CLAMP(0.0f, progress, 1.0f); - if (progress <= 0.001f) return; - if (progress >= .999f) { - nk_stroke_rect(canvas, bounds, rounding, thickness, color); - return; - } - float path[MODEL_PROGRESS_POINT_CAP * 2]; - int path_count = rounded_outline_path(bounds, rounding, path); - if (path_count < 2) return; - float lengths[MODEL_PROGRESS_POINT_CAP]; - lengths[0] = 0.0f; - for (int i = 1; i < path_count; ++i) { - float dx = path[i * 2] - path[(i - 1) * 2]; - float dy = path[i * 2 + 1] - path[(i - 1) * 2 + 1]; - lengths[i] = lengths[i - 1] + sqrtf(dx * dx + dy * dy); - } - float target = lengths[path_count - 1] * progress; - float partial[MODEL_PROGRESS_POINT_CAP * 2]; - int partial_count = progress_point(partial, 0, path[0], path[1]); - for (int i = 1; i < path_count; ++i) { - if (target >= lengths[i]) { - partial_count = progress_point(partial, partial_count, - path[i * 2], path[i * 2 + 1]); - continue; - } - float segment = lengths[i] - lengths[i - 1]; - float amount = segment > 0.0f ? - (target - lengths[i - 1]) / segment : 0.0f; - partial_count = progress_point(partial, partial_count, - path[(i - 1) * 2] + - (path[i * 2] - path[(i - 1) * 2]) * amount, - path[(i - 1) * 2 + 1] + - (path[i * 2 + 1] - path[(i - 1) * 2 + 1]) * amount); - break; - } - if (partial_count >= 2) - nk_stroke_polyline(canvas, partial, partial_count, thickness, color); -} - bool bongo_cat_preferences_model_import_card(BongoCatPreferences *value, struct nk_context *context) { struct nk_rect bounds; @@ -287,7 +205,8 @@ void bongo_cat_preferences_model_card(BongoCatPreferences *value, draw_actions(value, context, canvas, bounds, entry, p, selected_visual, &action_hover); float outline_width = 1.0f + selection_amount; - struct nk_rect outline = outline_bounds(bounds, outline_width); + struct nk_rect outline = bongo_cat_preferences_model_card_outline_bounds( + bounds, outline_width); float progress = selected_visual ? 1.0f : transition_target ? value->model_load_progress : 0.0f; struct nk_color outline_color = progress >= .999f ? p.pink : @@ -296,8 +215,8 @@ void bongo_cat_preferences_model_card(BongoCatPreferences *value, p.pink, selection_amount); nk_stroke_rect(canvas, outline, 14.0f - outline_width * .5f, outline_width, outline_color); - draw_progress_outline(canvas, outline, 14.0f - outline_width * .5f, - 2.0f, progress, p.pink); + bongo_cat_preferences_model_card_draw_progress(canvas, outline, + 14.0f - outline_width * .5f, 2.0f, progress, p.pink); if (!name_hover && (action_hover || hover)) bongo_cat_ui_cursor_hover_rect(context, bounds, BONGO_CAT_UI_CURSOR_POINTER); diff --git a/src/ui/preferences_model_card.h b/src/ui/preferences/preferences_model_card.h similarity index 100% rename from src/ui/preferences_model_card.h rename to src/ui/preferences/preferences_model_card.h diff --git a/src/ui/preferences/preferences_model_card_paint.c b/src/ui/preferences/preferences_model_card_paint.c new file mode 100644 index 00000000..fa236cad --- /dev/null +++ b/src/ui/preferences/preferences_model_card_paint.c @@ -0,0 +1,88 @@ +#include "preferences_model_card_paint.h" + +#include + +#define MODEL_PROGRESS_ARC_SEGMENTS 16 +#define MODEL_PROGRESS_POINT_CAP 80 + +struct nk_rect bongo_cat_preferences_model_card_outline_bounds( + struct nk_rect bounds, float thickness) { + float inset = thickness * .5f; + return nk_rect(bounds.x + inset, bounds.y + inset, + NK_MAX(1.0f, bounds.w - thickness), + NK_MAX(1.0f, bounds.h - thickness)); +} + +static int progress_point(float *points, int count, float x, float y) { + if (count >= MODEL_PROGRESS_POINT_CAP) return count; + points[count * 2] = x; + points[count * 2 + 1] = y; + return count + 1; +} + +static int rounded_outline_path(struct nk_rect bounds, float rounding, + float *points) { + const float pi = 3.14159265358979323846f; + float radius = NK_CLAMP(0.0f, rounding, + NK_MIN(bounds.w, bounds.h) * .5f); + float centers_x[] = {bounds.x + bounds.w - radius, + bounds.x + bounds.w - radius, bounds.x + radius, bounds.x + radius}; + float centers_y[] = {bounds.y + radius, bounds.y + bounds.h - radius, + bounds.y + bounds.h - radius, bounds.y + radius}; + float starts[] = {-pi * .5f, 0.0f, pi * .5f, pi}; + int count = 0; + count = progress_point(points, count, bounds.x + radius, bounds.y); + for (int corner = 0; corner < 4; ++corner) { + for (int i = 0; i <= MODEL_PROGRESS_ARC_SEGMENTS; ++i) { + float angle = starts[corner] + pi * .5f * i / + MODEL_PROGRESS_ARC_SEGMENTS; + count = progress_point(points, count, + centers_x[corner] + cosf(angle) * radius, + centers_y[corner] + sinf(angle) * radius); + } + } + count = progress_point(points, count, points[0], points[1]); + return count; +} + +void bongo_cat_preferences_model_card_draw_progress( + struct nk_command_buffer *canvas, struct nk_rect bounds, float rounding, + float thickness, float progress, struct nk_color color) { + progress = NK_CLAMP(0.0f, progress, 1.0f); + if (progress <= 0.001f) return; + if (progress >= .999f) { + nk_stroke_rect(canvas, bounds, rounding, thickness, color); + return; + } + float path[MODEL_PROGRESS_POINT_CAP * 2]; + int path_count = rounded_outline_path(bounds, rounding, path); + if (path_count < 2) return; + float lengths[MODEL_PROGRESS_POINT_CAP]; + lengths[0] = 0.0f; + for (int i = 1; i < path_count; ++i) { + float dx = path[i * 2] - path[(i - 1) * 2]; + float dy = path[i * 2 + 1] - path[(i - 1) * 2 + 1]; + lengths[i] = lengths[i - 1] + sqrtf(dx * dx + dy * dy); + } + float target = lengths[path_count - 1] * progress; + float partial[MODEL_PROGRESS_POINT_CAP * 2]; + int partial_count = progress_point(partial, 0, path[0], path[1]); + for (int i = 1; i < path_count; ++i) { + if (target >= lengths[i]) { + partial_count = progress_point(partial, partial_count, + path[i * 2], path[i * 2 + 1]); + continue; + } + float segment = lengths[i] - lengths[i - 1]; + float amount = segment > 0.0f ? + (target - lengths[i - 1]) / segment : 0.0f; + partial_count = progress_point(partial, partial_count, + path[(i - 1) * 2] + + (path[i * 2] - path[(i - 1) * 2]) * amount, + path[(i - 1) * 2 + 1] + + (path[i * 2 + 1] - path[(i - 1) * 2 + 1]) * amount); + break; + } + if (partial_count >= 2) + nk_stroke_polyline(canvas, partial, partial_count, thickness, color); +} diff --git a/src/ui/preferences/preferences_model_card_paint.h b/src/ui/preferences/preferences_model_card_paint.h new file mode 100644 index 00000000..ed1dabf9 --- /dev/null +++ b/src/ui/preferences/preferences_model_card_paint.h @@ -0,0 +1,12 @@ +#ifndef BONGO_CAT_PREFERENCES_MODEL_CARD_PAINT_H +#define BONGO_CAT_PREFERENCES_MODEL_CARD_PAINT_H + +#include "nuklear_config.h" + +struct nk_rect bongo_cat_preferences_model_card_outline_bounds( + struct nk_rect bounds, float thickness); +void bongo_cat_preferences_model_card_draw_progress( + struct nk_command_buffer *canvas, struct nk_rect bounds, float rounding, + float thickness, float progress, struct nk_color color); + +#endif diff --git a/src/ui/preferences_model_cover.c b/src/ui/preferences/preferences_model_cover.c similarity index 99% rename from src/ui/preferences_model_cover.c rename to src/ui/preferences/preferences_model_cover.c index 4dcf5adc..cd0487f7 100644 --- a/src/ui/preferences_model_cover.c +++ b/src/ui/preferences/preferences_model_cover.c @@ -1,6 +1,6 @@ #include "preferences_model_cover.h" #include "preferences_internal.h" -#include "../runtime/runtime.h" +#include "runtime.h" #include "bongo_cat/file.h" #include "bongo_cat/image.h" #include "bongo_cat/path.h" diff --git a/src/ui/preferences_model_cover.h b/src/ui/preferences/preferences_model_cover.h similarity index 100% rename from src/ui/preferences_model_cover.h rename to src/ui/preferences/preferences_model_cover.h diff --git a/src/ui/preferences_model_glyphs.c b/src/ui/preferences/preferences_model_glyphs.c similarity index 100% rename from src/ui/preferences_model_glyphs.c rename to src/ui/preferences/preferences_model_glyphs.c diff --git a/src/ui/preferences_model_glyphs.h b/src/ui/preferences/preferences_model_glyphs.h similarity index 100% rename from src/ui/preferences_model_glyphs.h rename to src/ui/preferences/preferences_model_glyphs.h diff --git a/src/ui/preferences_model_rename.c b/src/ui/preferences/preferences_model_rename.c similarity index 100% rename from src/ui/preferences_model_rename.c rename to src/ui/preferences/preferences_model_rename.c diff --git a/src/ui/preferences_notice.c b/src/ui/preferences/preferences_notice.c similarity index 100% rename from src/ui/preferences_notice.c rename to src/ui/preferences/preferences_notice.c diff --git a/src/ui/preferences_notice.h b/src/ui/preferences/preferences_notice.h similarity index 100% rename from src/ui/preferences_notice.h rename to src/ui/preferences/preferences_notice.h diff --git a/src/ui/preferences_overlay.c b/src/ui/preferences/preferences_overlay.c similarity index 100% rename from src/ui/preferences_overlay.c rename to src/ui/preferences/preferences_overlay.c diff --git a/src/ui/preferences_overlay.h b/src/ui/preferences/preferences_overlay.h similarity index 100% rename from src/ui/preferences_overlay.h rename to src/ui/preferences/preferences_overlay.h diff --git a/src/ui/preferences_pages.c b/src/ui/preferences/preferences_pages.c similarity index 99% rename from src/ui/preferences_pages.c rename to src/ui/preferences/preferences_pages.c index ba181070..0d54260d 100644 --- a/src/ui/preferences_pages.c +++ b/src/ui/preferences/preferences_pages.c @@ -6,7 +6,7 @@ #include "bongo_cat/i18n.h" #include "bongo_cat/preferences.h" #include "bongo_cat/tray.h" -#include "../runtime/runtime.h" +#include "runtime.h" #include #include diff --git a/src/ui/preferences_render.c b/src/ui/preferences/preferences_render.c similarity index 100% rename from src/ui/preferences_render.c rename to src/ui/preferences/preferences_render.c diff --git a/src/ui/preferences_scale.c b/src/ui/preferences/preferences_scale.c similarity index 100% rename from src/ui/preferences_scale.c rename to src/ui/preferences/preferences_scale.c diff --git a/src/ui/preferences_scrollbar.c b/src/ui/preferences/preferences_scrollbar.c similarity index 100% rename from src/ui/preferences_scrollbar.c rename to src/ui/preferences/preferences_scrollbar.c diff --git a/src/ui/preferences_scrollbar.h b/src/ui/preferences/preferences_scrollbar.h similarity index 100% rename from src/ui/preferences_scrollbar.h rename to src/ui/preferences/preferences_scrollbar.h diff --git a/src/ui/preferences_shortcut_clear.c b/src/ui/preferences/preferences_shortcut_clear.c similarity index 100% rename from src/ui/preferences_shortcut_clear.c rename to src/ui/preferences/preferences_shortcut_clear.c diff --git a/src/ui/preferences_shortcut_clear.h b/src/ui/preferences/preferences_shortcut_clear.h similarity index 100% rename from src/ui/preferences_shortcut_clear.h rename to src/ui/preferences/preferences_shortcut_clear.h diff --git a/src/ui/preferences_shortcuts.c b/src/ui/preferences/preferences_shortcuts.c similarity index 100% rename from src/ui/preferences_shortcuts.c rename to src/ui/preferences/preferences_shortcuts.c diff --git a/src/ui/preferences_smoke.c b/src/ui/preferences/preferences_smoke.c similarity index 100% rename from src/ui/preferences_smoke.c rename to src/ui/preferences/preferences_smoke.c diff --git a/src/ui/preferences_state.h b/src/ui/preferences/preferences_state.h similarity index 99% rename from src/ui/preferences_state.h rename to src/ui/preferences/preferences_state.h index 92796685..c8493018 100644 --- a/src/ui/preferences_state.h +++ b/src/ui/preferences/preferences_state.h @@ -7,7 +7,7 @@ #include "ui_backend.h" #include "bongo_cat/i18n.h" #include "bongo_cat/preferences.h" -#include "../runtime/modal_frame.h" +#include "modal_frame.h" typedef struct BongoCatPreferenceNotice { char message[384]; diff --git a/src/ui/preferences_text_edit.c b/src/ui/preferences/preferences_text_edit.c similarity index 100% rename from src/ui/preferences_text_edit.c rename to src/ui/preferences/preferences_text_edit.c diff --git a/src/ui/preferences_text_edit.h b/src/ui/preferences/preferences_text_edit.h similarity index 100% rename from src/ui/preferences_text_edit.h rename to src/ui/preferences/preferences_text_edit.h diff --git a/src/ui/preferences_text_session.c b/src/ui/preferences/preferences_text_session.c similarity index 100% rename from src/ui/preferences_text_session.c rename to src/ui/preferences/preferences_text_session.c diff --git a/src/ui/preferences_text_session.h b/src/ui/preferences/preferences_text_session.h similarity index 100% rename from src/ui/preferences_text_session.h rename to src/ui/preferences/preferences_text_session.h diff --git a/src/ui/preferences_theme.c b/src/ui/preferences/preferences_theme.c similarity index 100% rename from src/ui/preferences_theme.c rename to src/ui/preferences/preferences_theme.c diff --git a/src/ui/preferences_theme.h b/src/ui/preferences/preferences_theme.h similarity index 100% rename from src/ui/preferences_theme.h rename to src/ui/preferences/preferences_theme.h diff --git a/src/ui/preferences_toggle.c b/src/ui/preferences/preferences_toggle.c similarity index 100% rename from src/ui/preferences_toggle.c rename to src/ui/preferences/preferences_toggle.c diff --git a/src/ui/preferences_widgets.c b/src/ui/preferences/preferences_widgets.c similarity index 100% rename from src/ui/preferences_widgets.c rename to src/ui/preferences/preferences_widgets.c diff --git a/src/ui/preferences_widgets.h b/src/ui/preferences/preferences_widgets.h similarity index 100% rename from src/ui/preferences_widgets.h rename to src/ui/preferences/preferences_widgets.h diff --git a/src/ui/ui_animation.c b/src/ui/rendering/ui_animation.c similarity index 100% rename from src/ui/ui_animation.c rename to src/ui/rendering/ui_animation.c diff --git a/src/ui/ui_animation.h b/src/ui/rendering/ui_animation.h similarity index 100% rename from src/ui/ui_animation.h rename to src/ui/rendering/ui_animation.h diff --git a/src/ui/ui_font.c b/src/ui/rendering/ui_font.c similarity index 100% rename from src/ui/ui_font.c rename to src/ui/rendering/ui_font.c diff --git a/src/ui/ui_font.h b/src/ui/rendering/ui_font.h similarity index 100% rename from src/ui/ui_font.h rename to src/ui/rendering/ui_font.h diff --git a/src/ui/ui_font_atlas.c b/src/ui/rendering/ui_font_atlas.c similarity index 100% rename from src/ui/ui_font_atlas.c rename to src/ui/rendering/ui_font_atlas.c diff --git a/src/ui/ui_font_atlas.h b/src/ui/rendering/ui_font_atlas.h similarity index 100% rename from src/ui/ui_font_atlas.h rename to src/ui/rendering/ui_font_atlas.h diff --git a/src/ui/ui_font_atlas_internal.h b/src/ui/rendering/ui_font_atlas_internal.h similarity index 100% rename from src/ui/ui_font_atlas_internal.h rename to src/ui/rendering/ui_font_atlas_internal.h diff --git a/src/ui/ui_font_atlas_ranges.c b/src/ui/rendering/ui_font_atlas_ranges.c similarity index 100% rename from src/ui/ui_font_atlas_ranges.c rename to src/ui/rendering/ui_font_atlas_ranges.c diff --git a/src/ui/ui_font_atlas_sources.c b/src/ui/rendering/ui_font_atlas_sources.c similarity index 100% rename from src/ui/ui_font_atlas_sources.c rename to src/ui/rendering/ui_font_atlas_sources.c diff --git a/src/ui/ui_font_atlas_upload.c b/src/ui/rendering/ui_font_atlas_upload.c similarity index 100% rename from src/ui/ui_font_atlas_upload.c rename to src/ui/rendering/ui_font_atlas_upload.c diff --git a/src/ui/ui_font_reload.c b/src/ui/rendering/ui_font_reload.c similarity index 100% rename from src/ui/ui_font_reload.c rename to src/ui/rendering/ui_font_reload.c diff --git a/src/ui/ui_icons.h b/src/ui/rendering/ui_icons.h similarity index 100% rename from src/ui/ui_icons.h rename to src/ui/rendering/ui_icons.h diff --git a/src/ui/ui_native_theme.c b/src/ui/rendering/ui_native_theme.c similarity index 100% rename from src/ui/ui_native_theme.c rename to src/ui/rendering/ui_native_theme.c diff --git a/src/ui/ui_native_theme.h b/src/ui/rendering/ui_native_theme.h similarity index 100% rename from src/ui/ui_native_theme.h rename to src/ui/rendering/ui_native_theme.h diff --git a/src/ui/ui_paint.c b/src/ui/rendering/ui_paint.c similarity index 100% rename from src/ui/ui_paint.c rename to src/ui/rendering/ui_paint.c diff --git a/src/ui/ui_paint.h b/src/ui/rendering/ui_paint.h similarity index 100% rename from src/ui/ui_paint.h rename to src/ui/rendering/ui_paint.h diff --git a/src/ui/ui_paint_border.c b/src/ui/rendering/ui_paint_border.c similarity index 100% rename from src/ui/ui_paint_border.c rename to src/ui/rendering/ui_paint_border.c diff --git a/src/ui/ui_paint_border.h b/src/ui/rendering/ui_paint_border.h similarity index 100% rename from src/ui/ui_paint_border.h rename to src/ui/rendering/ui_paint_border.h diff --git a/src/ui/ui_paint_cache.c b/src/ui/rendering/ui_paint_cache.c similarity index 100% rename from src/ui/ui_paint_cache.c rename to src/ui/rendering/ui_paint_cache.c diff --git a/src/ui/ui_paint_cache.h b/src/ui/rendering/ui_paint_cache.h similarity index 100% rename from src/ui/ui_paint_cache.h rename to src/ui/rendering/ui_paint_cache.h diff --git a/src/ui/ui_paint_shell.c b/src/ui/rendering/ui_paint_shell.c similarity index 100% rename from src/ui/ui_paint_shell.c rename to src/ui/rendering/ui_paint_shell.c diff --git a/src/ui/ui_catime.c b/src/ui/theme/ui_catime.c similarity index 100% rename from src/ui/ui_catime.c rename to src/ui/theme/ui_catime.c diff --git a/src/ui/ui_catime.h b/src/ui/theme/ui_catime.h similarity index 100% rename from src/ui/ui_catime.h rename to src/ui/theme/ui_catime.h diff --git a/src/ui/ui_catime_icons.c b/src/ui/theme/ui_catime_icons.c similarity index 100% rename from src/ui/ui_catime_icons.c rename to src/ui/theme/ui_catime_icons.c diff --git a/src/ui/ui_catime_tabs.c b/src/ui/theme/ui_catime_tabs.c similarity index 100% rename from src/ui/ui_catime_tabs.c rename to src/ui/theme/ui_catime_tabs.c diff --git a/src/ui/ui_catime_theme.c b/src/ui/theme/ui_catime_theme.c similarity index 100% rename from src/ui/ui_catime_theme.c rename to src/ui/theme/ui_catime_theme.c diff --git a/tests/test_app_state.c b/tests/core/test_app_state.c similarity index 100% rename from tests/test_app_state.c rename to tests/core/test_app_state.c diff --git a/tests/test_config.c b/tests/core/test_config.c similarity index 100% rename from tests/test_config.c rename to tests/core/test_config.c diff --git a/tests/test_input.c b/tests/core/test_input.c similarity index 99% rename from tests/test_input.c rename to tests/core/test_input.c index 82a64d87..d3271bf4 100644 --- a/tests/test_input.c +++ b/tests/core/test_input.c @@ -4,7 +4,7 @@ #include #ifdef _WIN32 -#include "../src/platform/windows_keys.h" +#include "windows_keys.h" #endif void test_input(void) { diff --git a/tests/test_main.c b/tests/core/test_main.c similarity index 100% rename from tests/test_main.c rename to tests/core/test_main.c diff --git a/tests/test_models.c b/tests/core/test_models.c similarity index 82% rename from tests/test_models.c rename to tests/core/test_models.c index 7c152697..f6e5a050 100644 --- a/tests/test_models.c +++ b/tests/core/test_models.c @@ -4,6 +4,7 @@ #include "bongo_cat/path.h" #include +#include #include typedef struct ParameterSet { @@ -121,37 +122,43 @@ static bool validate_model(const BongoCatModelEntry *model) { } void test_models(void) { - BongoCatModelCatalog catalog; + BongoCatModelCatalog *catalog = calloc(1, sizeof(*catalog)); + BongoCatBehaviorCatalog *behaviors = calloc(1, sizeof(*behaviors)); BongoCatError error = {0}; - bongo_cat_models_init(&catalog); - CHECK(bongo_cat_models_scan(&catalog, + CHECK(catalog && behaviors); + if (!catalog || !behaviors) { + free(catalog); + free(behaviors); + return; + } + bongo_cat_models_init(catalog); + CHECK(bongo_cat_models_scan(catalog, BONGO_CAT_NATIVE_SOURCE_DIR "/resources/assets/models", false, &error) == BONGO_CAT_OK); - CHECK(catalog.count == 0); - bongo_cat_models_init(&catalog); - CHECK(bongo_cat_models_scan(&catalog, BONGO_CAT_NATIVE_SOURCE_DIR "/resources/assets/models", + CHECK(catalog->count == 0); + bongo_cat_models_init(catalog); + CHECK(bongo_cat_models_scan(catalog, BONGO_CAT_NATIVE_SOURCE_DIR "/resources/assets/models", true, &error) == BONGO_CAT_OK); - CHECK(catalog.count == 3); - const BongoCatModelEntry *standard = bongo_cat_models_find(&catalog, "standard"); - const BongoCatModelEntry *keyboard = bongo_cat_models_find(&catalog, "keyboard"); - const BongoCatModelEntry *gamepad = bongo_cat_models_find(&catalog, "gamepad"); + CHECK(catalog->count == 3); + const BongoCatModelEntry *standard = bongo_cat_models_find(catalog, "standard"); + const BongoCatModelEntry *keyboard = bongo_cat_models_find(catalog, "keyboard"); + const BongoCatModelEntry *gamepad = bongo_cat_models_find(catalog, "gamepad"); CHECK(standard && standard->mode == BONGO_CAT_MODE_STANDARD && validate_model(standard)); CHECK(keyboard && keyboard->mode == BONGO_CAT_MODE_KEYBOARD && validate_model(keyboard)); CHECK(gamepad && gamepad->mode == BONGO_CAT_MODE_GAMEPAD && validate_model(gamepad)); - BongoCatBehaviorCatalog behaviors; - CHECK(bongo_cat_behaviors_load(&behaviors, standard, &error) == BONGO_CAT_OK); - CHECK(behaviors.count == 7); - CHECK(behaviors.entries[0].kind == BONGO_CAT_BEHAVIOR_MOTION); - CHECK(strcmp(behaviors.entries[0].group, "CAT_motion") == 0); - CHECK(strstr(behaviors.entries[0].sound, "live2d_motion1.flac") != NULL); - CHECK(behaviors.entries[6].kind == BONGO_CAT_BEHAVIOR_EXPRESSION); + CHECK(bongo_cat_behaviors_load(behaviors, standard, &error) == BONGO_CAT_OK); + CHECK(behaviors->count == 7); + CHECK(behaviors->entries[0].kind == BONGO_CAT_BEHAVIOR_MOTION); + CHECK(strcmp(behaviors->entries[0].group, "CAT_motion") == 0); + CHECK(strstr(behaviors->entries[0].sound, "live2d_motion1.flac") != NULL); + CHECK(behaviors->entries[6].kind == BONGO_CAT_BEHAVIOR_EXPRESSION); - bongo_cat_models_init(&catalog); - CHECK(bongo_cat_models_scan(&catalog, + bongo_cat_models_init(catalog); + CHECK(bongo_cat_models_scan(catalog, BONGO_CAT_NATIVE_SOURCE_DIR "/tests/fixtures/model-packages", false, &error) == BONGO_CAT_OK); - CHECK(catalog.count == 1); - const BongoCatModelEntry *v2 = bongo_cat_models_find(&catalog, + CHECK(catalog->count == 1); + const BongoCatModelEntry *v2 = bongo_cat_models_find(catalog, "test-package-v2"); CHECK(v2 && v2->package_schema == 2 && strcmp(v2->content_digest, @@ -162,4 +169,6 @@ void test_models(void) { v2->source_format == BONGO_CAT_MODEL_SOURCE_TAURI && v2->adapter_schema == 1 && v2->adapter_generator == 1 && (v2->capabilities & BONGO_CAT_MODEL_CAPABILITY_KEYBOARD_INPUT)); + free(behaviors); + free(catalog); } diff --git a/tests/test_mver_pointer.c b/tests/core/test_mver_pointer.c similarity index 100% rename from tests/test_mver_pointer.c rename to tests/core/test_mver_pointer.c diff --git a/tests/test_shortcut.c b/tests/core/test_shortcut.c similarity index 100% rename from tests/test_shortcut.c rename to tests/core/test_shortcut.c diff --git a/tests/test_i18n.c b/tests/i18n/test_i18n.c similarity index 100% rename from tests/test_i18n.c rename to tests/i18n/test_i18n.c diff --git a/tests/test_model_import_identity.c b/tests/model_import/test_model_import_identity.c similarity index 100% rename from tests/test_model_import_identity.c rename to tests/model_import/test_model_import_identity.c diff --git a/tests/test_mver_import.c b/tests/model_import/test_mver_import.c similarity index 100% rename from tests/test_mver_import.c rename to tests/model_import/test_mver_import.c diff --git a/tests/test_mver_nearby_identity.c b/tests/model_import/test_mver_nearby_identity.c similarity index 100% rename from tests/test_mver_nearby_identity.c rename to tests/model_import/test_mver_nearby_identity.c diff --git a/tests/test_mver_pointer_import.c b/tests/model_import/test_mver_pointer_import.c similarity index 100% rename from tests/test_mver_pointer_import.c rename to tests/model_import/test_mver_pointer_import.c diff --git a/tests/test_mver_support.c b/tests/model_import/test_mver_support.c similarity index 100% rename from tests/test_mver_support.c rename to tests/model_import/test_mver_support.c diff --git a/tests/test_mver_support.h b/tests/model_import/test_mver_support.h similarity index 100% rename from tests/test_mver_support.h rename to tests/model_import/test_mver_support.h diff --git a/tests/test_windows_capture.c b/tests/platform/test_windows_capture.c similarity index 100% rename from tests/test_windows_capture.c rename to tests/platform/test_windows_capture.c diff --git a/tests/test.h b/tests/support/test.h similarity index 100% rename from tests/test.h rename to tests/support/test.h diff --git a/tests/test_nuklear.c b/tests/ui/test_nuklear.c similarity index 96% rename from tests/test_nuklear.c rename to tests/ui/test_nuklear.c index 736719c1..5bf94b37 100644 --- a/tests/test_nuklear.c +++ b/tests/ui/test_nuklear.c @@ -1,5 +1,5 @@ -#include "../src/ui/nuklear_config.h" -#include "../src/ui/ui_paint_border.h" +#include "nuklear_config.h" +#include "ui_paint_border.h" #include #include diff --git a/tests/test_preferences_text.c b/tests/ui/test_preferences_text.c similarity index 100% rename from tests/test_preferences_text.c rename to tests/ui/test_preferences_text.c From ed1d9f4648c61523bd5eda0f52efbed7f6efd9e6 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 16 Aug 2026 17:15:47 +0800 Subject: [PATCH 022/334] fix: keep current model active during switching --- include/bongo_cat/app.h | 1 + src/live2d/cubism_bridge.cpp | 50 ++------------------------------- src/runtime/model/model_files.c | 22 +++++++++++++-- 3 files changed, 23 insertions(+), 50 deletions(-) diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index caf62eba..29d9af8d 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -57,6 +57,7 @@ typedef struct BongoCatApp { bool session_store_blocked; bool autostart_launch; uint64_t startup_raise_due_ns; + uint64_t model_load_last_frame_ns; bool smoke; bool smoke_preferences; bool smoke_preference_shortcut; diff --git a/src/live2d/cubism_bridge.cpp b/src/live2d/cubism_bridge.cpp index dbb82692..d1a12e07 100644 --- a/src/live2d/cubism_bridge.cpp +++ b/src/live2d/cubism_bridge.cpp @@ -1,5 +1,4 @@ #include "bongo_cat/file.h" -#include "bongo_cat/memory.h" #include "bongo_cat/model.h" #if defined(CSM_TARGET_WIN_GL) || defined(CSM_TARGET_LINUX_GL) #include @@ -123,36 +122,6 @@ struct BongoCatLive2D { int height = 354; }; -static bool restore_previous(BongoCatLive2D *runtime, - bongo_cat::NativeModel *previous, BongoCatError *primary) noexcept { - if (!previous) { runtime->model = nullptr; return true; } - BongoCatError restore_error = {}; - try { - if (previous->load_textures(&restore_error, nullptr, nullptr)) { - runtime->model = previous; - return true; - } - } catch (const std::bad_alloc &) { - bongo_cat_error_set(&restore_error, BONGO_CAT_ERROR_MEMORY, - "Out of memory while restoring the previous model"); - } catch (const std::exception &exception) { - bongo_cat_error_set(&restore_error, BONGO_CAT_ERROR_CUBISM, - "Previous model restore failed: %s", exception.what()); - } catch (...) { - bongo_cat_error_set(&restore_error, BONGO_CAT_ERROR_CUBISM, - "Previous model restore failed with an unknown exception"); - } - if (primary) { - char original[sizeof(primary->message)]; - std::snprintf(original, sizeof(original), "%s", primary->message); - bongo_cat_error_set(primary, primary->code, - "%s; restore failed: %s", original, restore_error.message); - } - delete previous; - runtime->model = nullptr; - return false; -} - extern "C" BongoCatLive2D *bongo_cat_live2d_create(const char *asset_root, BongoCatError *error) { resource_root = asset_root ? asset_root : ""; @@ -180,7 +149,6 @@ extern "C" BongoCatResult bongo_cat_live2d_load(BongoCatLive2D *runtime, if (!runtime) return BONGO_CAT_ERROR_ARGUMENT; bongo_cat::NativeModel *previous = runtime->model; bongo_cat::NativeModel *model = nullptr; - bool previous_released = false; try { model = new(std::nothrow) bongo_cat::NativeModel(); if (!model) { @@ -193,24 +161,16 @@ extern "C" BongoCatResult bongo_cat_live2d_load(BongoCatLive2D *runtime, delete model; return error ? error->code : BONGO_CAT_ERROR_CUBISM; } - if (previous) { - previous_released = true; - previous->release_render_resources(); - glFinish(); - } + /* Keep the previous renderer alive until the replacement is complete. */ model->reshape(runtime->width, runtime->height); if (!model->load_textures(error, progress, userdata)) { BongoCatResult result = error ? error->code : BONGO_CAT_ERROR_CUBISM; delete model; - glFinish(); - restore_previous(runtime, previous, error); - glFinish(); - bongo_cat_platform_trim_memory(); return result; } if (progress) progress(userdata, 0.98f); - delete previous; runtime->model = model; + delete previous; return BONGO_CAT_OK; } catch (const std::bad_alloc &) { bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, @@ -223,12 +183,6 @@ extern "C" BongoCatResult bongo_cat_live2d_load(BongoCatLive2D *runtime, "Live2D model load failed with an unknown exception"); } delete model; - if (previous_released) { - glFinish(); - restore_previous(runtime, previous, error); - glFinish(); - bongo_cat_platform_trim_memory(); - } return error ? error->code : BONGO_CAT_ERROR_CUBISM; } diff --git a/src/runtime/model/model_files.c b/src/runtime/model/model_files.c index e045a1fc..8eced596 100644 --- a/src/runtime/model/model_files.c +++ b/src/runtime/model/model_files.c @@ -33,6 +33,20 @@ static void model_load_progress(void *userdata, float progress) { BongoCatApp *app = userdata; if (app && app->preferences) bongo_cat_preferences_model_load_progress(app->preferences, progress); + /* The main loop is blocked during loading, so advance the visible model here. */ + if (!app || !app->loaded_model[0] || !app->model_load_last_frame_ns || + !app->live2d) + return; + uint64_t now = SDL_GetTicksNS(); + if (progress < .999f && now - app->model_load_last_frame_ns < 16000000ull) + return; + uint64_t previous = app->model_load_last_frame_ns; + app->model_load_last_frame_ns = now; + float elapsed = (float)((now - previous) / 1000000000.0); + if (!app->smoke_freeze_model && elapsed > 0.0f) + bongo_cat_app_step_live2d(app, elapsed); + app->last_frame_ns = now; + bongo_cat_app_render_now(app); } static bool apply_model_aspect(BongoCatApp *app, @@ -117,9 +131,13 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, return false; } bongo_cat_live2d_reshape(app->live2d, pixel_width, pixel_height); - if (bongo_cat_live2d_load(app->live2d, entry->directory, + app->model_load_last_frame_ns = replacing_model ? SDL_GetTicksNS() : 0; + BongoCatResult load_result = bongo_cat_live2d_load(app->live2d, entry->directory, entry->setting_file, entry->preset, &render_options, - model_load_progress, app, failure) != BONGO_CAT_OK) { + model_load_progress, app, failure); + app->model_load_last_frame_ns = 0; + if (replacing_model) app->last_frame_ns = SDL_GetTicksNS(); + if (load_result != BONGO_CAT_OK) { SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "%s", failure->message); if (restore_context && previous_window && previous_context && !SDL_GL_MakeCurrent(previous_window, previous_context)) From 9118d8e7e056ebc5975ffb30273295a066c466ae Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 16 Aug 2026 17:31:19 +0800 Subject: [PATCH 023/334] build: exclude unused GLEW shared target --- cmake/Cubism.cmake | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmake/Cubism.cmake b/cmake/Cubism.cmake index 30f21098..1bb5b444 100644 --- a/cmake/Cubism.cmake +++ b/cmake/Cubism.cmake @@ -39,7 +39,8 @@ set_target_properties(Live2DCubismCore PROPERTIES INTERFACE_INCLUDE_DIRECTORIES "${CUBISM_CORE_PATH}/include") set(BUILD_UTILS OFF CACHE BOOL "" FORCE) -add_subdirectory("${CUBISM_GLEW_PATH}/build/cmake" "${CMAKE_BINARY_DIR}/cubism-glew") +add_subdirectory("${CUBISM_GLEW_PATH}/build/cmake" "${CMAKE_BINARY_DIR}/cubism-glew" + EXCLUDE_FROM_ALL) set(FRAMEWORK_SOURCE OpenGL) add_subdirectory("${CUBISM_FRAMEWORK_PATH}" "${CMAKE_BINARY_DIR}/cubism-framework") include(cmake/CubismShaderOptimize.cmake) From b831050e5401e3b45e411ec623ede786fcbd5f77 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 16 Aug 2026 19:17:12 +0800 Subject: [PATCH 024/334] build: run Windows app without elevation --- cmake/windows.manifest.in | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cmake/windows.manifest.in b/cmake/windows.manifest.in index e7496d4d..1b578abe 100644 --- a/cmake/windows.manifest.in +++ b/cmake/windows.manifest.in @@ -2,6 +2,13 @@ + + + + + + + From 754d9a6a4070f4cec34d5556bb1cbb46258255c8 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 16 Aug 2026 20:07:38 +0800 Subject: [PATCH 025/334] fix: keep current model animated while switching --- cmake/RuntimeSources.cmake | 1 + src/live2d/cubism_bridge.cpp | 2 +- src/live2d/cubism_model_load.cpp | 2 + src/media/image.c | 218 ++++++++++++------------------- src/media/image_alpha.c | 51 ++++++-- src/media/image_decode.c | 211 ++++++++++++++++++++++++++++++ src/media/image_internal.h | 22 ++++ src/runtime/model/model_files.c | 4 +- 8 files changed, 359 insertions(+), 152 deletions(-) create mode 100644 src/media/image_decode.c create mode 100644 src/media/image_internal.h diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index be041084..4b9d8fa7 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -14,6 +14,7 @@ set(BONGO_CAT_RUNTIME_INTERNAL_INCLUDE_DIRS set(BONGO_CAT_MEDIA_SOURCES src/media/audio.c src/media/image.c + src/media/image_decode.c src/media/image_info.c src/media/image_resize.c src/media/image_alpha.c diff --git a/src/live2d/cubism_bridge.cpp b/src/live2d/cubism_bridge.cpp index d1a12e07..e3fa5493 100644 --- a/src/live2d/cubism_bridge.cpp +++ b/src/live2d/cubism_bridge.cpp @@ -168,7 +168,7 @@ extern "C" BongoCatResult bongo_cat_live2d_load(BongoCatLive2D *runtime, delete model; return result; } - if (progress) progress(userdata, 0.98f); + if (progress) progress(userdata, 1.0f); runtime->model = model; delete previous; return BONGO_CAT_OK; diff --git a/src/live2d/cubism_model_load.cpp b/src/live2d/cubism_model_load.cpp index 1254b644..a5dd45f1 100644 --- a/src/live2d/cubism_model_load.cpp +++ b/src/live2d/cubism_model_load.cpp @@ -213,6 +213,8 @@ bool NativeModel::load_textures(BongoCatError *error, TextureProgressContext texture_context = {progress, userdata, .50f, .45f / (float)(count > 0 ? count : 1)}; for (int i = 0; i < count; ++i) { + texture_context.start = .50f + .45f * (float)i / + (float)(count > 0 ? count : 1); textures_[(size_t)i] = bongo_cat_image_texture_model( path(setting_->GetTextureFileName(i)).c_str(), direct_textures_, nullptr, nullptr, &texture_alpha_[(size_t)i], diff --git a/src/media/image.c b/src/media/image.c index 7dd44949..eb634d4a 100644 --- a/src/media/image.c +++ b/src/media/image.c @@ -1,123 +1,17 @@ -#include "bongo_cat/file.h" #include "bongo_cat/gl_api.h" #include "bongo_cat/image.h" -#ifdef _WIN32 -#define COBJMACROS -#include -#include -#include -#endif +#include "image_internal.h" + #include #include #include #include #include + #define BONGO_CAT_LIVE2D_EFFICIENT_TEXTURE_LIMIT 2048 -#ifdef _WIN32 -static bool needs_wic_scaling(const char *path, int limit) { - int width = 0, height = 0; - bool known = bongo_cat_image_info(path, &width, &height); - return !known || width > limit || height > limit; -} -static wchar_t *wide_path(const char *path) { - int count = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, - path, -1, NULL, 0); - wchar_t *wide = count > 0 ? malloc((size_t)count * sizeof(*wide)) : NULL; - if (wide && !MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, - path, -1, wide, count)) { - free(wide); return NULL; - } - return wide; -} -static bool wic_scaled_image(const char *path, BongoCatImage *image, - UINT max_width, UINT max_height, BongoCatImageProgress progress, void *userdata) { - memset(image, 0, sizeof(*image)); - HRESULT initialized = CoInitializeEx(NULL, COINIT_MULTITHREADED); - bool uninitialize = initialized == S_OK || initialized == S_FALSE; - if (FAILED(initialized) && initialized != RPC_E_CHANGED_MODE) return false; - IWICImagingFactory *factory = NULL; - IWICBitmapDecoder *decoder = NULL; - IWICBitmapFrameDecode *frame = NULL; - IWICBitmapScaler *scaler = NULL; - IWICFormatConverter *converter = NULL; - wchar_t *wide = wide_path(path); - HRESULT result = wide ? CoCreateInstance(&CLSID_WICImagingFactory, NULL, - CLSCTX_INPROC_SERVER, &IID_IWICImagingFactory, (void **)&factory) : E_FAIL; - if (SUCCEEDED(result)) result = IWICImagingFactory_CreateDecoderFromFilename(factory, - wide, NULL, GENERIC_READ, WICDecodeMetadataCacheOnDemand, &decoder); - if (SUCCEEDED(result)) result = IWICBitmapDecoder_GetFrame(decoder, 0, &frame); - UINT source_width = 0, source_height = 0; - if (SUCCEEDED(result)) result = IWICBitmapFrameDecode_GetSize(frame, - &source_width, &source_height); - UINT target_width = source_width, target_height = source_height; - if (max_width && max_height && - (source_width > max_width || source_height > max_height)) { - if ((uint64_t)max_width * source_height <= - (uint64_t)max_height * source_width) { - target_width = max_width; - target_height = (UINT)((uint64_t)source_height * max_width / - source_width); - } else { - target_height = max_height; - target_width = (UINT)((uint64_t)source_width * max_height / - source_height); - } - if (!target_width) target_width = 1; - if (!target_height) target_height = 1; - if (SUCCEEDED(result)) result = IWICImagingFactory_CreateBitmapScaler(factory, &scaler); - if (SUCCEEDED(result)) result = IWICBitmapScaler_Initialize(scaler, - (IWICBitmapSource *)frame, target_width, target_height, - WICBitmapInterpolationModeFant); - } - IWICBitmapSource *source = scaler ? (IWICBitmapSource *)scaler : - (IWICBitmapSource *)frame; - if (SUCCEEDED(result)) - result = IWICImagingFactory_CreateFormatConverter(factory, &converter); - if (SUCCEEDED(result)) result = IWICFormatConverter_Initialize(converter, source, - &GUID_WICPixelFormat32bppRGBA, WICBitmapDitherTypeNone, NULL, 0, - WICBitmapPaletteTypeCustom); - size_t stride = (size_t)target_width * 4; - size_t bytes = stride * target_height; - unsigned char *pixels = bytes <= UINT_MAX ? malloc(bytes) : NULL; - if (SUCCEEDED(result) && pixels) for (UINT y = 0; - y < target_height && SUCCEEDED(result); y += 256) { - UINT rows = SDL_min(256u, target_height - y); - WICRect rect = {0, (INT)y, (INT)target_width, (INT)rows}; - result = IWICFormatConverter_CopyPixels(converter, &rect, - (UINT)stride, (UINT)(stride * rows), pixels + (size_t)y * stride); - if (progress) progress(userdata, .05f + .25f * - (float)(y + rows) / (float)target_height); - } - bool ok = SUCCEEDED(result) && pixels; - if (ok) { - image->pixels = pixels; - image->width = (int)target_width; - image->height = (int)target_height; - } else free(pixels); - if (converter) IWICFormatConverter_Release(converter); - if (scaler) IWICBitmapScaler_Release(scaler); - if (frame) IWICBitmapFrameDecode_Release(frame); - if (decoder) IWICBitmapDecoder_Release(decoder); - if (factory) IWICImagingFactory_Release(factory); - free(wide); - if (uninitialize) CoUninitialize(); - return ok; -} -#endif BongoCatResult bongo_cat_image_load(const char *path, BongoCatImage *image, BongoCatError *error) { - if (!path || !image) return BONGO_CAT_ERROR_ARGUMENT; - memset(image, 0, sizeof(*image)); - int channels; - FILE *file = bongo_cat_file_open(path, "rb"); - image->pixels = file ? stbi_load_from_file(file, &image->width, - &image->height, &channels, STBI_rgb_alpha) : NULL; - image->pixels_stbi = image->pixels != NULL; - if (file) fclose(file); - if (!image->pixels) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot decode image: %s", path); - return BONGO_CAT_ERROR_IO; - } + BongoCatResult result = bongo_cat_image_decode_pixels(path, image, error); + if (result != BONGO_CAT_OK) return result; image->surface = SDL_CreateSurfaceFrom(image->width, image->height, SDL_PIXELFORMAT_RGBA32, image->pixels, image->width * 4); if (!image->surface) { @@ -136,7 +30,8 @@ void bongo_cat_image_free(BongoCatImage *image) { } memset(image, 0, sizeof(*image)); } -static unsigned int upload(const BongoCatImage *image, GLuint texture, bool model_texture) { +static unsigned int upload(const BongoCatImage *image, GLuint texture, + bool model_texture, BongoCatImageProgress progress, void *userdata) { bool created = texture == 0; if (created) glGenTextures(1, &texture); glBindTexture(GL_TEXTURE_2D, texture); @@ -158,16 +53,38 @@ static unsigned int upload(const BongoCatImage *image, GLuint texture, bool mode } } glPixelStorei(GL_UNPACK_ALIGNMENT, 1); - if (created) glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, image->width, - image->height, 0, GL_RGBA, GL_UNSIGNED_BYTE, image->pixels); - else glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, image->width, image->height, - GL_RGBA, GL_UNSIGNED_BYTE, image->pixels); + if (created && model_texture) { + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, image->width, + image->height, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL); + size_t row_bytes = (size_t)image->width * 4; + int rows_per_chunk = row_bytes > 0 + ? (int)((4u * 1024u * 1024u) / row_bytes) : 1; + rows_per_chunk = SDL_clamp(rows_per_chunk, 1, image->height); + for (int y = 0; y < image->height; y += rows_per_chunk) { + int rows = SDL_min(rows_per_chunk, image->height - y); + /* Rendering the old model changes texture bindings while loading. */ + glBindTexture(GL_TEXTURE_2D, texture); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, y, image->width, rows, + GL_RGBA, GL_UNSIGNED_BYTE, + image->pixels + (size_t)y * row_bytes); + if (progress) + progress(userdata, (float)(y + rows) / image->height); + } + } else if (created) { + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, image->width, + image->height, 0, GL_RGBA, GL_UNSIGNED_BYTE, image->pixels); + } else { + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, image->width, image->height, + GL_RGBA, GL_UNSIGNED_BYTE, image->pixels); + } + glPixelStorei(GL_UNPACK_ALIGNMENT, 4); return texture; } unsigned int bongo_cat_image_texture(const char *path, int *width, int *height, BongoCatError *error) { BongoCatImage image; if (bongo_cat_image_load(path, &image, error) != BONGO_CAT_OK) return 0; - GLuint texture = upload(&image, 0, false); + GLuint texture = upload(&image, 0, false, NULL, NULL); if (width) *width = image.width; if (height) *height = image.height; bongo_cat_image_free(&image); @@ -178,9 +95,10 @@ unsigned int bongo_cat_image_texture_thumbnail(const char *path, int max_width, int max_height, int *width, int *height, BongoCatError *error) { BongoCatImage image; #ifdef _WIN32 - if (max_width > 0 && max_height > 0 && wic_scaled_image(path, &image, - (UINT)max_width, (UINT)max_height, NULL, NULL)) { - GLuint texture = upload(&image, 0, false); + if (max_width > 0 && max_height > 0 && + bongo_cat_image_decode_wic_responsive(path, &image, + max_width, max_height, NULL, NULL)) { + GLuint texture = upload(&image, 0, false, NULL, NULL); if (width) *width = image.width; if (height) *height = image.height; bongo_cat_image_free(&image); @@ -200,7 +118,7 @@ unsigned int bongo_cat_image_texture_thumbnail(const char *path, int max_width, if (scaled) { BongoCatImage thumbnail = { .pixels = scaled->pixels, .width = scaled->w, .height = scaled->h}; - GLuint texture = upload(&thumbnail, 0, false); + GLuint texture = upload(&thumbnail, 0, false, NULL, NULL); if (width) *width = thumbnail.width; if (height) *height = thumbnail.height; SDL_DestroySurface(scaled); @@ -210,16 +128,31 @@ unsigned int bongo_cat_image_texture_thumbnail(const char *path, int max_width, target_width = image.width; target_height = image.height; } - GLuint texture = upload(&image, 0, false); + GLuint texture = upload(&image, 0, false, NULL, NULL); if (width) *width = target_width; if (height) *height = target_height; bongo_cat_image_free(&image); return texture; } +typedef struct ImageProgressStage { + BongoCatImageProgress progress; + void *userdata; + float start; + float span; +} ImageProgressStage; + +static void report_stage_progress(void *userdata, float progress) { + ImageProgressStage *stage = userdata; + if (stage && stage->progress) + stage->progress(stage->userdata, stage->start + stage->span * progress); +} + unsigned int bongo_cat_image_texture_model(const char *path, bool direct_decode, int *width, int *height, BongoCatImageAlphaMask *alpha, BongoCatImageProgress progress, void *userdata, BongoCatError *error) { - BongoCatImage image; + BongoCatImage image = {0}; + ImageProgressStage stage = {progress, userdata, 0.0f, .30f}; + BongoCatImageProgress staged = progress ? report_stage_progress : NULL; GLint hardware_limit = 0; glGetIntegerv(GL_MAX_TEXTURE_SIZE, &hardware_limit); if (hardware_limit < 1) hardware_limit = BONGO_CAT_LIVE2D_EFFICIENT_TEXTURE_LIMIT; @@ -227,12 +160,17 @@ unsigned int bongo_cat_image_texture_model(const char *path, bool direct_decode, int decode_limit = hardware_limit; /* Preset atlases are verified byte-identical in WIC and stb. Keep WIC for custom PNG color metadata and for memory-bounded oversized decoding. */ - bool scaled = needs_wic_scaling(path, decode_limit); + bool scaled = bongo_cat_image_needs_wic_scaling(path, decode_limit); if (!direct_decode || scaled) { - if (!wic_scaled_image(path, &image, (UINT)decode_limit, - (UINT)decode_limit, progress, userdata) && - bongo_cat_image_load(path, &image, error) != BONGO_CAT_OK) return 0; - } else if (bongo_cat_image_load(path, &image, error) != BONGO_CAT_OK) return 0; + stage.span = .20f; + if (!bongo_cat_image_decode_wic_responsive(path, &image, + decode_limit, decode_limit, staged, &stage)) { + stage = (ImageProgressStage){progress, userdata, .20f, .10f}; + if (bongo_cat_image_decode_pixels_responsive(path, &image, + staged, &stage, error) != BONGO_CAT_OK) return 0; + } + } else if (bongo_cat_image_decode_pixels_responsive(path, &image, + staged, &stage, error) != BONGO_CAT_OK) return 0; if (scaled) SDL_Log("Live2D texture constrained to %dx%d by a %d pixel " "texture limit: %s", image.width, image.height, decode_limit, path); else if (image.width > BONGO_CAT_LIVE2D_EFFICIENT_TEXTURE_LIMIT || @@ -241,13 +179,15 @@ unsigned int bongo_cat_image_texture_model(const char *path, bool direct_decode, image.width, image.height, path); #else (void)direct_decode; - if (bongo_cat_image_load(path, &image, error) != BONGO_CAT_OK) return 0; + if (bongo_cat_image_decode_pixels_responsive(path, &image, + staged, &stage, error) != BONGO_CAT_OK) return 0; #endif if (progress) progress(userdata, .30f); - bongo_cat_image_make_alpha_mask(&image, alpha); - if (progress) progress(userdata, .60f); + stage = (ImageProgressStage){progress, userdata, .30f, .30f}; + bongo_cat_image_make_alpha_mask_progress(&image, alpha, staged, &stage); bongo_cat_gl_clear_errors(); - GLuint texture = upload(&image, 0, true); + stage = (ImageProgressStage){progress, userdata, .60f, .30f}; + GLuint texture = upload(&image, 0, true, staged, &stage); GLenum upload_error = glGetError(); #ifdef _WIN32 if (upload_error == GL_OUT_OF_MEMORY && @@ -258,11 +198,15 @@ unsigned int bongo_cat_image_texture_model(const char *path, bool direct_decode, bongo_cat_image_free(&image); int fallback_limit = SDL_min(hardware_limit, BONGO_CAT_LIVE2D_EFFICIENT_TEXTURE_LIMIT); - if (wic_scaled_image(path, &image, (UINT)fallback_limit, - (UINT)fallback_limit, progress, userdata)) { - bongo_cat_image_make_alpha_mask(&image, alpha); + stage = (ImageProgressStage){progress, userdata, .90f, .04f}; + if (bongo_cat_image_decode_wic_responsive(path, &image, + fallback_limit, fallback_limit, staged, &stage)) { + stage = (ImageProgressStage){progress, userdata, .94f, .03f}; + bongo_cat_image_make_alpha_mask_progress( + &image, alpha, staged, &stage); bongo_cat_gl_clear_errors(); - texture = upload(&image, 0, true); + stage = (ImageProgressStage){progress, userdata, .97f, .02f}; + texture = upload(&image, 0, true, staged, &stage); upload_error = glGetError(); if (upload_error == GL_NO_ERROR) SDL_LogWarn( SDL_LOG_CATEGORY_RENDER, @@ -326,7 +270,7 @@ unsigned int bongo_cat_image_composite_texture(const char *base, const char *lef if (erase_left) erase_paw(&image, true); if (erase_right) erase_paw(&image, false); bool valid = blend_file(&image, left, error) && blend_file(&image, right, error); - if (valid) texture = upload(&image, texture, false); + if (valid) texture = upload(&image, texture, false, NULL, NULL); bongo_cat_image_free(&image); return texture; } diff --git a/src/media/image_alpha.c b/src/media/image_alpha.c index 246eb8ba..2497a05a 100644 --- a/src/media/image_alpha.c +++ b/src/media/image_alpha.c @@ -2,24 +2,49 @@ #include -void bongo_cat_image_make_alpha_mask(const BongoCatImage *image, - BongoCatImageAlphaMask *mask) { - if (!mask) return; +void bongo_cat_image_make_alpha_mask_progress(const BongoCatImage *image, + BongoCatImageAlphaMask *mask, BongoCatImageProgress progress, + void *userdata) { + if (!mask) { + if (progress) progress(userdata, 1.0f); + return; + } memset(mask, 0, sizeof(*mask)); - if (!image || !image->pixels || image->width <= 0 || image->height <= 0) return; + if (!image || !image->pixels || image->width <= 0 || image->height <= 0) { + if (progress) progress(userdata, 1.0f); + return; + } mask->width = image->width < BONGO_CAT_ALPHA_MASK_SIZE ? image->width : BONGO_CAT_ALPHA_MASK_SIZE; mask->height = image->height < BONGO_CAT_ALPHA_MASK_SIZE ? image->height : BONGO_CAT_ALPHA_MASK_SIZE; - for (int y = 0; y < image->height; ++y) { - int target_y = y * mask->height / image->height; - for (int x = 0; x < image->width; ++x) { - int target_x = x * mask->width / image->width; - unsigned char alpha = image->pixels[ - ((size_t)y * image->width + x) * 4 + 3]; - unsigned char *target = &mask->pixels[ - (size_t)target_y * mask->width + target_x]; - if (alpha > *target) *target = alpha; + for (int target_y = 0; target_y < mask->height; ++target_y) { + int y_begin = (int)(((int64_t)target_y * image->height + + mask->height - 1) / mask->height); + int y_end = (int)(((int64_t)(target_y + 1) * image->height + + mask->height - 1) / mask->height); + for (int target_x = 0; target_x < mask->width; ++target_x) { + int x_begin = (int)(((int64_t)target_x * image->width + + mask->width - 1) / mask->width); + int x_end = (int)(((int64_t)(target_x + 1) * image->width + + mask->width - 1) / mask->width); + unsigned char maximum = 0; + for (int y = y_begin; y < y_end && maximum < 255; ++y) { + const unsigned char *pixel = image->pixels + + ((size_t)y * image->width + x_begin) * 4 + 3; + for (int x = x_begin; x < x_end; ++x, pixel += 4) { + if (*pixel > maximum) maximum = *pixel; + if (maximum == 255) break; + } + } + mask->pixels[(size_t)target_y * mask->width + target_x] = maximum; } + if (progress) + progress(userdata, (float)(target_y + 1) / mask->height); } } + +void bongo_cat_image_make_alpha_mask(const BongoCatImage *image, + BongoCatImageAlphaMask *mask) { + bongo_cat_image_make_alpha_mask_progress(image, mask, NULL, NULL); +} diff --git a/src/media/image_decode.c b/src/media/image_decode.c new file mode 100644 index 00000000..3169b9d4 --- /dev/null +++ b/src/media/image_decode.c @@ -0,0 +1,211 @@ +#include "image_internal.h" + +#include "bongo_cat/file.h" + +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#define COBJMACROS +#include +#include +#include +#endif + +BongoCatResult bongo_cat_image_decode_pixels(const char *path, + BongoCatImage *image, BongoCatError *error) { + if (!path || !image) return BONGO_CAT_ERROR_ARGUMENT; + memset(image, 0, sizeof(*image)); + int channels; + FILE *file = bongo_cat_file_open(path, "rb"); + image->pixels = file ? stbi_load_from_file(file, &image->width, + &image->height, &channels, STBI_rgb_alpha) : NULL; + image->pixels_stbi = image->pixels != NULL; + if (file) fclose(file); + if (image->pixels) return BONGO_CAT_OK; + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot decode image: %s", path); + return BONGO_CAT_ERROR_IO; +} + +typedef struct PixelDecodeJob { + const char *path; + BongoCatImage *image; + BongoCatError *error; + BongoCatResult result; +} PixelDecodeJob; + +static int SDLCALL decode_pixels_worker(void *userdata) { + /* Workers only produce CPU pixels; Cubism and OpenGL remain on the caller. */ + PixelDecodeJob *job = userdata; + job->result = bongo_cat_image_decode_pixels(job->path, + job->image, job->error); + return (int)job->result; +} + +static void wait_for_decode(SDL_Thread *worker, + BongoCatImageProgress progress, void *userdata) { + while (SDL_GetThreadState(worker) == SDL_THREAD_ALIVE) { + if (progress) progress(userdata, .5f); + SDL_Delay(2); + } + SDL_WaitThread(worker, NULL); + if (progress) progress(userdata, 1.0f); +} + +BongoCatResult bongo_cat_image_decode_pixels_responsive(const char *path, + BongoCatImage *image, BongoCatImageProgress progress, void *userdata, + BongoCatError *error) { + if (!progress) + return bongo_cat_image_decode_pixels(path, image, error); + PixelDecodeJob job = {path, image, error, BONGO_CAT_ERROR_PLATFORM}; + SDL_Thread *worker = SDL_CreateThread(decode_pixels_worker, + "bongo-cat-image-decode", &job); + if (!worker) + return bongo_cat_image_decode_pixels(path, image, error); + wait_for_decode(worker, progress, userdata); + return job.result; +} + +#ifdef _WIN32 +bool bongo_cat_image_needs_wic_scaling(const char *path, int limit) { + int width = 0, height = 0; + bool known = bongo_cat_image_info(path, &width, &height); + return !known || width > limit || height > limit; +} + +static wchar_t *wide_path(const char *path) { + int count = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, + path, -1, NULL, 0); + wchar_t *wide = count > 0 ? malloc((size_t)count * sizeof(*wide)) : NULL; + if (wide && !MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, + path, -1, wide, count)) { + free(wide); + return NULL; + } + return wide; +} + +static bool decode_wic(const char *path, BongoCatImage *image, + UINT max_width, UINT max_height, BongoCatImageProgress progress, + void *userdata) { + memset(image, 0, sizeof(*image)); + HRESULT initialized = CoInitializeEx(NULL, COINIT_MULTITHREADED); + bool uninitialize = initialized == S_OK || initialized == S_FALSE; + if (FAILED(initialized) && initialized != RPC_E_CHANGED_MODE) return false; + IWICImagingFactory *factory = NULL; + IWICBitmapDecoder *decoder = NULL; + IWICBitmapFrameDecode *frame = NULL; + IWICBitmapScaler *scaler = NULL; + IWICFormatConverter *converter = NULL; + wchar_t *wide = wide_path(path); + HRESULT result = wide ? CoCreateInstance(&CLSID_WICImagingFactory, NULL, + CLSCTX_INPROC_SERVER, &IID_IWICImagingFactory, + (void **)&factory) : E_FAIL; + if (SUCCEEDED(result)) + result = IWICImagingFactory_CreateDecoderFromFilename(factory, wide, + NULL, GENERIC_READ, WICDecodeMetadataCacheOnDemand, &decoder); + if (SUCCEEDED(result)) result = IWICBitmapDecoder_GetFrame(decoder, 0, &frame); + UINT source_width = 0, source_height = 0; + if (SUCCEEDED(result)) + result = IWICBitmapFrameDecode_GetSize(frame, + &source_width, &source_height); + UINT target_width = source_width, target_height = source_height; + if (max_width && max_height && + (source_width > max_width || source_height > max_height)) { + if ((uint64_t)max_width * source_height <= + (uint64_t)max_height * source_width) { + target_width = max_width; + target_height = (UINT)((uint64_t)source_height * max_width / + source_width); + } else { + target_height = max_height; + target_width = (UINT)((uint64_t)source_width * max_height / + source_height); + } + if (!target_width) target_width = 1; + if (!target_height) target_height = 1; + if (SUCCEEDED(result)) + result = IWICImagingFactory_CreateBitmapScaler(factory, &scaler); + if (SUCCEEDED(result)) + result = IWICBitmapScaler_Initialize(scaler, + (IWICBitmapSource *)frame, target_width, target_height, + WICBitmapInterpolationModeFant); + } + IWICBitmapSource *source = scaler ? (IWICBitmapSource *)scaler : + (IWICBitmapSource *)frame; + if (SUCCEEDED(result)) + result = IWICImagingFactory_CreateFormatConverter(factory, &converter); + if (SUCCEEDED(result)) + result = IWICFormatConverter_Initialize(converter, source, + &GUID_WICPixelFormat32bppRGBA, WICBitmapDitherTypeNone, NULL, 0, + WICBitmapPaletteTypeCustom); + size_t stride = target_width <= SIZE_MAX / 4 + ? (size_t)target_width * 4 : 0; + size_t bytes = stride && target_height <= SIZE_MAX / stride + ? stride * target_height : 0; + unsigned char *pixels = bytes <= UINT_MAX ? malloc(bytes) : NULL; + if (SUCCEEDED(result) && pixels) { + for (UINT y = 0; y < target_height && SUCCEEDED(result); y += 256) { + UINT rows = SDL_min(256u, target_height - y); + WICRect rect = {0, (INT)y, (INT)target_width, (INT)rows}; + result = IWICFormatConverter_CopyPixels(converter, &rect, + (UINT)stride, (UINT)(stride * rows), + pixels + (size_t)y * stride); + if (progress) + progress(userdata, + (float)(y + rows) / (float)target_height); + } + } + bool ok = SUCCEEDED(result) && pixels; + if (ok) { + image->pixels = pixels; + image->width = (int)target_width; + image->height = (int)target_height; + } else { + free(pixels); + } + if (converter) IWICFormatConverter_Release(converter); + if (scaler) IWICBitmapScaler_Release(scaler); + if (frame) IWICBitmapFrameDecode_Release(frame); + if (decoder) IWICBitmapDecoder_Release(decoder); + if (factory) IWICImagingFactory_Release(factory); + free(wide); + if (uninitialize) CoUninitialize(); + return ok; +} + +typedef struct WicDecodeJob { + const char *path; + BongoCatImage *image; + UINT max_width; + UINT max_height; + bool result; +} WicDecodeJob; + +static int SDLCALL decode_wic_worker(void *userdata) { + WicDecodeJob *job = userdata; + job->result = decode_wic(job->path, job->image, + job->max_width, job->max_height, NULL, NULL); + return job->result ? 0 : -1; +} + +bool bongo_cat_image_decode_wic_responsive(const char *path, + BongoCatImage *image, int max_width, int max_height, + BongoCatImageProgress progress, void *userdata) { + if (!progress) + return decode_wic(path, image, (UINT)max_width, + (UINT)max_height, NULL, NULL); + WicDecodeJob job = {path, image, (UINT)max_width, (UINT)max_height, false}; + SDL_Thread *worker = SDL_CreateThread(decode_wic_worker, + "bongo-cat-wic-decode", &job); + if (!worker) + return decode_wic(path, image, (UINT)max_width, + (UINT)max_height, progress, userdata); + wait_for_decode(worker, progress, userdata); + return job.result; +} +#endif diff --git a/src/media/image_internal.h b/src/media/image_internal.h new file mode 100644 index 00000000..38b69ccf --- /dev/null +++ b/src/media/image_internal.h @@ -0,0 +1,22 @@ +#ifndef BONGO_CAT_IMAGE_INTERNAL_H +#define BONGO_CAT_IMAGE_INTERNAL_H + +#include "bongo_cat/image.h" + +BongoCatResult bongo_cat_image_decode_pixels(const char *path, + BongoCatImage *image, BongoCatError *error); +BongoCatResult bongo_cat_image_decode_pixels_responsive(const char *path, + BongoCatImage *image, BongoCatImageProgress progress, void *userdata, + BongoCatError *error); +void bongo_cat_image_make_alpha_mask_progress(const BongoCatImage *image, + BongoCatImageAlphaMask *mask, BongoCatImageProgress progress, + void *userdata); + +#ifdef _WIN32 +bool bongo_cat_image_needs_wic_scaling(const char *path, int limit); +bool bongo_cat_image_decode_wic_responsive(const char *path, + BongoCatImage *image, int max_width, int max_height, + BongoCatImageProgress progress, void *userdata); +#endif + +#endif diff --git a/src/runtime/model/model_files.c b/src/runtime/model/model_files.c index 8eced596..38769684 100644 --- a/src/runtime/model/model_files.c +++ b/src/runtime/model/model_files.c @@ -38,11 +38,13 @@ static void model_load_progress(void *userdata, float progress) { !app->live2d) return; uint64_t now = SDL_GetTicksNS(); - if (progress < .999f && now - app->model_load_last_frame_ns < 16000000ull) + if (progress < .98f && now - app->model_load_last_frame_ns < 16000000ull) return; uint64_t previous = app->model_load_last_frame_ns; app->model_load_last_frame_ns = now; float elapsed = (float)((now - previous) / 1000000000.0); + bongo_cat_app_drain_input(app, false); + bongo_cat_app_update_hover(app, now); if (!app->smoke_freeze_model && elapsed > 0.0f) bongo_cat_app_step_live2d(app, elapsed); app->last_frame_ns = now; From 3229b4e454d4c7c020e03322b4f74d26513cb726 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 16 Aug 2026 20:19:20 +0800 Subject: [PATCH 026/334] fix: avoid driver hangs during model texture upload --- src/media/image.c | 38 +++++++++----------------------------- 1 file changed, 9 insertions(+), 29 deletions(-) diff --git a/src/media/image.c b/src/media/image.c index eb634d4a..48058b22 100644 --- a/src/media/image.c +++ b/src/media/image.c @@ -31,7 +31,7 @@ void bongo_cat_image_free(BongoCatImage *image) { memset(image, 0, sizeof(*image)); } static unsigned int upload(const BongoCatImage *image, GLuint texture, - bool model_texture, BongoCatImageProgress progress, void *userdata) { + bool model_texture) { bool created = texture == 0; if (created) glGenTextures(1, &texture); glBindTexture(GL_TEXTURE_2D, texture); @@ -53,25 +53,7 @@ static unsigned int upload(const BongoCatImage *image, GLuint texture, } } glPixelStorei(GL_UNPACK_ALIGNMENT, 1); - if (created && model_texture) { - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, image->width, - image->height, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL); - size_t row_bytes = (size_t)image->width * 4; - int rows_per_chunk = row_bytes > 0 - ? (int)((4u * 1024u * 1024u) / row_bytes) : 1; - rows_per_chunk = SDL_clamp(rows_per_chunk, 1, image->height); - for (int y = 0; y < image->height; y += rows_per_chunk) { - int rows = SDL_min(rows_per_chunk, image->height - y); - /* Rendering the old model changes texture bindings while loading. */ - glBindTexture(GL_TEXTURE_2D, texture); - glPixelStorei(GL_UNPACK_ALIGNMENT, 1); - glTexSubImage2D(GL_TEXTURE_2D, 0, 0, y, image->width, rows, - GL_RGBA, GL_UNSIGNED_BYTE, - image->pixels + (size_t)y * row_bytes); - if (progress) - progress(userdata, (float)(y + rows) / image->height); - } - } else if (created) { + if (created) { glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, image->width, image->height, 0, GL_RGBA, GL_UNSIGNED_BYTE, image->pixels); } else { @@ -84,7 +66,7 @@ static unsigned int upload(const BongoCatImage *image, GLuint texture, unsigned int bongo_cat_image_texture(const char *path, int *width, int *height, BongoCatError *error) { BongoCatImage image; if (bongo_cat_image_load(path, &image, error) != BONGO_CAT_OK) return 0; - GLuint texture = upload(&image, 0, false, NULL, NULL); + GLuint texture = upload(&image, 0, false); if (width) *width = image.width; if (height) *height = image.height; bongo_cat_image_free(&image); @@ -98,7 +80,7 @@ unsigned int bongo_cat_image_texture_thumbnail(const char *path, int max_width, if (max_width > 0 && max_height > 0 && bongo_cat_image_decode_wic_responsive(path, &image, max_width, max_height, NULL, NULL)) { - GLuint texture = upload(&image, 0, false, NULL, NULL); + GLuint texture = upload(&image, 0, false); if (width) *width = image.width; if (height) *height = image.height; bongo_cat_image_free(&image); @@ -118,7 +100,7 @@ unsigned int bongo_cat_image_texture_thumbnail(const char *path, int max_width, if (scaled) { BongoCatImage thumbnail = { .pixels = scaled->pixels, .width = scaled->w, .height = scaled->h}; - GLuint texture = upload(&thumbnail, 0, false, NULL, NULL); + GLuint texture = upload(&thumbnail, 0, false); if (width) *width = thumbnail.width; if (height) *height = thumbnail.height; SDL_DestroySurface(scaled); @@ -128,7 +110,7 @@ unsigned int bongo_cat_image_texture_thumbnail(const char *path, int max_width, target_width = image.width; target_height = image.height; } - GLuint texture = upload(&image, 0, false, NULL, NULL); + GLuint texture = upload(&image, 0, false); if (width) *width = target_width; if (height) *height = target_height; bongo_cat_image_free(&image); @@ -186,8 +168,7 @@ unsigned int bongo_cat_image_texture_model(const char *path, bool direct_decode, stage = (ImageProgressStage){progress, userdata, .30f, .30f}; bongo_cat_image_make_alpha_mask_progress(&image, alpha, staged, &stage); bongo_cat_gl_clear_errors(); - stage = (ImageProgressStage){progress, userdata, .60f, .30f}; - GLuint texture = upload(&image, 0, true, staged, &stage); + GLuint texture = upload(&image, 0, true); GLenum upload_error = glGetError(); #ifdef _WIN32 if (upload_error == GL_OUT_OF_MEMORY && @@ -205,8 +186,7 @@ unsigned int bongo_cat_image_texture_model(const char *path, bool direct_decode, bongo_cat_image_make_alpha_mask_progress( &image, alpha, staged, &stage); bongo_cat_gl_clear_errors(); - stage = (ImageProgressStage){progress, userdata, .97f, .02f}; - texture = upload(&image, 0, true, staged, &stage); + texture = upload(&image, 0, true); upload_error = glGetError(); if (upload_error == GL_NO_ERROR) SDL_LogWarn( SDL_LOG_CATEGORY_RENDER, @@ -270,7 +250,7 @@ unsigned int bongo_cat_image_composite_texture(const char *base, const char *lef if (erase_left) erase_paw(&image, true); if (erase_right) erase_paw(&image, false); bool valid = blend_file(&image, left, error) && blend_file(&image, right, error); - if (valid) texture = upload(&image, texture, false, NULL, NULL); + if (valid) texture = upload(&image, texture, false); bongo_cat_image_free(&image); return texture; } From 05fd745440a524fca6f7dd5a39cddc47b8f0ecfe Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 16 Aug 2026 20:31:34 +0800 Subject: [PATCH 027/334] refactor: keep runtime logs focused --- include/bongo_cat/app.h | 2 + src/runtime/diagnostics/frame_audit.c | 3 +- .../diagnostics/frame_presentation_audit.c | 2 +- src/runtime/lifecycle/app.c | 4 +- src/runtime/lifecycle/startup.c | 67 ++++++++++++++++++- src/runtime/model/import/model_import_scan.c | 2 +- src/runtime/model/model_files.c | 37 +++++++++- src/runtime/runtime.h | 2 + 8 files changed, 111 insertions(+), 8 deletions(-) diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index 29d9af8d..9fdb6f39 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -49,6 +49,7 @@ typedef struct BongoCatApp { char smoke_viewer_trace[BONGO_CAT_PATH_CAP]; char pending_model_cover_path[BONGO_CAT_PATH_CAP]; char loaded_model[BONGO_CAT_ID_CAP]; + char loading_model[BONGO_CAT_ID_CAP]; BongoCatModelMode loaded_mode; bool running; bool settings_store_valid; @@ -74,6 +75,7 @@ typedef struct BongoCatApp { bool smoke_runtime_flow; bool smoke_freeze_model; unsigned smoke_runtime_stage; + unsigned model_load_runtime_stage; unsigned model_selection_serial; uint64_t smoke_runtime_flow_ns; int smoke_language; diff --git a/src/runtime/diagnostics/frame_audit.c b/src/runtime/diagnostics/frame_audit.c index abc77af6..0fc01884 100644 --- a/src/runtime/diagnostics/frame_audit.c +++ b/src/runtime/diagnostics/frame_audit.c @@ -171,12 +171,11 @@ static void record_frame(BongoCatApp *app, const unsigned char *pixels, } void bongo_cat_frame_audit(BongoCatApp *app, int width, int height) { - if (!app || width < 2 || height < 2) return; + if (!app || !app->smoke || width < 2 || height < 2) return; if (!app->smoke_frame_audited) { app->smoke_frame_audited = true; log_first_frame(app, width, height); } - if (!app->smoke) return; char path[BONGO_CAT_PATH_CAP]; size_t pitch = (size_t)width * 4, bytes = pitch * (size_t)height; unsigned char *pixels = malloc(bytes); diff --git a/src/runtime/diagnostics/frame_presentation_audit.c b/src/runtime/diagnostics/frame_presentation_audit.c index e26cb9d5..7cfa9002 100644 --- a/src/runtime/diagnostics/frame_presentation_audit.c +++ b/src/runtime/diagnostics/frame_presentation_audit.c @@ -138,7 +138,7 @@ static void log_post_present_buffer(const char *name, GLenum buffer) { } void bongo_cat_frame_presented_audit(BongoCatApp *app) { - if (!app || audit.presented || !audit.prepared) return; + if (!app || !app->smoke || audit.presented || !audit.prepared) return; audit.presented = true; SDL_Log("First-frame presentation: success=1 framebuffer_visible=%d " "window_opacity=%.3f obs_background=%d obs_background_color=%s", diff --git a/src/runtime/lifecycle/app.c b/src/runtime/lifecycle/app.c index 9426e6f8..7457a0b4 100644 --- a/src/runtime/lifecycle/app.c +++ b/src/runtime/lifecycle/app.c @@ -288,13 +288,13 @@ int bongo_cat_app_run(int argc, char **argv) { if (!initialize(app, argc, argv, &error)) { bongo_cat_startup_failure(app, &error); if (app->smoke) bongo_cat_startup_ci_failure(app, &error); - shutdown(app); free(app); + shutdown(app); bongo_cat_runtime_clean_shutdown(app, 1); free(app); bongo_cat_platform_single_instance_end(); return 1; } loop(app); - shutdown(app); int exit_code = app->exit_code; + shutdown(app); bongo_cat_runtime_clean_shutdown(app, exit_code); free(app); bongo_cat_platform_single_instance_end(); return exit_code; } diff --git a/src/runtime/lifecycle/startup.c b/src/runtime/lifecycle/startup.c index 13c9758e..b34d8842 100644 --- a/src/runtime/lifecycle/startup.c +++ b/src/runtime/lifecycle/startup.c @@ -14,6 +14,7 @@ static char startup_log_path[BONGO_CAT_PATH_CAP]; static bool startup_is_ready; +static bool verbose_logging; static void format_timestamp(char *target, size_t capacity) { if (!target || !capacity) return; @@ -41,21 +42,74 @@ static void append_log(const char *path, const char *timestamp, fclose(file); } +static bool essential_info(const char *message) { + static const char *prefixes[] = {"Startup ", "OpenGL window ready", + "OpenGL context:", "Model load ", "Previous run ", + "Shutdown complete"}; + if (!message) return false; + for (size_t i = 0; i < sizeof(prefixes) / sizeof(prefixes[0]); ++i) + if (strncmp(message, prefixes[i], strlen(prefixes[i])) == 0) return true; + return false; +} + static void SDLCALL log_output(void *userdata, int category, SDL_LogPriority priority, const char *message) { (void)userdata; + if (!verbose_logging && priority < SDL_LOG_PRIORITY_WARN && + !essential_info(message)) return; char timestamp[64]; format_timestamp(timestamp, sizeof(timestamp)); append_log(startup_log_path, timestamp, category, priority, message); fprintf(stderr, "%s [%d:%d] %s\n", timestamp, category, (int)priority, message ? message : ""); } +static bool runtime_state_path(BongoCatApp *app, char *path, size_t capacity) { + return app && app->state_root[0] && + bongo_cat_path_join(path, capacity, app->state_root, "runtime-state.txt"); +} + +static void read_runtime_state(BongoCatApp *app, char *state, size_t capacity) { + if (!state || !capacity) return; + state[0] = '\0'; + char path[BONGO_CAT_PATH_CAP]; + if (!runtime_state_path(app, path, sizeof(path))) return; + FILE *file = bongo_cat_file_open(path, "rb"); + if (!file) return; + size_t length = fread(state, 1, capacity - 1, file); + state[ferror(file) ? 0 : length] = '\0'; + fclose(file); + for (size_t i = 0; state[i]; ++i) + if (state[i] == '\r' || state[i] == '\n') state[i] = ' '; +} + +void bongo_cat_runtime_stage(BongoCatApp *app, const char *stage) { + char path[BONGO_CAT_PATH_CAP]; + if (!stage || !runtime_state_path(app, path, sizeof(path))) return; + FILE *file = bongo_cat_file_open(path, "wb"); + if (!file) return; + char timestamp[64]; + format_timestamp(timestamp, sizeof(timestamp)); + fprintf(file, "time=%s stage=", timestamp); + for (const char *cursor = stage; *cursor; ++cursor) + fputc(*cursor == '\r' || *cursor == '\n' ? ' ' : *cursor, file); + fputc('\n', file); + fclose(file); +} + +void bongo_cat_runtime_clean_shutdown(BongoCatApp *app, int exit_code) { + char path[BONGO_CAT_PATH_CAP]; + SDL_Log("Shutdown complete: exit_code=%d", exit_code); + if (runtime_state_path(app, path, sizeof(path)) && + !bongo_cat_file_remove(path)) + bongo_cat_runtime_stage(app, "clean-shutdown"); +} + static bool reset_log(const char *path) { FILE *file = path && path[0] ? bongo_cat_file_open(path, "wb") : NULL; if (!file) return false; char timestamp[64]; format_timestamp(timestamp, sizeof(timestamp)); - fprintf(file, "%s BongoCat %s startup log\n", timestamp, BONGO_CAT_VERSION); + fprintf(file, "%s BongoCat %s runtime log\n", timestamp, BONGO_CAT_VERSION); return fclose(file) == 0; } @@ -130,6 +184,8 @@ static bool parse_arguments(BongoCatApp *app, int argc, char **argv, static void begin_log(BongoCatApp *app) { char previous[BONGO_CAT_PATH_CAP], stage_path[BONGO_CAT_PATH_CAP]; char interrupted[128] = {0}; + char unexpected[256] = {0}; + read_runtime_state(app, unexpected, sizeof(unexpected)); bongo_cat_path_join(startup_log_path, sizeof(startup_log_path), app->log_root, "startup.log"); bongo_cat_path_join(previous, sizeof(previous), app->log_root, @@ -145,11 +201,16 @@ static void begin_log(BongoCatApp *app) { if (bongo_cat_path_is_file(startup_log_path)) bongo_cat_file_replace(startup_log_path, previous, true); reset_log(startup_log_path); + verbose_logging = app->smoke; SDL_SetLogOutputFunction(log_output, NULL); SDL_Log("Startup on %s; storage=%s", SDL_GetPlatform(), app->storage_root[0] ? app->storage_root : app->config_root); if (interrupted[0]) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Previous startup ended before readiness at stage: %s", interrupted); + if (unexpected[0] && !strstr(unexpected, "stage=clean-shutdown")) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Previous run ended unexpectedly (crash, forced close, or hang): %s", + unexpected); } bool bongo_cat_startup_prepare(BongoCatApp *app, int argc, char **argv, @@ -166,6 +227,9 @@ void bongo_cat_startup_stage(BongoCatApp *app, const char *stage) { FILE *file = bongo_cat_file_open(path, "wb"); if (file) { fputs(stage, file); fclose(file); } } + char runtime_stage[160]; + snprintf(runtime_stage, sizeof(runtime_stage), "startup:%s", stage); + bongo_cat_runtime_stage(app, runtime_stage); SDL_Log("Startup stage: %s", stage); } @@ -177,6 +241,7 @@ void bongo_cat_startup_ready(BongoCatApp *app) { "startup-stage.txt")) bongo_cat_file_remove(path); if (bongo_cat_path_join(path, sizeof(path), app->log_root, "startup-error.log")) bongo_cat_file_remove(path); + bongo_cat_runtime_stage(app, "running"); SDL_Log("Startup ready"); } diff --git a/src/runtime/model/import/model_import_scan.c b/src/runtime/model/import/model_import_scan.c index d0238384..42bd3ca3 100644 --- a/src/runtime/model/import/model_import_scan.c +++ b/src/runtime/model/import/model_import_scan.c @@ -145,7 +145,7 @@ static BongoCatResult scan_with_budget(const char *root, if (item != BONGO_CAT_OK && result == BONGO_CAT_OK) result = item; } } - if (scan.limited) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + if (scan.limited) SDL_Log( "Model folder scan reached its directory budget"); free(queue); free(work); return result; } diff --git a/src/runtime/model/model_files.c b/src/runtime/model/model_files.c index 38769684..cb3e9f53 100644 --- a/src/runtime/model/model_files.c +++ b/src/runtime/model/model_files.c @@ -29,8 +29,31 @@ static void request_model_frame(BongoCatApp *app, bool reveal) { app->dirty = true; } +static void model_runtime_stage(BongoCatApp *app, const char *stage, + const BongoCatModelEntry *entry) { + char state[BONGO_CAT_ID_CAP + 32]; + const char *id = entry ? entry->id : "unknown"; + snprintf(state, sizeof(state), "model-load:%s:%s", stage, id); + bongo_cat_runtime_stage(app, state); + SDL_Log("Model load %s: id=%s", stage, id); +} + +static void model_progress_runtime_stage(BongoCatApp *app, float progress) { + static const char *names[] = {"", "cubism-core", "texture-decode", + "texture-alpha", "texture-upload", "finalizing"}; + unsigned stage = progress >= .94f ? 5 : progress >= .74f ? 4 : + progress >= .63f ? 3 : progress >= .50f ? 2 : 1; + if (!app || stage == app->model_load_runtime_stage) return; + app->model_load_runtime_stage = stage; + char state[BONGO_CAT_ID_CAP + 40]; + snprintf(state, sizeof(state), "model-load:%s:%s", names[stage], + app->loading_model[0] ? app->loading_model : "unknown"); + bongo_cat_runtime_stage(app, state); +} + static void model_load_progress(void *userdata, float progress) { BongoCatApp *app = userdata; + model_progress_runtime_stage(app, progress); if (app && app->preferences) bongo_cat_preferences_model_load_progress(app->preferences, progress); /* The main loop is blocked during loading, so advance the visible model here. */ @@ -133,6 +156,9 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, return false; } bongo_cat_live2d_reshape(app->live2d, pixel_width, pixel_height); + snprintf(app->loading_model, sizeof(app->loading_model), "%s", entry->id); + app->model_load_runtime_stage = 0; + model_runtime_stage(app, "started", entry); app->model_load_last_frame_ns = replacing_model ? SDL_GetTicksNS() : 0; BongoCatResult load_result = bongo_cat_live2d_load(app->live2d, entry->directory, entry->setting_file, entry->preset, &render_options, @@ -140,7 +166,8 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, app->model_load_last_frame_ns = 0; if (replacing_model) app->last_frame_ns = SDL_GetTicksNS(); if (load_result != BONGO_CAT_OK) { - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "%s", failure->message); + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "Model load failed: id=%s error=%s", entry->id, failure->message); if (restore_context && previous_window && previous_context && !SDL_GL_MakeCurrent(previous_window, previous_context)) SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, @@ -148,8 +175,12 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, free(behaviors); if (!bongo_cat_live2d_ready(app->live2d)) app->loaded_model[0] = '\0'; request_model_frame(app, false); + app->loading_model[0] = '\0'; + app->model_load_runtime_stage = 0; + bongo_cat_runtime_stage(app, "running"); return false; } + model_runtime_stage(app, "committing", entry); BongoCatParameterRange pointer_x, pointer_y; bool model_pointer = bongo_cat_live2d_parameter(app->live2d, "ParamMouseX", &pointer_x) && @@ -187,6 +218,10 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Cannot restore the previous OpenGL context: %s", SDL_GetError()); bongo_cat_memory_policy_model_loaded(); + model_runtime_stage(app, "completed", entry); + app->loading_model[0] = '\0'; + app->model_load_runtime_stage = 0; + bongo_cat_runtime_stage(app, "running"); return true; } diff --git a/src/runtime/runtime.h b/src/runtime/runtime.h index 2c8b0f08..ac50e593 100644 --- a/src/runtime/runtime.h +++ b/src/runtime/runtime.h @@ -11,6 +11,8 @@ bool bongo_cat_startup_prepare(BongoCatApp *app, int argc, char **argv, BongoCatError *error); void bongo_cat_startup_stage(BongoCatApp *app, const char *stage); void bongo_cat_startup_ready(BongoCatApp *app); +void bongo_cat_runtime_stage(BongoCatApp *app, const char *stage); +void bongo_cat_runtime_clean_shutdown(BongoCatApp *app, int exit_code); void bongo_cat_startup_failure(BongoCatApp *app, const BongoCatError *error); void bongo_cat_startup_ci_failure(BongoCatApp *app, const BongoCatError *error); void bongo_cat_window_destroy(BongoCatApp *app); From c6f9d6a227de4b6ffce4c86afa59b1ec76d9de23 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 16 Aug 2026 20:54:02 +0800 Subject: [PATCH 028/334] fix: harden runtime crash diagnostics --- cmake/RuntimeSources.cmake | 2 + src/runtime/lifecycle/app.c | 16 +--- src/runtime/lifecycle/runtime_state.c | 93 +++++++++++++++++++ src/runtime/lifecycle/runtime_state.h | 12 +++ src/runtime/lifecycle/shutdown.c | 25 +++++ src/runtime/lifecycle/startup.c | 127 +++++++++++--------------- src/runtime/model/model_files.c | 9 +- src/runtime/runtime.h | 3 + src/runtime/shell/window.c | 4 +- 9 files changed, 198 insertions(+), 93 deletions(-) create mode 100644 src/runtime/lifecycle/runtime_state.c create mode 100644 src/runtime/lifecycle/runtime_state.h create mode 100644 src/runtime/lifecycle/shutdown.c diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 4b9d8fa7..46a66a97 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -34,6 +34,8 @@ set(BONGO_CAT_RUNTIME_LIFECYCLE_SOURCES src/runtime/lifecycle/frame_clock.c src/runtime/lifecycle/memory_policy.c src/runtime/lifecycle/runtime_flow.c + src/runtime/lifecycle/runtime_state.c + src/runtime/lifecycle/shutdown.c src/runtime/lifecycle/startup.c src/runtime/lifecycle/storage_paths.c) diff --git a/src/runtime/lifecycle/app.c b/src/runtime/lifecycle/app.c index 7457a0b4..e833113b 100644 --- a/src/runtime/lifecycle/app.c +++ b/src/runtime/lifecycle/app.c @@ -263,18 +263,6 @@ static void loop(BongoCatApp *app) { (unsigned long long)iterations, (unsigned long long)wakes, (unsigned long long)zero_waits); } -static void shutdown(BongoCatApp *app) { - bongo_cat_config_store_flush(app); - bongo_cat_preferences_destroy(app->preferences); - bongo_cat_i18n_destroy(app->i18n); bongo_cat_tray_destroy(app->tray); - bongo_cat_gamepads_set_enabled(app, false); - bongo_cat_audio_destroy(app->audio); - bongo_cat_overlay_destroy(app->overlay); - bongo_cat_live2d_destroy(app->live2d); - bongo_cat_platform_shutdown(&app->platform); - bongo_cat_window_destroy(app); -} - int bongo_cat_app_run(int argc, char **argv) { if (!bongo_cat_platform_single_instance_begin()) return 0; BongoCatApp *app = calloc(1, sizeof(*app)); @@ -288,13 +276,13 @@ int bongo_cat_app_run(int argc, char **argv) { if (!initialize(app, argc, argv, &error)) { bongo_cat_startup_failure(app, &error); if (app->smoke) bongo_cat_startup_ci_failure(app, &error); - shutdown(app); bongo_cat_runtime_clean_shutdown(app, 1); free(app); + bongo_cat_app_shutdown(app, "shutdown:startup-failure", 1); free(app); bongo_cat_platform_single_instance_end(); return 1; } loop(app); int exit_code = app->exit_code; - shutdown(app); bongo_cat_runtime_clean_shutdown(app, exit_code); + bongo_cat_app_shutdown(app, "shutdown:normal", exit_code); free(app); bongo_cat_platform_single_instance_end(); return exit_code; } diff --git a/src/runtime/lifecycle/runtime_state.c b/src/runtime/lifecycle/runtime_state.c new file mode 100644 index 00000000..c37408b3 --- /dev/null +++ b/src/runtime/lifecycle/runtime_state.c @@ -0,0 +1,93 @@ +#include "runtime.h" +#include "runtime_state.h" +#include "bongo_cat/file.h" +#include "bongo_cat/path.h" + +#include +#include + +static bool state_path(BongoCatApp *app, char *path, size_t capacity) { + return app && app->state_root[0] && + bongo_cat_path_join(path, capacity, app->state_root, + "runtime-state.txt"); +} + +static bool temporary_path(BongoCatApp *app, char *path, size_t capacity) { + return app && app->state_root[0] && + bongo_cat_path_join(path, capacity, app->state_root, + "runtime-state.tmp"); +} + +void bongo_cat_runtime_timestamp(char *target, size_t capacity) { + if (!target || !capacity) return; + SDL_Time ticks = 0; + SDL_DateTime date = {0}; + if (SDL_GetCurrentTime(&ticks) && + SDL_TimeToDateTime(ticks, &date, true)) { + int offset_minutes = date.utc_offset >= 0 + ? (date.utc_offset + 30) / 60 + : -((-date.utc_offset + 30) / 60); + char sign = offset_minutes < 0 ? '-' : '+'; + if (offset_minutes < 0) offset_minutes = -offset_minutes; + snprintf(target, capacity, + "%04d-%02d-%02d %02d:%02d:%02d.%03d %c%02d:%02d", + date.year, date.month, date.day, date.hour, date.minute, + date.second, date.nanosecond / 1000000, sign, + offset_minutes / 60, offset_minutes % 60); + } else { + snprintf(target, capacity, "%lld", (long long)time(NULL)); + } + target[capacity - 1] = '\0'; +} + +void bongo_cat_runtime_state_previous(BongoCatApp *app, char *state, + size_t capacity) { + if (!state || !capacity) return; + state[0] = '\0'; + char path[BONGO_CAT_PATH_CAP], temporary[BONGO_CAT_PATH_CAP]; + if (!state_path(app, path, sizeof(path)) || + !temporary_path(app, temporary, sizeof(temporary))) return; + if (!bongo_cat_path_is_file(path) && + bongo_cat_path_is_file(temporary)) + bongo_cat_file_replace(temporary, path, true); + else bongo_cat_file_remove(temporary); + FILE *file = bongo_cat_file_open(path, "rb"); + if (!file) return; + size_t length = fread(state, 1, capacity - 1, file); + state[ferror(file) ? 0 : length] = '\0'; + fclose(file); + for (size_t i = 0; state[i]; ++i) + if (state[i] == '\r' || state[i] == '\n') state[i] = ' '; +} + +static bool write_state(BongoCatApp *app, const char *stage, + const char *timestamp) { + char path[BONGO_CAT_PATH_CAP], temporary[BONGO_CAT_PATH_CAP]; + if (!stage || !timestamp || !state_path(app, path, sizeof(path)) || + !temporary_path(app, temporary, sizeof(temporary))) return false; + FILE *file = bongo_cat_file_open(temporary, "wb"); + if (!file) return false; + bool written = fprintf(file, "time=%s stage=", timestamp) >= 0; + for (const char *cursor = stage; *cursor; ++cursor) + if (fputc(*cursor == '\r' || *cursor == '\n' ? ' ' : *cursor, + file) == EOF) written = false; + if (fputc('\n', file) == EOF || fclose(file) != 0) written = false; + if (written && bongo_cat_file_replace(temporary, path, true)) return true; + bongo_cat_file_remove(temporary); + return false; +} + +void bongo_cat_runtime_stage(BongoCatApp *app, const char *stage) { + char timestamp[64]; + bongo_cat_runtime_timestamp(timestamp, sizeof(timestamp)); + write_state(app, stage, timestamp); +} + +void bongo_cat_runtime_state_clean(BongoCatApp *app, const char *timestamp) { + char path[BONGO_CAT_PATH_CAP], temporary[BONGO_CAT_PATH_CAP]; + if (temporary_path(app, temporary, sizeof(temporary))) + bongo_cat_file_remove(temporary); + if (state_path(app, path, sizeof(path)) && + !bongo_cat_file_remove(path) && bongo_cat_path_is_file(path)) + write_state(app, "clean-shutdown", timestamp); +} diff --git a/src/runtime/lifecycle/runtime_state.h b/src/runtime/lifecycle/runtime_state.h new file mode 100644 index 00000000..a7bee28e --- /dev/null +++ b/src/runtime/lifecycle/runtime_state.h @@ -0,0 +1,12 @@ +#ifndef BONGO_CAT_RUNTIME_STATE_H +#define BONGO_CAT_RUNTIME_STATE_H + +#include "bongo_cat/app.h" +#include + +void bongo_cat_runtime_timestamp(char *target, size_t capacity); +void bongo_cat_runtime_state_previous(BongoCatApp *app, char *state, + size_t capacity); +void bongo_cat_runtime_state_clean(BongoCatApp *app, const char *timestamp); + +#endif diff --git a/src/runtime/lifecycle/shutdown.c b/src/runtime/lifecycle/shutdown.c new file mode 100644 index 00000000..ca135333 --- /dev/null +++ b/src/runtime/lifecycle/shutdown.c @@ -0,0 +1,25 @@ +#include "runtime.h" +#include "bongo_cat/audio.h" +#include "bongo_cat/i18n.h" +#include "bongo_cat/overlay.h" +#include "bongo_cat/preferences.h" +#include "bongo_cat/tray.h" + +void bongo_cat_app_shutdown(BongoCatApp *app, const char *stage, + int exit_code) { + bongo_cat_runtime_stage(app, stage); + SDL_Log("[runtime] Shutdown started: stage=%s exit_code=%d", + stage, exit_code); + bongo_cat_config_store_flush(app); + bongo_cat_preferences_destroy(app->preferences); + bongo_cat_i18n_destroy(app->i18n); + bongo_cat_tray_destroy(app->tray); + bongo_cat_gamepads_set_enabled(app, false); + bongo_cat_audio_destroy(app->audio); + bongo_cat_overlay_destroy(app->overlay); + bongo_cat_live2d_destroy(app->live2d); + bongo_cat_platform_shutdown(&app->platform); + bongo_cat_runtime_log_stop(); + bongo_cat_window_destroy(app); + bongo_cat_runtime_clean_shutdown(app, exit_code); +} diff --git a/src/runtime/lifecycle/startup.c b/src/runtime/lifecycle/startup.c index b34d8842..0d503065 100644 --- a/src/runtime/lifecycle/startup.c +++ b/src/runtime/lifecycle/startup.c @@ -1,4 +1,5 @@ #include "runtime.h" +#include "runtime_state.h" #include "bongo_cat/file.h" #include "bongo_cat/path.h" #include "storage_paths.h" @@ -15,24 +16,7 @@ static char startup_log_path[BONGO_CAT_PATH_CAP]; static bool startup_is_ready; static bool verbose_logging; - -static void format_timestamp(char *target, size_t capacity) { - if (!target || !capacity) return; - SDL_Time ticks = 0; - SDL_DateTime date = {0}; - if (SDL_GetCurrentTime(&ticks) && SDL_TimeToDateTime(ticks, &date, true)) { - int offset_minutes = date.utc_offset >= 0 ? (date.utc_offset + 30) / 60 : -((-date.utc_offset + 30) / 60); - char sign = offset_minutes < 0 ? '-' : '+'; - if (offset_minutes < 0) offset_minutes = -offset_minutes; - snprintf(target, capacity, "%04d-%02d-%02d %02d:%02d:%02d.%03d %c%02d:%02d", - date.year, date.month, date.day, date.hour, date.minute, - date.second, date.nanosecond / 1000000, sign, - offset_minutes / 60, offset_minutes % 60); - } else { - snprintf(target, capacity, "%lld", (long long)time(NULL)); - } - target[capacity - 1] = '\0'; -} +static SDL_Mutex *log_mutex; static void append_log(const char *path, const char *timestamp, int category, SDL_LogPriority priority, const char *message) { @@ -43,13 +27,7 @@ static void append_log(const char *path, const char *timestamp, } static bool essential_info(const char *message) { - static const char *prefixes[] = {"Startup ", "OpenGL window ready", - "OpenGL context:", "Model load ", "Previous run ", - "Shutdown complete"}; - if (!message) return false; - for (size_t i = 0; i < sizeof(prefixes) / sizeof(prefixes[0]); ++i) - if (strncmp(message, prefixes[i], strlen(prefixes[i])) == 0) return true; - return false; + return message && strncmp(message, "[runtime] ", 10) == 0; } static void SDLCALL log_output(void *userdata, int category, @@ -57,58 +35,47 @@ static void SDLCALL log_output(void *userdata, int category, (void)userdata; if (!verbose_logging && priority < SDL_LOG_PRIORITY_WARN && !essential_info(message)) return; + if (log_mutex) SDL_LockMutex(log_mutex); char timestamp[64]; - format_timestamp(timestamp, sizeof(timestamp)); + bongo_cat_runtime_timestamp(timestamp, sizeof(timestamp)); append_log(startup_log_path, timestamp, category, priority, message); fprintf(stderr, "%s [%d:%d] %s\n", timestamp, category, (int)priority, message ? message : ""); + if (log_mutex) SDL_UnlockMutex(log_mutex); } -static bool runtime_state_path(BongoCatApp *app, char *path, size_t capacity) { - return app && app->state_root[0] && - bongo_cat_path_join(path, capacity, app->state_root, "runtime-state.txt"); +static void format_shutdown_timestamp(char *target, size_t capacity) { + time_t now = time(NULL); + struct tm *utc = gmtime(&now); + if (!utc || !strftime(target, capacity, "%Y-%m-%d %H:%M:%S UTC", utc)) + snprintf(target, capacity, "%lld", (long long)now); + target[capacity - 1] = '\0'; } -static void read_runtime_state(BongoCatApp *app, char *state, size_t capacity) { - if (!state || !capacity) return; - state[0] = '\0'; - char path[BONGO_CAT_PATH_CAP]; - if (!runtime_state_path(app, path, sizeof(path))) return; - FILE *file = bongo_cat_file_open(path, "rb"); - if (!file) return; - size_t length = fread(state, 1, capacity - 1, file); - state[ferror(file) ? 0 : length] = '\0'; - fclose(file); - for (size_t i = 0; state[i]; ++i) - if (state[i] == '\r' || state[i] == '\n') state[i] = ' '; -} +void bongo_cat_runtime_clean_shutdown(BongoCatApp *app, int exit_code) { + char timestamp[64], message[80]; + format_shutdown_timestamp(timestamp, sizeof(timestamp)); + snprintf(message, sizeof(message), + "[runtime] Shutdown complete: exit_code=%d", exit_code); + append_log(startup_log_path, timestamp, SDL_LOG_CATEGORY_APPLICATION, + SDL_LOG_PRIORITY_INFO, message); + fprintf(stderr, "%s [%d:%d] %s\n", timestamp, + SDL_LOG_CATEGORY_APPLICATION, SDL_LOG_PRIORITY_INFO, message); -void bongo_cat_runtime_stage(BongoCatApp *app, const char *stage) { - char path[BONGO_CAT_PATH_CAP]; - if (!stage || !runtime_state_path(app, path, sizeof(path))) return; - FILE *file = bongo_cat_file_open(path, "wb"); - if (!file) return; - char timestamp[64]; - format_timestamp(timestamp, sizeof(timestamp)); - fprintf(file, "time=%s stage=", timestamp); - for (const char *cursor = stage; *cursor; ++cursor) - fputc(*cursor == '\r' || *cursor == '\n' ? ' ' : *cursor, file); - fputc('\n', file); - fclose(file); + bongo_cat_runtime_state_clean(app, timestamp); } -void bongo_cat_runtime_clean_shutdown(BongoCatApp *app, int exit_code) { - char path[BONGO_CAT_PATH_CAP]; - SDL_Log("Shutdown complete: exit_code=%d", exit_code); - if (runtime_state_path(app, path, sizeof(path)) && - !bongo_cat_file_remove(path)) - bongo_cat_runtime_stage(app, "clean-shutdown"); +void bongo_cat_runtime_log_stop(void) { + if (!log_mutex) return; + SDL_Mutex *mutex = log_mutex; + log_mutex = NULL; + SDL_DestroyMutex(mutex); } static bool reset_log(const char *path) { FILE *file = path && path[0] ? bongo_cat_file_open(path, "wb") : NULL; if (!file) return false; char timestamp[64]; - format_timestamp(timestamp, sizeof(timestamp)); + bongo_cat_runtime_timestamp(timestamp, sizeof(timestamp)); fprintf(file, "%s BongoCat %s runtime log\n", timestamp, BONGO_CAT_VERSION); return fclose(file) == 0; } @@ -183,16 +150,24 @@ static bool parse_arguments(BongoCatApp *app, int argc, char **argv, static void begin_log(BongoCatApp *app) { char previous[BONGO_CAT_PATH_CAP], stage_path[BONGO_CAT_PATH_CAP]; + char stage_temporary[BONGO_CAT_PATH_CAP]; char interrupted[128] = {0}; char unexpected[256] = {0}; - read_runtime_state(app, unexpected, sizeof(unexpected)); + bongo_cat_runtime_state_previous(app, unexpected, sizeof(unexpected)); bongo_cat_path_join(startup_log_path, sizeof(startup_log_path), app->log_root, "startup.log"); bongo_cat_path_join(previous, sizeof(previous), app->log_root, "startup-previous.log"); - bongo_cat_path_join(stage_path, sizeof(stage_path), app->state_root, - "startup-stage.txt"); - FILE *stage = bongo_cat_file_open(stage_path, "rb"); + bool stage_paths_ready = bongo_cat_path_join(stage_path, + sizeof(stage_path), app->state_root, "startup-stage.txt") && + bongo_cat_path_join(stage_temporary, sizeof(stage_temporary), + app->state_root, "startup-stage.tmp"); + if (stage_paths_ready && !bongo_cat_path_is_file(stage_path) && + bongo_cat_path_is_file(stage_temporary)) + bongo_cat_file_replace(stage_temporary, stage_path, true); + else if (stage_paths_ready) bongo_cat_file_remove(stage_temporary); + FILE *stage = stage_paths_ready ? bongo_cat_file_open(stage_path, "rb") + : NULL; if (stage) { size_t length = fread(interrupted, 1, sizeof(interrupted) - 1, stage); interrupted[ferror(stage) ? 0 : length] = '\0'; @@ -202,8 +177,9 @@ static void begin_log(BongoCatApp *app) { bongo_cat_file_replace(startup_log_path, previous, true); reset_log(startup_log_path); verbose_logging = app->smoke; + if (!log_mutex) log_mutex = SDL_CreateMutex(); SDL_SetLogOutputFunction(log_output, NULL); - SDL_Log("Startup on %s; storage=%s", SDL_GetPlatform(), app->storage_root[0] + SDL_Log("[runtime] Startup on %s; storage=%s", SDL_GetPlatform(), app->storage_root[0] ? app->storage_root : app->config_root); if (interrupted[0]) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Previous startup ended before readiness at stage: %s", interrupted); @@ -222,15 +198,20 @@ bool bongo_cat_startup_prepare(BongoCatApp *app, int argc, char **argv, void bongo_cat_startup_stage(BongoCatApp *app, const char *stage) { if (!app || !app->state_root[0] || !stage) return; - char path[BONGO_CAT_PATH_CAP]; - if (bongo_cat_path_join(path, sizeof(path), app->state_root, "startup-stage.txt")) { - FILE *file = bongo_cat_file_open(path, "wb"); - if (file) { fputs(stage, file); fclose(file); } + char path[BONGO_CAT_PATH_CAP], temporary[BONGO_CAT_PATH_CAP]; + if (bongo_cat_path_join(path, sizeof(path), app->state_root, + "startup-stage.txt") && bongo_cat_path_join(temporary, + sizeof(temporary), app->state_root, "startup-stage.tmp")) { + FILE *file = bongo_cat_file_open(temporary, "wb"); + bool written = file && fputs(stage, file) >= 0; + if (file && fclose(file) != 0) written = false; + if (!written || !bongo_cat_file_replace(temporary, path, true)) + bongo_cat_file_remove(temporary); } char runtime_stage[160]; snprintf(runtime_stage, sizeof(runtime_stage), "startup:%s", stage); bongo_cat_runtime_stage(app, runtime_stage); - SDL_Log("Startup stage: %s", stage); + SDL_Log("[runtime] Startup stage: %s", stage); } void bongo_cat_startup_ready(BongoCatApp *app) { @@ -239,10 +220,12 @@ void bongo_cat_startup_ready(BongoCatApp *app) { char path[BONGO_CAT_PATH_CAP]; if (bongo_cat_path_join(path, sizeof(path), app->state_root, "startup-stage.txt")) bongo_cat_file_remove(path); + if (bongo_cat_path_join(path, sizeof(path), app->state_root, + "startup-stage.tmp")) bongo_cat_file_remove(path); if (bongo_cat_path_join(path, sizeof(path), app->log_root, "startup-error.log")) bongo_cat_file_remove(path); bongo_cat_runtime_stage(app, "running"); - SDL_Log("Startup ready"); + SDL_Log("[runtime] Startup ready"); } static void write_error(BongoCatApp *app, const char *name, const char *message) { diff --git a/src/runtime/model/model_files.c b/src/runtime/model/model_files.c index cb3e9f53..40ef8d56 100644 --- a/src/runtime/model/model_files.c +++ b/src/runtime/model/model_files.c @@ -35,14 +35,13 @@ static void model_runtime_stage(BongoCatApp *app, const char *stage, const char *id = entry ? entry->id : "unknown"; snprintf(state, sizeof(state), "model-load:%s:%s", stage, id); bongo_cat_runtime_stage(app, state); - SDL_Log("Model load %s: id=%s", stage, id); + SDL_Log("[runtime] Model load %s: id=%s", stage, id); } static void model_progress_runtime_stage(BongoCatApp *app, float progress) { - static const char *names[] = {"", "cubism-core", "texture-decode", - "texture-alpha", "texture-upload", "finalizing"}; - unsigned stage = progress >= .94f ? 5 : progress >= .74f ? 4 : - progress >= .63f ? 3 : progress >= .50f ? 2 : 1; + static const char *names[] = {"", "model-core", "texture-loading", + "finalizing"}; + unsigned stage = progress >= .95f ? 3 : progress >= .50f ? 2 : 1; if (!app || stage == app->model_load_runtime_stage) return; app->model_load_runtime_stage = stage; char state[BONGO_CAT_ID_CAP + 40]; diff --git a/src/runtime/runtime.h b/src/runtime/runtime.h index ac50e593..b067f8fe 100644 --- a/src/runtime/runtime.h +++ b/src/runtime/runtime.h @@ -12,7 +12,10 @@ bool bongo_cat_startup_prepare(BongoCatApp *app, int argc, char **argv, void bongo_cat_startup_stage(BongoCatApp *app, const char *stage); void bongo_cat_startup_ready(BongoCatApp *app); void bongo_cat_runtime_stage(BongoCatApp *app, const char *stage); +void bongo_cat_runtime_log_stop(void); void bongo_cat_runtime_clean_shutdown(BongoCatApp *app, int exit_code); +void bongo_cat_app_shutdown(BongoCatApp *app, const char *stage, + int exit_code); void bongo_cat_startup_failure(BongoCatApp *app, const BongoCatError *error); void bongo_cat_startup_ci_failure(BongoCatApp *app, const BongoCatError *error); void bongo_cat_window_destroy(BongoCatApp *app); diff --git a/src/runtime/shell/window.c b/src/runtime/shell/window.c index 6041f181..48dfe686 100644 --- a/src/runtime/shell/window.c +++ b/src/runtime/shell/window.c @@ -66,10 +66,10 @@ BongoCatResult bongo_cat_window_create(BongoCatApp *app, BongoCatError *error) { const GLubyte *vendor = glGetString(GL_VENDOR); const GLubyte *renderer = glGetString(GL_RENDERER); const GLubyte *version = glGetString(GL_VERSION); - SDL_Log("OpenGL window ready (transparent=%d, MSAA=%d, " + SDL_Log("[runtime] OpenGL window ready (transparent=%d, MSAA=%d, " "sample_buffers=%d, sample_count=%d)", options[i][0], options[i][1], sample_buffers, sample_count); - SDL_Log("OpenGL context: vendor=%s renderer=%s version=%s", + SDL_Log("[runtime] OpenGL context: vendor=%s renderer=%s version=%s", vendor ? (const char *)vendor : "unknown", renderer ? (const char *)renderer : "unknown", version ? (const char *)version : "unknown"); From f0661e51eb509485debb76eb3cc957e9c2d6f7f3 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 16 Aug 2026 21:21:57 +0800 Subject: [PATCH 029/334] Fix expression behavior toggling --- src/runtime/input/shortcuts.c | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/runtime/input/shortcuts.c b/src/runtime/input/shortcuts.c index 42316cba..2e529b52 100644 --- a/src/runtime/input/shortcuts.c +++ b/src/runtime/input/shortcuts.c @@ -31,7 +31,11 @@ bool bongo_cat_app_run_behavior(BongoCatApp *app, BongoCatError error = {0}; bongo_cat_audio_play(app->audio, behavior->sound, &error); } - } else if (!bongo_cat_live2d_set_expression(app->live2d, behavior->index)) return false; + } else { + int expression = bongo_cat_live2d_expression(app->live2d) == + behavior->index ? -1 : behavior->index; + if (!bongo_cat_live2d_set_expression(app->live2d, expression)) return false; + } app->dirty = true; return true; } From e71be80e45a4494527725053ec043f85f39f6115 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 16 Aug 2026 21:53:41 +0800 Subject: [PATCH 030/334] Persist model behavior state across restarts --- CMakeLists.txt | 2 +- cmake/RuntimeSources.cmake | 1 + cmake/Tests.cmake | 3 +- include/bongo_cat/app.h | 3 + include/bongo_cat/config.h | 7 ++ src/core/config_defaults.c | 29 -------- src/core/config_session_defaults.c | 79 ++++++++++++++++++++ src/core/config_session_io.c | 68 ++++++++++++++++- src/runtime/input/shortcuts.c | 1 + src/runtime/lifecycle/config_store.c | 6 ++ src/runtime/lifecycle/shutdown.c | 1 + src/runtime/model/model_behavior_state.c | 93 ++++++++++++++++++++++++ src/runtime/model/model_files.c | 2 + src/runtime/shell/window_menu_behavior.c | 1 + src/runtime/shell/window_menu_preview.c | 4 + tests/core/test_app_state.c | 82 +++++++++++++++++++++ tests/core/test_config.c | 47 +++++++++--- 17 files changed, 385 insertions(+), 44 deletions(-) create mode 100644 src/core/config_session_defaults.c create mode 100644 src/runtime/model/model_behavior_state.c diff --git a/CMakeLists.txt b/CMakeLists.txt index 4f440316..f5582a3a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -55,7 +55,7 @@ else() message(WARNING "Cubism SDK missing; building the diagnostic backend") endif() add_library(bongo_cat_core STATIC - src/core/config_defaults.c src/core/config_names.c src/core/config_fields.c src/core/config_io.c src/core/config_settings_save.c + src/core/config_defaults.c src/core/config_session_defaults.c src/core/config_names.c src/core/config_fields.c src/core/config_io.c src/core/config_settings_save.c src/core/config_file.c src/core/config_session_io.c src/core/error.c src/core/file.c src/core/i18n.c diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 46a66a97..7a6f6c1d 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -53,6 +53,7 @@ set(BONGO_CAT_RUNTIME_INPUT_SOURCES src/runtime/input/shortcuts.c) set(BONGO_CAT_RUNTIME_MODEL_SOURCES + src/runtime/model/model_behavior_state.c src/runtime/model/model_catalog_runtime.c src/runtime/model/model_files.c src/runtime/model/model_storage.c diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index 2cffd5c8..ac89ac3c 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -35,7 +35,8 @@ if(BUILD_TESTING) add_test(NAME ui COMMAND bongo_cat_ui_tests) add_executable(bongo_cat_app_state_tests - tests/core/test_app_state.c src/core/app_state.c) + tests/core/test_app_state.c src/core/app_state.c + src/runtime/model/model_behavior_state.c) target_link_libraries(bongo_cat_app_state_tests PRIVATE bongo_cat_warnings) target_include_directories(bongo_cat_app_state_tests PRIVATE "${BONGO_CAT_GENERATED_INCLUDE_DIR}" include tests/support) diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index 9fdb6f39..a9f9ba8c 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -149,6 +149,9 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, const char *id, BongoCatError *error); bool bongo_cat_app_run_behavior(BongoCatApp *app, const BongoCatBehaviorEntry *behavior); +void bongo_cat_app_capture_behavior_state(BongoCatApp *app); +void bongo_cat_app_restore_behavior_state(BongoCatApp *app, + const char *model_id); BongoCatResult bongo_cat_app_import_model(BongoCatApp *app, const char *source, BongoCatError *error); BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, BongoCatError *error); void bongo_cat_app_rescan_models(BongoCatApp *app); diff --git a/include/bongo_cat/config.h b/include/bongo_cat/config.h index 495e84e4..3f68cf54 100644 --- a/include/bongo_cat/config.h +++ b/include/bongo_cat/config.h @@ -91,6 +91,11 @@ typedef struct BongoCatModelLabel { char label[BONGO_CAT_ID_CAP]; } BongoCatModelLabel; +typedef struct BongoCatActiveBehavior { + char model_id[BONGO_CAT_ID_CAP]; + char behavior_id[BONGO_CAT_BEHAVIOR_ID_CAP]; +} BongoCatActiveBehavior; + #define BONGO_CAT_SETTINGS_EXTENSIONS_CAP 4096 typedef struct BongoCatSettings { @@ -108,6 +113,8 @@ typedef struct BongoCatSettings { typedef struct BongoCatSessionState { BongoCatWindowState window; char active_model_id[BONGO_CAT_ID_CAP]; + BongoCatActiveBehavior active_behaviors[BONGO_CAT_BEHAVIOR_BINDING_CAP]; + size_t active_behavior_count; } BongoCatSessionState; #ifdef __cplusplus diff --git a/src/core/config_defaults.c b/src/core/config_defaults.c index 5e51edcc..515b5e05 100644 --- a/src/core/config_defaults.c +++ b/src/core/config_defaults.c @@ -228,18 +228,6 @@ void bongo_cat_settings_defaults(BongoCatSettings *config) { memcpy(config->extensions_json, "{}", sizeof("{}")); } -void bongo_cat_session_defaults(BongoCatSessionState *session) { - if (!session) return; - memset(session, 0, sizeof(*session)); - session->window.visible = true; - session->window.scale_percent = BONGO_CAT_DEFAULT_WINDOW_SCALE_PERCENT; - session->window.opacity_percent = - BONGO_CAT_DEFAULT_WINDOW_OPACITY_PERCENT; - session->window.width = 612; - session->window.height = 354; - memcpy(session->active_model_id, "standard", sizeof("standard")); -} - void bongo_cat_settings_validate(BongoCatSettings *config) { if (!config) return; config->model.auto_release_seconds = clampf_or( @@ -261,20 +249,3 @@ void bongo_cat_settings_validate(BongoCatSettings *config) { validate_shortcuts(config); compact_behavior_overrides(config); } - -void bongo_cat_session_validate(BongoCatSessionState *session) { - if (!session) return; - session->window.scale_percent = clampf_or(session->window.scale_percent, - 10.0f, 500.0f, BONGO_CAT_DEFAULT_WINDOW_SCALE_PERCENT); - session->window.opacity_percent = clampf_or( - session->window.opacity_percent, 10.0f, 100.0f, - BONGO_CAT_DEFAULT_WINDOW_OPACITY_PERCENT); - if (session->window.width < 64) session->window.width = 64; - if (session->window.height < 64) session->window.height = 64; - if (session->window.width > 8192) session->window.width = 8192; - if (session->window.height > 8192) session->window.height = 8192; - if (!normalize_text(session->active_model_id, - sizeof(session->active_model_id)) || - !session->active_model_id[0]) - memcpy(session->active_model_id, "standard", sizeof("standard")); -} diff --git a/src/core/config_session_defaults.c b/src/core/config_session_defaults.c new file mode 100644 index 00000000..6f637caa --- /dev/null +++ b/src/core/config_session_defaults.c @@ -0,0 +1,79 @@ +#include "bongo_cat/config.h" +#include "bongo_cat/utf8.h" + +#include +#include + +static float clampf_or(float value, float low, float high, float fallback) { + if (!isfinite(value)) return fallback; + return value < low ? low : value > high ? high : value; +} + +static bool normalize_text(char *text, size_t capacity) { + text[capacity - 1] = '\0'; + if (!bongo_cat_utf8_valid(text)) { + memset(text, 0, capacity); + return false; + } + size_t length = strlen(text); + memset(text + length + 1, 0, capacity - length - 1); + return true; +} + +void bongo_cat_session_defaults(BongoCatSessionState *session) { + if (!session) return; + memset(session, 0, sizeof(*session)); + session->window.visible = true; + session->window.scale_percent = BONGO_CAT_DEFAULT_WINDOW_SCALE_PERCENT; + session->window.opacity_percent = + BONGO_CAT_DEFAULT_WINDOW_OPACITY_PERCENT; + session->window.width = 612; + session->window.height = 354; + memcpy(session->active_model_id, "standard", sizeof("standard")); +} + +static void compact_active_behaviors(BongoCatSessionState *session) { + size_t count = session->active_behavior_count; + if (count > BONGO_CAT_BEHAVIOR_BINDING_CAP) + count = BONGO_CAT_BEHAVIOR_BINDING_CAP; + size_t output = 0; + for (size_t i = 0; i < count; ++i) { + BongoCatActiveBehavior entry = session->active_behaviors[i]; + if (!normalize_text(entry.model_id, sizeof(entry.model_id)) || + !normalize_text(entry.behavior_id, sizeof(entry.behavior_id)) || + !entry.model_id[0] || !entry.behavior_id[0]) continue; + bool duplicate = false; + for (size_t j = 0; j < output; ++j) + if (!strcmp(session->active_behaviors[j].model_id, + entry.model_id) && + !strcmp(session->active_behaviors[j].behavior_id, + entry.behavior_id)) { + duplicate = true; + break; + } + if (!duplicate) session->active_behaviors[output++] = entry; + } + if (output < BONGO_CAT_BEHAVIOR_BINDING_CAP) + memset(&session->active_behaviors[output], 0, + (BONGO_CAT_BEHAVIOR_BINDING_CAP - output) * + sizeof(session->active_behaviors[0])); + session->active_behavior_count = output; +} + +void bongo_cat_session_validate(BongoCatSessionState *session) { + if (!session) return; + session->window.scale_percent = clampf_or(session->window.scale_percent, + 10.0f, 500.0f, BONGO_CAT_DEFAULT_WINDOW_SCALE_PERCENT); + session->window.opacity_percent = clampf_or( + session->window.opacity_percent, 10.0f, 100.0f, + BONGO_CAT_DEFAULT_WINDOW_OPACITY_PERCENT); + if (session->window.width < 64) session->window.width = 64; + if (session->window.height < 64) session->window.height = 64; + if (session->window.width > 8192) session->window.width = 8192; + if (session->window.height > 8192) session->window.height = 8192; + if (!normalize_text(session->active_model_id, + sizeof(session->active_model_id)) || + !session->active_model_id[0]) + memcpy(session->active_model_id, "standard", sizeof("standard")); + compact_active_behaviors(session); +} diff --git a/src/core/config_session_io.c b/src/core/config_session_io.c index 9068532f..6fdecd46 100644 --- a/src/core/config_session_io.c +++ b/src/core/config_session_io.c @@ -10,6 +10,8 @@ "Session", object, key, target, error) #define read_object(object, key, target, error) bongo_cat_config_read_object( \ "Session", object, key, target, error) +#define read_array(object, key, target, error) bongo_cat_config_read_array( \ + "Session", object, key, target, error) #define read_bool(object, key, target, error) bongo_cat_config_read_bool( \ "Session", object, key, target, error) #define read_int(object, key, target, required, error) \ @@ -17,6 +19,12 @@ "Session", object, key, target, required, error) #define read_float(object, key, target, error) bongo_cat_config_read_float( \ "Session", object, key, target, error) +#define read_string(object, key, target, length, error) \ + bongo_cat_config_read_string( \ + "Session", object, key, target, length, error) +#define copy_text(target, capacity, value, length, field, error) \ + bongo_cat_config_copy_text( \ + "Session", target, capacity, value, length, field, error) static bool read_active_model(yyjson_val *root, BongoCatSessionState *session, BongoCatError *error) { @@ -36,6 +44,38 @@ static bool read_active_model(yyjson_val *root, return true; } +static bool read_active_behaviors(yyjson_val *array, + BongoCatSessionState *session, BongoCatError *error) { + if (!array) return true; + if (yyjson_arr_size(array) > BONGO_CAT_BEHAVIOR_BINDING_CAP) + return type_error(error, "activeBehaviors", "a smaller array"); + session->active_behavior_count = 0; + size_t index, count; + yyjson_val *item; + yyjson_arr_foreach(array, index, count, item) { + if (!yyjson_is_obj(item)) + return type_error(error, "activeBehaviors[]", "an object"); + const char *model_id, *behavior_id; + size_t model_length, behavior_length; + if (!read_string(item, "modelId", &model_id, &model_length, + error) || + !read_string(item, "behaviorId", &behavior_id, + &behavior_length, error)) return false; + if (!model_id || !model_length || !behavior_id || !behavior_length) + return type_error(error, "activeBehaviors[]", + "non-empty modelId and behaviorId strings"); + BongoCatActiveBehavior *entry = &session->active_behaviors[ + session->active_behavior_count++]; + memset(entry, 0, sizeof(*entry)); + if (!copy_text(entry->model_id, sizeof(entry->model_id), model_id, + model_length, "modelId", error) || + !copy_text(entry->behavior_id, sizeof(entry->behavior_id), + behavior_id, behavior_length, "behaviorId", error)) + return false; + } + return true; +} + BongoCatResult bongo_cat_session_load(const char *path, BongoCatSessionState *session, BongoCatError *error) { if (!path || !session) return BONGO_CAT_ERROR_ARGUMENT; @@ -47,7 +87,9 @@ BongoCatResult bongo_cat_session_load(const char *path, BongoCatSessionState loaded = *session; yyjson_val *root = yyjson_doc_get_root(document); yyjson_val *window = NULL; - bool valid = read_object(root, "window", &window, error); + yyjson_val *active_behaviors = NULL; + bool valid = read_object(root, "window", &window, error) && + read_array(root, "activeBehaviors", &active_behaviors, error); if (valid && window) { valid = read_bool(window, "visible", &loaded.window.visible, error) && read_float(window, "scalePercent", @@ -69,7 +111,8 @@ BongoCatResult bongo_cat_session_load(const char *path, error) && read_int(size, "height", &loaded.window.height, true, error); } - if (valid) valid = read_active_model(root, &loaded, error); + if (valid) valid = read_active_model(root, &loaded, error) && + read_active_behaviors(active_behaviors, &loaded, error); yyjson_doc_free(document); if (!valid) return BONGO_CAT_ERROR_FORMAT; bongo_cat_session_validate(&loaded); @@ -83,6 +126,24 @@ static BongoCatResult build_error(BongoCatError *error) { return BONGO_CAT_ERROR_MEMORY; } +static bool write_active_behaviors(yyjson_mut_doc *doc, + yyjson_mut_val *root, const BongoCatSessionState *session) { + yyjson_mut_val *array = yyjson_mut_arr(doc); + if (!array || !yyjson_mut_obj_add_val( + doc, root, "activeBehaviors", array)) return false; + for (size_t i = 0; i < session->active_behavior_count; ++i) { + const BongoCatActiveBehavior *entry = + &session->active_behaviors[i]; + yyjson_mut_val *item = yyjson_mut_obj(doc); + if (!item || !yyjson_mut_obj_add_strcpy( + doc, item, "modelId", entry->model_id) || + !yyjson_mut_obj_add_strcpy( + doc, item, "behaviorId", entry->behavior_id) || + !yyjson_mut_arr_add_val(array, item)) return false; + } + return true; +} + BongoCatResult bongo_cat_session_save(const char *path, const BongoCatSessionState *session, BongoCatError *error) { if (!path || !session) return BONGO_CAT_ERROR_ARGUMENT; @@ -122,7 +183,8 @@ BongoCatResult bongo_cat_session_save(const char *path, yyjson_mut_obj_add_int(doc, size, "width", canonical.window.width) && yyjson_mut_obj_add_int(doc, size, "height", canonical.window.height) && yyjson_mut_obj_add_strcpy(doc, root, "activeModelId", - canonical.active_model_id); + canonical.active_model_id) && + write_active_behaviors(doc, root, &canonical); if (!built) { yyjson_mut_doc_free(doc); return build_error(error); diff --git a/src/runtime/input/shortcuts.c b/src/runtime/input/shortcuts.c index 2e529b52..d6ccde16 100644 --- a/src/runtime/input/shortcuts.c +++ b/src/runtime/input/shortcuts.c @@ -36,6 +36,7 @@ bool bongo_cat_app_run_behavior(BongoCatApp *app, behavior->index ? -1 : behavior->index; if (!bongo_cat_live2d_set_expression(app->live2d, expression)) return false; } + bongo_cat_app_capture_behavior_state(app); app->dirty = true; return true; } diff --git a/src/runtime/lifecycle/config_store.c b/src/runtime/lifecycle/config_store.c index e5bb047a..c90c032a 100644 --- a/src/runtime/lifecycle/config_store.c +++ b/src/runtime/lifecycle/config_store.c @@ -51,6 +51,12 @@ static uint64_t session_hash(const BongoCatSessionState *session) { HASH_FIELD(session->window.width); HASH_FIELD(session->window.height); HASH_FIELD(session->active_model_id); + HASH_FIELD(session->active_behavior_count); + size_t behavior_count = session->active_behavior_count; + if (behavior_count > BONGO_CAT_BEHAVIOR_BINDING_CAP) + behavior_count = BONGO_CAT_BEHAVIOR_BINDING_CAP; + hash = hash_bytes(hash, session->active_behaviors, + behavior_count * sizeof(session->active_behaviors[0])); #undef HASH_FIELD return hash; } diff --git a/src/runtime/lifecycle/shutdown.c b/src/runtime/lifecycle/shutdown.c index ca135333..edc5098c 100644 --- a/src/runtime/lifecycle/shutdown.c +++ b/src/runtime/lifecycle/shutdown.c @@ -10,6 +10,7 @@ void bongo_cat_app_shutdown(BongoCatApp *app, const char *stage, bongo_cat_runtime_stage(app, stage); SDL_Log("[runtime] Shutdown started: stage=%s exit_code=%d", stage, exit_code); + bongo_cat_app_capture_behavior_state(app); bongo_cat_config_store_flush(app); bongo_cat_preferences_destroy(app->preferences); bongo_cat_i18n_destroy(app->i18n); diff --git a/src/runtime/model/model_behavior_state.c b/src/runtime/model/model_behavior_state.c new file mode 100644 index 00000000..134b5649 --- /dev/null +++ b/src/runtime/model/model_behavior_state.c @@ -0,0 +1,93 @@ +#include "bongo_cat/app.h" + +#include +#include + +static bool behavior_active(const BongoCatApp *app, + const BongoCatBehaviorEntry *entry, int expression) { + if (entry->kind == BONGO_CAT_BEHAVIOR_MOTION) + return bongo_cat_live2d_motion_selected(app->live2d, + entry->group, entry->index); + return entry->kind == BONGO_CAT_BEHAVIOR_EXPRESSION && + entry->index == expression; +} + +static void remove_model_state(BongoCatSessionState *session, + const char *model_id) { + size_t count = session->active_behavior_count; + if (count > BONGO_CAT_BEHAVIOR_BINDING_CAP) + count = BONGO_CAT_BEHAVIOR_BINDING_CAP; + size_t output = 0; + for (size_t i = 0; i < count; ++i) { + BongoCatActiveBehavior entry = session->active_behaviors[i]; + if (strcmp(entry.model_id, model_id) != 0) + session->active_behaviors[output++] = entry; + } + session->active_behavior_count = output; +} + +static void make_room(BongoCatSessionState *session, size_t required) { + size_t count = session->active_behavior_count; + if (required > BONGO_CAT_BEHAVIOR_BINDING_CAP) + required = BONGO_CAT_BEHAVIOR_BINDING_CAP; + if (count + required <= BONGO_CAT_BEHAVIOR_BINDING_CAP) return; + size_t remove = count + required - BONGO_CAT_BEHAVIOR_BINDING_CAP; + memmove(session->active_behaviors, &session->active_behaviors[remove], + (count - remove) * sizeof(session->active_behaviors[0])); + session->active_behavior_count -= remove; +} + +void bongo_cat_app_capture_behavior_state(BongoCatApp *app) { + if (!app || !app->live2d || !app->loaded_model[0]) return; + int expression = bongo_cat_live2d_expression(app->live2d); + size_t active = 0; + for (size_t i = 0; i < app->behaviors.count; ++i) + if (behavior_active(app, &app->behaviors.entries[i], expression)) + active++; + remove_model_state(&app->session, app->loaded_model); + make_room(&app->session, active); + for (size_t i = 0; i < app->behaviors.count && + app->session.active_behavior_count < + BONGO_CAT_BEHAVIOR_BINDING_CAP; ++i) { + const BongoCatBehaviorEntry *entry = &app->behaviors.entries[i]; + if (!behavior_active(app, entry, expression)) continue; + BongoCatActiveBehavior *saved = &app->session.active_behaviors[ + app->session.active_behavior_count++]; + memset(saved, 0, sizeof(*saved)); + snprintf(saved->model_id, sizeof(saved->model_id), "%s", + app->loaded_model); + snprintf(saved->behavior_id, sizeof(saved->behavior_id), "%s", + entry->id); + } +} + +static const BongoCatBehaviorEntry *find_behavior(const BongoCatApp *app, + const char *id) { + for (size_t i = 0; i < app->behaviors.count; ++i) + if (strcmp(app->behaviors.entries[i].id, id) == 0) + return &app->behaviors.entries[i]; + return NULL; +} + +void bongo_cat_app_restore_behavior_state(BongoCatApp *app, + const char *model_id) { + if (!app || !app->live2d || !model_id || !model_id[0]) return; + size_t count = app->session.active_behavior_count; + if (count > BONGO_CAT_BEHAVIOR_BINDING_CAP) + count = BONGO_CAT_BEHAVIOR_BINDING_CAP; + for (size_t i = 0; i < count; ++i) { + const BongoCatActiveBehavior *saved = + &app->session.active_behaviors[i]; + if (strcmp(saved->model_id, model_id) != 0) continue; + const BongoCatBehaviorEntry *entry = find_behavior(app, + saved->behavior_id); + if (!entry) continue; + if (entry->kind == BONGO_CAT_BEHAVIOR_MOTION && + !bongo_cat_live2d_motion_selected(app->live2d, + entry->group, entry->index)) + bongo_cat_live2d_start_motion(app->live2d, + entry->group, entry->index); + else if (entry->kind == BONGO_CAT_BEHAVIOR_EXPRESSION) + bongo_cat_live2d_set_expression(app->live2d, entry->index); + } +} diff --git a/src/runtime/model/model_files.c b/src/runtime/model/model_files.c index 40ef8d56..d5f2ebab 100644 --- a/src/runtime/model/model_files.c +++ b/src/runtime/model/model_files.c @@ -124,6 +124,7 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, commit_model(app, entry, false, false); return true; } + bongo_cat_app_capture_behavior_state(app); bool replacing_model = app->loaded_model[0] != '\0'; BongoCatError optional = {0}; BongoCatBehaviorCatalog *behaviors = calloc(1, sizeof(*behaviors)); @@ -199,6 +200,7 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, bongo_cat_overlay_clear(app->overlay); } snprintf(app->loaded_model, sizeof(app->loaded_model), "%s", entry->id); + bongo_cat_app_restore_behavior_state(app, entry->id); app->pointer_known = false; bool geometry_changed = apply_model_aspect(app, &render_options); if (app->window) { diff --git a/src/runtime/shell/window_menu_behavior.c b/src/runtime/shell/window_menu_behavior.c index b8459193..89d9fabd 100644 --- a/src/runtime/shell/window_menu_behavior.c +++ b/src/runtime/shell/window_menu_behavior.c @@ -152,6 +152,7 @@ bool bongo_cat_window_behavior_commit_preview(BongoCatApp *app, if (companion != entry && bound && strcmp(bound, shortcut) == 0) bongo_cat_app_run_behavior(app, companion); } + bongo_cat_app_capture_behavior_state(app); app->dirty = true; return true; } diff --git a/src/runtime/shell/window_menu_preview.c b/src/runtime/shell/window_menu_preview.c index 9947f409..96b717bf 100644 --- a/src/runtime/shell/window_menu_preview.c +++ b/src/runtime/shell/window_menu_preview.c @@ -105,6 +105,8 @@ void bongo_cat_window_menu_restore(void *userdata, BongoCatMenuAction selected) bool keep_expression = keep_model || (selected >= BONGO_CAT_MENU_EXPRESSION_FIRST && selected < BONGO_CAT_MENU_EXPRESSION_FIRST + BONGO_CAT_BEHAVIOR_CAP); + bool committed_expression = selected >= BONGO_CAT_MENU_EXPRESSION_FIRST && + selected < BONGO_CAT_MENU_EXPRESSION_FIRST + BONGO_CAT_BEHAVIOR_CAP; if (!keep_scale && SDL_fabsf(app->session.window.scale_percent - state->scale) > .01f) { bongo_cat_window_set_scale(app, state->scale); @@ -123,6 +125,8 @@ void bongo_cat_window_menu_restore(void *userdata, BongoCatMenuAction selected) bongo_cat_app_step_live2d(app, 1.0f / 60.0f); changed = true; } + if (committed_expression) + bongo_cat_app_capture_behavior_state(app); if (changed) bongo_cat_app_render_now(app); } diff --git a/tests/core/test_app_state.c b/tests/core/test_app_state.c index ec3b6dec..618624ab 100644 --- a/tests/core/test_app_state.c +++ b/tests/core/test_app_state.c @@ -13,6 +13,8 @@ typedef struct ParameterValue { static ParameterValue parameters[128]; static size_t parameter_count; static bool left_hand, right_hand, left_trigger, right_trigger, left_thumb; +static bool active_motions[8]; +static int active_expression = -1; int bongo_cat_test_failures; static float parameter(const char *id) { @@ -38,6 +40,33 @@ bool bongo_cat_live2d_parameter(BongoCatLive2D *live2d, const char *id, return true; } +bool bongo_cat_live2d_start_motion(BongoCatLive2D *live2d, + const char *group, int index) { + (void)live2d; (void)group; + if (index < 0 || index >= (int)(sizeof(active_motions) / + sizeof(active_motions[0]))) return false; + active_motions[index] = true; + return true; +} + +bool bongo_cat_live2d_motion_selected(const BongoCatLive2D *live2d, + const char *group, int index) { + (void)live2d; (void)group; + return index >= 0 && index < (int)(sizeof(active_motions) / + sizeof(active_motions[0])) && active_motions[index]; +} + +bool bongo_cat_live2d_set_expression(BongoCatLive2D *live2d, int index) { + (void)live2d; + active_expression = index; + return true; +} + +int bongo_cat_live2d_expression(const BongoCatLive2D *live2d) { + (void)live2d; + return active_expression; +} + int bongo_cat_overlay_key(BongoCatOverlay *overlay, const char *name, bool pressed) { (void)overlay; if (strcmp(name, "KeyA") == 0 || strcmp(name, "DPadLeft") == 0) @@ -60,6 +89,58 @@ static BongoCatInputEvent input(BongoCatInputKind kind, const char *name, float return event; } +static void check_behavior_state(BongoCatApp *app) { + snprintf(app->loaded_model, sizeof(app->loaded_model), "model-a"); + app->behaviors.count = 3; + app->behaviors.entries[0] = (BongoCatBehaviorEntry){ + .kind = BONGO_CAT_BEHAVIOR_MOTION, .index = 1}; + snprintf(app->behaviors.entries[0].id, + sizeof(app->behaviors.entries[0].id), "model-a:motion:Tap:1"); + snprintf(app->behaviors.entries[0].group, + sizeof(app->behaviors.entries[0].group), "Tap"); + app->behaviors.entries[1] = (BongoCatBehaviorEntry){ + .kind = BONGO_CAT_BEHAVIOR_MOTION, .index = 2}; + snprintf(app->behaviors.entries[1].id, + sizeof(app->behaviors.entries[1].id), "model-a:motion:Tap:2"); + snprintf(app->behaviors.entries[1].group, + sizeof(app->behaviors.entries[1].group), "Tap"); + app->behaviors.entries[2] = (BongoCatBehaviorEntry){ + .kind = BONGO_CAT_BEHAVIOR_EXPRESSION, .index = 3}; + snprintf(app->behaviors.entries[2].id, + sizeof(app->behaviors.entries[2].id), "model-a:expression:3"); + + app->session.active_behavior_count = 1; + snprintf(app->session.active_behaviors[0].model_id, + sizeof(app->session.active_behaviors[0].model_id), "model-b"); + snprintf(app->session.active_behaviors[0].behavior_id, + sizeof(app->session.active_behaviors[0].behavior_id), + "model-b:expression:1"); + active_motions[1] = true; + active_expression = 3; + bongo_cat_app_capture_behavior_state(app); + CHECK(app->session.active_behavior_count == 3); + CHECK(strcmp(app->session.active_behaviors[0].model_id, + "model-b") == 0); + CHECK(strcmp(app->session.active_behaviors[1].behavior_id, + "model-a:motion:Tap:1") == 0); + CHECK(strcmp(app->session.active_behaviors[2].behavior_id, + "model-a:expression:3") == 0); + + active_motions[1] = false; + active_expression = -1; + bongo_cat_app_restore_behavior_state(app, "model-a"); + CHECK(active_motions[1]); + CHECK(!active_motions[2]); + CHECK(active_expression == 3); + + active_motions[1] = false; + active_expression = -1; + bongo_cat_app_capture_behavior_state(app); + CHECK(app->session.active_behavior_count == 1); + CHECK(strcmp(app->session.active_behaviors[0].model_id, + "model-b") == 0); +} + int main(void) { /* BongoCatApp contains the input queue and model catalogs and is too large for the default 1 MiB Windows test-thread stack. */ @@ -131,5 +212,6 @@ int main(void) { CHECK(parameter("CatParamStickShowLeftHand") == 0.0f); CHECK(parameter("CatParamLeftHandDown") == 0.0f); CHECK(!right_hand && !left_trigger && !right_trigger && !left_thumb); + check_behavior_state(&app); return bongo_cat_test_failures ? 1 : 0; } diff --git a/tests/core/test_config.c b/tests/core/test_config.c index 5c772290..6014a4ab 100644 --- a/tests/core/test_config.c +++ b/tests/core/test_config.c @@ -25,8 +25,8 @@ static bool contains_text(const char *path, const char *needle) { } static void check_defaults_and_validation(void) { - BongoCatSettings settings; - BongoCatSessionState session; + static BongoCatSettings settings; + static BongoCatSessionState session; bongo_cat_settings_defaults(&settings); bongo_cat_session_defaults(&session); CHECK(settings.model.max_fps == 60 && settings.model.mouse_centered); @@ -44,6 +44,14 @@ static void check_defaults_and_validation(void) { settings.window.hide_delay_seconds = NAN; session.window.scale_percent = -2.0f; session.window.opacity_percent = NAN; + session.active_behavior_count = 3; + memcpy(session.active_behaviors[0].model_id, "model", + sizeof("model")); + memcpy(session.active_behaviors[0].behavior_id, "model:motion:Tap:0", + sizeof("model:motion:Tap:0")); + session.active_behaviors[1] = session.active_behaviors[0]; + memcpy(session.active_behaviors[2].model_id, "other", + sizeof("other")); bongo_cat_settings_validate(&settings); bongo_cat_session_validate(&session); CHECK(settings.model.max_fps == 240); @@ -55,10 +63,12 @@ static void check_defaults_and_validation(void) { CHECK(session.window.scale_percent == 10.0f); CHECK(session.window.opacity_percent == BONGO_CAT_DEFAULT_WINDOW_OPACITY_PERCENT); + CHECK(session.active_behavior_count == 1); + CHECK(strcmp(session.active_behaviors[0].model_id, "model") == 0); } static void check_override_canonicalization(void) { - BongoCatSettings settings; + static BongoCatSettings settings; bongo_cat_settings_defaults(&settings); settings.behavior_shortcut_count = 4; memcpy(settings.behavior_shortcuts[0].id, "motion:1", @@ -89,7 +99,7 @@ static void check_override_canonicalization(void) { } static void check_shortcuts(void) { - BongoCatSettings settings; + static BongoCatSettings settings; bongo_cat_settings_defaults(&settings); memcpy(settings.shortcuts.visible_cat, "Control+B", sizeof("Control+B")); memcpy(settings.shortcuts.visible_preferences, "control+b", @@ -120,8 +130,8 @@ void test_config(void) { for (int i = 0; i < BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT; ++i) CHECK(bongo_cat_obs_background_color_rgb(i) == colors[i]); - BongoCatSettings settings; - BongoCatSessionState session; + static BongoCatSettings settings; + static BongoCatSessionState session; bongo_cat_settings_defaults(&settings); bongo_cat_session_defaults(&session); settings.model.max_fps = 30; @@ -145,6 +155,15 @@ void test_config(void) { session.window.position_known = true; session.window.opacity_percent = 75.0f; memcpy(session.active_model_id, "model", sizeof("model")); + session.active_behavior_count = 2; + memcpy(session.active_behaviors[0].model_id, "model", + sizeof("model")); + memcpy(session.active_behaviors[0].behavior_id, + "model:motion:Tap:0", sizeof("model:motion:Tap:0")); + memcpy(session.active_behaviors[1].model_id, "model", + sizeof("model")); + memcpy(session.active_behaviors[1].behavior_id, + "model:expression:2", sizeof("model:expression:2")); const char *settings_path = "bongocat-settings.json"; const char *session_path = "bongocat-session.json"; @@ -159,10 +178,13 @@ void test_config(void) { CHECK(!contains_text(settings_path, "activeModelId")); CHECK(contains_text(session_path, "\"format\": \"bongocat/session\"")); CHECK(contains_text(session_path, "\"activeModelId\": \"model\"")); + CHECK(contains_text(session_path, "\"activeBehaviors\"")); + CHECK(contains_text(session_path, + "\"behaviorId\": \"model:expression:2\"")); CHECK(!contains_text(session_path, "clickThrough")); - BongoCatSettings loaded_settings; - BongoCatSessionState loaded_session; + static BongoCatSettings loaded_settings; + static BongoCatSessionState loaded_session; bongo_cat_settings_defaults(&loaded_settings); bongo_cat_session_defaults(&loaded_session); CHECK(bongo_cat_settings_load(settings_path, &loaded_settings, &error) == @@ -181,6 +203,11 @@ void test_config(void) { CHECK(loaded_session.window.x == -321 && loaded_session.window.opacity_percent == 75.0f); CHECK(strcmp(loaded_session.active_model_id, "model") == 0); + CHECK(loaded_session.active_behavior_count == 2); + CHECK(strcmp(loaded_session.active_behaviors[0].behavior_id, + "model:motion:Tap:0") == 0); + CHECK(strcmp(loaded_session.active_behaviors[1].behavior_id, + "model:expression:2") == 0); const char *unsupported = "bongocat-unsupported.json"; write_text(unsupported, @@ -215,8 +242,8 @@ void test_config(void) { CHECK(bongo_cat_session_load(unsupported, &loaded_session, &error) == BONGO_CAT_ERROR_FORMAT); - BongoCatSettings canonical_settings; - BongoCatSessionState canonical_session; + static BongoCatSettings canonical_settings; + static BongoCatSessionState canonical_session; bongo_cat_settings_defaults(&canonical_settings); bongo_cat_session_defaults(&canonical_session); canonical_settings.model.max_fps = 900; From 85e0c03a6b86802af6563ad8caa985f6d78c5d48 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 16 Aug 2026 23:15:36 +0800 Subject: [PATCH 031/334] Optimize preferences memory usage --- src/ui/backend/ui_backend.c | 18 +++++++++++++++ src/ui/backend/ui_backend.h | 1 + src/ui/preferences/preferences.c | 16 ++++++++++++++ src/ui/preferences/preferences_assets.c | 28 +++++++++++++++++------- src/ui/preferences/preferences_fonts.c | 12 +++++++--- src/ui/preferences/preferences_render.c | 22 +++++++++++-------- src/ui/preferences/preferences_state.h | 6 ++++- src/ui/rendering/ui_font_atlas.c | 9 +++++--- src/ui/rendering/ui_font_atlas_sources.c | 8 +++++-- src/ui/rendering/ui_paint_cache.c | 4 +++- 10 files changed, 97 insertions(+), 27 deletions(-) diff --git a/src/ui/backend/ui_backend.c b/src/ui/backend/ui_backend.c index bfac4941..0e709cb1 100644 --- a/src/ui/backend/ui_backend.c +++ b/src/ui/backend/ui_backend.c @@ -6,6 +6,8 @@ #include #include +#define UI_COMMAND_BUFFER_TRIM_LIMIT (1024u * 1024u) + typedef struct UIVertex { float position[2]; float uv[2]; @@ -68,6 +70,12 @@ static bool create_device(BongoCatUIBackend *ui, BongoCatError *error) { return true; } +static void trim_commands(BongoCatUIBackend *ui) { + if (!ui || ui->commands.allocated <= UI_COMMAND_BUFFER_TRIM_LIMIT) return; + nk_buffer_free(&ui->commands); + nk_buffer_init_default(&ui->commands); +} + bool bongo_cat_ui_init(BongoCatUIBackend *ui, SDL_Window *window, const char *body_font_path, const char *body_fallback_path, const char *body_korean_fallback_path, const char *heading_font_path, @@ -201,10 +209,20 @@ void bongo_cat_ui_render(BongoCatUIBackend *ui) { } nk_clear(&ui->context); nk_buffer_clear(&ui->commands); + /* Complex pages can grow this buffer substantially. Once a simple frame + is back on screen, release that transient capacity without affecting + the commands that were just presented. */ + if (ui->last_draw_commands < 256) trim_commands(ui); glDisable(GL_SCISSOR_TEST); ui->last_gl_error = glGetError(); } +void bongo_cat_ui_trim_idle(BongoCatUIBackend *ui) { + if (!ui) return; + bongo_cat_ui_paint_destroy(ui); + trim_commands(ui); +} + bool bongo_cat_ui_frame_valid(const BongoCatUIBackend *ui) { return ui && ui->last_convert_result == NK_CONVERT_SUCCESS && ui->last_vertex_bytes && ui->last_element_bytes && diff --git a/src/ui/backend/ui_backend.h b/src/ui/backend/ui_backend.h index deb0ff63..4a67d70f 100644 --- a/src/ui/backend/ui_backend.h +++ b/src/ui/backend/ui_backend.h @@ -87,6 +87,7 @@ void bongo_cat_ui_input_begin(BongoCatUIBackend *ui); void bongo_cat_ui_input_end(BongoCatUIBackend *ui); bool bongo_cat_ui_event(BongoCatUIBackend *ui, const SDL_Event *event); void bongo_cat_ui_render(BongoCatUIBackend *ui); +void bongo_cat_ui_trim_idle(BongoCatUIBackend *ui); bool bongo_cat_ui_resize_cache_capture(BongoCatUIBackend *ui); bool bongo_cat_ui_resize_cache_present(BongoCatUIBackend *ui); void bongo_cat_ui_resize_cache_destroy(BongoCatUIBackend *ui); diff --git a/src/ui/preferences/preferences.c b/src/ui/preferences/preferences.c index 54ec0ab4..d2eced85 100644 --- a/src/ui/preferences/preferences.c +++ b/src/ui/preferences/preferences.c @@ -1,6 +1,7 @@ #include "bongo_cat/preferences.h" #include "bongo_cat/app.h" #include "bongo_cat/memory.h" +#include "bongo_cat/memory_policy.h" #include "bongo_cat/platform.h" #include "preferences_controls.h" #include "preferences_model_cover.h" @@ -25,6 +26,21 @@ void bongo_cat_preferences_apply_theme(BongoCatPreferences *value) { bongo_cat_ui_native_theme_apply(value->window, dark != 0); } +void bongo_cat_preferences_page_cache_clear(BongoCatPreferences *value, + int previous_page, int next_page) { + if (!value || previous_page == next_page) return; + bool released = false; + if (previous_page == 2 && next_page != 2) { + bongo_cat_preferences_model_cover_cache_clear(value->app); + released = true; + } + if (previous_page == 4 && next_page != 4) { + bongo_cat_preferences_support_assets_clear(value); + released = true; + } + if (released) bongo_cat_memory_policy_ui_loaded(); +} + BongoCatPreferences *bongo_cat_preferences_create(BongoCatApp *app) { if (!app) return NULL; BongoCatPreferences *value = calloc(1, sizeof(*value)); diff --git a/src/ui/preferences/preferences_assets.c b/src/ui/preferences/preferences_assets.c index 903f88d2..a8e800fe 100644 --- a/src/ui/preferences/preferences_assets.c +++ b/src/ui/preferences/preferences_assets.c @@ -40,18 +40,23 @@ void bongo_cat_preferences_assets_load(BongoCatPreferences *value) { &value->logo_width, &value->logo_height); int width = 0, height = 0; value->icon_texture = load(value, "ui-symbols.png", 0, &width, &height); - value->icon_texture_hidpi = load(value, "ui-symbols@4x.png", 0, - &width, &height); + value->icon_hidpi_attempted = false; } -void bongo_cat_preferences_icon_draw(const BongoCatPreferences *value, +void bongo_cat_preferences_icon_draw(BongoCatPreferences *value, struct nk_command_buffer *canvas, int icon, struct nk_rect bounds, struct nk_color color) { if (!value || !value->icon_texture || icon < 0 || icon >= BONGO_CAT_UI_ICON_COUNT) return; bool large = bounds.w > 24.0f || bounds.h > 24.0f; - bool hidpi = value->icon_texture_hidpi && (large || - value->ui.raster_scale > 1.05f); + bool needs_hidpi = large || value->ui.raster_scale > 1.05f; + if (needs_hidpi && !value->icon_hidpi_attempted) { + int width = 0, height = 0; + value->icon_hidpi_attempted = true; + value->icon_texture_hidpi = load(value, "ui-symbols@4x.png", 0, + &width, &height); + } + bool hidpi = value->icon_texture_hidpi && needs_hidpi; int cell = hidpi ? 96 : 24; int atlas_width = cell * BONGO_CAT_UI_ICON_COUNT; unsigned int texture = hidpi ? value->icon_texture_hidpi : value->icon_texture; @@ -83,11 +88,18 @@ static void clear(unsigned int *texture) { *texture = 0; } +void bongo_cat_preferences_support_assets_clear(BongoCatPreferences *value) { + if (!value) return; + clear(&value->catime_texture); + clear(&value->vlaina_texture); + value->support_assets_loaded = false; +} + void bongo_cat_preferences_assets_clear(BongoCatPreferences *value) { + if (!value) return; clear(&value->logo_texture); clear(&value->icon_texture); clear(&value->icon_texture_hidpi); - clear(&value->catime_texture); - clear(&value->vlaina_texture); - value->support_assets_loaded = false; + value->icon_hidpi_attempted = false; + bongo_cat_preferences_support_assets_clear(value); } diff --git a/src/ui/preferences/preferences_fonts.c b/src/ui/preferences/preferences_fonts.c index 6a5a2023..481949d5 100644 --- a/src/ui/preferences/preferences_fonts.c +++ b/src/ui/preferences/preferences_fonts.c @@ -6,6 +6,7 @@ #include "ui_font_atlas.h" #include "ui_paint.h" #include "bongo_cat/i18n.h" +#include "bongo_cat/memory_policy.h" #include #include @@ -34,17 +35,22 @@ void bongo_cat_preferences_fonts_resolve(BongoCatPreferences *value, if (!value->app->i18n) return; bongo_cat_i18n_glyph_ranges(value->app->i18n, value->glyph_ranges, sizeof(value->glyph_ranges) / sizeof(value->glyph_ranges[0])); - bongo_cat_preferences_model_glyphs(value->app, value->glyph_ranges, - sizeof(value->glyph_ranges) / sizeof(value->glyph_ranges[0])); + /* Model and behavior labels can contain hundreds of otherwise unused + glyphs. Bake them only for the pages that can display those labels. */ + if (value->page == 2) + bongo_cat_preferences_model_glyphs(value->app, value->glyph_ranges, + sizeof(value->glyph_ranges) / sizeof(value->glyph_ranges[0])); fonts->ranges = value->glyph_ranges; } static bool reload(BongoCatPreferences *value, const BongoCatPreferenceFonts *fonts, float raster_scale) { - return bongo_cat_ui_font_atlas_reload(&value->ui, fonts->body, + bool reloaded = bongo_cat_ui_font_atlas_reload(&value->ui, fonts->body, fonts->body_fallback, fonts->body_korean_fallback, fonts->heading, fonts->heading_fallback, fonts->heading_korean_fallback, fonts->ranges, raster_scale); + if (reloaded) bongo_cat_memory_policy_ui_loaded(); + return reloaded; } bool bongo_cat_preferences_reload_fonts(BongoCatPreferences *value) { diff --git a/src/ui/preferences/preferences_render.c b/src/ui/preferences/preferences_render.c index 957a75e2..8b6cab56 100644 --- a/src/ui/preferences/preferences_render.c +++ b/src/ui/preferences/preferences_render.c @@ -101,8 +101,14 @@ static bool draw_shell(BongoCatPreferences *value, struct nk_context *context, if (!value->page_seen) { value->page_seen = true; value->last_page = value->page; } else if (value->last_page != value->page) { + bool had_dynamic_glyphs = value->last_page == 2; + bool needs_dynamic_glyphs = value->page == 2; + bongo_cat_preferences_page_cache_clear(value, + value->last_page, value->page); value->last_page = value->page; value->page_transition_ns = SDL_GetTicksNS(); + if (had_dynamic_glyphs != needs_dynamic_glyphs) value->font_reload_pending = true; + value->render_dirty = true; } nk_group_end(context); } @@ -222,7 +228,6 @@ static bool draw_frame(BongoCatPreferences *value, float width, float height, root_style_restore(context, &saved); return close_requested; } - void bongo_cat_preferences_render(BongoCatPreferences *value) { if (!value || !value->window) return; bongo_cat_preferences_drag_tick(value); @@ -241,8 +246,7 @@ void bongo_cat_preferences_render(BongoCatPreferences *value) { "Preferences GL context could not be activated: %s", SDL_GetError()); return; } - value->render_retry_ns = 0; - bongo_cat_preferences_refresh_raster(value); + value->render_retry_ns = 0; bongo_cat_preferences_refresh_raster(value); bongo_cat_preferences_reload_language(value); if (value->font_reload_pending && !bongo_cat_preferences_reload_fonts(value)) { @@ -261,8 +265,7 @@ void bongo_cat_preferences_render(BongoCatPreferences *value) { value->ui.frame_building = false; bongo_cat_preferences_shortcut_smoke(value); BongoCatUIPalette palette = bongo_cat_ui_palette(dark); - glDisable(GL_SCISSOR_TEST); - glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + glDisable(GL_SCISSOR_TEST); glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); glClearColor(value->transparent_window ? 0.0f : palette.background.r / 255.0f, value->transparent_window ? 0.0f : palette.background.g / 255.0f, value->transparent_window ? 0.0f : palette.background.b / 255.0f, @@ -277,6 +280,11 @@ void bongo_cat_preferences_render(BongoCatPreferences *value) { "Preferences frame presentation failed: %s", SDL_GetError()); } else bongo_cat_memory_policy_ui_frame_presented(); bongo_cat_preferences_record_frame(value); + if (value->shortcut_recording || bongo_cat_pref_controls_animating( + &value->ui.context) || bongo_cat_ui_animations_active(&value->ui.context)) + value->render_dirty = true; + else if (!value->chrome_dragging && !value->live_resize_active) + bongo_cat_ui_trim_idle(&value->ui); SDL_GL_MakeCurrent(value->app->window, value->app->gl_context); bongo_cat_ui_cursor_apply(&value->ui); if (close_requested) { @@ -289,8 +297,4 @@ void bongo_cat_preferences_render(BongoCatPreferences *value) { if (!bongo_cat_preferences_import_open(value->import_dialog, value->window)) value->render_dirty = true; } - if (value->shortcut_recording || - bongo_cat_pref_controls_animating(&value->ui.context) || - bongo_cat_ui_animations_active(&value->ui.context)) - value->render_dirty = true; } diff --git a/src/ui/preferences/preferences_state.h b/src/ui/preferences/preferences_state.h index c8493018..60cb8aa7 100644 --- a/src/ui/preferences/preferences_state.h +++ b/src/ui/preferences/preferences_state.h @@ -27,6 +27,7 @@ struct BongoCatPreferences { unsigned int logo_texture; unsigned int icon_texture; unsigned int icon_texture_hidpi; + bool icon_hidpi_attempted; int logo_width; int logo_height; unsigned int catime_texture; @@ -109,13 +110,16 @@ void bongo_cat_preferences_live_resize_uninstall(BongoCatPreferences *value); void bongo_cat_preferences_record_frame(BongoCatPreferences *value); void bongo_cat_preferences_assets_load(BongoCatPreferences *value); void bongo_cat_preferences_support_assets_load(BongoCatPreferences *value); +void bongo_cat_preferences_support_assets_clear(BongoCatPreferences *value); void bongo_cat_preferences_process_model_selection(BongoCatPreferences *value); void bongo_cat_preferences_model_load_progress(BongoCatPreferences *value, float progress); void bongo_cat_preferences_assets_clear(BongoCatPreferences *value); void bongo_cat_preferences_model_cover_cache_clear(BongoCatApp *app); +void bongo_cat_preferences_page_cache_clear(BongoCatPreferences *value, + int previous_page, int next_page); void bongo_cat_preferences_smoke_frame(BongoCatPreferences *value); -void bongo_cat_preferences_icon_draw(const BongoCatPreferences *value, +void bongo_cat_preferences_icon_draw(BongoCatPreferences *value, struct nk_command_buffer *canvas, int icon, struct nk_rect bounds, struct nk_color color); diff --git a/src/ui/rendering/ui_font_atlas.c b/src/ui/rendering/ui_font_atlas.c index 09191b24..684496af 100644 --- a/src/ui/rendering/ui_font_atlas.c +++ b/src/ui/rendering/ui_font_atlas.c @@ -61,11 +61,14 @@ bool bongo_cat_ui_font_atlas_create(BongoCatUIBackend *ui, nk_font_atlas_begin(&ui->atlas); GLint maximum_texture = 0; glGetIntegerv(GL_MAX_TEXTURE_SIZE, &maximum_texture); - float scale_limit = maximum_texture >= 16384 ? 2.0f : - (maximum_texture >= 8192 ? 1.25f : 1.0f); + /* Keep the atlas bounded on high-DPI displays. The UI is laid out in + logical pixels, so a 1.5x raster is enough for crisp text while a 2x + atlas can quadruple both the texture and bake working memory. */ + float scale_limit = maximum_texture >= 8192 ? 1.5f : 1.0f; float font_scale = NK_CLAMP(1.0f, raster_scale, scale_limit); if (font_scale + .01f < raster_scale) SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, - "Preferences font raster scale limited to %.2fx by GPU texture size %d", + "Preferences font raster scale limited to %.2fx by the UI memory " + "budget (GPU texture limit %d)", font_scale, maximum_texture); const UIFontSource *body_source = body_loaded ? &body : NULL; const UIFontSource *fallback_source = body_fallback_loaded ? diff --git a/src/ui/rendering/ui_font_atlas_sources.c b/src/ui/rendering/ui_font_atlas_sources.c index 71bdb654..007b02b4 100644 --- a/src/ui/rendering/ui_font_atlas_sources.c +++ b/src/ui/rendering/ui_font_atlas_sources.c @@ -48,8 +48,12 @@ void bongo_cat_ui_font_source_release(UIFontSource *source) { static struct nk_font_config font_config(float size, const nk_rune *ranges) { struct nk_font_config config = nk_font_config(size); config.range = ranges; - config.oversample_h = 2; - config.oversample_v = 2; + /* CJK glyphs dominate the atlas and gain little from subpixel + oversampling. Keep the sharper 2x bake for Latin and symbols while + using the native raster for the large fallback ranges. */ + bool fallback_range = ranges && ranges[0] >= 0x1100; + config.oversample_h = fallback_range ? 1 : 2; + config.oversample_v = fallback_range ? 1 : 2; return config; } diff --git a/src/ui/rendering/ui_paint_cache.c b/src/ui/rendering/ui_paint_cache.c index 62e19c99..f605d999 100644 --- a/src/ui/rendering/ui_paint_cache.c +++ b/src/ui/rendering/ui_paint_cache.c @@ -13,7 +13,9 @@ struct BongoCatUIPaintTexture { }; #define PAINT_CACHE_COUNT 48 -#define PAINT_CACHE_BYTE_LIMIT (32u * 1024u * 1024u) +/* Effects are regenerated when evicted, so keep the cache bounded instead + of allowing a long settings session to retain a large GPU allocation. */ +#define PAINT_CACHE_BYTE_LIMIT (16u * 1024u * 1024u) static BongoCatUIPaintTexture textures[PAINT_CACHE_COUNT]; static uint64_t use_counter; From b6f41dbb9f98f147d499fbb892e24db8a6dfc127 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 17 Aug 2026 08:51:58 +0800 Subject: [PATCH 032/334] fix: avoid first-hover window flicker --- src/platform/windows/windows_borderless.c | 7 ++--- tests/platform/test_windows_capture.c | 37 +++++++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/platform/windows/windows_borderless.c b/src/platform/windows/windows_borderless.c index 8272a975..9accf533 100644 --- a/src/platform/windows/windows_borderless.c +++ b/src/platform/windows/windows_borderless.c @@ -149,10 +149,7 @@ void bongo_cat_windows_borderless_set_click_through(HWND window, bool enabled) { LONG_PTR style = GetWindowLongPtrW(window, GWL_EXSTYLE); LONG_PTR next = enabled ? style | WS_EX_TRANSPARENT : style & ~WS_EX_TRANSPARENT; - if (next != style) { - SetWindowLongPtrW(window, GWL_EXSTYLE, next); - SetWindowPos(window, NULL, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | - SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED); - } + /* The hit-test property is immediate; a frame refresh flickers OpenGL windows. */ + if (next != style) SetWindowLongPtrW(window, GWL_EXSTYLE, next); } #endif diff --git a/tests/platform/test_windows_capture.c b/tests/platform/test_windows_capture.c index e52a07f7..6f3f764b 100644 --- a/tests/platform/test_windows_capture.c +++ b/tests/platform/test_windows_capture.c @@ -1,4 +1,5 @@ #include "windows_capture.h" +#include "windows_borderless.h" #include "windows_diagnostics.h" #include "windows_direct_input.h" #include "windows_input.h" @@ -9,6 +10,8 @@ #include static int failures; +static int window_position_messages; +static int nonclient_size_messages; #define CHECK(value) do { if (!(value)) { \ fprintf(stderr, "%s:%d: check failed: %s\n", \ @@ -16,6 +19,39 @@ static int failures; failures++; \ } } while (0) +static LRESULT CALLBACK click_through_test_proc(HWND window, UINT message, + WPARAM wparam, LPARAM lparam) { + if (message == WM_WINDOWPOSCHANGED) window_position_messages++; + if (message == WM_NCCALCSIZE) nonclient_size_messages++; + return DefWindowProcW(window, message, wparam, lparam); +} + +static void test_click_through_does_not_refresh_frame(void) { + const wchar_t class_name[] = L"BongoCat click-through test"; + WNDCLASSW type = {.lpfnWndProc = click_through_test_proc, + .hInstance = GetModuleHandleW(NULL), .lpszClassName = class_name}; + ATOM registered = RegisterClassW(&type); + CHECK(registered != 0 || GetLastError() == ERROR_CLASS_ALREADY_EXISTS); + HWND window = CreateWindowExW(WS_EX_LAYERED, class_name, + L"BongoCat click-through test", WS_POPUP, 0, 0, 32, 32, + NULL, NULL, GetModuleHandleW(NULL), NULL); + CHECK(window != NULL); + if (!window) return; + bongo_cat_windows_borderless_install(window); + window_position_messages = 0; + nonclient_size_messages = 0; + bongo_cat_windows_borderless_set_click_through(window, true); + CHECK((GetWindowLongPtrW(window, GWL_EXSTYLE) & WS_EX_TRANSPARENT) != 0); + CHECK(SendMessageW(window, WM_NCHITTEST, 0, 0) == HTTRANSPARENT); + bongo_cat_windows_borderless_set_click_through(window, false); + CHECK((GetWindowLongPtrW(window, GWL_EXSTYLE) & WS_EX_TRANSPARENT) == 0); + CHECK(window_position_messages == 0); + CHECK(nonclient_size_messages == 0); + bongo_cat_windows_borderless_uninstall(window); + DestroyWindow(window); + if (registered) UnregisterClassW(class_name, GetModuleHandleW(NULL)); +} + static void test_popup_completion(void) { HWND window = CreateWindowExW(0, L"STATIC", L"BongoCat popup test", 0, 0, 0, 0, 0, HWND_MESSAGE, NULL, GetModuleHandleW(NULL), NULL); @@ -134,6 +170,7 @@ static void test_capture_styles(void) { } int main(void) { + test_click_through_does_not_refresh_frame(); test_capture_styles(); test_tray_restart_notification(); test_popup_completion(); From e3b39a18f077306e2422bb7fdfb15f860a5d5d02 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 17 Aug 2026 17:31:20 +0800 Subject: [PATCH 033/334] fix: preserve model behavior lifecycle --- CMakeLists.txt | 6 +- cmake/RuntimeSources.cmake | 1 + cmake/Tests.cmake | 10 + include/bongo_cat/model.h | 7 + src/live2d/cubism_bridge.cpp | 14 + src/live2d/cubism_expression.cpp | 55 ++++ src/live2d/cubism_model.hpp | 35 +++ src/live2d/cubism_model_load.cpp | 1 + src/live2d/cubism_model_update.cpp | 85 +++--- src/live2d/cubism_motion_playback.cpp | 147 ++++++++++ src/live2d/cubism_motion_state.cpp | 257 ++++++++++++++---- src/live2d/live2d_stub.c | 14 + src/runtime/input/shortcuts.c | 31 ++- src/runtime/model/model_behavior_state.c | 17 +- src/runtime/shell/window_menu_behavior.c | 170 ++---------- src/runtime/shell/window_menu_behavior_test.c | 246 +++++++++++++++++ src/runtime/shell/window_menu_preview.c | 9 +- tests/core/test_app_state.c | 34 +++ tests/live2d/test_motion_state.cpp | 102 +++++++ 19 files changed, 989 insertions(+), 252 deletions(-) create mode 100644 src/live2d/cubism_expression.cpp create mode 100644 src/live2d/cubism_motion_playback.cpp create mode 100644 src/runtime/shell/window_menu_behavior_test.c create mode 100644 tests/live2d/test_motion_state.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f5582a3a..91dfd4b0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -43,8 +43,10 @@ if(EXISTS "${CUBISM_CORE_HEADER}") else() include(cmake/Cubism.cmake) set(BONGO_CAT_LIVE2D_SOURCES - src/live2d/cubism_bridge.cpp src/live2d/cubism_model_load.cpp - src/live2d/cubism_model_update.cpp src/live2d/cubism_motion_state.cpp src/live2d/cubism_viewer_look.cpp + src/live2d/cubism_bridge.cpp src/live2d/cubism_expression.cpp + src/live2d/cubism_model_load.cpp + src/live2d/cubism_model_update.cpp src/live2d/cubism_motion_playback.cpp + src/live2d/cubism_motion_state.cpp src/live2d/cubism_viewer_look.cpp src/live2d/cubism_model_bounds.cpp src/live2d/cubism_model_validate.cpp) set(BONGO_CAT_CUBISM_ENABLED ON) diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 7a6f6c1d..8e55f22e 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -94,6 +94,7 @@ set(BONGO_CAT_RUNTIME_SHELL_SOURCES src/runtime/shell/window_hit.c src/runtime/shell/window_menu_actions.c src/runtime/shell/window_menu_behavior.c + src/runtime/shell/window_menu_behavior_test.c src/runtime/shell/window_menu_preview.c src/runtime/shell/window_wheel.c) diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index ac89ac3c..deb2791a 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -66,6 +66,16 @@ if(BUILD_TESTING) endif() add_test(NAME model-import-unit COMMAND bongo_cat_mver_import_tests) + if(BONGO_CAT_CUBISM_ENABLED) + add_executable(bongo_cat_motion_state_tests + tests/live2d/test_motion_state.cpp) + target_include_directories(bongo_cat_motion_state_tests PRIVATE + src/live2d tests/support) + target_link_libraries(bongo_cat_motion_state_tests PRIVATE + bongo_cat_runtime bongo_cat_warnings) + add_test(NAME live2d-motion-state COMMAND bongo_cat_motion_state_tests) + endif() + if(WIN32) add_executable(bongo_cat_windows_capture_tests tests/platform/test_windows_capture.c) diff --git a/include/bongo_cat/model.h b/include/bongo_cat/model.h index 519fedd7..898dd4a9 100644 --- a/include/bongo_cat/model.h +++ b/include/bongo_cat/model.h @@ -141,6 +141,8 @@ bool bongo_cat_live2d_set_parameter(BongoCatLive2D *live2d, const char *id, floa bool bongo_cat_live2d_parameter(BongoCatLive2D *live2d, const char *id, BongoCatParameterRange *range); bool bongo_cat_live2d_start_motion(BongoCatLive2D *live2d, const char *group, int index); +bool bongo_cat_live2d_restore_motion_state(BongoCatLive2D *live2d, + const char *group, int index); bool bongo_cat_live2d_preview_motion(BongoCatLive2D *live2d, const char *group, int index); bool bongo_cat_live2d_restore_motion_preview(BongoCatLive2D *live2d); @@ -148,8 +150,13 @@ bool bongo_cat_live2d_commit_motion_preview(BongoCatLive2D *live2d, const char *group, int index); bool bongo_cat_live2d_motion_selected(const BongoCatLive2D *live2d, const char *group, int index); +bool bongo_cat_live2d_motion_persistent(const BongoCatLive2D *live2d, + const char *group, int index); bool bongo_cat_live2d_motion_visible(const BongoCatLive2D *live2d, const char *group, int index); +bool bongo_cat_live2d_motion_same_toggle(const BongoCatLive2D *live2d, + const char *left_group, int left_index, + const char *right_group, int right_index); bool bongo_cat_live2d_set_expression(BongoCatLive2D *live2d, int index); int bongo_cat_live2d_expression(const BongoCatLive2D *live2d); bool bongo_cat_live2d_visual_state(const BongoCatLive2D *live2d, diff --git a/src/live2d/cubism_bridge.cpp b/src/live2d/cubism_bridge.cpp index e3fa5493..8504be31 100644 --- a/src/live2d/cubism_bridge.cpp +++ b/src/live2d/cubism_bridge.cpp @@ -235,14 +235,28 @@ extern "C" bool bongo_cat_live2d_parameter(BongoCatLive2D *runtime, const char * extern "C" bool bongo_cat_live2d_start_motion(BongoCatLive2D *runtime, const char *group, int index) { return runtime && runtime->model && runtime->model->start_motion(group, index); } +extern "C" bool bongo_cat_live2d_restore_motion_state(BongoCatLive2D *runtime, + const char *group, int index) { + return runtime && runtime->model && + runtime->model->restore_motion_state(group, index); +} extern "C" bool bongo_cat_live2d_preview_motion(BongoCatLive2D *runtime, const char *group, int index) { return runtime && runtime->model && runtime->model->preview_motion(group, index); } extern "C" bool bongo_cat_live2d_restore_motion_preview(BongoCatLive2D *runtime) { return runtime && runtime->model && runtime->model->restore_motion_preview(); } extern "C" bool bongo_cat_live2d_commit_motion_preview(BongoCatLive2D *runtime, const char *group, int index) { return runtime && runtime->model && runtime->model->commit_motion_preview(group, index); } extern "C" bool bongo_cat_live2d_motion_selected(const BongoCatLive2D *runtime, const char *group, int index) { return runtime && runtime->model && runtime->model->motion_selected(group, index); } +extern "C" bool bongo_cat_live2d_motion_persistent(const BongoCatLive2D *runtime, + const char *group, int index) { + return runtime && runtime->model && + runtime->model->motion_persistent(group, index); } extern "C" bool bongo_cat_live2d_motion_visible(const BongoCatLive2D *runtime, const char *group, int index) { return runtime && runtime->model && runtime->model->motion_visible(group, index); } +extern "C" bool bongo_cat_live2d_motion_same_toggle( + const BongoCatLive2D *runtime, const char *left_group, int left_index, + const char *right_group, int right_index) { + return runtime && runtime->model && runtime->model->motion_same_toggle( + left_group, left_index, right_group, right_index); } extern "C" bool bongo_cat_live2d_set_expression(BongoCatLive2D *runtime, int index) { return runtime && runtime->model && runtime->model->set_expression(index); } extern "C" int bongo_cat_live2d_expression(const BongoCatLive2D *runtime) { diff --git a/src/live2d/cubism_expression.cpp b/src/live2d/cubism_expression.cpp new file mode 100644 index 00000000..27828cb5 --- /dev/null +++ b/src/live2d/cubism_expression.cpp @@ -0,0 +1,55 @@ +#include "cubism_model.hpp" + +#include +#include + +namespace bongo_cat { + +bool NativeModel::set_expression(int index) { + if (index == -1) { + expression_index_ = -1; + auto *entries = _expressionManager->GetCubismMotionQueueEntries(); + expression_clearing_ = entries && entries->GetSize() > 0; + if (!expression_clearing_) { + _expressionManager->StopAllMotions(); + return true; + } + for (Csm::csmUint32 i = 0; i < entries->GetSize(); ++i) { + Csm::CubismMotionQueueEntry *entry = entries->At(i); + if (!entry) continue; + Csm::ACubismMotion *motion = entry->GetCubismMotion(); + if (motion) { + float fade_out = motion->GetFadeOutTime(); + entry->SetFadeout(fade_out < 0.0f ? 0.0f : fade_out); + } + } + return true; + } + if (index < 0 || (size_t)index >= expression_names_.size()) return false; + auto found = expressions_.find(expression_names_[(size_t)index]); + if (found == expressions_.end()) return false; + Csm::CubismMotionQueueEntryHandle handle = _expressionManager->StartMotion( + found->second, false); + if (handle == Csm::InvalidMotionQueueEntryHandleValue) return false; + expression_clearing_ = false; + expression_index_ = index; + return true; +} + +void NativeModel::expire_expression_fade() { + if (!expression_clearing_) return; + auto *entries = _expressionManager->GetCubismMotionQueueEntries(); + if (!entries || entries->GetSize() == 0) { + expression_clearing_ = false; + return; + } + for (Csm::csmUint32 i = 0; i < entries->GetSize(); ++i) { + Csm::CubismMotionQueueEntry *entry = entries->At(i); + if (entry && (entry->GetEndTime() < 0.0f || + entry->GetStateTime() < entry->GetEndTime())) return; + } + _expressionManager->StopAllMotions(); + expression_clearing_ = false; +} + +} // namespace bongo_cat diff --git a/src/live2d/cubism_model.hpp b/src/live2d/cubism_model.hpp index 228036ef..370c6b83 100644 --- a/src/live2d/cubism_model.hpp +++ b/src/live2d/cubism_model.hpp @@ -42,11 +42,15 @@ class NativeModel final : public Csm::CubismUserModel { bool set_parameter(const char *id, float value); bool parameter(const char *id, float *minimum, float *maximum, float *value); bool start_motion(const char *group, int index); + bool restore_motion_state(const char *group, int index); bool preview_motion(const char *group, int index); bool restore_motion_preview(); bool commit_motion_preview(const char *group, int index); bool motion_selected(const char *group, int index) const; + bool motion_persistent(const char *group, int index) const; bool motion_visible(const char *group, int index) const; + bool motion_same_toggle(const char *left_group, int left_index, + const char *right_group, int right_index) const; bool set_expression(int index); int expression() const { return expression_index_; } bool visual_state(BongoCatLive2DVisualState *state) const; @@ -57,13 +61,26 @@ class NativeModel final : public Csm::CubismUserModel { struct MotionStateCurve { std::string target, id; float start = 0.0f, end = 0.0f; + float normal = 0.0f; + int parameter = -1; + int part = -1; + bool model_opacity = false; }; struct MotionState { std::string group; int index = -1; std::vector curves; + bool self_contained = false; }; private: + struct MotionRun { + Csm::CubismMotionQueueEntryHandle handle = + Csm::InvalidMotionQueueEntryHandleValue; + std::string key; + bool one_shot = false; + bool selected = false; + bool committed = false; + }; struct ModelBounds { float min_x = 0.0f; float min_y = 0.0f; @@ -84,6 +101,15 @@ class NativeModel final : public Csm::CubismUserModel { const std::vector &bytes); void pair_motion_states(); std::string motion_to_play(const std::string &key, bool *selected) const; + bool motion_is_persistent(const std::string &key) const; + void record_motion_run(Csm::CubismMotionQueueEntryHandle handle, + const std::string &key, bool selected, bool committed); + void stop_motion_runs(const std::string &key); + void expire_motion_runs(); + void clear_motion_runs(); + void expire_expression_fade(); + bool apply_motion_curve(const MotionStateCurve &curve, float value); + bool restore_motion_defaults(const std::string &key); void select_motion(const std::string &key, bool selected); void release_textures(); void release_renderer(); @@ -117,6 +143,7 @@ class NativeModel final : public Csm::CubismUserModel { int renderer_width_ = 0; int renderer_height_ = 0; int expression_index_ = -1; + bool expression_clearing_ = false; BongoCatLive2DVisualState visual_state_{}; bool visual_state_ready_ = false; bool motion_updated_ = false; @@ -127,6 +154,7 @@ class NativeModel final : public Csm::CubismUserModel { bool direct_textures_ = false; bool external_parameters_dirty_ = false; std::vector idle_motion_keys_; + std::vector motion_runs_; ViewerLookUpdater *viewer_look_ = nullptr; int last_idle_motion_ = -1; float opacity_snapshot_ = -1.0f; @@ -134,8 +162,15 @@ class NativeModel final : public Csm::CubismUserModel { std::string motion_preview_key_; bool motion_preview_selected_ = false; bool motion_preview_active_ = false; + bool motion_preview_completed_ = false; }; +bool motion_toggle_pair(const NativeModel::MotionState &left, + const NativeModel::MotionState &right, bool *left_enabled); +bool motion_enables_state(const NativeModel::MotionState &state); +bool motion_run_clears_selection(bool one_shot, bool committed, + bool replacement_running); + } // namespace bongo_cat #endif diff --git a/src/live2d/cubism_model_load.cpp b/src/live2d/cubism_model_load.cpp index a5dd45f1..2b714f22 100644 --- a/src/live2d/cubism_model_load.cpp +++ b/src/live2d/cubism_model_load.cpp @@ -178,6 +178,7 @@ void NativeModel::load_motions(BongoCatLive2DLoadProgress progress, motion_toggle_partners_.clear(); motion_toggle_owners_.clear(); selected_motion_keys_.clear(); + clear_motion_runs(); int total = 0, completed = 0; for (int group_index = 0; group_index < setting_->GetMotionGroupCount(); ++group_index) diff --git a/src/live2d/cubism_model_update.cpp b/src/live2d/cubism_model_update.cpp index e478101b..cebad066 100644 --- a/src/live2d/cubism_model_update.cpp +++ b/src/live2d/cubism_model_update.cpp @@ -61,8 +61,10 @@ bool NativeModel::update(float delta_seconds) { start_idle_motion(); else if (!_motionManager->IsFinished()) motion_updated_ = _motionManager->UpdateMotion(_model, delta_seconds); + expire_motion_runs(); _model->SaveParameters(); _updateScheduler.OnLateUpdate(_model, delta_seconds); + expire_expression_fade(); _opacity = _model->GetModelOpacity(); _model->Update(); bool result = changed(parameter_snapshot_, _model->GetParameterCount(), @@ -133,6 +135,7 @@ void NativeModel::prepare_viewer_audit() { automatic_idle_ = false; suppress_eye_blink_ = true; _motionManager->StopAllMotions(); + clear_motion_runs(); for (int i = 0; i < _model->GetParameterCount(); ++i) _model->SetParameterValue(i, _model->GetParameterDefaultValue(i)); _model->SaveParameters(); @@ -161,20 +164,6 @@ bool NativeModel::parameter(const char *id, float *minimum, float *maximum, floa return true; } -bool NativeModel::start_motion(const char *group, int index) { - if (!group || index < 0) return false; - std::string key = std::string(group) + "_" + std::to_string(index); - bool selected = false; - std::string playback = motion_to_play(key, &selected); - auto found = motions_.find(playback); - if (found == motions_.end()) return false; - constexpr int priority = 2; - bool started = _motionManager->StartMotionPriority(found->second, false, - priority) != Csm::InvalidMotionQueueEntryHandleValue; - if (started) select_motion(key, selected); - return started; -} - void NativeModel::start_idle_motion() { if (idle_motion_keys_.empty()) return; constexpr int priority = 1; @@ -211,6 +200,7 @@ void NativeModel::capture_motion_preview() { void NativeModel::restore_motion_preview_state() { if (!_model || !motion_preview_active_) return; _motionManager->StopAllMotions(); + clear_motion_runs(); const int parameter_count = _model->GetParameterCount(); for (int i = 0; i < parameter_count && (size_t)i < motion_preview_parameters_.size(); ++i) { @@ -232,14 +222,35 @@ bool NativeModel::preview_motion(const char *group, int index) { if (motion_preview_active_) restore_motion_preview_state(); else capture_motion_preview(); std::string key = std::string(group) + "_" + std::to_string(index); - std::string playback = motion_to_play(key, &motion_preview_selected_); - auto found = motions_.find(playback); - if (found == motions_.end()) return false; auto owner = motion_toggle_owners_.find(key); motion_preview_key_ = owner == motion_toggle_owners_.end() ? key : owner->second; + bool active = selected_motion_keys_.find(motion_preview_key_) != + selected_motion_keys_.end(); + std::string playback = motion_to_play(key, &motion_preview_selected_); + /* A running one-shot is previewed as a cancellation, while a committed + click keeps the normal replay behavior in start_motion(). */ + if (active && !motion_is_persistent(motion_preview_key_)) { + motion_preview_selected_ = false; + playback.clear(); + } + motion_preview_completed_ = false; + if (playback.empty()) + return restore_motion_defaults(motion_preview_key_); + auto found = motions_.find(playback); + if (found == motions_.end()) { + restore_motion_preview(); + return false; + } constexpr int priority = 2; - return _motionManager->StartMotionPriority(found->second, false, priority) != - Csm::InvalidMotionQueueEntryHandleValue; + Csm::CubismMotionQueueEntryHandle handle = + _motionManager->StartMotionPriority(found->second, false, priority); + if (handle == Csm::InvalidMotionQueueEntryHandleValue) { + restore_motion_preview(); + return false; + } + record_motion_run(handle, motion_preview_key_, + motion_preview_selected_, false); + return true; } bool NativeModel::commit_motion_preview(const char *group, int index) { @@ -250,10 +261,15 @@ bool NativeModel::commit_motion_preview(const char *group, int index) { key : owner->second; if (canonical != motion_preview_key_) return false; select_motion(canonical, motion_preview_selected_); + for (auto &run : motion_runs_) + if (!run.committed && run.key == canonical) run.committed = true; + if (motion_preview_completed_ && !motion_is_persistent(canonical)) + select_motion(canonical, false); motion_preview_parameters_.clear(); motion_preview_parts_.clear(); motion_preview_key_.clear(); motion_preview_active_ = false; + motion_preview_completed_ = false; return true; } @@ -264,37 +280,8 @@ bool NativeModel::restore_motion_preview() { motion_preview_parts_.clear(); motion_preview_key_.clear(); motion_preview_active_ = false; + motion_preview_completed_ = false; return true; } -bool NativeModel::motion_selected(const char *group, int index) const { - if (!group || index < 0) return false; - std::string key = std::string(group) + "_" + std::to_string(index); - auto owner = motion_toggle_owners_.find(key); - if (owner != motion_toggle_owners_.end()) key = owner->second; - return selected_motion_keys_.find(key) != selected_motion_keys_.end(); -} - -bool NativeModel::motion_visible(const char *group, int index) const { - if (!group || index < 0) return false; - std::string key = std::string(group) + "_" + std::to_string(index); - auto owner = motion_toggle_owners_.find(key); - return owner == motion_toggle_owners_.end() || owner->second == key; -} -bool NativeModel::set_expression(int index) { - if (index == -1) { - _expressionManager->StopAllMotions(); - expression_index_ = -1; - return true; - } - if (index < 0 || (size_t)index >= expression_names_.size()) return false; - auto found = expressions_.find(expression_names_[(size_t)index]); - if (found == expressions_.end()) return false; - _expressionManager->StopAllMotions(); - Csm::CubismMotionQueueEntryHandle handle = _expressionManager->StartMotion( - found->second, false); - if (handle == Csm::InvalidMotionQueueEntryHandleValue) return false; - expression_index_ = index; - return true; -} } // namespace bongo_cat diff --git a/src/live2d/cubism_motion_playback.cpp b/src/live2d/cubism_motion_playback.cpp new file mode 100644 index 00000000..70959f75 --- /dev/null +++ b/src/live2d/cubism_motion_playback.cpp @@ -0,0 +1,147 @@ +#include "cubism_model.hpp" + +#include +#include +#include + +namespace bongo_cat { + +bool NativeModel::start_motion(const char *group, int index) { + if (!group || index < 0) return false; + std::string key = std::string(group) + "_" + std::to_string(index); + bool selected = false; + std::string playback = motion_to_play(key, &selected); + if (playback.empty()) { + auto owner = motion_toggle_owners_.find(key); + const std::string &canonical = owner == motion_toggle_owners_.end() ? + key : owner->second; + if (!restore_motion_defaults(canonical)) return false; + select_motion(canonical, false); + return true; + } + auto found = motions_.find(playback); + if (found == motions_.end()) return false; + constexpr int priority = 2; + Csm::CubismMotionQueueEntryHandle handle = + _motionManager->StartMotionPriority(found->second, false, priority); + if (handle == Csm::InvalidMotionQueueEntryHandleValue) return false; + select_motion(key, selected); + record_motion_run(handle, key, selected, true); + return true; +} + +void NativeModel::record_motion_run(Csm::CubismMotionQueueEntryHandle handle, + const std::string &key, bool selected, bool committed) { + auto owner = motion_toggle_owners_.find(key); + const std::string &canonical = owner == motion_toggle_owners_.end() ? + key : owner->second; + MotionRun run; + run.handle = handle; + run.key = canonical; + run.one_shot = !motion_is_persistent(canonical); + run.selected = selected; + run.committed = committed; + motion_runs_.push_back(std::move(run)); +} + +void NativeModel::stop_motion_runs(const std::string &key) { + for (const MotionRun &run : motion_runs_) { + if (run.key != key) continue; + auto *entry = _motionManager->GetCubismMotionQueueEntry(run.handle); + if (entry) entry->IsFinished(true); + } +} + +void NativeModel::expire_motion_runs() { + if (motion_runs_.empty()) return; + std::vector finished(motion_runs_.size()); + for (size_t i = 0; i < motion_runs_.size(); ++i) + finished[i] = _motionManager->IsFinished(motion_runs_[i].handle); + bool restored_persistent_state = false; + for (size_t i = 0; i < motion_runs_.size(); ++i) { + const MotionRun &run = motion_runs_[i]; + if (!finished[i]) continue; + if (!run.committed) { + if (motion_preview_active_ && run.key == motion_preview_key_) + motion_preview_completed_ = true; + continue; + } + if (!run.one_shot) { + bool currently_selected = selected_motion_keys_.find(run.key) != + selected_motion_keys_.end(); + if (currently_selected == run.selected) { + auto state = motion_states_.find(run.key); + if (state != motion_states_.end()) + for (const auto &curve : state->second.curves) + restored_persistent_state = apply_motion_curve(curve, + run.selected ? curve.end : curve.normal) || + restored_persistent_state; + } + } + bool replacement_running = false; + if (run.one_shot) { + auto source_signature = motion_signatures_.find(run.key); + for (size_t j = 0; j < motion_runs_.size(); ++j) { + auto replacement_signature = motion_signatures_.find( + motion_runs_[j].key); + if (j != i && !finished[j] && motion_runs_[j].committed && + (motion_runs_[j].key == run.key || + (source_signature != motion_signatures_.end() && + !source_signature->second.empty() && + replacement_signature != motion_signatures_.end() && + source_signature->second == replacement_signature->second))) { + replacement_running = true; + break; + } + } + } + if (motion_run_clears_selection(run.one_shot, run.committed, + replacement_running)) select_motion(run.key, false); + } + if (restored_persistent_state) { + _model->SaveParameters(); + external_parameters_dirty_ = true; + } + size_t output = 0; + for (size_t i = 0; i < motion_runs_.size(); ++i) + if (!finished[i]) motion_runs_[output++] = std::move(motion_runs_[i]); + motion_runs_.resize(output); +} + +void NativeModel::clear_motion_runs() { + for (const MotionRun &run : motion_runs_) + if (run.committed && run.one_shot) select_motion(run.key, false); + motion_runs_.clear(); +} + +bool NativeModel::motion_selected(const char *group, int index) const { + if (!group || index < 0) return false; + std::string key = std::string(group) + "_" + std::to_string(index); + auto owner = motion_toggle_owners_.find(key); + if (owner != motion_toggle_owners_.end()) key = owner->second; + return selected_motion_keys_.find(key) != selected_motion_keys_.end(); +} + +bool NativeModel::motion_visible(const char *group, int index) const { + if (!group || index < 0) return false; + std::string key = std::string(group) + "_" + std::to_string(index); + auto owner = motion_toggle_owners_.find(key); + return owner == motion_toggle_owners_.end() || owner->second == key; +} + +bool NativeModel::motion_same_toggle(const char *left_group, int left_index, + const char *right_group, int right_index) const { + if (!left_group || !right_group || left_index < 0 || right_index < 0) + return false; + std::string left = std::string(left_group) + "_" + + std::to_string(left_index); + std::string right = std::string(right_group) + "_" + + std::to_string(right_index); + auto left_owner = motion_toggle_owners_.find(left); + auto right_owner = motion_toggle_owners_.find(right); + if (left_owner == motion_toggle_owners_.end() || + right_owner == motion_toggle_owners_.end()) return false; + return left_owner->second == right_owner->second; +} + +} // namespace bongo_cat diff --git a/src/live2d/cubism_motion_state.cpp b/src/live2d/cubism_motion_state.cpp index 183f1846..0d888c76 100644 --- a/src/live2d/cubism_motion_state.cpp +++ b/src/live2d/cubism_motion_state.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -20,6 +21,37 @@ static bool curve_endpoints(yyjson_val *segments, float *start, float *end) { return true; } +static bool curve_returns_to_default(yyjson_val *segments, float normal) { + float start = 0.0f, end = 0.0f; + if (!curve_endpoints(segments, &start, &end) || + std::fabs(start - normal) > .0001f || + std::fabs(end - normal) > .0001f) return false; + bool deviates = false; + size_t cursor = 2, count = yyjson_arr_size(segments); + while (cursor < count) { + yyjson_val *kind_value = yyjson_arr_get(segments, cursor); + if (!yyjson_is_int(kind_value) && !yyjson_is_uint(kind_value)) + return false; + int kind = (int)yyjson_get_int(kind_value); + size_t points = kind == 1 ? 3 : + (kind == 0 || kind == 2 || kind == 3 ? 1 : 0); + if (!points || cursor + points * 2 >= count) return false; + for (size_t point = 0; point < points; ++point) { + yyjson_val *value = yyjson_arr_get(segments, + cursor + 2 + point * 2); + if (!yyjson_is_num(value)) return false; + if (std::fabs((float)yyjson_get_num(value) - normal) > .0001f) + deviates = true; + } + cursor += 1 + points * 2; + } + return deviates; +} + +static bool curve_has_state_target(const NativeModel::MotionStateCurve &curve) { + return curve.parameter >= 0 || curve.part >= 0 || curve.model_opacity; +} + void NativeModel::load_motion_state(const std::string &key, const char *group, int motion_index, const std::vector &bytes) { yyjson_doc *document = yyjson_read( @@ -36,14 +68,36 @@ void NativeModel::load_motion_state(const std::string &key, const char *group, if (!target || !id) continue; targets.push_back(std::string(target) + ":" + id); MotionStateCurve value{target, id}; - if (curve_endpoints(yyjson_obj_get(curve, "Segments"), - &value.start, &value.end) && std::fabs(value.start - value.end) > .0001f) + yyjson_val *segments = yyjson_obj_get(curve, "Segments"); + if (std::strcmp(target, "Parameter") == 0) { + auto handle = Csm::CubismFramework::GetIdManager()->GetId(id); + value.parameter = _model->GetParameterIndex(handle); + if (value.parameter >= 0 && + value.parameter < _model->GetParameterCount()) + value.normal = _model->GetParameterDefaultValue(value.parameter); + else value.parameter = -1; + } else if (std::strcmp(target, "PartOpacity") == 0) { + auto handle = Csm::CubismFramework::GetIdManager()->GetId(id); + value.part = _model->GetPartIndex(handle); + if (value.part >= 0 && value.part < _model->GetPartCount()) + value.normal = _model->GetPartOpacity(value.part); + else value.part = -1; + } else if (std::strcmp(target, "Model") == 0 && + std::strcmp(id, "Opacity") == 0) { + value.model_opacity = true; + value.normal = _model->GetModelOpacity(); + } + if (curve_has_state_target(value) && + curve_returns_to_default(segments, value.normal)) + state.self_contained = true; + if (curve_endpoints(segments, &value.start, &value.end)) state.curves.push_back(value); } if (document) yyjson_doc_free(document); std::sort(targets.begin(), targets.end()); targets.erase(std::unique(targets.begin(), targets.end()), targets.end()); - for (const std::string &target : targets) motion_signatures_[key] += target + '\n'; + for (const std::string &target : targets) + motion_signatures_[key] += target + '\n'; std::sort(state.curves.begin(), state.curves.end(), [](const MotionStateCurve &a, const MotionStateCurve &b) { return a.target == b.target ? a.id < b.id : a.target < b.target; @@ -51,70 +105,85 @@ void NativeModel::load_motion_state(const std::string &key, const char *group, motion_states_[key] = std::move(state); } -static bool inverse(const NativeModel::MotionState &a, - const NativeModel::MotionState &b) { - if (a.group != b.group || a.curves.size() != b.curves.size() || - a.curves.empty()) return false; - int first = std::min(a.index, b.index), second = std::max(a.index, b.index); - if (first < 0 || (first & 1) || second != first + 1) return false; - size_t reciprocal = 0; +bool motion_toggle_pair(const NativeModel::MotionState &a, + const NativeModel::MotionState &b, bool *first_enabled) { + if (!first_enabled || a.group != b.group || + a.curves.size() != b.curves.size() || a.curves.empty()) return false; + bool direction_known = false; for (size_t i = 0; i < a.curves.size(); ++i) { - const auto &left = a.curves[i], &right = b.curves[i]; + const auto &left = a.curves[i]; + const auto &right = b.curves[i]; if (left.target != right.target || left.id != right.id) return false; bool same = std::fabs(left.start - right.start) <= .0001f && std::fabs(left.end - right.end) <= .0001f; bool reversed = std::fabs(left.start - right.end) <= .0001f && std::fabs(left.end - right.start) <= .0001f; if (!same && !reversed) return false; - if (reversed && !same) { - if (left.target != "Parameter") return false; - reciprocal++; - } + if (!reversed || same) continue; + if (!curve_has_state_target(left) || !curve_has_state_target(right)) + return false; + bool left_normal = std::fabs(left.end - left.normal) <= .0001f; + bool right_normal = std::fabs(right.end - right.normal) <= .0001f; + if (left_normal == right_normal) return false; + bool enabled = !left_normal; + if (direction_known && enabled != *first_enabled) return false; + *first_enabled = enabled; + direction_known = true; } - return reciprocal == 1; + return direction_known; } -static bool enabled_direction(Csm::CubismModel *model, - const NativeModel::MotionState &a, const NativeModel::MotionState &b, - bool *first_enabled) { - for (size_t i = 0; i < a.curves.size(); ++i) { - const auto &left = a.curves[i], &right = b.curves[i]; - bool same = std::fabs(left.start - right.start) <= .0001f && - std::fabs(left.end - right.end) <= .0001f; - if (same) continue; - auto id = Csm::CubismFramework::GetIdManager()->GetId(left.id.c_str()); - int parameter = model->GetParameterIndex(id); - if (parameter < 0) return false; - float normal = model->GetParameterDefaultValue(parameter); - bool left_normal = std::fabs(left.end - normal) <= .0001f; - bool right_normal = std::fabs(right.end - normal) <= .0001f; - if (left_normal == right_normal) return false; - *first_enabled = !left_normal; - return true; - } +bool motion_enables_state(const NativeModel::MotionState &state) { + if (state.self_contained) return false; + for (const auto &curve : state.curves) + if (curve_has_state_target(curve) && + std::fabs(curve.end - curve.normal) > .0001f) return true; return false; } -void NativeModel::pair_motion_states() { - if (!render_options_.mver_projection) return; - for (auto first = motion_states_.begin(); first != motion_states_.end(); ++first) { - if (first->second.group != "CAT_motion_lock") continue; - if (motion_toggle_owners_.find(first->first) != motion_toggle_owners_.end()) continue; - auto second = first; ++second; - for (; second != motion_states_.end(); ++second) { - if (motion_toggle_owners_.find(second->first) != motion_toggle_owners_.end() || - motion_signatures_[first->first] != motion_signatures_[second->first] || - !inverse(first->second, second->second)) continue; - bool first_enabled = false; - if (!enabled_direction(_model, first->second, second->second, - &first_enabled)) continue; - const std::string &owner = first_enabled ? first->first : second->first; - const std::string &partner = owner == first->first ? second->first : first->first; - motion_toggle_partners_[owner] = partner; - motion_toggle_owners_[owner] = motion_toggle_owners_[partner] = owner; - break; +static bool same_signature(const NativeModel::MotionSignatures &signatures, + const std::string &left, const std::string &right) { + auto a = signatures.find(left), b = signatures.find(right); + return a != signatures.end() && b != signatures.end() && + !a->second.empty() && a->second == b->second; +} + +static size_t pair_candidates( + const std::map &states, + const NativeModel::MotionSignatures &signatures, const std::string &key, + std::string *candidate, bool *key_enabled) { + auto source = states.find(key); + if (source == states.end()) return 0; + size_t count = 0; + for (const auto &item : states) { + if (item.first == key || !same_signature(signatures, key, item.first)) + continue; + bool enabled = false; + if (!motion_toggle_pair(source->second, item.second, &enabled)) continue; + if (++count == 1) { + if (candidate) *candidate = item.first; + if (key_enabled) *key_enabled = enabled; } } + return count; +} + +void NativeModel::pair_motion_states() { + for (const auto &item : motion_states_) { + if (motion_toggle_owners_.find(item.first) != motion_toggle_owners_.end()) + continue; + std::string candidate; + bool item_enabled = false; + if (pair_candidates(motion_states_, motion_signatures_, item.first, + &candidate, &item_enabled) != 1 || + pair_candidates(motion_states_, motion_signatures_, candidate, + nullptr, nullptr) != 1) continue; + const std::string &owner = item_enabled ? item.first : candidate; + const std::string &partner = item_enabled ? candidate : item.first; + motion_toggle_partners_[owner] = partner; + motion_toggle_owners_[owner] = owner; + motion_toggle_owners_[partner] = owner; + } } std::string NativeModel::motion_to_play(const std::string &key, @@ -124,11 +193,93 @@ std::string NativeModel::motion_to_play(const std::string &key, key : owner->second; auto partner = motion_toggle_partners_.find(canonical); bool active = selected_motion_keys_.find(canonical) != selected_motion_keys_.end(); - if (selected) *selected = partner == motion_toggle_partners_.end() || !active; + bool persistent = motion_is_persistent(canonical); + /* One-shot actions stay checked only while their own playback is alive. */ + if (selected) *selected = persistent ? !active : true; + if (active && persistent && partner == motion_toggle_partners_.end()) return {}; return active && partner != motion_toggle_partners_.end() ? partner->second : canonical; } +bool NativeModel::motion_is_persistent(const std::string &key) const { + auto owner = motion_toggle_owners_.find(key); + const std::string &canonical = owner == motion_toggle_owners_.end() ? + key : owner->second; + if (motion_toggle_partners_.find(canonical) != motion_toggle_partners_.end()) + return true; + auto state = motion_states_.find(canonical); + return state != motion_states_.end() && motion_enables_state(state->second); +} + +bool NativeModel::motion_persistent(const char *group, int index) const { + if (!group || index < 0) return false; + return motion_is_persistent( + std::string(group) + "_" + std::to_string(index)); +} + +bool motion_run_clears_selection(bool one_shot, bool committed, + bool replacement_running) { + return one_shot && committed && !replacement_running; +} + +bool NativeModel::restore_motion_defaults(const std::string &key) { + auto state = motion_states_.find(key); + if (_model == nullptr || state == motion_states_.end()) return false; + bool had_run = false; + for (const MotionRun &run : motion_runs_) + if (run.key == key) { had_run = true; break; } + bool restored = false; + stop_motion_runs(key); + for (const auto &curve : state->second.curves) { + restored = apply_motion_curve(curve, curve.normal) || restored; + } + if (!restored) return had_run; + _model->SaveParameters(); + external_parameters_dirty_ = true; + return true; +} + +bool NativeModel::apply_motion_curve(const MotionStateCurve &curve, + float value) { + if (!_model) return false; + if (curve.parameter >= 0 && + curve.parameter < _model->GetParameterCount()) { + _model->SetParameterValue(curve.parameter, value); + pending_parameters_[(size_t)curve.parameter] = 0; + return true; + } + if (curve.part >= 0 && curve.part < _model->GetPartCount()) { + _model->SetPartOpacity(curve.part, value); + return true; + } + if (curve.model_opacity) { + _model->SetModelOpacity(value); + _opacity = value; + return true; + } + return false; +} + +bool NativeModel::restore_motion_state(const char *group, int index) { + if (!_model || !group || index < 0) return false; + std::string key = std::string(group) + "_" + std::to_string(index); + auto owner = motion_toggle_owners_.find(key); + const std::string &canonical = owner == motion_toggle_owners_.end() ? + key : owner->second; + auto state = motion_states_.find(canonical); + if (state == motion_states_.end() || !motion_is_persistent(canonical)) + return false; + bool restored = false; + stop_motion_runs(canonical); + for (const auto &curve : state->second.curves) + restored = apply_motion_curve(curve, curve.end) || restored; + if (!restored) return false; + select_motion(canonical, true); + _model->SaveParameters(); + external_parameters_dirty_ = true; + return true; +} + void NativeModel::select_motion(const std::string &key, bool selected) { auto owner = motion_toggle_owners_.find(key); const std::string &canonical = owner == motion_toggle_owners_.end() ? diff --git a/src/live2d/live2d_stub.c b/src/live2d/live2d_stub.c index 96d66796..8f314749 100644 --- a/src/live2d/live2d_stub.c +++ b/src/live2d/live2d_stub.c @@ -66,6 +66,10 @@ bool bongo_cat_live2d_parameter(BongoCatLive2D *value, const char *id, BongoCatP bool bongo_cat_live2d_start_motion(BongoCatLive2D *value, const char *group, int index) { (void)value; (void)group; (void)index; return false; } +bool bongo_cat_live2d_restore_motion_state(BongoCatLive2D *value, + const char *group, int index) { + (void)value; (void)group; (void)index; return false; +} bool bongo_cat_live2d_preview_motion(BongoCatLive2D *value, const char *group, int index) { (void)value; (void)group; (void)index; return false; @@ -81,10 +85,20 @@ bool bongo_cat_live2d_motion_selected(const BongoCatLive2D *value, const char *group, int index) { (void)value; (void)group; (void)index; return false; } +bool bongo_cat_live2d_motion_persistent(const BongoCatLive2D *value, + const char *group, int index) { + (void)value; (void)group; (void)index; return false; +} bool bongo_cat_live2d_motion_visible(const BongoCatLive2D *value, const char *group, int index) { (void)value; (void)group; (void)index; return true; } +bool bongo_cat_live2d_motion_same_toggle(const BongoCatLive2D *value, + const char *left_group, int left_index, + const char *right_group, int right_index) { + (void)value; (void)left_group; (void)left_index; + (void)right_group; (void)right_index; return false; +} bool bongo_cat_live2d_set_expression(BongoCatLive2D *value, int index) { (void)value; (void)index; return false; } diff --git a/src/runtime/input/shortcuts.c b/src/runtime/input/shortcuts.c index d6ccde16..fc882d95 100644 --- a/src/runtime/input/shortcuts.c +++ b/src/runtime/input/shortcuts.c @@ -41,6 +41,31 @@ bool bongo_cat_app_run_behavior(BongoCatApp *app, return true; } +static bool hidden_toggle_has_visible_binding(BongoCatApp *app, + const BongoCatBehaviorEntry *behavior, const char *shortcut) { + if (!app || !behavior || !shortcut || !shortcut[0] || + behavior->kind != BONGO_CAT_BEHAVIOR_MOTION || + bongo_cat_live2d_motion_visible(app->live2d, + behavior->group, behavior->index)) return false; + for (size_t i = 0; i < app->behaviors.count; ++i) { + const BongoCatBehaviorEntry *candidate = &app->behaviors.entries[i]; + if (candidate == behavior || + candidate->kind != BONGO_CAT_BEHAVIOR_MOTION || + !bongo_cat_live2d_motion_visible(app->live2d, + candidate->group, candidate->index) || + !bongo_cat_live2d_motion_same_toggle(app->live2d, + behavior->group, behavior->index, + candidate->group, candidate->index)) continue; + for (size_t j = 0; j < app->settings.behavior_shortcut_count; ++j) { + const BongoCatBehaviorShortcut *binding = + &app->settings.behavior_shortcuts[j]; + if (strcmp(binding->id, candidate->id) == 0 && + strcmp(binding->shortcut, shortcut) == 0) return true; + } + } + return false; +} + static bool behavior_shortcut(BongoCatApp *app, const BongoCatInputEvent *event) { bool handled = false; for (size_t i = 0; i < app->settings.behavior_shortcut_count; ++i) { @@ -57,8 +82,10 @@ static bool behavior_shortcut(BongoCatApp *app, const BongoCatInputEvent *event) handled = true; } } else if (bongo_cat_shortcut_matches(&app->shortcut_state, - event, shortcut->shortcut)) handled = - bongo_cat_app_run_behavior(app, behavior) || handled; + event, shortcut->shortcut) && + !hidden_toggle_has_visible_binding(app, behavior, + shortcut->shortcut)) handled = + bongo_cat_app_run_behavior(app, behavior) || handled; } } if (handled) return true; diff --git a/src/runtime/model/model_behavior_state.c b/src/runtime/model/model_behavior_state.c index 134b5649..4c0c122e 100644 --- a/src/runtime/model/model_behavior_state.c +++ b/src/runtime/model/model_behavior_state.c @@ -5,9 +5,14 @@ static bool behavior_active(const BongoCatApp *app, const BongoCatBehaviorEntry *entry, int expression) { - if (entry->kind == BONGO_CAT_BEHAVIOR_MOTION) - return bongo_cat_live2d_motion_selected(app->live2d, - entry->group, entry->index); + if (entry->kind == BONGO_CAT_BEHAVIOR_MOTION) { + if (!bongo_cat_live2d_motion_visible(app->live2d, + entry->group, entry->index)) return false; + return bongo_cat_live2d_motion_persistent(app->live2d, + entry->group, entry->index) && + bongo_cat_live2d_motion_selected(app->live2d, + entry->group, entry->index); + } return entry->kind == BONGO_CAT_BEHAVIOR_EXPRESSION && entry->index == expression; } @@ -83,9 +88,13 @@ void bongo_cat_app_restore_behavior_state(BongoCatApp *app, saved->behavior_id); if (!entry) continue; if (entry->kind == BONGO_CAT_BEHAVIOR_MOTION && + bongo_cat_live2d_motion_visible(app->live2d, + entry->group, entry->index) && + bongo_cat_live2d_motion_persistent(app->live2d, + entry->group, entry->index) && !bongo_cat_live2d_motion_selected(app->live2d, entry->group, entry->index)) - bongo_cat_live2d_start_motion(app->live2d, + bongo_cat_live2d_restore_motion_state(app->live2d, entry->group, entry->index); else if (entry->kind == BONGO_CAT_BEHAVIOR_EXPRESSION) bongo_cat_live2d_set_expression(app->live2d, entry->index); diff --git a/src/runtime/shell/window_menu_behavior.c b/src/runtime/shell/window_menu_behavior.c index 89d9fabd..f195936d 100644 --- a/src/runtime/shell/window_menu_behavior.c +++ b/src/runtime/shell/window_menu_behavior.c @@ -28,6 +28,27 @@ static const char *behavior_shortcut(const BongoCatApp *app, const char *id) { return NULL; } +static bool hidden_toggle_has_visible_binding(BongoCatApp *app, + const BongoCatBehaviorEntry *entry, const char *shortcut) { + if (!app || !entry || !shortcut || !shortcut[0] || + entry->kind != BONGO_CAT_BEHAVIOR_MOTION || + bongo_cat_live2d_motion_visible(app->live2d, + entry->group, entry->index)) return false; + for (size_t i = 0; i < app->behaviors.count; ++i) { + const BongoCatBehaviorEntry *candidate = &app->behaviors.entries[i]; + const char *bound = behavior_shortcut(app, candidate->id); + if (candidate != entry && + candidate->kind == BONGO_CAT_BEHAVIOR_MOTION && + bongo_cat_live2d_motion_visible(app->live2d, + candidate->group, candidate->index) && + bongo_cat_live2d_motion_same_toggle(app->live2d, + entry->group, entry->index, + candidate->group, candidate->index) && + bound && strcmp(bound, shortcut) == 0) return true; + } + return false; +} + static const char *behavior_label(const BongoCatApp *app, const BongoCatBehaviorEntry *entry) { if (!app || !entry) return ""; @@ -55,7 +76,8 @@ static bool run_binding(BongoCatApp *app, const BongoCatBehaviorEntry *selected) for (size_t i = 0; i < app->behaviors.count; ++i) { const BongoCatBehaviorEntry *entry = &app->behaviors.entries[i]; const char *bound = behavior_shortcut(app, entry->id); - if (entry != selected && bound && strcmp(bound, shortcut) == 0) + if (entry != selected && bound && strcmp(bound, shortcut) == 0 && + !hidden_toggle_has_visible_binding(app, entry, shortcut)) handled = bongo_cat_app_run_behavior(app, entry) || handled; } return handled; @@ -88,8 +110,6 @@ void bongo_cat_window_behavior_labels(BongoCatApp *app, (*expression_count)++; } } - if (current_expression && active_expression < 0 && *expression_count) - *current_expression = 0; } bool bongo_cat_window_behavior_action(BongoCatApp *app, @@ -125,8 +145,10 @@ bool bongo_cat_window_behavior_preview(BongoCatApp *app, size_t position = (size_t)(action - BONGO_CAT_MENU_EXPRESSION_FIRST); const BongoCatBehaviorEntry *entry = nth_behavior(app, BONGO_CAT_BEHAVIOR_EXPRESSION, position); - return entry && bongo_cat_live2d_set_expression(app->live2d, - entry->index); + if (!entry) return false; + int target = bongo_cat_live2d_expression(app->live2d) == entry->index ? + -1 : entry->index; + return bongo_cat_live2d_set_expression(app->live2d, target); } return false; } @@ -149,7 +171,8 @@ bool bongo_cat_window_behavior_commit_preview(BongoCatApp *app, if (shortcut && shortcut[0]) for (size_t i = 0; i < app->behaviors.count; ++i) { const BongoCatBehaviorEntry *companion = &app->behaviors.entries[i]; const char *bound = behavior_shortcut(app, companion->id); - if (companion != entry && bound && strcmp(bound, shortcut) == 0) + if (companion != entry && bound && strcmp(bound, shortcut) == 0 && + !hidden_toggle_has_visible_binding(app, companion, shortcut)) bongo_cat_app_run_behavior(app, companion); } bongo_cat_app_capture_behavior_state(app); @@ -163,138 +186,3 @@ bool bongo_cat_window_behavior_menu_action(BongoCatMenuAction action) { (action >= BONGO_CAT_MENU_EXPRESSION_FIRST && action < BONGO_CAT_MENU_EXPRESSION_FIRST + BONGO_CAT_BEHAVIOR_CAP); } - -static bool expression_self_test(BongoCatApp *app) { - size_t count = 0; - while (nth_behavior(app, BONGO_CAT_BEHAVIOR_EXPRESSION, count)) count++; - if (!count) return true; - int original = bongo_cat_live2d_expression(app->live2d); - char motions[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; - bool motion_checked[BONGO_CAT_BEHAVIOR_CAP] = {false}; - char expressions[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; - size_t motion_count, expression_count, current_expression; - bool passed = bongo_cat_live2d_set_expression(app->live2d, -1); - bongo_cat_window_behavior_labels(app, motions, motion_checked, &motion_count, - expressions, &expression_count, ¤t_expression); - passed = passed && expression_count == count && current_expression == 0; - size_t selected_position = count > 2 ? 2 : count - 1; - const BongoCatBehaviorEntry *selected = nth_behavior(app, - BONGO_CAT_BEHAVIOR_EXPRESSION, selected_position); - passed = selected && bongo_cat_window_behavior_action(app, - BONGO_CAT_MENU_EXPRESSION_FIRST + selected_position) && - bongo_cat_live2d_expression(app->live2d) == selected->index && passed; - bongo_cat_window_behavior_labels(app, motions, motion_checked, &motion_count, - expressions, &expression_count, ¤t_expression); - passed = passed && expression_count == count && - current_expression == selected_position; - - if (passed && count > 1) { - size_t alternate_position = selected_position ? 0 : 1; - const BongoCatBehaviorEntry *alternate = nth_behavior(app, - BONGO_CAT_BEHAVIOR_EXPRESSION, alternate_position); - BongoCatWindowMenuPreview preview; - bongo_cat_window_menu_preview_init(&preview, app); - bongo_cat_window_menu_preview(&preview, - BONGO_CAT_MENU_EXPRESSION_FIRST + alternate_position); - passed = alternate && bongo_cat_live2d_expression(app->live2d) == - alternate->index; - bongo_cat_window_menu_restore(&preview, BONGO_CAT_MENU_NONE); - passed = passed && bongo_cat_live2d_expression(app->live2d) == - selected->index; - - bongo_cat_window_menu_preview_init(&preview, app); - bongo_cat_window_menu_preview(&preview, - BONGO_CAT_MENU_EXPRESSION_FIRST + alternate_position); - bongo_cat_window_menu_restore(&preview, - BONGO_CAT_MENU_EXPRESSION_FIRST + alternate_position); - passed = passed && bongo_cat_live2d_expression(app->live2d) == - alternate->index && bongo_cat_window_menu_preview_applied(&preview, - BONGO_CAT_MENU_EXPRESSION_FIRST + alternate_position); - } - bool restored = bongo_cat_live2d_set_expression(app->live2d, original); - if (!passed || !restored) SDL_Log("Expression menu self-test failed: " - "count=%llu mapped=%llu current=%llu original=%d restored=%d", - (unsigned long long)count, (unsigned long long)expression_count, - (unsigned long long)current_expression, original, restored); - return restored && passed; -} - -bool bongo_cat_window_behavior_self_test(BongoCatApp *app) { - if (!app) return false; - bool passed = true; - if (nth_behavior(app, BONGO_CAT_BEHAVIOR_MOTION, 0)) { - const BongoCatBehaviorEntry *first = nth_behavior(app, - BONGO_CAT_BEHAVIOR_MOTION, 0); - passed = bongo_cat_window_behavior_action(app, - BONGO_CAT_MENU_MOTION_FIRST) && passed; - char motions[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; - bool checked[BONGO_CAT_BEHAVIOR_CAP] = {false}; - bool before[BONGO_CAT_BEHAVIOR_CAP] = {false}; - char expressions[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; - size_t motion_count, expression_count, current_expression; - bongo_cat_window_behavior_labels(app, motions, checked, &motion_count, - expressions, &expression_count, ¤t_expression); - passed = motion_count > 0 && checked[0] && passed; - bool initial_passed = passed; - const char *first_shortcut = behavior_shortcut(app, first->id); - if (first_shortcut && first_shortcut[0]) - passed = strstr(motions[0], first_shortcut) != NULL && passed; - memcpy(before, checked, motion_count * sizeof(before[0])); - size_t preview_position = nth_behavior(app, - BONGO_CAT_BEHAVIOR_MOTION, 1) ? 1 : 0; - BongoCatWindowMenuPreview preview; - bongo_cat_window_menu_preview_init(&preview, app); - bongo_cat_window_menu_preview(&preview, - BONGO_CAT_MENU_MOTION_FIRST + preview_position); - bongo_cat_window_behavior_labels(app, motions, checked, &motion_count, - expressions, &expression_count, ¤t_expression); - bool preview_stable = memcmp(before, checked, - motion_count * sizeof(before[0])) == 0; - passed = preview_stable && passed; - bongo_cat_window_menu_restore(&preview, BONGO_CAT_MENU_NONE); - passed = bongo_cat_live2d_motion_selected(app->live2d, - first->group, first->index) && passed; - passed = bongo_cat_window_behavior_action(app, - BONGO_CAT_MENU_MOTION_FIRST + preview_position) && passed; - bongo_cat_window_behavior_labels(app, motions, checked, &motion_count, - expressions, &expression_count, ¤t_expression); - passed = checked[preview_position] && passed; - bool independent = false; - for (size_t i = 1; i < motion_count && !independent; ++i) { - passed = bongo_cat_window_behavior_action(app, - BONGO_CAT_MENU_MOTION_FIRST) && passed; - passed = bongo_cat_window_behavior_action(app, - BONGO_CAT_MENU_MOTION_FIRST + i) && passed; - bool first_selected = bongo_cat_live2d_motion_selected(app->live2d, - first->group, first->index); - bool candidate_selected = bongo_cat_live2d_motion_selected(app->live2d, - nth_behavior(app, BONGO_CAT_BEHAVIOR_MOTION, i)->group, - nth_behavior(app, BONGO_CAT_BEHAVIOR_MOTION, i)->index); - independent = independent || (first_selected && candidate_selected); - } - if (motion_count > 2 && strcmp(nth_behavior(app, - BONGO_CAT_BEHAVIOR_MOTION, 0)->group, "CAT_motion_lock") == 0) - passed = independent && passed; - passed = bongo_cat_window_behavior_action(app, - BONGO_CAT_MENU_MOTION_FIRST) && passed; - bool paired = false; - for (size_t i = 0; i < app->behaviors.count; ++i) { - const BongoCatBehaviorEntry *item = &app->behaviors.entries[i]; - if (item->kind == BONGO_CAT_BEHAVIOR_MOTION && - strcmp(item->group, first->group) == 0 && - (item->index == first->index - 1 || - item->index == first->index + 1) && - !bongo_cat_live2d_motion_visible(app->live2d, - item->group, item->index)) paired = true; - } - bool toggled_off = !bongo_cat_live2d_motion_selected(app->live2d, - first->group, first->index); - passed = (!paired || toggled_off) && passed; - if (!passed) SDL_Log("Motion menu self-test failed: initial=%d " - "preview=%d count=%llu independent=%d off=%d", - initial_passed, preview_stable, (unsigned long long)motion_count, - independent, toggled_off); - } - passed = expression_self_test(app) && passed; - return passed; -} diff --git a/src/runtime/shell/window_menu_behavior_test.c b/src/runtime/shell/window_menu_behavior_test.c new file mode 100644 index 00000000..781abe7d --- /dev/null +++ b/src/runtime/shell/window_menu_behavior_test.c @@ -0,0 +1,246 @@ +#include "window_menu.h" + +#include +#include + +static const BongoCatBehaviorEntry *test_nth_behavior(BongoCatApp *app, + BongoCatBehaviorKind kind, size_t position) { + if (!app) return NULL; + for (size_t i = 0; i < app->behaviors.count; ++i) { + const BongoCatBehaviorEntry *entry = &app->behaviors.entries[i]; + if (entry->kind != kind) continue; + if (kind == BONGO_CAT_BEHAVIOR_MOTION && + !bongo_cat_live2d_motion_visible(app->live2d, + entry->group, entry->index)) continue; + if (!position) return entry; + position--; + } + return NULL; +} + +static const char *test_behavior_shortcut(const BongoCatApp *app, + const char *id) { + if (!app || !id) return NULL; + for (size_t i = 0; i < app->settings.behavior_shortcut_count; ++i) { + const BongoCatBehaviorShortcut *binding = + &app->settings.behavior_shortcuts[i]; + if (strcmp(binding->id, id) == 0) return binding->shortcut; + } + return NULL; +} + +static bool expression_self_test(BongoCatApp *app) { + size_t count = 0; + while (test_nth_behavior(app, BONGO_CAT_BEHAVIOR_EXPRESSION, count)) count++; + if (!count) return true; + int original = bongo_cat_live2d_expression(app->live2d); + char motions[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; + bool motion_checked[BONGO_CAT_BEHAVIOR_CAP] = {false}; + char expressions[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; + size_t motion_count, expression_count, current_expression; + bool passed = bongo_cat_live2d_set_expression(app->live2d, -1); + bongo_cat_window_behavior_labels(app, motions, motion_checked, &motion_count, + expressions, &expression_count, ¤t_expression); + passed = passed && expression_count == count && + current_expression == BONGO_CAT_BEHAVIOR_CAP; + size_t selected_position = count > 2 ? 2 : count - 1; + const BongoCatBehaviorEntry *selected = test_nth_behavior(app, + BONGO_CAT_BEHAVIOR_EXPRESSION, selected_position); + passed = selected && bongo_cat_window_behavior_action(app, + BONGO_CAT_MENU_EXPRESSION_FIRST + selected_position) && + bongo_cat_live2d_expression(app->live2d) == selected->index && passed; + bongo_cat_window_behavior_labels(app, motions, motion_checked, &motion_count, + expressions, &expression_count, ¤t_expression); + passed = passed && expression_count == count && + current_expression == selected_position; + if (passed && count > 1) { + size_t alternate_position = selected_position ? 0 : 1; + const BongoCatBehaviorEntry *alternate = test_nth_behavior(app, + BONGO_CAT_BEHAVIOR_EXPRESSION, alternate_position); + BongoCatWindowMenuPreview preview; + bongo_cat_window_menu_preview_init(&preview, app); + bongo_cat_window_menu_preview(&preview, + BONGO_CAT_MENU_EXPRESSION_FIRST + alternate_position); + passed = alternate && bongo_cat_live2d_expression(app->live2d) == + alternate->index; + bongo_cat_window_menu_restore(&preview, BONGO_CAT_MENU_NONE); + passed = passed && bongo_cat_live2d_expression(app->live2d) == + selected->index; + bongo_cat_window_menu_preview_init(&preview, app); + bongo_cat_window_menu_preview(&preview, + BONGO_CAT_MENU_EXPRESSION_FIRST + alternate_position); + bongo_cat_window_menu_restore(&preview, + BONGO_CAT_MENU_EXPRESSION_FIRST + alternate_position); + passed = passed && bongo_cat_live2d_expression(app->live2d) == + alternate->index && bongo_cat_window_menu_preview_applied(&preview, + BONGO_CAT_MENU_EXPRESSION_FIRST + alternate_position); + } + if (passed) { + bongo_cat_live2d_set_expression(app->live2d, selected->index); + BongoCatWindowMenuPreview preview; + bongo_cat_window_menu_preview_init(&preview, app); + BongoCatMenuAction selected_action = + BONGO_CAT_MENU_EXPRESSION_FIRST + selected_position; + bongo_cat_window_menu_preview(&preview, selected_action); + passed = bongo_cat_live2d_expression(app->live2d) == -1; + bongo_cat_window_menu_restore(&preview, selected_action); + passed = passed && bongo_cat_live2d_expression(app->live2d) == -1 && + bongo_cat_window_menu_preview_applied(&preview, selected_action); + } + bool restored = bongo_cat_live2d_set_expression(app->live2d, original); + if (!passed || !restored) SDL_Log("Expression menu self-test failed: " + "count=%llu mapped=%llu current=%llu original=%d restored=%d", + (unsigned long long)count, (unsigned long long)expression_count, + (unsigned long long)current_expression, original, restored); + return restored && passed; +} + +static void advance_motion(BongoCatApp *app, unsigned steps) { + for (unsigned i = 0; i < steps; ++i) + bongo_cat_app_step_live2d(app, 0.25f); +} + +static bool motion_lifecycle_self_test(BongoCatApp *app) { + const BongoCatBehaviorEntry *persistent = NULL, *one_shot = NULL; + for (size_t i = 0; i < app->behaviors.count; ++i) { + const BongoCatBehaviorEntry *entry = &app->behaviors.entries[i]; + if (entry->kind != BONGO_CAT_BEHAVIOR_MOTION || + !bongo_cat_live2d_motion_visible(app->live2d, + entry->group, entry->index)) continue; + if (bongo_cat_live2d_motion_persistent(app->live2d, + entry->group, entry->index)) { + if (!persistent) persistent = entry; + } else if (!one_shot) one_shot = entry; + } + bool persistent_kept = true, checked = true, cleared = true; + bool isolated = true, started = true; + if (persistent) { + if (bongo_cat_live2d_motion_selected(app->live2d, + persistent->group, persistent->index)) { + started = bongo_cat_live2d_start_motion(app->live2d, + persistent->group, persistent->index) && started; + advance_motion(app, 8); + } + started = bongo_cat_live2d_start_motion(app->live2d, + persistent->group, persistent->index) && started; + advance_motion(app, 240); + persistent_kept = bongo_cat_live2d_motion_selected(app->live2d, + persistent->group, persistent->index); + } + unsigned steps = 0; + if (one_shot) { + started = bongo_cat_live2d_start_motion(app->live2d, + one_shot->group, one_shot->index) && started; + checked = bongo_cat_live2d_motion_selected(app->live2d, + one_shot->group, one_shot->index); + while (steps++ < 1200 && bongo_cat_live2d_motion_selected(app->live2d, + one_shot->group, one_shot->index)) advance_motion(app, 1); + cleared = !bongo_cat_live2d_motion_selected(app->live2d, + one_shot->group, one_shot->index); + isolated = !persistent || bongo_cat_live2d_motion_selected(app->live2d, + persistent->group, persistent->index); + } + if (persistent && bongo_cat_live2d_motion_selected(app->live2d, + persistent->group, persistent->index)) + bongo_cat_live2d_start_motion(app->live2d, + persistent->group, persistent->index); + bool passed = started && persistent_kept && checked && cleared && isolated; + SDL_Log("Motion lifecycle self-test: persistent=%d one_shot=%d " + "checked=%d cleared=%d isolated=%d steps=%u result=%d", + persistent != NULL, one_shot != NULL, checked, cleared, isolated, + steps, passed); + return passed; +} + +bool bongo_cat_window_behavior_self_test(BongoCatApp *app) { + if (!app) return false; + bool passed = true; + if (test_nth_behavior(app, BONGO_CAT_BEHAVIOR_MOTION, 0)) { + const BongoCatBehaviorEntry *first = test_nth_behavior(app, + BONGO_CAT_BEHAVIOR_MOTION, 0); + passed = bongo_cat_window_behavior_action(app, + BONGO_CAT_MENU_MOTION_FIRST) && passed; + char motions[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; + bool checked[BONGO_CAT_BEHAVIOR_CAP] = {false}; + bool before[BONGO_CAT_BEHAVIOR_CAP] = {false}; + char expressions[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; + size_t motion_count, expression_count, current_expression; + bongo_cat_window_behavior_labels(app, motions, checked, &motion_count, + expressions, &expression_count, ¤t_expression); + passed = motion_count > 0 && checked[0] && passed; + bool initial_passed = passed; + const char *first_shortcut = test_behavior_shortcut(app, first->id); + if (first_shortcut && first_shortcut[0]) + passed = strstr(motions[0], first_shortcut) != NULL && passed; + memcpy(before, checked, motion_count * sizeof(before[0])); + size_t preview_position = test_nth_behavior(app, + BONGO_CAT_BEHAVIOR_MOTION, 1) ? 1 : 0; + BongoCatWindowMenuPreview preview; + bongo_cat_window_menu_preview_init(&preview, app); + bongo_cat_window_menu_preview(&preview, + BONGO_CAT_MENU_MOTION_FIRST + preview_position); + bongo_cat_window_behavior_labels(app, motions, checked, &motion_count, + expressions, &expression_count, ¤t_expression); + bool preview_stable = memcmp(before, checked, + motion_count * sizeof(before[0])) == 0; + passed = preview_stable && passed; + bongo_cat_window_menu_restore(&preview, BONGO_CAT_MENU_NONE); + passed = bongo_cat_live2d_motion_selected(app->live2d, + first->group, first->index) && passed; + passed = bongo_cat_window_behavior_action(app, + BONGO_CAT_MENU_MOTION_FIRST + preview_position) && passed; + bongo_cat_window_behavior_labels(app, motions, checked, &motion_count, + expressions, &expression_count, ¤t_expression); + passed = checked[preview_position] && passed; + const BongoCatBehaviorEntry *preview_entry = test_nth_behavior(app, + BONGO_CAT_BEHAVIOR_MOTION, preview_position); + BongoCatWindowMenuPreview cancel_preview; + bongo_cat_window_menu_preview_init(&cancel_preview, app); + bongo_cat_window_menu_preview(&cancel_preview, + BONGO_CAT_MENU_MOTION_FIRST + preview_position); + bongo_cat_window_menu_restore(&cancel_preview, + BONGO_CAT_MENU_MOTION_FIRST + preview_position); + passed = preview_entry && bongo_cat_window_menu_preview_applied( + &cancel_preview, BONGO_CAT_MENU_MOTION_FIRST + preview_position) && + !bongo_cat_live2d_motion_selected(app->live2d, + preview_entry->group, preview_entry->index) && passed; + bool independent = false; + for (size_t i = 1; i < motion_count && !independent; ++i) { + passed = bongo_cat_window_behavior_action(app, + BONGO_CAT_MENU_MOTION_FIRST) && passed; + passed = bongo_cat_window_behavior_action(app, + BONGO_CAT_MENU_MOTION_FIRST + i) && passed; + bool first_selected = bongo_cat_live2d_motion_selected(app->live2d, + first->group, first->index); + const BongoCatBehaviorEntry *candidate = test_nth_behavior(app, + BONGO_CAT_BEHAVIOR_MOTION, i); + bool candidate_selected = bongo_cat_live2d_motion_selected(app->live2d, + candidate->group, candidate->index); + independent = independent || (first_selected && candidate_selected); + } + if (motion_count > 2 && strcmp(first->group, "CAT_motion_lock") == 0) + passed = independent && passed; + passed = bongo_cat_window_behavior_action(app, + BONGO_CAT_MENU_MOTION_FIRST) && passed; + bool paired = false; + for (size_t i = 0; i < app->behaviors.count; ++i) { + const BongoCatBehaviorEntry *item = &app->behaviors.entries[i]; + if (item->kind == BONGO_CAT_BEHAVIOR_MOTION && + !bongo_cat_live2d_motion_visible(app->live2d, + item->group, item->index) && + bongo_cat_live2d_motion_same_toggle(app->live2d, + first->group, first->index, + item->group, item->index)) paired = true; + } + bool toggled_off = !bongo_cat_live2d_motion_selected(app->live2d, + first->group, first->index); + passed = (!paired || toggled_off) && passed; + if (!passed) SDL_Log("Motion menu self-test failed: initial=%d " + "preview=%d count=%llu independent=%d off=%d", + initial_passed, preview_stable, (unsigned long long)motion_count, + independent, toggled_off); + } + passed = motion_lifecycle_self_test(app) && passed; + passed = expression_self_test(app) && passed; + return passed; +} diff --git a/src/runtime/shell/window_menu_preview.c b/src/runtime/shell/window_menu_preview.c index 96b717bf..3b3e04d6 100644 --- a/src/runtime/shell/window_menu_preview.c +++ b/src/runtime/shell/window_menu_preview.c @@ -66,7 +66,14 @@ void bongo_cat_window_menu_preview(void *userdata, BongoCatMenuAction action) { bongo_cat_window_behavior_menu_action(action)) { bongo_cat_modal_frame_tick(&state->modal_frame); return; - } else if (bongo_cat_window_behavior_preview(app, action)) { + } else { + if (next_group == 5 && + bongo_cat_live2d_expression(app->live2d) != state->expression) + bongo_cat_live2d_set_expression(app->live2d, state->expression); + if (!bongo_cat_window_behavior_preview(app, action)) { + bongo_cat_app_render_now(app); + return; + } state->applied = action; bongo_cat_modal_frame_tick(&state->modal_frame); return; diff --git a/tests/core/test_app_state.c b/tests/core/test_app_state.c index 618624ab..1227257f 100644 --- a/tests/core/test_app_state.c +++ b/tests/core/test_app_state.c @@ -15,6 +15,7 @@ static size_t parameter_count; static bool left_hand, right_hand, left_trigger, right_trigger, left_thumb; static bool active_motions[8]; static int active_expression = -1; +static int restored_motion_count; int bongo_cat_test_failures; static float parameter(const char *id) { @@ -49,6 +50,16 @@ bool bongo_cat_live2d_start_motion(BongoCatLive2D *live2d, return true; } +bool bongo_cat_live2d_restore_motion_state(BongoCatLive2D *live2d, + const char *group, int index) { + (void)live2d; (void)group; + if (index < 0 || index >= (int)(sizeof(active_motions) / + sizeof(active_motions[0]))) return false; + active_motions[index] = true; + restored_motion_count++; + return true; +} + bool bongo_cat_live2d_motion_selected(const BongoCatLive2D *live2d, const char *group, int index) { (void)live2d; (void)group; @@ -56,6 +67,26 @@ bool bongo_cat_live2d_motion_selected(const BongoCatLive2D *live2d, sizeof(active_motions[0])) && active_motions[index]; } +bool bongo_cat_live2d_motion_persistent(const BongoCatLive2D *live2d, + const char *group, int index) { + (void)live2d; (void)group; + return index == 1; +} + +bool bongo_cat_live2d_motion_visible(const BongoCatLive2D *live2d, + const char *group, int index) { + (void)live2d; (void)group; (void)index; + return true; +} + +bool bongo_cat_live2d_motion_same_toggle(const BongoCatLive2D *live2d, + const char *left_group, int left_index, + const char *right_group, int right_index) { + (void)live2d; (void)left_group; (void)left_index; + (void)right_group; (void)right_index; + return false; +} + bool bongo_cat_live2d_set_expression(BongoCatLive2D *live2d, int index) { (void)live2d; active_expression = index; @@ -116,6 +147,7 @@ static void check_behavior_state(BongoCatApp *app) { sizeof(app->session.active_behaviors[0].behavior_id), "model-b:expression:1"); active_motions[1] = true; + active_motions[2] = true; active_expression = 3; bongo_cat_app_capture_behavior_state(app); CHECK(app->session.active_behavior_count == 3); @@ -127,11 +159,13 @@ static void check_behavior_state(BongoCatApp *app) { "model-a:expression:3") == 0); active_motions[1] = false; + active_motions[2] = false; active_expression = -1; bongo_cat_app_restore_behavior_state(app, "model-a"); CHECK(active_motions[1]); CHECK(!active_motions[2]); CHECK(active_expression == 3); + CHECK(restored_motion_count == 1); active_motions[1] = false; active_expression = -1; diff --git a/tests/live2d/test_motion_state.cpp b/tests/live2d/test_motion_state.cpp new file mode 100644 index 00000000..206064f1 --- /dev/null +++ b/tests/live2d/test_motion_state.cpp @@ -0,0 +1,102 @@ +#include "cubism_model.hpp" +#include "test.h" + +#include + +int bongo_cat_test_failures; + +using MotionState = bongo_cat::NativeModel::MotionState; +using MotionStateCurve = bongo_cat::NativeModel::MotionStateCurve; + +static MotionStateCurve parameter(const char *id, float start, float end, + float normal, int index) { + MotionStateCurve curve; + curve.target = "Parameter"; + curve.id = id; + curve.start = start; + curve.end = end; + curve.normal = normal; + curve.parameter = index; + return curve; +} + +static MotionStateCurve part(const char *id, float start, float end, + float normal, int index) { + MotionStateCurve curve; + curve.target = "PartOpacity"; + curve.id = id; + curve.start = start; + curve.end = end; + curve.normal = normal; + curve.part = index; + return curve; +} + +static MotionState motion(const char *group, int index, + std::initializer_list curves) { + MotionState state; + state.group = group; + state.index = index; + state.curves = curves; + return state; +} + +int main() { + MotionState show = motion("toggle", 6, + {parameter("Visible", 0.0f, 1.0f, 0.0f, 4)}); + MotionState hide = motion("toggle", 9, + {parameter("Visible", 1.0f, 0.0f, 0.0f, 4)}); + bool enabled = false; + CHECK(bongo_cat::motion_toggle_pair(show, hide, &enabled)); + CHECK(enabled); + + MotionState part_show = motion("parts", 1, + {part("Accessory", 0.0f, 1.0f, 0.0f, 3)}); + MotionState part_hide = motion("parts", 8, + {part("Accessory", 1.0f, 0.0f, 0.0f, 3)}); + CHECK(bongo_cat::motion_toggle_pair(part_show, part_hide, &enabled)); + CHECK(enabled); + CHECK(bongo_cat::motion_enables_state(part_show)); + CHECK(!bongo_cat::motion_enables_state(part_hide)); + CHECK(bongo_cat::motion_enables_state(show)); + CHECK(!bongo_cat::motion_enables_state(hide)); + MotionState one_shot = motion("action", 16, + {parameter("Rotation", 360.0f, 45.0f, 0.0f, 4)}); + one_shot.self_contained = true; + CHECK(!bongo_cat::motion_enables_state(one_shot)); + + CHECK(bongo_cat::motion_run_clears_selection(true, true, false)); + CHECK(!bongo_cat::motion_run_clears_selection(false, true, false)); + CHECK(!bongo_cat::motion_run_clears_selection(true, false, false)); + CHECK(!bongo_cat::motion_run_clears_selection(true, true, true)); + + enabled = true; + CHECK(bongo_cat::motion_toggle_pair(hide, show, &enabled)); + CHECK(!enabled); + + MotionState multi_show = motion("other", 3, { + parameter("Left", 0.0f, 1.0f, 0.0f, 1), + parameter("Right", 0.0f, 1.0f, 0.0f, 2)}); + MotionState multi_hide = motion("other", 20, { + parameter("Left", 1.0f, 0.0f, 0.0f, 1), + parameter("Right", 1.0f, 0.0f, 0.0f, 2)}); + CHECK(bongo_cat::motion_toggle_pair(multi_show, multi_hide, &enabled)); + CHECK(enabled); + + MotionState wrong_group = hide; + wrong_group.group = "different"; + CHECK(!bongo_cat::motion_toggle_pair(show, wrong_group, &enabled)); + + MotionState contradictory = motion("other", 21, { + parameter("Left", 1.0f, 0.0f, 0.0f, 1), + parameter("Right", 1.0f, 0.0f, 1.0f, 2)}); + CHECK(!bongo_cat::motion_toggle_pair(multi_show, contradictory, &enabled)); + + MotionState missing_parameter = hide; + missing_parameter.curves[0].parameter = -1; + CHECK(!bongo_cat::motion_toggle_pair(show, missing_parameter, &enabled)); + + if (bongo_cat_test_failures) return 1; + puts("motion state checks passed"); + return 0; +} From 7c7f1a1da95f4ef27df044cd732ab16be227c43a Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 17 Aug 2026 23:24:24 +0800 Subject: [PATCH 034/334] perf: smooth model loading and batch imports --- cmake/RuntimeSources.cmake | 3 + include/bongo_cat/app.h | 9 + include/bongo_cat/preferences.h | 2 +- src/media/image.c | 11 +- src/runtime/model/import/model_import.c | 225 ++++++++---------- src/runtime/model/import/model_import.h | 20 ++ .../model/import/model_import_adapter.c | 115 +++++++++ .../model/import/model_import_digest.c | 41 ++-- .../model/import/model_import_digest_cache.c | 119 +++++++++ .../model/import/model_import_identity.c | 16 +- src/runtime/model/model_catalog_runtime.c | 41 +++- src/runtime/model/model_files.c | 51 +++- src/ui/preferences/preferences.c | 17 +- src/ui/preferences/preferences_import.c | 86 ++----- .../preferences/preferences_import_dialog.c | 36 +++ .../preferences/preferences_import_internal.h | 40 ++++ src/ui/preferences/preferences_internal.h | 2 + src/ui/preferences/preferences_model.c | 94 ++++++-- src/ui/preferences/preferences_model_card.c | 31 ++- src/ui/preferences/preferences_render.c | 30 +-- src/ui/preferences/preferences_state.h | 14 ++ .../model_import/test_model_import_identity.c | 24 +- tests/model_import/test_mver_import.c | 30 +++ 23 files changed, 783 insertions(+), 274 deletions(-) create mode 100644 src/runtime/model/import/model_import_adapter.c create mode 100644 src/runtime/model/import/model_import_digest_cache.c create mode 100644 src/ui/preferences/preferences_import_dialog.c create mode 100644 src/ui/preferences/preferences_import_internal.h diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 8e55f22e..2b98957e 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -61,7 +61,9 @@ set(BONGO_CAT_RUNTIME_MODEL_SOURCES set(BONGO_CAT_RUNTIME_IMPORT_SOURCES src/runtime/model/import/model_import.c + src/runtime/model/import/model_import_adapter.c src/runtime/model/import/model_import_digest.c + src/runtime/model/import/model_import_digest_cache.c src/runtime/model/import/model_import_discovery.c src/runtime/model/import/model_import_format.c src/runtime/model/import/model_import_identity.c @@ -140,6 +142,7 @@ set(BONGO_CAT_UI_PREFERENCES_SOURCES src/ui/preferences/preferences_dialog.c src/ui/preferences/preferences_fonts.c src/ui/preferences/preferences_import.c + src/ui/preferences/preferences_import_dialog.c src/ui/preferences/preferences_language.c src/ui/preferences/preferences_live_resize.c src/ui/preferences/preferences_model.c diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index a9f9ba8c..479e01ef 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -22,6 +22,14 @@ typedef struct BongoCatApp { BongoCatShortcutState shortcut_state; BongoCatModelCatalog models; BongoCatBehaviorCatalog behaviors; + /* One immutable installed package's behavior catalog can be reused when + the user toggles back to it. Nearby source models are excluded because + their files may change outside the application. */ + BongoCatBehaviorCatalog behavior_cache; + char behavior_cache_model_id[BONGO_CAT_ID_CAP]; + char behavior_cache_digest[65]; + bool behavior_cache_valid; + bool behavior_catalog_valid; BongoCatLive2DRenderOptions model_render_options; BongoCatI18n *i18n; BongoCatPlatform platform; @@ -155,6 +163,7 @@ void bongo_cat_app_restore_behavior_state(BongoCatApp *app, BongoCatResult bongo_cat_app_import_model(BongoCatApp *app, const char *source, BongoCatError *error); BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, BongoCatError *error); void bongo_cat_app_rescan_models(BongoCatApp *app); +void bongo_cat_app_refresh_installed_models(BongoCatApp *app); void bongo_cat_config_store_flush(BongoCatApp *app); #endif diff --git a/include/bongo_cat/preferences.h b/include/bongo_cat/preferences.h index 50ce910c..0a05e860 100644 --- a/include/bongo_cat/preferences.h +++ b/include/bongo_cat/preferences.h @@ -13,7 +13,7 @@ void bongo_cat_preferences_destroy(BongoCatPreferences *preferences); void bongo_cat_preferences_show(BongoCatPreferences *preferences); void bongo_cat_preferences_close(BongoCatPreferences *preferences); bool bongo_cat_preferences_visible(const BongoCatPreferences *preferences); -bool bongo_cat_preferences_needs_frame(const BongoCatPreferences *preferences); +bool bongo_cat_preferences_needs_frame(BongoCatPreferences *preferences); void bongo_cat_preferences_input_begin(BongoCatPreferences *preferences); void bongo_cat_preferences_input_end(BongoCatPreferences *preferences); bool bongo_cat_preferences_event(BongoCatPreferences *preferences, const SDL_Event *event); diff --git a/src/media/image.c b/src/media/image.c index 48058b22..6818ca82 100644 --- a/src/media/image.c +++ b/src/media/image.c @@ -139,7 +139,16 @@ unsigned int bongo_cat_image_texture_model(const char *path, bool direct_decode, glGetIntegerv(GL_MAX_TEXTURE_SIZE, &hardware_limit); if (hardware_limit < 1) hardware_limit = BONGO_CAT_LIVE2D_EFFICIENT_TEXTURE_LIMIT; #ifdef _WIN32 - int decode_limit = hardware_limit; + /* + * A model atlas can advertise 8192px even when the pet is rendered in a + * few hundred pixels. Decoding that atlas at full size costs hundreds of + * MB of temporary RAM and makes alpha-mask generation and glTexImage2D + * dominate model switching. Keep a bounded working size for normal loads; + * this is still well above the largest bundled 1024px atlas. The hardware + * limit remains authoritative on low-end GPUs. + */ + int decode_limit = SDL_min(hardware_limit, + BONGO_CAT_LIVE2D_EFFICIENT_TEXTURE_LIMIT); /* Preset atlases are verified byte-identical in WIC and stb. Keep WIC for custom PNG color metadata and for memory-bounded oversized decoding. */ bool scaled = bongo_cat_image_needs_wic_scaling(path, decode_limit); diff --git a/src/runtime/model/import/model_import.c b/src/runtime/model/import/model_import.c index 4a9f72a2..f994470d 100644 --- a/src/runtime/model/import/model_import.c +++ b/src/runtime/model/import/model_import.c @@ -1,7 +1,6 @@ #include "model_import.h" #include "model_storage.h" #include "runtime.h" -#include "bongo_cat/file.h" #include "bongo_cat/path.h" #include @@ -16,115 +15,18 @@ typedef struct ImportInstall { bool committed; } ImportInstall; +struct BongoCatImportSession { + char root[BONGO_CAT_PATH_CAP]; + BongoCatModelCatalog *existing; + BongoCatImportDigestCache *digests; +}; + static bool custom_root(const char *data_root, char *path, size_t capacity) { return data_root && data_root[0] && bongo_cat_path_join(path, capacity, data_root, "models") && bongo_cat_path_create_directory(path); } -static bool copy_optional(const char *source_dir, const char *source_name, - const char *target_dir, const char *target_name) { - char source[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; - return bongo_cat_path_join(source, sizeof(source), source_dir, source_name) && - bongo_cat_path_join(target, sizeof(target), target_dir, target_name) && - bongo_cat_path_copy_file(source, target); -} - -static bool copy_first(const char *source_dir, const char *const *names, size_t count, - const char *target_dir, const char *target_name) { - for (size_t i = 0; i < count; ++i) { - char source[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(source, sizeof(source), source_dir, names[i]) || - !bongo_cat_path_is_file(source)) continue; - return copy_optional(source_dir, names[i], target_dir, target_name); - } - return true; -} - -static bool copy_preview_file(const char *source_resources, const char *source_root, - const char *const *names, size_t count, const char *target_resources, - const char *target_name) { - for (size_t i = 0; i < count; ++i) { - char source[BONGO_CAT_PATH_CAP]; - if (bongo_cat_path_join(source, sizeof(source), source_resources, names[i]) && - bongo_cat_path_is_file(source)) - return copy_optional(source_resources, names[i], target_resources, target_name); - } - return copy_first(source_root, names, count, target_resources, target_name); -} - -static bool preview_file_exists(const char *directory, const char *name) { - char path[BONGO_CAT_PATH_CAP]; - return bongo_cat_path_join(path, sizeof(path), directory, name) && - bongo_cat_path_is_file(path); -} - -static void mark_preview_fallback(const char *resources) { - char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), resources, - ".bongo-cat-cover-fallback")) return; - FILE *file = bongo_cat_file_open(path, "wb"); - if (file) fclose(file); -} - -static bool copy_preview(const BongoCatImportCandidate *candidate, const char *target, - BongoCatError *error) { - char source_resources[BONGO_CAT_PATH_CAP], target_resources[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(source_resources, sizeof(source_resources), - candidate->assets, "resources") || - !bongo_cat_path_join(target_resources, sizeof(target_resources), target, "resources")) - return false; - bool target_exists = bongo_cat_path_is_dir(target_resources); - if (bongo_cat_path_is_dir(source_resources) && !target_exists) { - if (bongo_cat_copy_directory(source_resources, target_resources, error) != BONGO_CAT_OK) - return false; - target_exists = true; - } - if (!target_exists && !bongo_cat_path_create_directory(target_resources)) return false; - const char *covers[] = {"cover.png", "cat.png", "bg.png", "mousebg.png", - "tabletbg.png"}; - const char *backgrounds[] = {"background.png", "bg.png", "mousebg.png", - "tabletbg.png"}; - bool authored_cover = preview_file_exists(source_resources, "cover.png") || - preview_file_exists(candidate->assets, "cover.png"); - bool ok = (preview_file_exists(target_resources, "cover.png") || - copy_preview_file(source_resources, candidate->assets, covers, 5, - target_resources, "cover.png")) && - (preview_file_exists(target_resources, "background.png") || - copy_preview_file(source_resources, candidate->assets, backgrounds, 4, - target_resources, "background.png")); - if (ok && !authored_cover && preview_file_exists(target_resources, "cover.png")) - mark_preview_fallback(target_resources); - if (!ok) bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot copy model preview assets: %s", SDL_GetError()); - return ok; -} - -static bool write_mode(const char *target, BongoCatModelMode mode, BongoCatError *error) { - char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), target, ".bongo-cat-mode")) return false; - FILE *file = bongo_cat_file_open(path, "wb"); - if (!file) return false; - const char *name = mode == BONGO_CAT_MODE_KEYBOARD ? "keyboard" : - mode == BONGO_CAT_MODE_GAMEPAD ? "gamepad" : "standard"; - bool ok = fputs(name, file) >= 0; - if (fclose(file) != 0) ok = false; - if (!ok) bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot write imported model metadata"); - return ok; -} - -bool bongo_cat_import_prepare_adapter(const BongoCatImportCandidate *candidate, - const char *target, BongoCatError *error) { - if (!candidate || !target || - (!bongo_cat_path_is_dir(target) && !bongo_cat_path_create_directory(target))) return false; - return copy_preview(candidate, target, error) && - bongo_cat_import_mver_assets(candidate, target, error) && - bongo_cat_import_adapter_metadata(candidate, target, error) && - bongo_cat_import_write_report(candidate, target, error) && - write_mode(target, candidate->mode, error); -} - static void cleanup(ImportInstall *installs, size_t count, bool committed) { for (size_t i = 0; i < count; ++i) if (!installs[i].existing) @@ -171,10 +73,65 @@ static bool prepare_install(const BongoCatImportCandidate *candidate, installed.setting, error); } -BongoCatResult bongo_cat_import_install(const char *source, const char *data_root, +static void remember_installs(BongoCatImportSession *session, + const BongoCatImportDiscovery *discovery, + const BongoCatPackageMetadata *metadata, const ImportInstall *installs) { + if (!session || !session->existing) return; + for (size_t i = 0; i < discovery->count; ++i) { + if (installs[i].existing || session->existing->count >= + BONGO_CAT_MODEL_CAP) continue; + BongoCatModelEntry *entry = + &session->existing->entries[session->existing->count++]; + memset(entry, 0, sizeof(*entry)); + snprintf(entry->id, sizeof(entry->id), "%s", installs[i].id); + snprintf(entry->package_id, sizeof(entry->package_id), "%s", + installs[i].id); + snprintf(entry->content_digest, sizeof(entry->content_digest), "%s", + metadata[i].content_digest); + entry->mode = discovery->candidates[i].mode; + entry->package_schema = BONGO_CAT_MODEL_PACKAGE_SCHEMA; + } +} + +BongoCatImportSession *bongo_cat_import_session_create(const char *data_root, + BongoCatError *error) { + if (!data_root) return NULL; + BongoCatImportSession *session = calloc(1, sizeof(*session)); + if (session) session->existing = calloc(1, sizeof(*session->existing)); + if (!session || !session->existing) { + if (session) free(session->existing); + free(session); + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Cannot allocate model import session"); + return NULL; + } + session->digests = bongo_cat_import_digest_cache_create(); + if (!custom_root(data_root, session->root, sizeof(session->root)) || + !bongo_cat_model_cleanup_imports(session->root, error)) { + bongo_cat_import_session_destroy(session); + return NULL; + } + bongo_cat_models_init(session->existing); + if (bongo_cat_models_scan(session->existing, session->root, false, + error) != BONGO_CAT_OK) { + bongo_cat_import_session_destroy(session); + return NULL; + } + return session; +} + +void bongo_cat_import_session_destroy(BongoCatImportSession *session) { + if (!session) return; + bongo_cat_import_digest_cache_destroy(session->digests); + free(session->existing); + free(session); +} + +BongoCatResult bongo_cat_import_session_install( + BongoCatImportSession *session, const char *source, BongoCatImportReceipt *receipt, BongoCatError *error) { if (receipt) memset(receipt, 0, sizeof(*receipt)); - if (!source || !data_root) return BONGO_CAT_ERROR_ARGUMENT; + if (!session || !source) return BONGO_CAT_ERROR_ARGUMENT; char source_directory[BONGO_CAT_PATH_CAP]; BongoCatResult source_result = bongo_cat_import_source_directory(source, source_directory, sizeof(source_directory), error); @@ -183,40 +140,31 @@ BongoCatResult bongo_cat_import_install(const char *source, const char *data_roo ImportInstall *installs = calloc(BONGO_CAT_IMPORT_CANDIDATE_CAP, sizeof(*installs)); BongoCatPackageMetadata *metadata = calloc(BONGO_CAT_IMPORT_CANDIDATE_CAP, sizeof(*metadata)); - BongoCatModelCatalog *existing = calloc(1, sizeof(*existing)); - if (!discovery || !installs || !metadata || !existing) { - free(discovery); free(installs); free(metadata); free(existing); + if (!discovery || !installs || !metadata) { + free(discovery); free(installs); free(metadata); bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, "Cannot allocate model import workspace"); return BONGO_CAT_ERROR_MEMORY; } if (!bongo_cat_import_discover(source_directory, discovery, error)) { - free(discovery); free(installs); free(metadata); free(existing); + free(discovery); free(installs); free(metadata); return BONGO_CAT_ERROR_FORMAT; } - char root[BONGO_CAT_PATH_CAP]; - if (!custom_root(data_root, root, sizeof(root)) || - !bongo_cat_model_cleanup_imports(root, error)) { - free(discovery); free(installs); free(metadata); free(existing); - return BONGO_CAT_ERROR_IO; - } - bongo_cat_models_init(existing); - BongoCatResult scan = bongo_cat_models_scan(existing, root, false, error); - if (scan != BONGO_CAT_OK || - !bongo_cat_import_prepare_package_metadata(discovery, metadata, - error)) { - BongoCatResult result = scan != BONGO_CAT_OK ? scan : - error && error->code ? error->code : BONGO_CAT_ERROR_IO; - free(discovery); free(installs); free(metadata); free(existing); + if (!bongo_cat_import_prepare_package_metadata_cached(discovery, metadata, + session->digests, error)) { + BongoCatResult result = error && error->code ? error->code : + BONGO_CAT_ERROR_IO; + free(discovery); free(installs); free(metadata); return result; } for (size_t i = 0; i < discovery->count; ++i) { - if (prepare_install(&discovery->candidates[i], &metadata[i], existing, - &installs[i], root, i, error)) continue; + if (prepare_install(&discovery->candidates[i], &metadata[i], + session->existing, &installs[i], session->root, i, error)) + continue; cleanup(installs, i + 1, false); BongoCatResult result = error && error->code == BONGO_CAT_ERROR_FORMAT ? BONGO_CAT_ERROR_FORMAT : BONGO_CAT_ERROR_IO; - free(discovery); free(installs); free(metadata); free(existing); + free(discovery); free(installs); free(metadata); return result; } for (size_t i = 0; i < discovery->count; ++i) { @@ -225,7 +173,7 @@ BongoCatResult bongo_cat_import_install(const char *source, const char *data_roo bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, "Cannot finish model import: %s", SDL_GetError()); cleanup(installs, discovery->count, true); - free(discovery); free(installs); free(metadata); free(existing); + free(discovery); free(installs); free(metadata); return BONGO_CAT_ERROR_IO; } installs[i].committed = true; @@ -238,10 +186,25 @@ BongoCatResult bongo_cat_import_install(const char *source, const char *data_roo if (receipt->installed[i]) receipt->installed_count++; } } - free(discovery); free(installs); free(metadata); free(existing); + remember_installs(session, discovery, metadata, installs); + free(discovery); free(installs); free(metadata); return BONGO_CAT_OK; } +BongoCatResult bongo_cat_import_install(const char *source, const char *data_root, + BongoCatImportReceipt *receipt, BongoCatError *error) { + if (receipt) memset(receipt, 0, sizeof(*receipt)); + if (!source || !data_root) return BONGO_CAT_ERROR_ARGUMENT; + BongoCatImportSession *session = bongo_cat_import_session_create( + data_root, error); + if (!session) return error && error->code ? error->code : + BONGO_CAT_ERROR_IO; + BongoCatResult result = bongo_cat_import_session_install(session, + source, receipt, error); + bongo_cat_import_session_destroy(session); + return result; +} + static void remove_receipt(const char *data_root, const BongoCatImportReceipt *receipt) { char root[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; @@ -269,7 +232,7 @@ BongoCatResult bongo_cat_app_import_model(BongoCatApp *app, const char *source, for (size_t i = 0; i < receipt.count; ++i) if (receipt.installed[i]) { preferred = i; break; } const char *imported_id = receipt.count ? receipt.ids[preferred] : NULL; - bongo_cat_app_rescan_models(app); + bongo_cat_app_refresh_installed_models(app); if (imported_id && bongo_cat_app_select_model(app, imported_id)) return BONGO_CAT_OK; #ifndef BONGO_CAT_HAS_CUBISM @@ -283,7 +246,7 @@ BongoCatResult bongo_cat_app_import_model(BongoCatApp *app, const char *source, return BONGO_CAT_OK; #else remove_receipt(app->data_root, &receipt); - bongo_cat_app_rescan_models(app); + bongo_cat_app_refresh_installed_models(app); if (previous[0]) bongo_cat_app_select_model(app, previous); bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, "Model import was rolled back because the Live2D model could not be loaded"); diff --git a/src/runtime/model/import/model_import.h b/src/runtime/model/import/model_import.h index 0e2ba097..74865f3d 100644 --- a/src/runtime/model/import/model_import.h +++ b/src/runtime/model/import/model_import.h @@ -48,6 +48,9 @@ typedef struct BongoCatPackageMetadata { uint32_t capabilities; } BongoCatPackageMetadata; +typedef struct BongoCatImportDigestCache BongoCatImportDigestCache; +typedef struct BongoCatImportSession BongoCatImportSession; + typedef BongoCatResult (*BongoCatImportVisitor)(void *userdata, const char *source, BongoCatImportDiscovery *discovery, BongoCatError *error); @@ -86,9 +89,20 @@ bool bongo_cat_import_candidate_digest(const BongoCatImportCandidate *candidate, char output[65], BongoCatError *error); bool bongo_cat_import_candidate_inspect(const BongoCatImportCandidate *candidate, char output[65], bool *placeholder, BongoCatError *error); +bool bongo_cat_import_candidate_inspect_cached( + const BongoCatImportCandidate *candidate, char output[65], + bool *placeholder, BongoCatImportDigestCache *cache, + BongoCatError *error); +BongoCatImportDigestCache *bongo_cat_import_digest_cache_create(void); +void bongo_cat_import_digest_cache_destroy(BongoCatImportDigestCache *cache); +bool bongo_cat_import_digest_file_cached(BongoCatImportDigestCache *cache, + const char *path, uint64_t size, uint64_t modified, char output[65]); bool bongo_cat_import_prepare_package_metadata( BongoCatImportDiscovery *discovery, BongoCatPackageMetadata *metadata, BongoCatError *error); +bool bongo_cat_import_prepare_package_metadata_cached( + BongoCatImportDiscovery *discovery, BongoCatPackageMetadata *metadata, + BongoCatImportDigestCache *cache, BongoCatError *error); const BongoCatModelEntry *bongo_cat_import_find_existing_package( const BongoCatModelCatalog *catalog, const BongoCatPackageMetadata *metadata, BongoCatModelMode mode); @@ -116,5 +130,11 @@ BongoCatResult bongo_cat_import_nearby(BongoCatApp *app, BongoCatResult bongo_cat_import_install(const char *source, const char *data_root, BongoCatImportReceipt *receipt, BongoCatError *error); +BongoCatImportSession *bongo_cat_import_session_create(const char *data_root, + BongoCatError *error); +void bongo_cat_import_session_destroy(BongoCatImportSession *session); +BongoCatResult bongo_cat_import_session_install( + BongoCatImportSession *session, const char *source, + BongoCatImportReceipt *receipt, BongoCatError *error); #endif diff --git a/src/runtime/model/import/model_import_adapter.c b/src/runtime/model/import/model_import_adapter.c new file mode 100644 index 00000000..1fa577b0 --- /dev/null +++ b/src/runtime/model/import/model_import_adapter.c @@ -0,0 +1,115 @@ +#include "model_import.h" +#include "model_storage.h" +#include "runtime.h" +#include "bongo_cat/file.h" +#include "bongo_cat/path.h" + +#include +#include + +static bool copy_optional(const char *source_dir, const char *source_name, + const char *target_dir, const char *target_name) { + char source[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; + return bongo_cat_path_join(source, sizeof(source), source_dir, source_name) && + bongo_cat_path_join(target, sizeof(target), target_dir, target_name) && + bongo_cat_path_copy_file(source, target); +} + +static bool copy_first(const char *source_dir, const char *const *names, + size_t count, const char *target_dir, const char *target_name) { + for (size_t i = 0; i < count; ++i) { + char source[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(source, sizeof(source), source_dir, names[i]) || + !bongo_cat_path_is_file(source)) continue; + return copy_optional(source_dir, names[i], target_dir, target_name); + } + return true; +} + +static bool copy_preview_file(const char *source_resources, + const char *source_root, const char *const *names, size_t count, + const char *target_resources, const char *target_name) { + for (size_t i = 0; i < count; ++i) { + char source[BONGO_CAT_PATH_CAP]; + if (bongo_cat_path_join(source, sizeof(source), source_resources, + names[i]) && bongo_cat_path_is_file(source)) + return copy_optional(source_resources, names[i], target_resources, + target_name); + } + return copy_first(source_root, names, count, target_resources, target_name); +} + +static bool preview_file_exists(const char *directory, const char *name) { + char path[BONGO_CAT_PATH_CAP]; + return bongo_cat_path_join(path, sizeof(path), directory, name) && + bongo_cat_path_is_file(path); +} + +static void mark_preview_fallback(const char *resources) { + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), resources, + ".bongo-cat-cover-fallback")) return; + FILE *file = bongo_cat_file_open(path, "wb"); + if (file) fclose(file); +} + +static bool copy_preview(const BongoCatImportCandidate *candidate, + const char *target, BongoCatError *error) { + char source_resources[BONGO_CAT_PATH_CAP], target_resources[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(source_resources, sizeof(source_resources), + candidate->assets, "resources") || + !bongo_cat_path_join(target_resources, sizeof(target_resources), target, + "resources")) return false; + bool target_exists = bongo_cat_path_is_dir(target_resources); + if (bongo_cat_path_is_dir(source_resources) && !target_exists) { + if (bongo_cat_copy_directory(source_resources, target_resources, + error) != BONGO_CAT_OK) return false; + target_exists = true; + } + if (!target_exists && !bongo_cat_path_create_directory(target_resources)) + return false; + const char *covers[] = { + "cover.png", "cat.png", "bg.png", "mousebg.png", "tabletbg.png"}; + const char *backgrounds[] = { + "background.png", "bg.png", "mousebg.png", "tabletbg.png"}; + bool authored_cover = preview_file_exists(source_resources, "cover.png") || + preview_file_exists(candidate->assets, "cover.png"); + bool ok = (preview_file_exists(target_resources, "cover.png") || + copy_preview_file(source_resources, candidate->assets, covers, 5, + target_resources, "cover.png")) && + (preview_file_exists(target_resources, "background.png") || + copy_preview_file(source_resources, candidate->assets, backgrounds, 4, + target_resources, "background.png")); + if (ok && !authored_cover && preview_file_exists(target_resources, + "cover.png")) mark_preview_fallback(target_resources); + if (!ok) bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot copy model preview assets: %s", SDL_GetError()); + return ok; +} + +static bool write_mode(const char *target, BongoCatModelMode mode, + BongoCatError *error) { + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), target, ".bongo-cat-mode")) + return false; + FILE *file = bongo_cat_file_open(path, "wb"); + if (!file) return false; + const char *name = mode == BONGO_CAT_MODE_KEYBOARD ? "keyboard" : + mode == BONGO_CAT_MODE_GAMEPAD ? "gamepad" : "standard"; + bool ok = fputs(name, file) >= 0; + if (fclose(file) != 0) ok = false; + if (!ok) bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot write imported model metadata"); + return ok; +} + +bool bongo_cat_import_prepare_adapter(const BongoCatImportCandidate *candidate, + const char *target, BongoCatError *error) { + if (!candidate || !target || (!bongo_cat_path_is_dir(target) && + !bongo_cat_path_create_directory(target))) return false; + return copy_preview(candidate, target, error) && + bongo_cat_import_mver_assets(candidate, target, error) && + bongo_cat_import_adapter_metadata(candidate, target, error) && + bongo_cat_import_write_report(candidate, target, error) && + write_mode(target, candidate->mode, error); +} diff --git a/src/runtime/model/import/model_import_digest.c b/src/runtime/model/import/model_import_digest.c index 95cf304a..efdcac9a 100644 --- a/src/runtime/model/import/model_import_digest.c +++ b/src/runtime/model/import/model_import_digest.c @@ -8,9 +8,7 @@ #define DIGEST_ROOT_CAP 12 -typedef struct DigestStamp { - uint64_t sum, exclusive, bytes, files, entries; -} DigestStamp; +typedef struct DigestStamp { uint64_t sum, exclusive, bytes, files, entries; } DigestStamp; typedef struct DigestWalk { const char *root; @@ -20,13 +18,11 @@ typedef struct DigestWalk { DigestStamp *content; const char *override_root; DigestStamp *overrides; + BongoCatImportDigestCache *cache; int depth; } DigestWalk; -typedef struct DigestRoot { - char path[BONGO_CAT_PATH_CAP]; - const char *group; -} DigestRoot; +typedef struct DigestRoot { char path[BONGO_CAT_PATH_CAP]; const char *group; } DigestRoot; static bool separator(char value) { return value == '/' || value == '\\'; } @@ -97,11 +93,13 @@ static bool stamp_path(const char *path, DigestWalk *walk) { walk->depth--; return ok; } - uint64_t size = 0; - if (!bongo_cat_path_file_size(path, &size) || walk->stamp->files >= 8192 || + uint64_t size = 0, modified = 0; + if (!bongo_cat_path_file_info(path, &size, &modified) || + walk->stamp->files >= 8192 || size > 1073741824ull - walk->stamp->bytes) return false; char file_hash[65]; - if (bongo_cat_sha256_file(path, file_hash, NULL) != BONGO_CAT_OK) + if (!bongo_cat_import_digest_file_cached(walk->cache, path, size, + modified, file_hash)) return false; uint64_t value = mix(hash_text(walk->group) ^ hash_text(relative_path(walk->root, path)) ^ hash_text(file_hash) ^ @@ -133,10 +131,11 @@ static bool stamp_path(const char *path, DigestWalk *walk) { static bool stamp_root(const char *root, const char *group, DigestStamp *stamp, const BongoCatImportCandidate *candidate, - DigestStamp *content, DigestStamp *overrides) { + DigestStamp *content, DigestStamp *overrides, + BongoCatImportDigestCache *cache) { if (!root || !root[0]) return true; DigestWalk walk = {root, group, stamp, - candidate->assets, content, candidate->overrides, overrides, 0}; + candidate->assets, content, candidate->overrides, overrides, cache, 0}; return stamp_path(root, &walk); } @@ -208,8 +207,8 @@ static void add_tauri_preview_roots(const BongoCatImportCandidate *candidate, add_root(roots, count, path, "authored-cover"); } -static bool stamp_equal(const DigestStamp *value, uint64_t sum, - uint64_t exclusive, uint64_t bytes, uint64_t files) { +static bool stamp_equal(const DigestStamp *value, uint64_t sum, uint64_t exclusive, + uint64_t bytes, uint64_t files) { return value->sum == sum && value->exclusive == exclusive && value->bytes == bytes && value->files == files; } @@ -238,8 +237,10 @@ static bool candidate_placeholder(const BongoCatImportCandidate *candidate, 0x83fd63c53a241538ull, 1711338ull, 51ull); } -bool bongo_cat_import_candidate_inspect(const BongoCatImportCandidate *candidate, - char output[65], bool *placeholder, BongoCatError *error) { +bool bongo_cat_import_candidate_inspect_cached( + const BongoCatImportCandidate *candidate, char output[65], + bool *placeholder, BongoCatImportDigestCache *cache, + BongoCatError *error) { if (!candidate || !output) return false; char setting[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(setting, sizeof(setting), @@ -264,7 +265,7 @@ bool bongo_cat_import_candidate_inspect(const BongoCatImportCandidate *candidate DigestStamp stamp = {0}, content = {0}, overrides = {0}; for (size_t i = 0; i < root_count; ++i) { if (stamp_root(roots[i].path, roots[i].group, &stamp, candidate, - &content, &overrides)) continue; + &content, &overrides, cache)) continue; bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, "Cannot fingerprint imported model assets: %s", candidate->package_root); @@ -287,6 +288,12 @@ bool bongo_cat_import_candidate_inspect(const BongoCatImportCandidate *candidate return true; } +bool bongo_cat_import_candidate_inspect(const BongoCatImportCandidate *candidate, + char output[65], bool *placeholder, BongoCatError *error) { + return bongo_cat_import_candidate_inspect_cached(candidate, output, + placeholder, NULL, error); +} + bool bongo_cat_import_candidate_digest(const BongoCatImportCandidate *candidate, char output[65], BongoCatError *error) { return bongo_cat_import_candidate_inspect(candidate, output, NULL, error); diff --git a/src/runtime/model/import/model_import_digest_cache.c b/src/runtime/model/import/model_import_digest_cache.c new file mode 100644 index 00000000..d4bc8caa --- /dev/null +++ b/src/runtime/model/import/model_import_digest_cache.c @@ -0,0 +1,119 @@ +#include "model_import.h" +#include "bongo_cat/sha256.h" + +#include +#include +#include + +#define DIGEST_CACHE_BUCKET_CAP 4093 +#define DIGEST_CACHE_ENTRY_CAP 16384 + +typedef struct DigestCacheEntry { + struct DigestCacheEntry *next; + char *path; + char digest[65]; + uint64_t size; + uint64_t modified; +} DigestCacheEntry; + +struct BongoCatImportDigestCache { + DigestCacheEntry **buckets; + size_t count; +}; + +static uint64_t hash_path(const char *value) { + uint64_t hash = 1469598103934665603ull; + for (const unsigned char *cursor = (const unsigned char *)value; + cursor && *cursor; ++cursor) { + unsigned char byte = *cursor == '\\' ? '/' : *cursor; +#ifdef _WIN32 + if (byte >= 'A' && byte <= 'Z') byte = (unsigned char)(byte + 32); +#endif + hash ^= byte; + hash *= 1099511628211ull; + } + return hash; +} + +static bool path_equal(const char *left, const char *right) { +#ifdef _WIN32 + return SDL_strcasecmp(left, right) == 0; +#else + return strcmp(left, right) == 0; +#endif +} + +static DigestCacheEntry *cache_entry(BongoCatImportDigestCache *cache, + const char *path, size_t *bucket) { + if (!cache || !cache->buckets || !path) return NULL; + size_t index = (size_t)(hash_path(path) % DIGEST_CACHE_BUCKET_CAP); + if (bucket) *bucket = index; + for (DigestCacheEntry *entry = cache->buckets[index]; entry; + entry = entry->next) + if (path_equal(entry->path, path)) return entry; + return NULL; +} + +static void cache_store(BongoCatImportDigestCache *cache, const char *path, + uint64_t size, uint64_t modified, const char digest[65]) { + if (!cache || !cache->buckets || cache->count >= DIGEST_CACHE_ENTRY_CAP) + return; + size_t bucket = 0; + DigestCacheEntry *entry = cache_entry(cache, path, &bucket); + if (!entry) { + entry = calloc(1, sizeof(*entry)); + size_t length = strlen(path) + 1; + if (entry) entry->path = malloc(length); + if (!entry || !entry->path) { + if (entry) free(entry->path); + free(entry); + return; + } + memcpy(entry->path, path, length); + entry->next = cache->buckets[bucket]; + cache->buckets[bucket] = entry; + cache->count++; + } + entry->size = size; + entry->modified = modified; + memcpy(entry->digest, digest, 65); +} + +BongoCatImportDigestCache *bongo_cat_import_digest_cache_create(void) { + BongoCatImportDigestCache *cache = calloc(1, sizeof(*cache)); + if (cache) cache->buckets = calloc(DIGEST_CACHE_BUCKET_CAP, + sizeof(*cache->buckets)); + if (cache && cache->buckets) return cache; + if (cache) free(cache->buckets); + free(cache); + return NULL; +} + +void bongo_cat_import_digest_cache_destroy(BongoCatImportDigestCache *cache) { + if (!cache) return; + if (cache->buckets) for (size_t i = 0; + i < DIGEST_CACHE_BUCKET_CAP; ++i) { + DigestCacheEntry *entry = cache->buckets[i]; + while (entry) { + DigestCacheEntry *next = entry->next; + free(entry->path); + free(entry); + entry = next; + } + } + free(cache->buckets); + free(cache); +} + +bool bongo_cat_import_digest_file_cached(BongoCatImportDigestCache *cache, + const char *path, uint64_t size, uint64_t modified, char output[65]) { + DigestCacheEntry *entry = cache_entry(cache, path, NULL); + if (entry && entry->size == size && entry->modified == modified) { + memcpy(output, entry->digest, 65); + return true; + } + if (bongo_cat_sha256_file(path, output, NULL) != BONGO_CAT_OK) + return false; + cache_store(cache, path, size, modified, output); + return true; +} diff --git a/src/runtime/model/import/model_import_identity.c b/src/runtime/model/import/model_import_identity.c index c8d1b1d5..07d17790 100644 --- a/src/runtime/model/import/model_import_identity.c +++ b/src/runtime/model/import/model_import_identity.c @@ -50,17 +50,18 @@ static const char *display_source(const BongoCatImportCandidate *candidate, return source; } -bool bongo_cat_import_prepare_package_metadata( +bool bongo_cat_import_prepare_package_metadata_cached( BongoCatImportDiscovery *discovery, - BongoCatPackageMetadata *metadata, BongoCatError *error) { + BongoCatPackageMetadata *metadata, BongoCatImportDigestCache *cache, + BongoCatError *error) { char family_material[BONGO_CAT_IMPORT_CANDIDATE_CAP * 66 + 32]; size_t used = 0, output = 0; for (size_t i = 0; i < discovery->count; ++i) { BongoCatImportCandidate candidate = discovery->candidates[i]; BongoCatPackageMetadata *item = &metadata[output]; bool placeholder = false; - if (!bongo_cat_import_candidate_inspect(&candidate, - item->content_digest, &placeholder, error)) return false; + if (!bongo_cat_import_candidate_inspect_cached(&candidate, + item->content_digest, &placeholder, cache, error)) return false; if (placeholder) continue; discovery->candidates[output] = candidate; int written = snprintf(family_material + used, @@ -95,6 +96,13 @@ bool bongo_cat_import_prepare_package_metadata( return true; } +bool bongo_cat_import_prepare_package_metadata( + BongoCatImportDiscovery *discovery, + BongoCatPackageMetadata *metadata, BongoCatError *error) { + return bongo_cat_import_prepare_package_metadata_cached(discovery, + metadata, NULL, error); +} + const BongoCatModelEntry *bongo_cat_import_find_existing_package( const BongoCatModelCatalog *catalog, const BongoCatPackageMetadata *metadata, BongoCatModelMode mode) { diff --git a/src/runtime/model/model_catalog_runtime.c b/src/runtime/model/model_catalog_runtime.c index 8ecedbfd..fbf563bc 100644 --- a/src/runtime/model/model_catalog_runtime.c +++ b/src/runtime/model/model_catalog_runtime.c @@ -5,6 +5,7 @@ #include #include +#include #include typedef struct TreeContext { @@ -109,26 +110,54 @@ static void scan_nearby_root(BongoCatApp *app, const char *root) { SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", error.message); } -void bongo_cat_app_rescan_models(BongoCatApp *app) { - if (!app) return; +static void scan_owned_models(BongoCatApp *app, bool cleanup) { BongoCatError error = {0}; char root[BONGO_CAT_PATH_CAP]; bongo_cat_models_init(&app->models); bongo_cat_path_join(root, sizeof(root), app->asset_root, "models"); bongo_cat_models_scan(&app->models, root, true, &error); if (custom_root(app, root, sizeof(root))) { - bongo_cat_model_cleanup_imports(root, &error); + if (cleanup) bongo_cat_model_cleanup_imports(root, &error); bongo_cat_models_scan(&app->models, root, false, &error); } - const char *base = SDL_GetBasePath(); - if (!SDL_getenv("BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN")) - scan_nearby_root(app, base); +} + +static void finish_model_catalog(BongoCatApp *app) { prune_behavior_shortcuts(app); for (size_t i = 0; i < app->models.count; ++i) bongo_cat_import_apply_metadata(app, app->models.entries[i].id, app->models.entries[i].adapter_directory); } +void bongo_cat_app_rescan_models(BongoCatApp *app) { + if (!app) return; + scan_owned_models(app, true); + const char *base = SDL_GetBasePath(); + if (!SDL_getenv("BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN")) + scan_nearby_root(app, base); + finish_model_catalog(app); +} + +void bongo_cat_app_refresh_installed_models(BongoCatApp *app) { + if (!app) return; + BongoCatModelCatalog *previous = malloc(sizeof(*previous)); + if (!previous) { + bongo_cat_app_rescan_models(app); + return; + } + *previous = app->models; + scan_owned_models(app, false); + for (size_t i = 0; i < previous->count && + app->models.count < BONGO_CAT_MODEL_CAP; ++i) { + const BongoCatModelEntry *entry = &previous->entries[i]; + if (!entry->managed || bongo_cat_models_find(&app->models, entry->id)) + continue; + app->models.entries[app->models.count++] = *entry; + } + free(previous); + finish_model_catalog(app); +} + BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, BongoCatError *error) { if (!app || !id) { diff --git a/src/runtime/model/model_files.c b/src/runtime/model/model_files.c index d5f2ebab..777b843d 100644 --- a/src/runtime/model/model_files.c +++ b/src/runtime/model/model_files.c @@ -38,6 +38,35 @@ static void model_runtime_stage(BongoCatApp *app, const char *stage, SDL_Log("[runtime] Model load %s: id=%s", stage, id); } +static bool behavior_cacheable(const BongoCatModelEntry *entry) { + /* Installed packages are content-addressed and immutable. Nearby models + are managed by their source directory, so do not retain stale data. */ + return entry && (entry->preset || (!entry->managed && + entry->content_digest[0])); +} + +static bool behavior_cache_matches(const BongoCatApp *app, + const BongoCatModelEntry *entry) { + return app && app->behavior_cache_valid && behavior_cacheable(entry) && + strcmp(app->behavior_cache_model_id, entry->id) == 0 && + (entry->preset || strcmp(app->behavior_cache_digest, + entry->content_digest) == 0); +} + +static void behavior_cache_store(BongoCatApp *app, + const BongoCatModelEntry *entry) { + if (!app || !behavior_cacheable(entry)) { + if (app) app->behavior_cache_valid = false; + return; + } + app->behavior_cache = app->behaviors; + snprintf(app->behavior_cache_model_id, + sizeof(app->behavior_cache_model_id), "%s", entry->id); + snprintf(app->behavior_cache_digest, sizeof(app->behavior_cache_digest), + "%s", entry->content_digest); + app->behavior_cache_valid = true; +} + static void model_progress_runtime_stage(BongoCatApp *app, float progress) { static const char *names[] = {"", "model-core", "texture-loading", "finalizing"}; @@ -53,6 +82,9 @@ static void model_progress_runtime_stage(BongoCatApp *app, float progress) { static void model_load_progress(void *userdata, float progress) { BongoCatApp *app = userdata; model_progress_runtime_stage(app, progress); + /* Keep native window procedures responsive while the main loop is + synchronously loading Cubism and OpenGL resources. */ + SDL_PumpEvents(); if (app && app->preferences) bongo_cat_preferences_model_load_progress(app->preferences, progress); /* The main loop is blocked during loading, so advance the visible model here. */ @@ -133,8 +165,15 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, "Cannot allocate model behavior state"); return false; } - if (bongo_cat_behaviors_load(behaviors, entry, &optional) != BONGO_CAT_OK) - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", optional.message); + bool behavior_catalog_valid = behavior_cache_matches(app, entry); + if (behavior_catalog_valid) { + *behaviors = app->behavior_cache; + } else { + behavior_catalog_valid = bongo_cat_behaviors_load( + behaviors, entry, &optional) == BONGO_CAT_OK; + if (!behavior_catalog_valid) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", optional.message); + } BongoCatLive2DRenderOptions render_options = {0}; bool adapted_profile = bongo_cat_import_render_options( entry->adapter_directory, &render_options); @@ -190,7 +229,15 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, app->model_render_options = render_options; bongo_cat_app_reset_pointer_tracking(app); bongo_cat_live2d_set_render_options(app->live2d, &render_options); + if (app->loaded_model[0] && app->behavior_catalog_valid) { + const BongoCatModelEntry *previous_entry = bongo_cat_models_find( + &app->models, app->loaded_model); + behavior_cache_store(app, previous_entry); + } else if (app->loaded_model[0]) { + app->behavior_cache_valid = false; + } app->behaviors = *behaviors; + app->behavior_catalog_valid = behavior_catalog_valid; free(behaviors); optional = (BongoCatError){0}; if (bongo_cat_overlay_load(app->overlay, entry->adapter_directory, diff --git a/src/ui/preferences/preferences.c b/src/ui/preferences/preferences.c index d2eced85..4ba1000d 100644 --- a/src/ui/preferences/preferences.c +++ b/src/ui/preferences/preferences.c @@ -93,6 +93,9 @@ void bongo_cat_preferences_close(BongoCatPreferences *value) { value->transparent_window = false; value->ui_initialized = false; value->font_reload_pending = false; + value->font_reload_defer_once = false; + value->model_load_visual_active = false; + value->model_load_visual_completion_ns = 0; value->smoke_behavior_open_pending = false; value->native_drag = false; value->chrome_dragging = false; @@ -118,9 +121,19 @@ bool bongo_cat_preferences_open_model_import(BongoCatPreferences *value, value->import_dialog, parent); } bool bongo_cat_preferences_visible(const BongoCatPreferences *value) { return value && value->window; } -bool bongo_cat_preferences_needs_frame(const BongoCatPreferences *value) { - if (!value || !value->window) return false; +bool bongo_cat_preferences_needs_frame(BongoCatPreferences *value) { + if (!value) return false; uint64_t now = SDL_GetTicksNS(); + if (value->model_load_visual_active && + !value->model_load_visual_completion_ns && !value->model_loading && + !value->model_selection_pending && now - + value->model_load_visual_started_ns >= + BONGO_CAT_MODEL_LOAD_VISUAL_DURATION_NS) { + value->model_load_visual_active = false; + value->model_load_progress = 1.0f; + value->render_dirty = true; + } + if (!value->window) return false; if (value->render_retry_ns > now) return false; bool raster_due = value->pending_raster_scale > 0.0f && value->raster_retry_ns <= now; diff --git a/src/ui/preferences/preferences_import.c b/src/ui/preferences/preferences_import.c index 859eb1a0..ae652248 100644 --- a/src/ui/preferences/preferences_import.c +++ b/src/ui/preferences/preferences_import.c @@ -1,4 +1,4 @@ -#include "preferences_internal.h" +#include "preferences_import_internal.h" #include "preferences_state.h" #include "model_import.h" @@ -7,37 +7,6 @@ #include #include -typedef struct BongoCatImportJob { - size_t count; - char **paths; - char data_root[BONGO_CAT_PATH_CAP]; - char first_id[BONGO_CAT_ID_CAP]; - bool first_id_installed; - size_t resolved_count; - size_t installed_count; - BongoCatResult result; - BongoCatError error; - BongoCatImportDialog *dialog; - bool completion; -} BongoCatImportJob; - -enum { - BONGO_CAT_IMPORT_EVENT_CODE = 0x42434e49, - BONGO_CAT_IMPORT_COMPLETE_CODE = 0x42434e4a -}; - -struct BongoCatImportDialog { - SDL_Mutex *mutex; - Uint32 event_type; - SDL_WindowID window_id; - SDL_Thread *worker; - BongoCatImportJob *worker_job; - int references; - bool active; - bool open; - bool busy; -}; - static void free_job(BongoCatImportJob *job) { if (!job) return; for (size_t i = 0; i < job->count; ++i) SDL_free(job->paths[i]); @@ -56,21 +25,6 @@ static void release_dialog(BongoCatImportDialog *dialog) { } } -BongoCatImportDialog *bongo_cat_preferences_import_create(void) { - BongoCatImportDialog *dialog = SDL_calloc(1, sizeof(*dialog)); - if (!dialog) return NULL; - dialog->event_type = SDL_RegisterEvents(1); - dialog->mutex = SDL_CreateMutex(); - if (dialog->event_type == (Uint32)-1 || !dialog->mutex) { - if (dialog->mutex) SDL_DestroyMutex(dialog->mutex); - SDL_free(dialog); - return NULL; - } - dialog->references = 1; - dialog->active = true; - return dialog; -} - void bongo_cat_preferences_import_destroy(BongoCatImportDialog *dialog) { if (!dialog) return; SDL_LockMutex(dialog->mutex); @@ -92,19 +46,13 @@ void bongo_cat_preferences_import_destroy(BongoCatImportDialog *dialog) { } dialog->worker_job = NULL; dialog->busy = false; + dialog->started_ns = 0; + dialog->completed = dialog->total = 0; SDL_UnlockMutex(dialog->mutex); if (release_worker) release_dialog(dialog); release_dialog(dialog); } -bool bongo_cat_preferences_import_is_open(const BongoCatImportDialog *dialog) { - if (!dialog) return false; - SDL_LockMutex(dialog->mutex); - bool open = dialog->open || dialog->busy; - SDL_UnlockMutex(dialog->mutex); - return open; -} - static BongoCatImportJob *copy_job(const char *const *files) { if (!files || !files[0]) return NULL; size_t count = 0; @@ -134,11 +82,15 @@ static int SDLCALL import_worker(void *userdata) { SDL_ClearError(); } job->result = BONGO_CAT_ERROR_FORMAT; - for (size_t i = 0; i < job->count; ++i) { + BongoCatImportSession *session = bongo_cat_import_session_create( + job->data_root, &job->error); + if (!session) job->result = job->error.code ? job->error.code : + BONGO_CAT_ERROR_IO; + for (size_t i = 0; session && i < job->count; ++i) { BongoCatImportReceipt receipt; BongoCatError error = {0}; - BongoCatResult result = bongo_cat_import_install(job->paths[i], - job->data_root, &receipt, &error); + BongoCatResult result = bongo_cat_import_session_install(session, + job->paths[i], &receipt, &error); if (result == BONGO_CAT_OK) { for (size_t j = 0; j < receipt.count; ++j) { if (!job->first_id[0] || @@ -155,7 +107,12 @@ static int SDLCALL import_worker(void *userdata) { job->result = result; job->error = error; } + SDL_LockMutex(job->dialog->mutex); + if (job->dialog->worker_job == job) + job->dialog->completed = i + 1; + SDL_UnlockMutex(job->dialog->mutex); } + bongo_cat_import_session_destroy(session); if (job->resolved_count) job->result = BONGO_CAT_OK; if (!job->resolved_count && !job->error.message[0]) bongo_cat_error_set(&job->error, BONGO_CAT_ERROR_FORMAT, @@ -175,6 +132,8 @@ static int SDLCALL import_worker(void *userdata) { if (!notify) { job->dialog->worker_job = NULL; job->dialog->busy = false; + job->dialog->started_ns = 0; + job->dialog->completed = job->dialog->total = 0; } SDL_UnlockMutex(job->dialog->mutex); if (!notify) { @@ -198,11 +157,16 @@ static bool start_job(BongoCatImportDialog *dialog, BongoCatApp *app, dialog->window_id = window_id; dialog->busy = true; dialog->worker_job = job; + dialog->started_ns = SDL_GetTicksNS(); + dialog->completed = 0; + dialog->total = job->count; ++dialog->references; dialog->worker = SDL_CreateThread(import_worker, "bongo-cat-model-import", job); if (!dialog->worker) { dialog->worker_job = NULL; dialog->busy = false; + dialog->started_ns = 0; + dialog->completed = dialog->total = 0; --dialog->references; } SDL_UnlockMutex(dialog->mutex); @@ -241,7 +205,7 @@ bool bongo_cat_preferences_import_open(BongoCatImportDialog *dialog, dialog->window_id = SDL_GetWindowID(window); ++dialog->references; SDL_UnlockMutex(dialog->mutex); - SDL_ShowOpenFolderDialog(import_callback, dialog, window, NULL, false); + SDL_ShowOpenFolderDialog(import_callback, dialog, window, NULL, true); return true; } @@ -254,13 +218,15 @@ static void complete_job(BongoCatImportDialog *dialog, BongoCatApp *app, dialog->worker = NULL; dialog->worker_job = NULL; dialog->busy = false; + dialog->started_ns = 0; + dialog->completed = dialog->total = 0; } SDL_UnlockMutex(dialog->mutex); if (worker) SDL_WaitThread(worker, NULL); if (job->result == BONGO_CAT_OK && job->first_id[0] && job->first_id_installed) { SDL_GL_MakeCurrent(app->window, app->gl_context); - bongo_cat_app_rescan_models(app); + bongo_cat_app_refresh_installed_models(app); if (!bongo_cat_app_select_model(app, job->first_id)) { job->result = BONGO_CAT_ERROR_CUBISM; bongo_cat_error_set(&job->error, job->result, diff --git a/src/ui/preferences/preferences_import_dialog.c b/src/ui/preferences/preferences_import_dialog.c new file mode 100644 index 00000000..e2538d8a --- /dev/null +++ b/src/ui/preferences/preferences_import_dialog.c @@ -0,0 +1,36 @@ +#include "preferences_import_internal.h" + +BongoCatImportDialog *bongo_cat_preferences_import_create(void) { + BongoCatImportDialog *dialog = SDL_calloc(1, sizeof(*dialog)); + if (!dialog) return NULL; + dialog->event_type = SDL_RegisterEvents(1); + dialog->mutex = SDL_CreateMutex(); + if (dialog->event_type == (Uint32)-1 || !dialog->mutex) { + if (dialog->mutex) SDL_DestroyMutex(dialog->mutex); + SDL_free(dialog); + return NULL; + } + dialog->references = 1; + dialog->active = true; + return dialog; +} + +bool bongo_cat_preferences_import_is_open(const BongoCatImportDialog *dialog) { + if (!dialog) return false; + SDL_LockMutex(dialog->mutex); + bool open = dialog->open || dialog->busy; + SDL_UnlockMutex(dialog->mutex); + return open; +} + +bool bongo_cat_preferences_import_status(const BongoCatImportDialog *dialog, + uint64_t *started_ns, size_t *completed, size_t *total) { + if (!dialog) return false; + SDL_LockMutex(dialog->mutex); + bool busy = dialog->busy; + if (started_ns) *started_ns = dialog->started_ns; + if (completed) *completed = dialog->completed; + if (total) *total = dialog->total; + SDL_UnlockMutex(dialog->mutex); + return busy; +} diff --git a/src/ui/preferences/preferences_import_internal.h b/src/ui/preferences/preferences_import_internal.h new file mode 100644 index 00000000..220e4e2e --- /dev/null +++ b/src/ui/preferences/preferences_import_internal.h @@ -0,0 +1,40 @@ +#ifndef BONGO_CAT_PREFERENCES_IMPORT_INTERNAL_H +#define BONGO_CAT_PREFERENCES_IMPORT_INTERNAL_H + +#include "preferences_internal.h" + +typedef struct BongoCatImportJob { + size_t count; + char **paths; + char data_root[BONGO_CAT_PATH_CAP]; + char first_id[BONGO_CAT_ID_CAP]; + bool first_id_installed; + size_t resolved_count; + size_t installed_count; + BongoCatResult result; + BongoCatError error; + BongoCatImportDialog *dialog; + bool completion; +} BongoCatImportJob; + +enum { + BONGO_CAT_IMPORT_EVENT_CODE = 0x42434e49, + BONGO_CAT_IMPORT_COMPLETE_CODE = 0x42434e4a +}; + +struct BongoCatImportDialog { + SDL_Mutex *mutex; + Uint32 event_type; + SDL_WindowID window_id; + SDL_Thread *worker; + BongoCatImportJob *worker_job; + uint64_t started_ns; + size_t completed; + size_t total; + int references; + bool active; + bool open; + bool busy; +}; + +#endif diff --git a/src/ui/preferences/preferences_internal.h b/src/ui/preferences/preferences_internal.h index 417b232e..1dad8905 100644 --- a/src/ui/preferences/preferences_internal.h +++ b/src/ui/preferences/preferences_internal.h @@ -72,6 +72,8 @@ void bongo_cat_preferences_import_destroy(BongoCatImportDialog *dialog); bool bongo_cat_preferences_import_open(BongoCatImportDialog *dialog, SDL_Window *window); bool bongo_cat_preferences_import_is_open(const BongoCatImportDialog *dialog); +bool bongo_cat_preferences_import_status(const BongoCatImportDialog *dialog, + uint64_t *started_ns, size_t *completed, size_t *total); bool bongo_cat_preferences_import_event(BongoCatImportDialog *dialog, BongoCatApp *app, const SDL_Event *event); diff --git a/src/ui/preferences/preferences_model.c b/src/ui/preferences/preferences_model.c index 643d55d9..125e96ab 100644 --- a/src/ui/preferences/preferences_model.c +++ b/src/ui/preferences/preferences_model.c @@ -10,23 +10,46 @@ #include #define MODEL_CARD_HEIGHT 214 -#define MODEL_LOAD_REPORTED_LIMIT .60f +#define MODEL_LOAD_RENDER_INTERVAL_NS 33333333ull static const char *tr(BongoCatApp *app, const char *key, const char *fallback) { return bongo_cat_i18n_get(app->i18n, key, fallback); } +static float visual_wait_progress(uint64_t elapsed) { + if (elapsed <= BONGO_CAT_MODEL_LOAD_VISUAL_RAMP_NS) + return .8f * (float)((double)elapsed / + BONGO_CAT_MODEL_LOAD_VISUAL_RAMP_NS); + uint64_t slow_elapsed = elapsed - BONGO_CAT_MODEL_LOAD_VISUAL_RAMP_NS; + uint64_t slow_duration = BONGO_CAT_MODEL_LOAD_VISUAL_DURATION_NS - + BONGO_CAT_MODEL_LOAD_VISUAL_RAMP_NS; + float slow = slow_duration ? (float)((double)slow_elapsed / slow_duration) : 1.0f; + return .8f + (BONGO_CAT_MODEL_LOAD_VISUAL_WAIT_CAP - .8f) * + NK_CLAMP(0.0f, slow, 1.0f); +} + +void bongo_cat_preferences_model_visual_begin(BongoCatPreferences *value, + const char *model_id) { + if (!value || !model_id || !model_id[0]) return; + value->model_load_visual_active = true; + value->model_load_visual_started_ns = SDL_GetTicksNS(); + value->model_load_visual_completion_ns = 0; + value->model_load_progress = 0.0f; + snprintf(value->model_load_visual_id, + sizeof(value->model_load_visual_id), "%s", model_id); +} + void bongo_cat_preferences_model_load_progress(BongoCatPreferences *value, float progress) { if (!value || !value->model_loading) return; - value->model_load_progress = NK_CLAMP(0.0f, - progress * MODEL_LOAD_REPORTED_LIMIT, MODEL_LOAD_REPORTED_LIMIT); - value->render_dirty = true; + (void)progress; uint64_t now = SDL_GetTicksNS(); - bool due = progress >= .999f || - progress - value->model_load_render_progress >= .02f || - now - value->model_load_render_ns >= 16000000ull; + uint64_t elapsed = now - value->model_load_visual_started_ns; + value->model_load_progress = visual_wait_progress(elapsed); + value->render_dirty = true; + bool due = !value->model_load_render_ns || + now - value->model_load_render_ns >= MODEL_LOAD_RENDER_INTERVAL_NS; if (value->window && due) { value->model_load_render_progress = progress; value->model_load_render_ns = now; @@ -34,19 +57,42 @@ void bongo_cat_preferences_model_load_progress(BongoCatPreferences *value, } } -static void finish_model_load_progress(BongoCatPreferences *value) { - if (!value || !value->window) { - if (value) value->model_load_progress = 1.0f; - return; - } - float start = value->model_load_progress; - for (int i = 1; i <= 10; ++i) { - float amount = (float)i / 10.0f; +float bongo_cat_preferences_model_visual_progress(BongoCatPreferences *value, + const char *model_id) { + if (!value || !model_id || !value->model_load_visual_active || + strcmp(value->model_load_visual_id, model_id)) return 0.0f; + uint64_t now = SDL_GetTicksNS(); + uint64_t elapsed = now - value->model_load_visual_started_ns; + if (!value->model_loading && !value->model_selection_pending) { + if (!value->model_load_visual_completion_ns) + value->model_load_visual_completion_ns = now; + uint64_t completed = now - value->model_load_visual_completion_ns; + float amount = NK_CLAMP(0.0f, (float)((double)completed / + BONGO_CAT_MODEL_LOAD_VISUAL_COMPLETE_NS), 1.0f); + float start = visual_wait_progress(elapsed); value->model_load_progress = start + (1.0f - start) * amount; + if (amount >= 1.0f) { + value->model_load_visual_active = false; + value->model_load_visual_completion_ns = 0; + value->model_load_progress = 1.0f; + } value->render_dirty = true; - bongo_cat_preferences_render(value); - if (i < 10) SDL_Delay(8); + return value->model_load_progress; } + value->model_load_progress = visual_wait_progress(elapsed); + value->render_dirty = true; + return value->model_load_progress; +} + +static void finish_model_load_progress(BongoCatPreferences *value) { + if (!value) return; + if (value->model_load_visual_active) { + uint64_t now = SDL_GetTicksNS(); + value->model_load_visual_completion_ns = now; + value->model_load_progress = visual_wait_progress(now - + value->model_load_visual_started_ns); + } else value->model_load_progress = 1.0f; + value->render_dirty = true; } void bongo_cat_preferences_process_model_selection(BongoCatPreferences *value) { @@ -60,11 +106,17 @@ void bongo_cat_preferences_process_model_selection(BongoCatPreferences *value) { value->model_load_progress = 0.0f; value->model_load_render_progress = 0.0f; value->model_load_render_ns = 0; + if (!value->model_load_visual_active || strcmp(value->model_load_visual_id, id)) + bongo_cat_preferences_model_visual_begin(value, id); snprintf(value->loading_model_id, sizeof(value->loading_model_id), "%s", id); BongoCatError error = {0}; bool selected = bongo_cat_app_select_model_with_error(value->app, id, &error); if (selected) finish_model_load_progress(value); - else value->model_load_progress = 0.0f; + else { + value->model_load_progress = 0.0f; + value->model_load_visual_active = false; + value->model_load_visual_id[0] = '\0'; + } value->model_loading = false; value->loading_model_id[0] = '\0'; if (!selected) bongo_cat_preferences_notice_show(value->app, tr(value->app, @@ -89,8 +141,10 @@ void bongo_cat_preferences_import_complete(BongoCatApp *app, } else bongo_cat_preferences_notice_show(app, message, result != BONGO_CAT_OK || partial); if (result == BONGO_CAT_OK) { - if (installed_count) - bongo_cat_preferences_reload_fonts(app->preferences); + if (installed_count && app->preferences->page == 2) { + app->preferences->font_reload_pending = true; + app->preferences->font_reload_defer_once = true; + } SDL_Log("Resolved %zu model package(s); installed %zu new package(s)", resolved_count, installed_count); } diff --git a/src/ui/preferences/preferences_model_card.c b/src/ui/preferences/preferences_model_card.c index 5bc24b46..3dfa76bb 100644 --- a/src/ui/preferences/preferences_model_card.c +++ b/src/ui/preferences/preferences_model_card.c @@ -46,6 +46,19 @@ bool bongo_cat_preferences_model_import_card(BongoCatPreferences *value, nk_fill_rect(canvas, bounds, 14, hover ? p.hover_pink : p.hover); bongo_cat_ui_paint_dashed_rounded(context, bounds, 14, 2, 5, 5, hover ? p.pink : p.accent); + uint64_t import_started = 0; + bool importing = bongo_cat_preferences_import_status(value->import_dialog, + &import_started, NULL, NULL); + if (importing && import_started) { + const double lap_ns = 5000000000.0; + double elapsed = (double)(SDL_GetTicksNS() - import_started); + float progress = (float)fmod(elapsed / lap_ns, 1.0); + struct nk_rect outline = + bongo_cat_preferences_model_card_outline_bounds(bounds, 2.0f); + bongo_cat_preferences_model_card_draw_progress(canvas, outline, + 13.0f, 2.0f, progress, p.pink); + value->render_dirty = true; + } float cx = bounds.x + bounds.w * .5f, cy = bounds.y + 91; bongo_cat_preferences_icon_draw(value, canvas, BONGO_CAT_UI_ICON_UPLOAD, nk_rect(cx - 20, cy - 20, 40, 40), p.pink); @@ -77,6 +90,8 @@ void bongo_cat_preferences_model_select(BongoCatPreferences *value, return; snprintf(value->pending_model_id, sizeof(value->pending_model_id), "%s", entry->id); + if (strcmp(entry->id, value->app->session.active_model_id)) + bongo_cat_preferences_model_visual_begin(value, entry->id); value->model_selection_pending = true; value->model_load_progress = 0.0f; bongo_cat_preferences_invalidate(value); @@ -160,12 +175,9 @@ void bongo_cat_preferences_model_card(BongoCatPreferences *value, struct nk_context *context, const BongoCatModelEntry *entry) { BongoCatApp *app = value->app; bool selected = !strcmp(entry->id, app->session.active_model_id); - bool transitioning = value->model_selection_pending || value->model_loading; - const char *transition_id = value->model_loading ? value->loading_model_id : - value->pending_model_id; - bool transition_target = transitioning && transition_id[0] && - !strcmp(entry->id, transition_id); - bool selected_visual = selected && !transitioning; + bool visual_target = value->model_load_visual_active && + !strcmp(entry->id, value->model_load_visual_id); + bool selected_visual = selected && !visual_target; struct nk_rect bounds; if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return; bounds.y += 5.0f; bounds.h += 1.0f; @@ -202,13 +214,14 @@ void bongo_cat_preferences_model_card(BongoCatPreferences *value, bool name_hover = bongo_cat_preferences_model_name_draw(value, context, canvas, entry, name_bounds, p); bool action_hover = false; - draw_actions(value, context, canvas, bounds, entry, p, selected_visual, + draw_actions(value, context, canvas, bounds, entry, p, selected, &action_hover); float outline_width = 1.0f + selection_amount; struct nk_rect outline = bongo_cat_preferences_model_card_outline_bounds( bounds, outline_width); - float progress = selected_visual ? 1.0f : transition_target ? - value->model_load_progress : 0.0f; + float progress = visual_target ? + bongo_cat_preferences_model_visual_progress(value, entry->id) : + selected_visual ? 1.0f : 0.0f; struct nk_color outline_color = progress >= .999f ? p.pink : bongo_cat_ui_color_mix( bongo_cat_ui_color_mix(p.border_subtle, p.accent, lift), diff --git a/src/ui/preferences/preferences_render.c b/src/ui/preferences/preferences_render.c index 8b6cab56..56d1063b 100644 --- a/src/ui/preferences/preferences_render.c +++ b/src/ui/preferences/preferences_render.c @@ -9,14 +9,8 @@ #include #include #include -typedef struct RootStyle { - struct nk_vec2 padding; - struct nk_vec2 group_padding; - struct nk_vec2 spacing; - struct nk_style_item fixed; - struct nk_color background; - float group_border; -} RootStyle; +typedef struct RootStyle { struct nk_vec2 padding, group_padding, spacing; + struct nk_style_item fixed; struct nk_color background; float group_border; } RootStyle; static const char *tr(const BongoCatPreferences *value, const char *key, const char *fallback) { return bongo_cat_i18n_get(value->app->i18n, key, fallback); @@ -248,11 +242,15 @@ void bongo_cat_preferences_render(BongoCatPreferences *value) { } value->render_retry_ns = 0; bongo_cat_preferences_refresh_raster(value); bongo_cat_preferences_reload_language(value); - if (value->font_reload_pending && - !bongo_cat_preferences_reload_fonts(value)) { - value->font_reload_pending = false; - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, - "Deferred preferences font atlas rebuild failed"); + if (value->font_reload_pending) { + if (value->font_reload_defer_once) { + value->font_reload_defer_once = false; + value->render_dirty = true; + } else if (!bongo_cat_preferences_reload_fonts(value)) { + value->font_reload_pending = false; + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "Deferred preferences font atlas rebuild failed"); + } } bongo_cat_preferences_apply_theme(value); float width = 0.0f, height = 0.0f; @@ -280,8 +278,10 @@ void bongo_cat_preferences_render(BongoCatPreferences *value) { "Preferences frame presentation failed: %s", SDL_GetError()); } else bongo_cat_memory_policy_ui_frame_presented(); bongo_cat_preferences_record_frame(value); - if (value->shortcut_recording || bongo_cat_pref_controls_animating( - &value->ui.context) || bongo_cat_ui_animations_active(&value->ui.context)) + bool importing = bongo_cat_preferences_import_status(value->import_dialog, NULL, NULL, NULL); + if (value->shortcut_recording || value->model_load_visual_active || importing || + bongo_cat_pref_controls_animating(&value->ui.context) || + bongo_cat_ui_animations_active(&value->ui.context)) value->render_dirty = true; else if (!value->chrome_dragging && !value->live_resize_active) bongo_cat_ui_trim_idle(&value->ui); diff --git a/src/ui/preferences/preferences_state.h b/src/ui/preferences/preferences_state.h index 60cb8aa7..d3b0403c 100644 --- a/src/ui/preferences/preferences_state.h +++ b/src/ui/preferences/preferences_state.h @@ -5,6 +5,11 @@ #include "preferences_scrollbar.h" #include "preferences_text_session.h" #include "ui_backend.h" + +#define BONGO_CAT_MODEL_LOAD_VISUAL_DURATION_NS 5000000000ull +#define BONGO_CAT_MODEL_LOAD_VISUAL_RAMP_NS 3000000000ull +#define BONGO_CAT_MODEL_LOAD_VISUAL_COMPLETE_NS 160000000ull +#define BONGO_CAT_MODEL_LOAD_VISUAL_WAIT_CAP 0.95f #include "bongo_cat/i18n.h" #include "bongo_cat/preferences.h" #include "modal_frame.h" @@ -45,14 +50,19 @@ struct BongoCatPreferences { bool frame_checked; bool render_dirty; bool font_reload_pending; + bool font_reload_defer_once; bool smoke_behavior_open_pending; bool model_selection_pending; bool model_loading; + bool model_load_visual_active; float model_load_progress; float model_load_render_progress; uint64_t model_load_render_ns; + uint64_t model_load_visual_started_ns; + uint64_t model_load_visual_completion_ns; char pending_model_id[BONGO_CAT_ID_CAP]; char loading_model_id[BONGO_CAT_ID_CAP]; + char model_load_visual_id[BONGO_CAT_ID_CAP]; uint64_t last_render_ns; float pending_raster_scale; uint64_t raster_retry_ns; @@ -112,8 +122,12 @@ void bongo_cat_preferences_assets_load(BongoCatPreferences *value); void bongo_cat_preferences_support_assets_load(BongoCatPreferences *value); void bongo_cat_preferences_support_assets_clear(BongoCatPreferences *value); void bongo_cat_preferences_process_model_selection(BongoCatPreferences *value); +void bongo_cat_preferences_model_visual_begin(BongoCatPreferences *value, + const char *model_id); void bongo_cat_preferences_model_load_progress(BongoCatPreferences *value, float progress); +float bongo_cat_preferences_model_visual_progress(BongoCatPreferences *value, + const char *model_id); void bongo_cat_preferences_assets_clear(BongoCatPreferences *value); void bongo_cat_preferences_model_cover_cache_clear(BongoCatApp *app); void bongo_cat_preferences_page_cache_clear(BongoCatPreferences *value, diff --git a/tests/model_import/test_model_import_identity.c b/tests/model_import/test_model_import_identity.c index d44a4d4c..a3482c51 100644 --- a/tests/model_import/test_model_import_identity.c +++ b/tests/model_import/test_model_import_identity.c @@ -22,7 +22,7 @@ int test_model_import_identity(void) { if (!temporary) return failures; char root[BONGO_CAT_PATH_CAP], data[BONGO_CAT_PATH_CAP]; char source[BONGO_CAT_PATH_CAP], config[BONGO_CAT_PATH_CAP]; - char manifest[BONGO_CAT_PATH_CAP]; + char manifest[BONGO_CAT_PATH_CAP], missing[BONGO_CAT_PATH_CAP]; unsigned long long nonce = (unsigned long long)SDL_GetTicksNS(); snprintf(root, sizeof(root), "%s/bongo-cat-import-identity-%llu", temporary, nonce); @@ -35,6 +35,7 @@ int test_model_import_identity(void) { CHECK(child(config, sizeof(config), source, "config.json", false)); CHECK(child(manifest, sizeof(manifest), source, "img/standard/cat_model/cat.model3.json", false)); + CHECK(child(missing, sizeof(missing), root, "missing", false)); BongoCatError error = {0}; BongoCatImportDiscovery discovered = {0}; CHECK(bongo_cat_import_mver_discover(source, &discovered, &error) == 1); @@ -42,14 +43,25 @@ int test_model_import_identity(void) { bool placeholder = true; CHECK(bongo_cat_import_candidate_inspect(&discovered.candidates[0], digest, &placeholder, &error) && !placeholder); - BongoCatImportReceipt first = {0}, repeated = {0}; - CHECK(bongo_cat_import_install(config, data, &first, &error) == - BONGO_CAT_OK); + BongoCatImportReceipt first = {0}, repeated = {0}, wrapped = {0}; + BongoCatImportSession *session = bongo_cat_import_session_create(data, + &error); + CHECK(session != NULL); + CHECK(session && bongo_cat_import_session_install(session, config, &first, + &error) == BONGO_CAT_OK); CHECK(first.count == 1 && first.installed_count == 1 && first.installed[0]); - CHECK(bongo_cat_import_install(manifest, data, &repeated, &error) == - BONGO_CAT_OK); + CHECK(session && bongo_cat_import_session_install(session, missing, NULL, + &error) != BONGO_CAT_OK); + error = (BongoCatError){0}; + CHECK(session && bongo_cat_import_session_install(session, manifest, + &repeated, &error) == BONGO_CAT_OK); CHECK(repeated.count == 1 && repeated.installed_count == 0 && !repeated.installed[0] && strcmp(first.ids[0], repeated.ids[0]) == 0); + bongo_cat_import_session_destroy(session); + CHECK(bongo_cat_import_install(manifest, data, &wrapped, &error) == + BONGO_CAT_OK); + CHECK(wrapped.count == 1 && wrapped.installed_count == 0 && + strcmp(first.ids[0], wrapped.ids[0]) == 0); char models_root[BONGO_CAT_PATH_CAP], adapter_metadata[BONGO_CAT_PATH_CAP]; CHECK(child(models_root, sizeof(models_root), data, "models", false)); BongoCatModelCatalog *installed = calloc(1, sizeof(*installed)); diff --git a/tests/model_import/test_mver_import.c b/tests/model_import/test_mver_import.c index 2a36cf08..4626f851 100644 --- a/tests/model_import/test_mver_import.c +++ b/tests/model_import/test_mver_import.c @@ -162,6 +162,34 @@ static void font_reload_defers_during_frame(void) { NULL, NULL, NULL, NULL, NULL, 1.0f)); } +static void model_visual_expires_without_window(void) { + BongoCatPreferences value = {0}; + value.model_load_visual_active = true; + value.model_load_visual_started_ns = SDL_GetTicksNS() - + BONGO_CAT_MODEL_LOAD_VISUAL_DURATION_NS; + CHECK(!bongo_cat_preferences_needs_frame(&value)); + CHECK(!value.model_load_visual_active && value.model_load_progress == 1.0f); +} + +static void model_visual_curve(void) { + BongoCatPreferences value = {0}; + bongo_cat_preferences_model_visual_begin(&value, "curve"); + value.model_loading = true; + uint64_t now = SDL_GetTicksNS(); + value.model_load_visual_started_ns = now - + BONGO_CAT_MODEL_LOAD_VISUAL_RAMP_NS; + float ramp = bongo_cat_preferences_model_visual_progress(&value, "curve"); + CHECK(ramp > .79f && ramp < .81f); + value.model_load_visual_started_ns = now - 4000000000ull; + float waiting = bongo_cat_preferences_model_visual_progress(&value, "curve"); + CHECK(waiting > .86f && waiting < .89f); + value.model_loading = false; + value.model_load_visual_completion_ns = SDL_GetTicksNS() - + BONGO_CAT_MODEL_LOAD_VISUAL_COMPLETE_NS; + float complete = bongo_cat_preferences_model_visual_progress(&value, "curve"); + CHECK(complete == 1.0f && !value.model_load_visual_active); +} + static void container_discovery(void) { char root[BONGO_CAT_PATH_CAP], package[BONGO_CAT_PATH_CAP]; char mode[BONGO_CAT_PATH_CAP]; @@ -263,6 +291,8 @@ int main(void) { metadata_backfills_labels(); behavior_labels_add_font_glyphs(); font_reload_defers_during_frame(); + model_visual_expires_without_window(); + model_visual_curve(); failures += test_mver_nearby_identity(); failures += test_model_import_identity(); container_discovery(); From 94505829dfd23ae0276ecf1c182dcf48f8d13d19 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 18 Aug 2026 12:32:18 +0800 Subject: [PATCH 035/334] fix(ui): smooth preferences scrolling --- src/ui/preferences/preferences_render.c | 32 ++++++++++++------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/ui/preferences/preferences_render.c b/src/ui/preferences/preferences_render.c index 56d1063b..d252a7a5 100644 --- a/src/ui/preferences/preferences_render.c +++ b/src/ui/preferences/preferences_render.c @@ -129,13 +129,10 @@ static bool draw_shell(BongoCatPreferences *value, struct nk_context *context, 16 + 6.0f * (1.0f - page_progress)); context->style.window.spacing = nk_vec2(10, 10); int scroll_page = NK_CLAMP(0, value->page, 4); - bool scroll_animating = fabsf(value->scroll_current[scroll_page] - - value->scroll_target[scroll_page]) > .5f; + bool scroll_animating = fabsf(value->scroll_current[scroll_page] - value->scroll_target[scroll_page]) > .5f; struct nk_style_scrollbar saved_scrollv = context->style.scrollv; - struct nk_rect scrollbar_hit = nk_rect(body_bounds.x + body_bounds.w - 14, - body_bounds.y, 14, body_bounds.h); - bool scrollbar_hover = !modal && nk_input_is_mouse_hovering_rect( - &context->input, scrollbar_hit); + struct nk_rect scrollbar_hit = nk_rect(body_bounds.x + body_bounds.w - 14, body_bounds.y, 14, body_bounds.h); + bool scrollbar_hover = !modal && nk_input_is_mouse_hovering_rect(&context->input, scrollbar_hit); context->style.scrollv.padding.x = scrollbar_hover ? 0.0f : 2.0f; context->style.scrollv.cursor_hover = nk_style_item_color(p.accent); context->style.scrollv.cursor_active = nk_style_item_color(p.accent); @@ -155,10 +152,9 @@ static bool draw_shell(BongoCatPreferences *value, struct nk_context *context, draw_page(value, context); float wheel = context->input.mouse.scroll_delta.y; struct nk_panel *layout = context->current->layout; - float maximum = layout ? NK_MAX(0.0f, - layout->at_y + layout->row.height - layout->bounds.y - layout->bounds.h) : 0.0f; - if (wheel != 0.0f) - context->style.scrollv.cursor_normal = nk_style_item_color(p.accent); + float maximum = layout ? NK_MAX(0.0f, layout->at_y + layout->row.height - layout->bounds.y - layout->bounds.h) : 0.0f; + if (wheel != 0.0f) context->style.scrollv.cursor_normal = + nk_style_item_color(p.accent); context->input.mouse.scroll_delta.y = 0; nk_group_end(context); context->style.scrollv = saved_scrollv; @@ -170,17 +166,18 @@ static bool draw_shell(BongoCatPreferences *value, struct nk_context *context, value->scroll_current[scroll_page] = actual; value->scroll_target[scroll_page] = actual; } - value->scroll_target[scroll_page] = NK_CLAMP(0.0f, - value->scroll_target[scroll_page], maximum); + value->scroll_target[scroll_page] = NK_CLAMP(0.0f, value->scroll_target[scroll_page], maximum); if (wheel != 0.0f) value->scroll_target[scroll_page] = NK_CLAMP(0.0f, - actual - wheel * 72.0f, maximum); + value->scroll_target[scroll_page] - wheel * 72.0f, maximum); else if (fabsf(actual - value->scroll_current[scroll_page]) > 2.0f) value->scroll_target[scroll_page] = NK_CLAMP(0.0f, actual, maximum); - float next = actual + (value->scroll_target[scroll_page] - actual) * .24f; - if (fabsf(next - value->scroll_target[scroll_page]) < .5f) - next = value->scroll_target[scroll_page]; + float blend = 1.0f - expf(-context->delta_time_seconds / .035f); + float next = actual + + (value->scroll_target[scroll_page] - actual) * blend; + if (fabsf(next - value->scroll_target[scroll_page]) < .5f) next = + value->scroll_target[scroll_page]; value->scroll_current[scroll_page] = NK_CLAMP(0.0f, next, maximum); bool still_animating = fabsf(value->scroll_current[scroll_page] - value->scroll_target[scroll_page]) > .5f; @@ -231,6 +228,9 @@ void bongo_cat_preferences_render(BongoCatPreferences *value) { if (value->render_retry_ns > now || (!value->render_dirty && value->last_render_ns && !raster_due)) return; value->render_dirty = false; + value->ui.context.delta_time_seconds = value->last_render_ns ? NK_CLAMP( + .001f, (float)(now - value->last_render_ns) / 1000000000.0f, 1.0f / 60.0f) : + 1.0f / 60.0f; value->last_render_ns = now; bongo_cat_preferences_input_end(value); if (!SDL_GL_MakeCurrent(value->window, value->gl_context)) { From c9591076f4228c13633d1814a4cfd68d57a32b27 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 18 Aug 2026 14:01:54 +0800 Subject: [PATCH 036/334] Improve desktop pet anti-aliasing --- cmake/CubismShaderOptimize.cmake | 9 +++-- cmake/RuntimeSources.cmake | 1 + docs/parity.md | 11 +++-- src/media/image.c | 14 +++++-- src/media/image_internal.h | 1 + src/media/image_mipmap.c | 69 ++++++++++++++++++++++++++++++++ src/runtime/shell/window.c | 15 +++---- 7 files changed, 100 insertions(+), 20 deletions(-) create mode 100644 src/media/image_mipmap.c diff --git a/cmake/CubismShaderOptimize.cmake b/cmake/CubismShaderOptimize.cmake index 3e6c6311..3d2f92ce 100644 --- a/cmake/CubismShaderOptimize.cmake +++ b/cmake/CubismShaderOptimize.cmake @@ -36,10 +36,13 @@ function(bongo_cat_optimize_cubism_shaders target) set(output_source "${output_dir}/CubismShader_OpenGLES2.cpp") file(READ "${source_path}" source) string(REPLACE "\r\n" "\n" source "${source}") - # Match Pixi's Live2D texture defaults and avoid an unnecessary mip chain. + # The uploader selects trilinear filtering after building a complete, + # alpha-safe mip chain and retains linear filtering if that work fails. + # Do not overwrite that per-texture decision on every drawable. bongo_cat_replace_cubism_text(source - "GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR" - "GL_TEXTURE_MIN_FILTER, GL_LINEAR" "linear model texture sampling") + "glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);" + "/* Model upload owns minification filtering and fallback. */" + "model texture minification ownership") set(compile_anchor [=[ _shaderSets[ShaderNames_MultMaskedInvertedPremultipliedAlpha]->ShaderProgram = _shaderSets[ShaderNames_NormalMaskedInvertedPremultipliedAlpha]->ShaderProgram;]=]) diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 2b98957e..c45836ea 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -16,6 +16,7 @@ set(BONGO_CAT_MEDIA_SOURCES src/media/image.c src/media/image_decode.c src/media/image_info.c + src/media/image_mipmap.c src/media/image_resize.c src/media/image_alpha.c src/media/stb_image_impl.c) diff --git a/docs/parity.md b/docs/parity.md index b46b8dcb..6c39b3d3 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -143,9 +143,8 @@ command-line arguments and trace formats. ## Live2D texture quality -Live2D atlases retain their authored pixel dimensions for both bundled and -imported models. The loader only downsamples an atlas when it exceeds the -active GPU's `GL_MAX_TEXTURE_SIZE`, or retries at 2048 pixels after an explicit -`GL_OUT_OF_MEMORY` upload failure. Model textures use the same linear, -non-mipmapped sampling as Mver so changing the pet window size does not switch -filtering behavior or silently select a lower-resolution source. +Live2D atlases retain their authored pixel dimensions unless the Windows +working-size cap or the active GPU's `GL_MAX_TEXTURE_SIZE` requires a smaller +decode. Model textures use alpha-aware CPU-generated mipmaps with trilinear +sampling, while bounded anisotropic filtering remains enabled when supported. +This keeps minified edges stable without transparent-pixel color bleeding. diff --git a/src/media/image.c b/src/media/image.c index 6818ca82..c1cbc4c8 100644 --- a/src/media/image.c +++ b/src/media/image.c @@ -36,9 +36,8 @@ static unsigned int upload(const BongoCatImage *image, GLuint texture, if (created) glGenTextures(1, &texture); glBindTexture(GL_TEXTURE_2D, texture); if (created) { - /* Pixi's default Live2D texture source uses linear filtering without - * generated mipmaps. This keeps the same sharpness and saves texture - * memory for large model atlases. */ + /* Keep direct image/UI textures on the inexpensive linear path. Model + * textures switch to alpha-safe trilinear filtering after upload. */ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); @@ -53,7 +52,14 @@ static unsigned int upload(const BongoCatImage *image, GLuint texture, } } glPixelStorei(GL_UNPACK_ALIGNMENT, 1); - if (created) { + if (created && model_texture) { + if (!bongo_cat_image_upload_mipmaps(image)) { + bongo_cat_gl_clear_errors(); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, image->width, + image->height, 0, GL_RGBA, GL_UNSIGNED_BYTE, image->pixels); + } + } else if (created) { glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, image->width, image->height, 0, GL_RGBA, GL_UNSIGNED_BYTE, image->pixels); } else { diff --git a/src/media/image_internal.h b/src/media/image_internal.h index 38b69ccf..ec45061c 100644 --- a/src/media/image_internal.h +++ b/src/media/image_internal.h @@ -11,6 +11,7 @@ BongoCatResult bongo_cat_image_decode_pixels_responsive(const char *path, void bongo_cat_image_make_alpha_mask_progress(const BongoCatImage *image, BongoCatImageAlphaMask *mask, BongoCatImageProgress progress, void *userdata); +bool bongo_cat_image_upload_mipmaps(const BongoCatImage *image); #ifdef _WIN32 bool bongo_cat_image_needs_wic_scaling(const char *path, int limit); diff --git a/src/media/image_mipmap.c b/src/media/image_mipmap.c new file mode 100644 index 00000000..96f2c06d --- /dev/null +++ b/src/media/image_mipmap.c @@ -0,0 +1,69 @@ +#include "image_internal.h" + +#include +#include +#include +#include + +static unsigned char *next_level(const unsigned char *source, + int source_width, int source_height, int *width, int *height) { + *width = source_width > 1 ? source_width / 2 : 1; + *height = source_height > 1 ? source_height / 2 : 1; + size_t count = (size_t)*width * (size_t)*height; + unsigned char *target = count <= SIZE_MAX / 4 ? malloc(count * 4) : NULL; + if (!target) return NULL; + for (int y = 0; y < *height; ++y) for (int x = 0; x < *width; ++x) { + int sx = x * 2, sy = y * 2; + int sx1 = SDL_min(sx + 1, source_width - 1); + int sy1 = SDL_min(sy + 1, source_height - 1); + const unsigned char *samples[] = { + source + ((size_t)sy * source_width + sx) * 4, + source + ((size_t)sy * source_width + sx1) * 4, + source + ((size_t)sy1 * source_width + sx) * 4, + source + ((size_t)sy1 * source_width + sx1) * 4}; + unsigned alpha = 0, red = 0, green = 0, blue = 0; + for (size_t i = 0; i < 4; ++i) { + unsigned sample_alpha = samples[i][3]; + alpha += sample_alpha; + red += samples[i][0] * sample_alpha; + green += samples[i][1] * sample_alpha; + blue += samples[i][2] * sample_alpha; + } + unsigned char *pixel = target + ((size_t)y * *width + x) * 4; + pixel[3] = (unsigned char)((alpha + 2) / 4); + if (alpha) { + pixel[0] = (unsigned char)((red + alpha / 2) / alpha); + pixel[1] = (unsigned char)((green + alpha / 2) / alpha); + pixel[2] = (unsigned char)((blue + alpha / 2) / alpha); + } else pixel[0] = pixel[1] = pixel[2] = 0; + } + return target; +} + +bool bongo_cat_image_upload_mipmaps(const BongoCatImage *image) { + if (!image || !image->pixels || image->width < 1 || image->height < 1) + return false; + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, image->width, image->height, + 0, GL_RGBA, GL_UNSIGNED_BYTE, image->pixels); + if (glGetError() != GL_NO_ERROR) return false; + const unsigned char *source = image->pixels; + unsigned char *owned = NULL; + int source_width = image->width, source_height = image->height, level = 1; + while (source_width > 1 || source_height > 1) { + int width, height; + unsigned char *target = next_level(source, source_width, source_height, + &width, &height); + if (!target) { free(owned); return false; } + glTexImage2D(GL_TEXTURE_2D, level++, GL_RGBA8, width, height, 0, + GL_RGBA, GL_UNSIGNED_BYTE, target); + if (glGetError() != GL_NO_ERROR) { + free(target); free(owned); return false; + } + free(owned); + owned = target; + source = target; source_width = width; source_height = height; + } + free(owned); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); + return glGetError() == GL_NO_ERROR; +} diff --git a/src/runtime/shell/window.c b/src/runtime/shell/window.c index 48dfe686..4dfa5cfd 100644 --- a/src/runtime/shell/window.c +++ b/src/runtime/shell/window.c @@ -2,7 +2,7 @@ #include #include -static bool set_gl_attributes(bool multisampling) { +static bool set_gl_attributes(int samples) { SDL_GL_ResetAttributes(); #ifdef __APPLE__ const int major = 4, minor = 1, profile = SDL_GL_CONTEXT_PROFILE_CORE; @@ -17,13 +17,13 @@ static bool set_gl_attributes(bool multisampling) { SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1) && SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 0) && SDL_GL_SetAttribute(SDL_GL_ALPHA_SIZE, 8) && - SDL_GL_SetAttribute(SDL_GL_MULTISAMPLEBUFFERS, multisampling ? 1 : 0) && - SDL_GL_SetAttribute(SDL_GL_MULTISAMPLESAMPLES, multisampling ? 4 : 0); + SDL_GL_SetAttribute(SDL_GL_MULTISAMPLEBUFFERS, samples > 0 ? 1 : 0) && + SDL_GL_SetAttribute(SDL_GL_MULTISAMPLESAMPLES, samples); } -static bool try_window(BongoCatApp *app, bool transparent, bool multisampling, +static bool try_window(BongoCatApp *app, bool transparent, int samples, char *failure, size_t capacity) { - if (!set_gl_attributes(multisampling)) { + if (!set_gl_attributes(samples)) { snprintf(failure, capacity, "OpenGL attributes: %s", SDL_GetError()); return false; } SDL_WindowFlags flags = SDL_WINDOW_OPENGL | SDL_WINDOW_BORDERLESS | @@ -52,11 +52,12 @@ BongoCatResult bongo_cat_window_create(BongoCatApp *app, BongoCatError *error) { "SDL initialization failed: %s", SDL_GetError()); return BONGO_CAT_ERROR_PLATFORM; } - const bool options[][2] = {{true, true}, {true, false}, {false, false}}; + /* Keep a lower-cost MSAA path for drivers that cannot provide 4 samples. */ + const int options[][2] = {{true, 4}, {true, 2}, {true, 0}, {false, 0}}; char failure[256] = {0}; bool force_fallback = SDL_getenv("BONGO_CAT_TEST_GL_FALLBACK") != NULL; for (size_t i = 0; i < sizeof(options) / sizeof(options[0]); ++i) { - if (force_fallback && i == 0) { + if (force_fallback && options[i][1] > 0) { SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, "Test requested OpenGL fallback"); continue; } if (try_window(app, options[i][0], options[i][1], failure, sizeof(failure))) { From 54137047d59da43d2c0825c5acc660da689b30f7 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 18 Aug 2026 16:13:46 +0800 Subject: [PATCH 037/334] fix: improve centered mouse tracking --- docs/parity.md | 13 +++++--- include/bongo_cat/model.h | 2 ++ include/bongo_cat/mouse.h | 2 ++ src/core/mouse_tracking.c | 21 ++++++++++++ src/live2d/cubism_bridge.cpp | 3 ++ src/live2d/cubism_model.hpp | 2 +- src/live2d/cubism_model_update.cpp | 8 +++-- src/live2d/cubism_viewer_look.cpp | 25 ++++++++++----- src/live2d/cubism_viewer_look.hpp | 8 +++-- src/live2d/live2d_stub.c | 2 ++ src/runtime/diagnostics/live2d_audit.c | 17 +++++----- src/runtime/diagnostics/live2d_viewer_audit.c | 32 +++++++++---------- src/runtime/input/mouse_mapping.c | 19 ++++++++--- tests/core/test_input.c | 5 +++ 14 files changed, 111 insertions(+), 48 deletions(-) diff --git a/docs/parity.md b/docs/parity.md index 6c39b3d3..ae23a077 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -77,13 +77,18 @@ Desktop tracking is model-centered by default, so look direction changes only when the pointer crosses the pet's visible center. Users can disable the setting to retain the legacy screen/work-area-centered coordinate mapping. In model-centered mode, the authored hand and device range maps absolutely to -the full display containing the pet; legacy mode retains Mver relative input. +the full display containing the pet, while gaze maps each side of the pet +center independently to the corresponding display edge. This keeps the pet +center neutral and preserves the full authored look range near display edges. +Legacy mode retains Mver relative input. The display is selected from the pet's visible center. Negative-coordinate, stacked, and mixed-DPI layouts retain desktop coordinates, and display mode, topology, or scale changes invalidate and immediately resample the mapping. -`ParamAngleZ` is not synthesized by the Viewer-equivalent path. Models that -author `ParamMouseX` or `ParamMouseY` receive the normalized pointer domain in -addition to Cubism dragging; missing parameters remain a no-op. +In model-centered mode, `ParamAngleZ` receives the Mver-compatible `-X * Y` +pointer tilt when present, scaled to its authored positive or negative extent. +Legacy mode does not synthesize Z. Models that author `ParamMouseX` or +`ParamMouseY` receive the normalized pointer domain in addition to Cubism +dragging; missing parameters remain a no-op. Mver 0.1.6 remains the compatibility baseline for package discovery, imported files, configuration, shortcuts, window composition, and projection only. A diff --git a/include/bongo_cat/model.h b/include/bongo_cat/model.h index 898dd4a9..0f59bcc3 100644 --- a/include/bongo_cat/model.h +++ b/include/bongo_cat/model.h @@ -136,6 +136,8 @@ void bongo_cat_live2d_set_mirror(BongoCatLive2D *live2d, bool mirror); void bongo_cat_live2d_set_render_options(BongoCatLive2D *live2d, const BongoCatLive2DRenderOptions *options); void bongo_cat_live2d_set_dragging(BongoCatLive2D *live2d, float x, float y); +void bongo_cat_live2d_set_centered_dragging(BongoCatLive2D *live2d, + float x, float y); void bongo_cat_live2d_prepare_viewer_audit(BongoCatLive2D *live2d); bool bongo_cat_live2d_set_parameter(BongoCatLive2D *live2d, const char *id, float value); bool bongo_cat_live2d_parameter(BongoCatLive2D *live2d, const char *id, diff --git a/include/bongo_cat/mouse.h b/include/bongo_cat/mouse.h index f8646bcd..aed9cd2f 100644 --- a/include/bongo_cat/mouse.h +++ b/include/bongo_cat/mouse.h @@ -15,6 +15,8 @@ typedef struct BongoCatMouseTracking { void bongo_cat_mouse_target(BongoCatMouseTracking *tracking, double x, double y); bool bongo_cat_mouse_step(BongoCatMouseTracking *tracking, float delta_seconds, double *x, double *y); +float bongo_cat_mouse_centered_ratio(double position, double center, + double minimum, double maximum); float bongo_cat_mouse_parameter_value(float minimum, float maximum, float x_ratio, float y_ratio, char axis, bool mirror); diff --git a/src/core/mouse_tracking.c b/src/core/mouse_tracking.c index ed68f331..a1668df4 100644 --- a/src/core/mouse_tracking.c +++ b/src/core/mouse_tracking.c @@ -36,6 +36,27 @@ bool bongo_cat_mouse_step(BongoCatMouseTracking *tracking, float delta_seconds, return true; } +float bongo_cat_mouse_centered_ratio(double position, double center, + double minimum, double maximum) { + if (!isfinite(position) || !isfinite(center) || !isfinite(minimum) || + !isfinite(maximum) || maximum <= minimum) return 0.5f; + if (center < minimum) center = minimum; + if (center > maximum) center = maximum; + if (position < center) { + double span = center - minimum; + if (span <= 0.0) return 0.5f; + double ratio = 0.5 * (position - minimum) / span; + return (float)(ratio < 0.0 ? 0.0 : ratio); + } + if (position > center) { + double span = maximum - center; + if (span <= 0.0) return 0.5f; + double ratio = 0.5 + 0.5 * (position - center) / span; + return (float)(ratio > 1.0 ? 1.0 : ratio); + } + return 0.5f; +} + float bongo_cat_mouse_parameter_value(float minimum, float maximum, float x_ratio, float y_ratio, char axis, bool mirror) { float value; diff --git a/src/live2d/cubism_bridge.cpp b/src/live2d/cubism_bridge.cpp index 8504be31..20d2df7c 100644 --- a/src/live2d/cubism_bridge.cpp +++ b/src/live2d/cubism_bridge.cpp @@ -221,6 +221,9 @@ extern "C" void bongo_cat_live2d_set_render_options(BongoCatLive2D *runtime, extern "C" void bongo_cat_live2d_set_dragging(BongoCatLive2D *runtime, float x, float y) { if (runtime && runtime->model) runtime->model->set_dragging(x, y); } +extern "C" void bongo_cat_live2d_set_centered_dragging(BongoCatLive2D *runtime, + float x, float y) { + if (runtime && runtime->model) runtime->model->set_dragging(x, y, true); } extern "C" void bongo_cat_live2d_prepare_viewer_audit(BongoCatLive2D *runtime) { if (runtime && runtime->model) runtime->model->prepare_viewer_audit(); } diff --git a/src/live2d/cubism_model.hpp b/src/live2d/cubism_model.hpp index 370c6b83..40067c06 100644 --- a/src/live2d/cubism_model.hpp +++ b/src/live2d/cubism_model.hpp @@ -37,7 +37,7 @@ class NativeModel final : public Csm::CubismUserModel { void draw(); void set_mirror(bool mirror); void set_render_options(const BongoCatLive2DRenderOptions &options); - void set_dragging(float x, float y); + void set_dragging(float x, float y, bool angle_z = false); void prepare_viewer_audit(); bool set_parameter(const char *id, float value); bool parameter(const char *id, float *minimum, float *maximum, float *value); diff --git a/src/live2d/cubism_model_update.cpp b/src/live2d/cubism_model_update.cpp index cebad066..c5cb3a9c 100644 --- a/src/live2d/cubism_model_update.cpp +++ b/src/live2d/cubism_model_update.cpp @@ -123,11 +123,11 @@ void NativeModel::set_render_options(const BongoCatLive2DRenderOptions &options) render_options_ = options; } -void NativeModel::set_dragging(float x, float y) { +void NativeModel::set_dragging(float x, float y, bool angle_z) { if (!viewer_look_) return; x = std::max(-1.0f, std::min(1.0f, x)); y = std::max(-1.0f, std::min(1.0f, y)); - viewer_look_->set_target(x, y); + viewer_look_->set_target(x, y, angle_z); external_parameters_dirty_ = true; } @@ -136,8 +136,10 @@ void NativeModel::prepare_viewer_audit() { suppress_eye_blink_ = true; _motionManager->StopAllMotions(); clear_motion_runs(); - for (int i = 0; i < _model->GetParameterCount(); ++i) + for (int i = 0; i < _model->GetParameterCount(); ++i) { _model->SetParameterValue(i, _model->GetParameterDefaultValue(i)); + pending_parameters_[(size_t)i] = 0; + } _model->SaveParameters(); } diff --git a/src/live2d/cubism_viewer_look.cpp b/src/live2d/cubism_viewer_look.cpp index 95c24d14..0f65aee2 100644 --- a/src/live2d/cubism_viewer_look.cpp +++ b/src/live2d/cubism_viewer_look.cpp @@ -20,6 +20,7 @@ ViewerLookUpdater::ViewerLookUpdater(Csm::CubismModel &model) : Csm::ICubismUpdater(Csm::CubismUpdateOrder_Look) { add_parameter(model, "ParamAngleX", Axis::X); add_parameter(model, "ParamAngleY", Axis::Y); + add_parameter(model, "ParamAngleZ", Axis::Z); add_parameter(model, "ParamEyeBallX", Axis::X); add_parameter(model, "ParamEyeBallY", Axis::Y); add_parameter(model, "ParamBodyAngleX", Axis::X); @@ -27,7 +28,7 @@ ViewerLookUpdater::ViewerLookUpdater(Csm::CubismModel &model) void ViewerLookUpdater::add_parameter(Csm::CubismModel &model, const char *id, Axis axis) { - if (parameter_count_ >= 5) return; + if (parameter_count_ >= 6) return; Csm::CubismIdHandle handle = Csm::CubismFramework::GetIdManager()->GetId(id); Csm::csmInt32 index = model.GetParameterIndex(handle); @@ -36,9 +37,10 @@ void ViewerLookUpdater::add_parameter(Csm::CubismModel &model, } void ViewerLookUpdater::set_target(Csm::csmFloat32 x, - Csm::csmFloat32 y) { + Csm::csmFloat32 y, bool angle_z_enabled) { target_x_ = std::max(-1.0f, std::min(1.0f, x)); target_y_ = std::max(-1.0f, std::min(1.0f, y)); + angle_z_enabled_ = angle_z_enabled; } void ViewerLookUpdater::update_target(Csm::csmFloat32 delta_seconds) { @@ -96,15 +98,22 @@ void ViewerLookUpdater::OnLateUpdate(Csm::CubismModel *model, const Csm::csmFloat32 input_y = y(); for (Csm::csmInt32 i = 0; i < parameter_count_; ++i) { const Parameter ¶meter = parameters_[i]; - const Csm::csmFloat32 current = - model->GetParameterValue(parameter.index); - const Csm::csmFloat32 bound = current >= 0.0f + if (parameter.axis == Axis::Z && !angle_z_enabled_) continue; + const Csm::csmFloat32 current = model->GetParameterValue(parameter.index); + Csm::csmFloat32 input = parameter.axis == Axis::X ? input_x : input_y; + Csm::csmFloat32 gain = viewer_input_gain; + Csm::csmFloat32 bound = current >= 0.0f ? model->GetParameterMaximumValue(parameter.index) : model->GetParameterMinimumValue(parameter.index); - const Csm::csmFloat32 input = - parameter.axis == Axis::X ? input_x : input_y; + if (parameter.axis == Axis::Z) { + input = -input_x * input_y; + gain = 1.0f; + bound = input >= 0.0f + ? model->GetParameterMaximumValue(parameter.index) + : model->GetParameterMinimumValue(parameter.index); + } model->AddParameterValue(parameter.index, - input * std::fabs(bound) * viewer_input_gain, parameter.weight); + input * std::fabs(bound) * gain, parameter.weight); } } diff --git a/src/live2d/cubism_viewer_look.hpp b/src/live2d/cubism_viewer_look.hpp index 261f95e0..559c3dd2 100644 --- a/src/live2d/cubism_viewer_look.hpp +++ b/src/live2d/cubism_viewer_look.hpp @@ -8,12 +8,13 @@ namespace bongo_cat { class ViewerLookUpdater final : public Csm::ICubismUpdater { public: explicit ViewerLookUpdater(Csm::CubismModel &model); - void set_target(Csm::csmFloat32 x, Csm::csmFloat32 y); + void set_target(Csm::csmFloat32 x, Csm::csmFloat32 y, + bool angle_z_enabled); void OnLateUpdate(Csm::CubismModel *model, Csm::csmFloat32 delta_seconds) override; private: - enum class Axis { X, Y }; + enum class Axis { X, Y, Z }; struct Parameter { Csm::csmInt32 index; Axis axis; @@ -25,7 +26,7 @@ class ViewerLookUpdater final : public Csm::ICubismUpdater { Csm::csmFloat32 x() const; Csm::csmFloat32 y() const; - Parameter parameters_[5]{}; + Parameter parameters_[6]{}; Csm::csmInt32 parameter_count_ = 0; Csm::csmFloat32 target_x_ = 0.0f; Csm::csmFloat32 target_y_ = 0.0f; @@ -33,6 +34,7 @@ class ViewerLookUpdater final : public Csm::ICubismUpdater { Csm::csmFloat32 face_y_ = 0.0f; Csm::csmFloat32 velocity_x_ = 0.0f; Csm::csmFloat32 velocity_y_ = 0.0f; + bool angle_z_enabled_ = false; bool initialized_ = false; }; diff --git a/src/live2d/live2d_stub.c b/src/live2d/live2d_stub.c index 8f314749..27fe71a3 100644 --- a/src/live2d/live2d_stub.c +++ b/src/live2d/live2d_stub.c @@ -56,6 +56,8 @@ void bongo_cat_live2d_set_render_options(BongoCatLive2D *live2d, void bongo_cat_live2d_set_dragging(BongoCatLive2D *live2d, float x, float y) { (void)live2d; (void)x; (void)y; } +void bongo_cat_live2d_set_centered_dragging(BongoCatLive2D *live2d, + float x, float y) { (void)live2d; (void)x; (void)y; } void bongo_cat_live2d_prepare_viewer_audit(BongoCatLive2D *live2d) { (void)live2d; } bool bongo_cat_live2d_set_parameter(BongoCatLive2D *live2d, const char *id, float value) { (void)live2d; (void)id; (void)value; return false; diff --git a/src/runtime/diagnostics/live2d_audit.c b/src/runtime/diagnostics/live2d_audit.c index a5af384c..516deb0b 100644 --- a/src/runtime/diagnostics/live2d_audit.c +++ b/src/runtime/diagnostics/live2d_audit.c @@ -211,10 +211,15 @@ static bool assertions(BongoCatApp *app, const char *scenario, bool operation) { return active(app, "ParamMouseLeftDown"); if (strcmp(scenario, "mouse-right") == 0) return active(app, "ParamMouseRightDown"); - if (strcmp(scenario, "mouse-move") == 0) + if (strcmp(scenario, "mouse-move") == 0) { + float angle_z = 0.0f; + bool z_valid = !value(app, "ParamAngleZ", &angle_z) || + (app->model_render_options.mver_projection ? angle_z < -0.05f : + angle_z > 0.05f); return signed_value(app, "ParamAngleX", app->model_render_options.mver_projection) && - signed_value(app, "ParamAngleY", true); + signed_value(app, "ParamAngleY", true) && z_valid; + } if (strcmp(scenario, "mouse-move-mirror") == 0) return signed_value(app, "ParamAngleX", true) && signed_value(app, "ParamAngleY", true); @@ -233,13 +238,7 @@ static bool assertions(BongoCatApp *app, const char *scenario, bool operation) { pointer_audit.angle_y[1] > 5.0f && pointer_audit.angle_y[2] < -5.0f && pointer_audit.angle_y[3] < -5.0f; - bool custom = true; - if (!pointer_audit.mver && pointer_audit.has_mouse) { - for (int corner = 0; corner < 4; ++corner) - custom = SDL_fabsf(pointer_audit.mouse_x[corner]) < 0.001f && - SDL_fabsf(pointer_audit.mouse_y[corner]) < 0.001f && custom; - } - return direction && custom && + return direction && pointer_audit.maximum_step < POINTER_VISIBLE_STEP_LIMIT; } if (strcmp(scenario, "gamepad-sticks") == 0) diff --git a/src/runtime/diagnostics/live2d_viewer_audit.c b/src/runtime/diagnostics/live2d_viewer_audit.c index 66b2a780..fe1439bc 100644 --- a/src/runtime/diagnostics/live2d_viewer_audit.c +++ b/src/runtime/diagnostics/live2d_viewer_audit.c @@ -23,7 +23,6 @@ static const char *track_names[] = {"track-001", "track-002", "track-004", "track-008", "track-015", "track-030"}; static const char *return_names[] = {"return-001", "return-002", "return-004", "return-008", "return-015", "return-030"}; - static bool viewer_timing(const char *path, int *track, int *returning) { if (!path || !path[0]) return true; FILE *file = bongo_cat_file_open(path, "rb"); @@ -63,25 +62,21 @@ static bool parameter(BongoCatApp *app, const char *id, float *value) { *value = range.value; return true; } - static bool parameter_range(BongoCatApp *app, const char *id, BongoCatParameterRange *range) { return bongo_cat_live2d_parameter(app->live2d, id, range); } - static float viewer_target(const BongoCatParameterRange *range, float direction, float weight) { float extent = direction < 0.0f ? fabsf(range->minimum) : fabsf(range->maximum); return direction * extent * weight; } - static bool parameter_near(BongoCatApp *app, const char *id, float expected, float tolerance) { float actual = 0.0f; return parameter(app, id, &actual) && fabsf(actual - expected) <= tolerance; } - static bool save_frame(BongoCatApp *app, const char *directory, const char *name, FILE *metrics) { int width = 0, height = 0; @@ -134,7 +129,6 @@ static bool save_frame(BongoCatApp *app, const char *directory, } return saved && required; } - static void render_step(BongoCatApp *app) { int width = 0, height = 0; SDL_GetWindowSizeInPixels(app->window, &width, &height); @@ -147,21 +141,18 @@ static void render_step(BongoCatApp *app) { bongo_cat_live2d_draw(app->live2d); SDL_GL_SwapWindow(app->window); } - static void advance(BongoCatApp *app, int frames) { for (int frame = 0; frame < frames; ++frame) { bongo_cat_live2d_update(app->live2d, 1.0f / 60.0f); render_step(app); } } - static bool save_timed(BongoCatApp *app, const char *directory, const char *name, int frame, FILE *metrics, FILE *timing) { if (timing) fprintf(timing, "%s,%d,%.3f\n", name, frame, frame * (1000.0 / 60.0)); return save_frame(app, directory, name, metrics); } - static bool advance_tracking(BongoCatApp *app, int frames, float *previous_x, float *maximum_step) { for (int frame = 0; frame < frames; ++frame) { @@ -212,15 +203,20 @@ bool bongo_cat_live2d_viewer_audit_run(BongoCatApp *app) { bool passed = bongo_cat_live2d_set_expression(app->live2d, -1); bongo_cat_live2d_set_dragging(app->live2d, 0.0f, 0.0f); advance(app, 120); + const char *expression_id = "Param6"; float expression_before = 0.0f; - bool expression_parameter = parameter(app, "Param6", &expression_before); + bool expression_parameter = parameter(app, expression_id, &expression_before); + if (!expression_parameter) { + expression_id = "Param5"; + expression_parameter = parameter(app, expression_id, &expression_before); + } if (bongo_cat_live2d_set_expression(app->live2d, 2)) { advance(app, 60); passed = save_frame(app, native, "track-000", metrics) && passed; passed = save_frame(app, native, "idle", metrics) && passed; passed = save_frame(app, native, "expression3", metrics) && passed; float expression_after = expression_before; - passed = expression_parameter && parameter(app, "Param6", + passed = expression_parameter && parameter(app, expression_id, &expression_after) && expression_after > expression_before + 0.5f && passed; } else passed = false; @@ -228,13 +224,14 @@ bool bongo_cat_live2d_viewer_audit_run(BongoCatApp *app) { advance(app, 12); const float target_drag_x = 0.497041464f; const float target_drag_y = 0.598930478f; - BongoCatParameterRange angle_x_range, angle_y_range; + BongoCatParameterRange angle_x_range, angle_y_range, angle_z_range; BongoCatParameterRange eye_x_range, eye_y_range, body_x_range; passed = parameter_range(app, "ParamAngleX", &angle_x_range) && parameter_range(app, "ParamAngleY", &angle_y_range) && parameter_range(app, "ParamEyeBallX", &eye_x_range) && parameter_range(app, "ParamEyeBallY", &eye_y_range) && passed; bool has_body_x = parameter_range(app, "ParamBodyAngleX", &body_x_range); + bool has_angle_z = parameter_range(app, "ParamAngleZ", &angle_z_range); const float viewer_gain = 1.2f; const float target_x = viewer_target(&angle_x_range, target_drag_x, viewer_gain); @@ -244,7 +241,9 @@ bool bongo_cat_live2d_viewer_audit_run(BongoCatApp *app) { target_drag_x, viewer_gain); const float target_eye_y = viewer_target(&eye_y_range, target_drag_y, viewer_gain); - bongo_cat_live2d_set_dragging(app->live2d, target_drag_x, target_drag_y); + const float target_z = has_angle_z ? viewer_target(&angle_z_range, + -target_drag_x * target_drag_y, 1.0f) : 0.0f; + bongo_cat_live2d_set_centered_dragging(app->live2d, target_drag_x, target_drag_y); float previous_x = 0.0f, maximum_step = 0.0f; int track_frames[] = {1, 2, 4, 8, 15, 30}; int return_frames[] = {1, 2, 4, 8, 15, 30}; @@ -270,14 +269,14 @@ bool bongo_cat_live2d_viewer_audit_run(BongoCatApp *app) { parameter_near(app, "ParamAngleY", target_y, 0.4f) && parameter_near(app, "ParamEyeBallX", target_eye_x, 0.02f) && parameter_near(app, "ParamEyeBallY", target_eye_y, 0.02f) && passed; + if (has_angle_z) passed = parameter_near(app, "ParamAngleZ", target_z, 0.4f) && passed; if (has_body_x) passed = parameter_near(app, "ParamBodyAngleX", viewer_target(&body_x_range, target_drag_x, viewer_gain), 0.2f) && passed; - bongo_cat_live2d_set_dragging(app->live2d, 0.0f, 0.0f); + bongo_cat_live2d_set_centered_dragging(app->live2d, 0.0f, 0.0f); previous = 0; for (size_t i = 0; i < 6; ++i) { - passed = advance_tracking(app, return_frames[i] - previous, - &previous_x, &maximum_step) && passed; + passed = advance_tracking(app, return_frames[i] - previous, &previous_x, &maximum_step) && passed; passed = save_timed(app, native, return_names[i], return_frames[i], metrics, timing) && passed; previous = return_frames[i]; @@ -289,6 +288,7 @@ bool bongo_cat_live2d_viewer_audit_run(BongoCatApp *app) { parameter_near(app, "ParamEyeBallY", 0.0f, 0.01f) && parameter_near(app, "ParamMouseX", 0.0f, 0.001f) && parameter_near(app, "ParamMouseY", 0.0f, 0.001f) && passed; + if (has_angle_z) passed = parameter_near(app, "ParamAngleZ", 0.0f, 0.25f) && passed; fclose(timing); fclose(metrics); SDL_SetWindowSize(app->window, original_width, original_height); diff --git a/src/runtime/input/mouse_mapping.c b/src/runtime/input/mouse_mapping.c index ce11cd66..169a7d08 100644 --- a/src/runtime/input/mouse_mapping.c +++ b/src/runtime/input/mouse_mapping.c @@ -125,8 +125,10 @@ static bool model_pointer_ratios(BongoCatApp *app, double x, double y, double center_x, center_y; SDL_Rect bounds; if (!x_ratio || !y_ratio || !model_pointer_bounds(app, &bounds, ¢er_x, ¢er_y)) return false; - *x_ratio = clamp_ratio(0.5f + (float)((x - center_x) / bounds.w)); - *y_ratio = clamp_ratio(0.5f + (float)((y - center_y) / bounds.h)); + *x_ratio = bongo_cat_mouse_centered_ratio(x, center_x, bounds.x, + bounds.x + bounds.w); + *y_ratio = bongo_cat_mouse_centered_ratio(y, center_y, bounds.y, + bounds.y + bounds.h); return true; } @@ -163,7 +165,9 @@ void bongo_cat_app_apply_mouse_coordinates(BongoCatApp *app, double hand_x, set_parameter(app, "ParamMouseX", 1.0f - hand_x_ratio, hand_y_ratio); set_parameter(app, "ParamMouseY", hand_x_ratio, hand_y_ratio); } - bongo_cat_live2d_set_dragging(app->live2d, drag_x, drag_y); + if (app->settings.model.mouse_centered) + bongo_cat_live2d_set_centered_dragging(app->live2d, drag_x, drag_y); + else bongo_cat_live2d_set_dragging(app->live2d, drag_x, drag_y); app->dirty = true; } @@ -193,10 +197,17 @@ bool bongo_cat_app_audit_screen_pointer(BongoCatApp *app) { bounds.y + bounds.h * 0.5, 1.0f / 60.0f); for (int frame = 0; frame < 90; ++frame) bongo_cat_app_step_live2d(app, 1.0f / 60.0f); - BongoCatParameterRange x, y; + BongoCatParameterRange x, y, z; bool passed = bongo_cat_live2d_parameter(app->live2d, "ParamAngleX", &x) && bongo_cat_live2d_parameter(app->live2d, "ParamAngleY", &y) && fabsf(x.value) < 0.5f && fabsf(y.value) < 0.5f; + bool has_z = bongo_cat_live2d_parameter(app->live2d, "ParamAngleZ", &z); + bongo_cat_app_apply_mouse_position(app, bounds.x + bounds.w * 0.8, + bounds.y + bounds.h * 0.2, 1.0f / 60.0f); + for (int frame = 0; frame < 90; ++frame) + bongo_cat_app_step_live2d(app, 1.0f / 60.0f); + passed = (!has_z || (bongo_cat_live2d_parameter(app->live2d, + "ParamAngleZ", &z) && fabsf(z.value) < 0.25f)) && passed; app->settings.model.mouse_centered = mouse_centered; return passed; } diff --git a/tests/core/test_input.c b/tests/core/test_input.c index d3271bf4..2c67b1d8 100644 --- a/tests/core/test_input.c +++ b/tests/core/test_input.c @@ -91,6 +91,11 @@ void test_input(void) { 0.5f, 0.0f, 'Y', true) == 30.0f); CHECK(bongo_cat_mouse_parameter_value(-30.0f, 30.0f, 0.0f, 0.0f, 'Z', false) == -30.0f); + CHECK(bongo_cat_mouse_centered_ratio(80.0, 80.0, 0.0, 100.0) == 0.5f); + CHECK(bongo_cat_mouse_centered_ratio(0.0, 80.0, 0.0, 100.0) == 0.0f); + CHECK(bongo_cat_mouse_centered_ratio(100.0, 80.0, 0.0, 100.0) == 1.0f); + CHECK(bongo_cat_mouse_centered_ratio(40.0, 80.0, 0.0, 100.0) == 0.25f); + CHECK(bongo_cat_mouse_centered_ratio(90.0, 80.0, 0.0, 100.0) == 0.75f); bongo_cat_input_init(&state); event.kind = BONGO_CAT_INPUT_KEY_DOWN; From b0c6cfbc6060f6299c6bc6acb6f7ad02874d013b Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 18 Aug 2026 20:57:10 +0800 Subject: [PATCH 038/334] feat: add multi-pet display support --- cmake/RuntimeSources.cmake | 7 +- include/bongo_cat/app.h | 28 +- include/bongo_cat/common.h | 2 + include/bongo_cat/config.h | 13 + include/bongo_cat/platform.h | 3 + resources/assets/locales/de-DE.json | 4 + resources/assets/locales/en-US.json | 4 + resources/assets/locales/es-ES.json | 4 + resources/assets/locales/fr-FR.json | 4 + resources/assets/locales/ja-JP.json | 4 + resources/assets/locales/ko-KR.json | 4 + resources/assets/locales/pt-BR.json | 4 + resources/assets/locales/ru-RU.json | 4 + resources/assets/locales/zh-CN.json | 4 + resources/assets/locales/zh-TW.json | 4 + src/core/config_defaults.c | 5 + src/core/config_io.c | 8 +- src/core/config_session_defaults.c | 82 +++++ src/core/config_session_io.c | 39 ++- src/core/config_settings_save.c | 9 +- src/platform/linux/linux_menu.c | 59 +++- src/platform/macos/macos_menu.m | 5 + src/platform/windows/windows_menu.c | 11 +- src/runtime/lifecycle/app.c | 70 +++-- src/runtime/lifecycle/app_entry.c | 32 ++ src/runtime/lifecycle/config_store.c | 13 +- src/runtime/lifecycle/shutdown.c | 5 + src/runtime/lifecycle/startup.c | 18 ++ src/runtime/lifecycle/storage_paths.c | 15 + src/runtime/model/model_catalog_runtime.c | 50 +++- src/runtime/model/model_files.c | 17 +- src/runtime/model/model_update.c | 67 +++++ src/runtime/model/multi_pet.c | 273 +++++++++++++++++ src/runtime/model/multi_pet_process.c | 279 ++++++++++++++++++ src/runtime/model/multi_pet_settings.c | 101 +++++++ src/runtime/runtime.h | 21 ++ src/runtime/shell/window_menu_actions.c | 57 +++- src/ui/preferences/preferences_model.c | 15 +- src/ui/preferences/preferences_model_card.c | 52 +++- src/ui/preferences/preferences_pages.c | 5 + src/ui/preferences/preferences_state.h | 2 + src/ui/preferences/preferences_widgets.c | 21 +- src/ui/preferences/preferences_widgets.h | 3 + .../preferences_widgets_compound.c | 31 ++ .../preferences_widgets_internal.h | 22 ++ tests/core/test_config.c | 41 ++- 46 files changed, 1419 insertions(+), 102 deletions(-) create mode 100644 src/runtime/lifecycle/app_entry.c create mode 100644 src/runtime/model/multi_pet.c create mode 100644 src/runtime/model/multi_pet_process.c create mode 100644 src/runtime/model/multi_pet_settings.c create mode 100644 src/ui/preferences/preferences_widgets_compound.c create mode 100644 src/ui/preferences/preferences_widgets_internal.h diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index c45836ea..7fae2423 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -30,6 +30,7 @@ set(BONGO_CAT_RENDER_SOURCES set(BONGO_CAT_RUNTIME_LIFECYCLE_SOURCES src/runtime/lifecycle/app.c + src/runtime/lifecycle/app_entry.c src/runtime/lifecycle/assets.c src/runtime/lifecycle/config_store.c src/runtime/lifecycle/frame_clock.c @@ -57,6 +58,9 @@ set(BONGO_CAT_RUNTIME_MODEL_SOURCES src/runtime/model/model_behavior_state.c src/runtime/model/model_catalog_runtime.c src/runtime/model/model_files.c + src/runtime/model/multi_pet.c + src/runtime/model/multi_pet_process.c + src/runtime/model/multi_pet_settings.c src/runtime/model/model_storage.c src/runtime/model/model_update.c) @@ -165,7 +169,8 @@ set(BONGO_CAT_UI_PREFERENCES_SOURCES src/ui/preferences/preferences_text_session.c src/ui/preferences/preferences_theme.c src/ui/preferences/preferences_toggle.c - src/ui/preferences/preferences_widgets.c) + src/ui/preferences/preferences_widgets.c + src/ui/preferences/preferences_widgets_compound.c) set(BONGO_CAT_RUNTIME_SOURCES src/core/app_state.c diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index 479e01ef..ff04c240 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -14,6 +14,7 @@ typedef struct BongoCatTray BongoCatTray; typedef struct BongoCatOverlay BongoCatOverlay; typedef struct BongoCatPreferences BongoCatPreferences; typedef struct BongoCatI18n BongoCatI18n; +typedef struct BongoCatMultiPetRuntime BongoCatMultiPetRuntime; typedef struct BongoCatApp { BongoCatSettings settings; @@ -24,8 +25,9 @@ typedef struct BongoCatApp { BongoCatBehaviorCatalog behaviors; /* One immutable installed package's behavior catalog can be reused when the user toggles back to it. Nearby source models are excluded because - their files may change outside the application. */ - BongoCatBehaviorCatalog behavior_cache; + their files may change outside the application. Allocate it lazily so + single-model child processes do not reserve the catalog. */ + BongoCatBehaviorCatalog *behavior_cache; char behavior_cache_model_id[BONGO_CAT_ID_CAP]; char behavior_cache_digest[65]; bool behavior_cache_valid; @@ -38,6 +40,7 @@ typedef struct BongoCatApp { BongoCatTray *tray; BongoCatOverlay *overlay; BongoCatPreferences *preferences; + BongoCatMultiPetRuntime *multi_pet; SDL_Window *window; void *gl_context; char settings_path[BONGO_CAT_PATH_CAP]; @@ -50,6 +53,10 @@ typedef struct BongoCatApp { char storage_root[BONGO_CAT_PATH_CAP]; char asset_root[BONGO_CAT_PATH_CAP]; char locale_root[BONGO_CAT_PATH_CAP]; + char executable_path[BONGO_CAT_PATH_CAP]; + char primary_state_root[BONGO_CAT_PATH_CAP]; + char primary_log_root[BONGO_CAT_PATH_CAP]; + char secondary_model_id[BONGO_CAT_ID_CAP]; char smoke_import_path[BONGO_CAT_PATH_CAP]; char smoke_model[BONGO_CAT_ID_CAP]; char smoke_runtime_model[BONGO_CAT_ID_CAP]; @@ -65,6 +72,15 @@ typedef struct BongoCatApp { bool settings_store_blocked; bool session_store_blocked; bool autostart_launch; + bool secondary_pet; + bool secondary_origin_known; + bool secondary_control_known; + bool secondary_control_visible; + bool secondary_control_pass_through; + int secondary_origin_x, secondary_origin_y; + uint64_t secondary_control_check_ns; + uint64_t secondary_control_failure_ns; + uint64_t secondary_settings_check_ns; uint64_t startup_raise_due_ns; uint64_t model_load_last_frame_ns; bool smoke; @@ -99,6 +115,9 @@ typedef struct BongoCatApp { uint64_t display_recovery_due_ns; uint64_t mouse_last_ns; uint64_t frame_audit_bmp_ns; + uint64_t random_expression_due_ns; + float random_expression_interval_seconds; + uint32_t random_expression_state; uint64_t settings_saved_hash, settings_observed_hash; uint64_t session_saved_hash, session_observed_hash; uint64_t settings_save_due_ns, session_save_due_ns; @@ -155,6 +174,11 @@ void bongo_cat_app_shortcuts(BongoCatApp *app, const BongoCatInputEvent *event); bool bongo_cat_app_select_model(BongoCatApp *app, const char *id); bool bongo_cat_app_select_model_with_error(BongoCatApp *app, const char *id, BongoCatError *error); +bool bongo_cat_app_model_active(const BongoCatApp *app, const char *id); +size_t bongo_cat_app_active_model_count(const BongoCatApp *app); +bool bongo_cat_app_set_model_active(BongoCatApp *app, const char *id, + bool active, BongoCatError *error); +void bongo_cat_app_set_multiple_pets(BongoCatApp *app, bool enabled); bool bongo_cat_app_run_behavior(BongoCatApp *app, const BongoCatBehaviorEntry *behavior); void bongo_cat_app_capture_behavior_state(BongoCatApp *app); diff --git a/include/bongo_cat/common.h b/include/bongo_cat/common.h index 31470473..1d2ff894 100644 --- a/include/bongo_cat/common.h +++ b/include/bongo_cat/common.h @@ -19,6 +19,8 @@ #define BONGO_CAT_ID_CAP 128 #define BONGO_CAT_SHORTCUT_CAP 128 #define BONGO_CAT_MODEL_CAP 128 +#define BONGO_CAT_ACTIVE_MODEL_CAP 8 +#define BONGO_CAT_ADDITIONAL_MODEL_CAP (BONGO_CAT_ACTIVE_MODEL_CAP - 1) #define BONGO_CAT_BEHAVIOR_CAP 128 #define BONGO_CAT_BEHAVIOR_ID_CAP 384 #define BONGO_CAT_BEHAVIOR_BINDING_CAP 256 diff --git a/include/bongo_cat/config.h b/include/bongo_cat/config.h index 3f68cf54..0e43d75e 100644 --- a/include/bongo_cat/config.h +++ b/include/bongo_cat/config.h @@ -7,6 +7,7 @@ #define BONGO_CAT_DEFAULT_MAX_FPS 60 #define BONGO_CAT_DEFAULT_WINDOW_SCALE_PERCENT 100.0f #define BONGO_CAT_DEFAULT_WINDOW_OPACITY_PERCENT 100.0f +#define BONGO_CAT_DEFAULT_RANDOM_EXPRESSION_SECONDS 5.0f typedef enum BongoCatTheme { BONGO_CAT_THEME_AUTO, BONGO_CAT_THEME_LIGHT, BONGO_CAT_THEME_DARK } BongoCatTheme; typedef enum BongoCatLanguage { @@ -36,6 +37,7 @@ typedef enum BongoCatObsBackgroundColor { } BongoCatObsBackgroundColor; typedef struct BongoCatModelPreferences { + bool multiple_pets; bool mirror; bool mouse_mirror; bool mouse_centered; @@ -50,8 +52,10 @@ typedef struct BongoCatWindowPreferences { bool hide_on_hover; bool keep_in_screen; bool obs_background; + bool random_expression; BongoCatObsBackgroundColor obs_background_color; float hide_delay_seconds; + float random_expression_interval_seconds; } BongoCatWindowPreferences; typedef struct BongoCatWindowState { @@ -113,6 +117,8 @@ typedef struct BongoCatSettings { typedef struct BongoCatSessionState { BongoCatWindowState window; char active_model_id[BONGO_CAT_ID_CAP]; + char additional_model_ids[BONGO_CAT_ADDITIONAL_MODEL_CAP][BONGO_CAT_ID_CAP]; + size_t additional_model_count; BongoCatActiveBehavior active_behaviors[BONGO_CAT_BEHAVIOR_BINDING_CAP]; size_t active_behavior_count; } BongoCatSessionState; @@ -125,6 +131,13 @@ void bongo_cat_settings_defaults(BongoCatSettings *settings); void bongo_cat_settings_validate(BongoCatSettings *settings); void bongo_cat_session_defaults(BongoCatSessionState *session); void bongo_cat_session_validate(BongoCatSessionState *session); +bool bongo_cat_session_model_active(const BongoCatSessionState *session, + const char *model_id); +bool bongo_cat_session_add_model(BongoCatSessionState *session, + const char *model_id); +bool bongo_cat_session_remove_model(BongoCatSessionState *session, + const char *model_id); +void bongo_cat_session_clear_additional_models(BongoCatSessionState *session); bool bongo_cat_settings_shortcut_conflicts(const BongoCatSettings *settings, const char *shortcut, const char *exclude); const char *bongo_cat_settings_model_label(const BongoCatSettings *settings, diff --git a/include/bongo_cat/platform.h b/include/bongo_cat/platform.h index 998c7fc1..2927f77f 100644 --- a/include/bongo_cat/platform.h +++ b/include/bongo_cat/platform.h @@ -52,6 +52,7 @@ typedef enum BongoCatMenuAction { BONGO_CAT_MENU_OPACITY_100, BONGO_CAT_MENU_EXIT, BONGO_CAT_MENU_MODEL_ADD, + BONGO_CAT_MENU_REMOVE_PET, BONGO_CAT_MENU_MODEL_FIRST = 1000, BONGO_CAT_MENU_MOTION_FIRST = 2000, BONGO_CAT_MENU_EXPRESSION_FIRST = 3000 @@ -74,6 +75,8 @@ typedef struct BongoCatMenuLabels { void (*preview_tick)(void *userdata); BongoCatMenuPreview restore; void *preview_userdata; + const char *remove_pet; + bool remove_pet_visible; } BongoCatMenuLabels; typedef void (*BongoCatTrayClick)(void *userdata); diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index e769234a..ce9fadac 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -22,6 +22,7 @@ "keepInScreen": "Auf dem Bildschirm halten", "windowSize": "Fenstergröße", "opacity": "Deckkraft", + "randomExpression": "Zufällige Ausdrücke", "obsBackground": "Einfarbiger Hintergrund", "autoReleaseDelay": "Verzögerung der automatischen Freigabe", "hideOnHover": "Beim Darüberfahren ausblenden", @@ -175,6 +176,9 @@ } }, "native": { + "multiplePets": "Mehrere gleichzeitig anzeigen", + "multiplePetsLimit": "Bis zu 8 Desktop-Begleiter k\u00f6nnen gleichzeitig angezeigt werden", + "removeDesktopPet": "Diesen Desktop-Begleiter entfernen", "modelLoadFailed": "Dieses Modell kann nicht angezeigt werden", "noBehaviors": "Keine Einträge", "support": { diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index 2c931901..71f8d657 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -22,6 +22,7 @@ "keepInScreen": "Keep on Screen", "windowSize": "Window Size", "opacity": "Opacity", + "randomExpression": "Random Expressions", "obsBackground": "Solid Background", "autoReleaseDelay": "Auto Release Delay", "hideOnHover": "Hide on Hover", @@ -175,6 +176,9 @@ } }, "native": { + "multiplePets": "Display multiple", + "multiplePetsLimit": "Up to 8 desktop pets can be displayed at once", + "removeDesktopPet": "Remove this desktop pet", "modelLoadFailed": "Unable to display this model", "noBehaviors": "No items", "support": { diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index 526cf04a..971e667f 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -22,6 +22,7 @@ "keepInScreen": "Mantener en pantalla", "windowSize": "Tamaño de la ventana", "opacity": "Opacidad", + "randomExpression": "Expresiones aleatorias", "obsBackground": "Fondo sólido", "autoReleaseDelay": "Retardo de liberación automática", "hideOnHover": "Ocultar al pasar el ratón", @@ -175,6 +176,9 @@ } }, "native": { + "multiplePets": "Mostrar varias a la vez", + "multiplePetsLimit": "Se pueden mostrar hasta 8 mascotas a la vez", + "removeDesktopPet": "Quitar esta mascota del escritorio", "modelLoadFailed": "No se puede mostrar este modelo", "noBehaviors": "No hay elementos", "support": { diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index 9e513de6..c8fc0a26 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -22,6 +22,7 @@ "keepInScreen": "Maintenir à l'écran", "windowSize": "Taille de la fenêtre", "opacity": "Opacité", + "randomExpression": "Expressions aléatoires", "obsBackground": "Arrière-plan uni", "autoReleaseDelay": "Délai de relâchement automatique", "hideOnHover": "Masquer au survol", @@ -175,6 +176,9 @@ } }, "native": { + "multiplePets": "Afficher plusieurs modèles", + "multiplePetsLimit": "Jusqu'\u00e0 8 mascottes peuvent \u00eatre affich\u00e9es \u00e0 la fois", + "removeDesktopPet": "Retirer cette mascotte du bureau", "modelLoadFailed": "Impossible d'afficher ce modèle", "noBehaviors": "Aucun élément", "support": { diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index 160b23e5..23098faf 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -22,6 +22,7 @@ "keepInScreen": "画面内に保持", "windowSize": "ウィンドウサイズ", "opacity": "不透明度", + "randomExpression": "ランダム表情", "obsBackground": "単色背景", "autoReleaseDelay": "自動リリースまでの時間", "hideOnHover": "ホバー時に非表示", @@ -175,6 +176,9 @@ } }, "native": { + "multiplePets": "複数を同時に表示", + "multiplePetsLimit": "\u4e00\u5ea6\u306b\u8868\u793a\u3067\u304d\u308b\u30c7\u30b9\u30af\u30c8\u30c3\u30d7\u30da\u30c3\u30c8\u306f 8 \u4f53\u307e\u3067\u3067\u3059", + "removeDesktopPet": "\u3053\u306e\u30da\u30c3\u30c8\u3092\u8868\u793a\u304b\u3089\u5916\u3059", "modelLoadFailed": "このモデルを表示できません", "noBehaviors": "項目がありません", "support": { diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index 673ae05e..6de8daf8 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -22,6 +22,7 @@ "keepInScreen": "화면 안에 유지", "windowSize": "창 크기", "opacity": "불투명도", + "randomExpression": "무작위 표정", "obsBackground": "단색 배경", "autoReleaseDelay": "자동 해제 지연", "hideOnHover": "마우스를 올리면 숨기기", @@ -175,6 +176,9 @@ } }, "native": { + "multiplePets": "여러 개 동시에 표시", + "multiplePetsLimit": "\ub370\uc2a4\ud06c\ud1b1 \ud3ab\uc740 \ud55c \ubc88\uc5d0 \ucd5c\ub300 8\uac1c\uae4c\uc9c0 \ud45c\uc2dc\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4", + "removeDesktopPet": "\uc774 \ub370\uc2a4\ud06c\ud1b1 \ud3ab \uc81c\uac70", "modelLoadFailed": "이 모델을 표시할 수 없습니다", "noBehaviors": "항목 없음", "support": { diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index fd39a6a2..28703520 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -22,6 +22,7 @@ "keepInScreen": "Manter na Tela", "windowSize": "Tamanho da Janela", "opacity": "Opacidade", + "randomExpression": "Expressões Aleatórias", "obsBackground": "Fundo sólido", "autoReleaseDelay": "Atraso de Liberação Automática", "hideOnHover": "Ocultar ao Passar o Mouse", @@ -175,6 +176,9 @@ } }, "native": { + "multiplePets": "Exibir vários", + "multiplePetsLimit": "At\u00e9 8 mascotes podem ser exibidos ao mesmo tempo", + "removeDesktopPet": "Remover este mascote da \u00e1rea de trabalho", "modelLoadFailed": "N\u00e3o foi poss\u00edvel exibir este modelo", "noBehaviors": "Nenhum item", "support": { diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index 04412647..267f57d6 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -22,6 +22,7 @@ "keepInScreen": "Удерживать на экране", "windowSize": "Размер окна", "opacity": "Непрозрачность", + "randomExpression": "Случайные выражения", "obsBackground": "Однотонный фон", "autoReleaseDelay": "Задержка автоотпускания", "hideOnHover": "Скрывать при наведении", @@ -175,6 +176,9 @@ } }, "native": { + "multiplePets": "Показывать несколько", + "multiplePetsLimit": "\u041e\u0434\u043d\u043e\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e \u043c\u043e\u0436\u043d\u043e \u043e\u0442\u043e\u0431\u0440\u0430\u0437\u0438\u0442\u044c \u0434\u043e 8 \u043f\u0438\u0442\u043e\u043c\u0446\u0435\u0432", + "removeDesktopPet": "\u0423\u0431\u0440\u0430\u0442\u044c \u044d\u0442\u043e\u0433\u043e \u043f\u0438\u0442\u043e\u043c\u0446\u0430 \u0441 \u0440\u0430\u0431\u043e\u0447\u0435\u0433\u043e \u0441\u0442\u043e\u043b\u0430", "modelLoadFailed": "Не удалось отобразить эту модель", "noBehaviors": "Нет элементов", "support": { diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index 43a2d6bb..d61beb4f 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -22,6 +22,7 @@ "keepInScreen": "保持在屏幕内", "windowSize": "窗口尺寸", "opacity": "不透明度", + "randomExpression": "随机表情", "obsBackground": "纯色背景", "autoReleaseDelay": "按键自动释放延迟", "hideOnHover": "鼠标移入隐藏", @@ -175,6 +176,9 @@ } }, "native": { + "multiplePets": "同时显示多个", + "multiplePetsLimit": "\u6700\u591a\u53ef\u540c\u65f6\u663e\u793a 8 \u4e2a\u684c\u5ba0", + "removeDesktopPet": "\u79fb\u9664\u6b64\u684c\u5ba0", "modelLoadFailed": "\u6a21\u578b\u65e0\u6cd5\u663e\u793a", "noBehaviors": "暂无可用项目", "support": { diff --git a/resources/assets/locales/zh-TW.json b/resources/assets/locales/zh-TW.json index 3830bb6a..92abcb1d 100644 --- a/resources/assets/locales/zh-TW.json +++ b/resources/assets/locales/zh-TW.json @@ -22,6 +22,7 @@ "keepInScreen": "保持在螢幕內", "windowSize": "視窗尺寸", "opacity": "不透明度", + "randomExpression": "隨機表情", "obsBackground": "純色背景", "autoReleaseDelay": "按鍵自動釋放延遲", "hideOnHover": "滑鼠游標移入隱藏", @@ -175,6 +176,9 @@ } }, "native": { + "multiplePets": "同時顯示多個", + "multiplePetsLimit": "\u6700\u591a\u53ef\u540c\u6642\u986f\u793a 8 \u500b\u684c\u5bf5", + "removeDesktopPet": "\u79fb\u9664\u6b64\u684c\u5bf5", "modelLoadFailed": "\u6a21\u578b\u7121\u6cd5\u986f\u793a", "noBehaviors": "暫無可用項目", "support": { diff --git a/src/core/config_defaults.c b/src/core/config_defaults.c index 515b5e05..d85e32ec 100644 --- a/src/core/config_defaults.c +++ b/src/core/config_defaults.c @@ -222,6 +222,8 @@ void bongo_cat_settings_defaults(BongoCatSettings *config) { config->window.always_on_top = true; config->window.keep_in_screen = false; config->window.obs_background_color = BONGO_CAT_OBS_BACKGROUND_GREEN; + config->window.random_expression_interval_seconds = + BONGO_CAT_DEFAULT_RANDOM_EXPRESSION_SECONDS; config->app.tray_visible = true; config->app.theme = BONGO_CAT_THEME_AUTO; config->app.language = BONGO_CAT_LANG_EN_US; @@ -237,6 +239,9 @@ void bongo_cat_settings_validate(BongoCatSettings *config) { if (config->model.max_fps > 240) config->model.max_fps = 240; config->window.hide_delay_seconds = clampf_or( config->window.hide_delay_seconds, 0.0f, 60.0f, 0.0f); + config->window.random_expression_interval_seconds = clampf_or( + config->window.random_expression_interval_seconds, 1.0f, 3600.0f, + BONGO_CAT_DEFAULT_RANDOM_EXPRESSION_SECONDS); if ((unsigned)config->window.obs_background_color >= BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT) config->window.obs_background_color = BONGO_CAT_OBS_BACKGROUND_GREEN; diff --git a/src/core/config_io.c b/src/core/config_io.c index 971b8ef4..fa6f5073 100644 --- a/src/core/config_io.c +++ b/src/core/config_io.c @@ -59,7 +59,8 @@ static bool parse_background_color(const char *value, static bool read_model(yyjson_val *object, BongoCatModelPreferences *value, BongoCatError *error) { - return read_bool(object, "modelMirrored", &value->mirror, error) && + return read_bool(object, "multiplePets", &value->multiple_pets, error) && + read_bool(object, "modelMirrored", &value->mirror, error) && read_bool(object, "pointerMirrored", &value->mouse_mirror, error) && read_bool(object, "centerPointerTracking", &value->mouse_centered, error) && @@ -77,7 +78,12 @@ static bool read_window(yyjson_val *object, BongoCatWindowPreferences *value, !read_bool(object, "keepOnScreen", &value->keep_in_screen, error) || !read_bool(object, "captureBackground", &value->obs_background, error) || + !read_bool(object, "randomExpression", &value->random_expression, + error) || !read_float(object, "hideDelaySeconds", &value->hide_delay_seconds, + error) || + !read_float(object, "randomExpressionIntervalSeconds", + &value->random_expression_interval_seconds, error)) return false; const char *color; size_t length; diff --git a/src/core/config_session_defaults.c b/src/core/config_session_defaults.c index 6f637caa..0547641e 100644 --- a/src/core/config_session_defaults.c +++ b/src/core/config_session_defaults.c @@ -2,6 +2,7 @@ #include "bongo_cat/utf8.h" #include +#include #include static float clampf_or(float value, float low, float high, float fallback) { @@ -60,6 +61,35 @@ static void compact_active_behaviors(BongoCatSessionState *session) { session->active_behavior_count = output; } +static void compact_additional_models(BongoCatSessionState *session) { + size_t count = session->additional_model_count; + if (count > BONGO_CAT_ADDITIONAL_MODEL_CAP) + count = BONGO_CAT_ADDITIONAL_MODEL_CAP; + size_t output = 0; + for (size_t i = 0; i < count; ++i) { + char model_id[BONGO_CAT_ID_CAP]; + memcpy(model_id, session->additional_model_ids[i], sizeof(model_id)); + if (!normalize_text(model_id, sizeof(model_id)) || !model_id[0] || + !strcmp(model_id, session->active_model_id)) continue; + bool duplicate = false; + for (size_t j = 0; j < output; ++j) + if (!strcmp(session->additional_model_ids[j], model_id)) { + duplicate = true; + break; + } + if (!duplicate) { + memset(session->additional_model_ids[output], 0, + sizeof(session->additional_model_ids[0])); + snprintf(session->additional_model_ids[output++], + sizeof(session->additional_model_ids[0]), "%s", model_id); + } + } + memset(&session->additional_model_ids[output], 0, + (BONGO_CAT_ADDITIONAL_MODEL_CAP - output) * + sizeof(session->additional_model_ids[0])); + session->additional_model_count = output; +} + void bongo_cat_session_validate(BongoCatSessionState *session) { if (!session) return; session->window.scale_percent = clampf_or(session->window.scale_percent, @@ -75,5 +105,57 @@ void bongo_cat_session_validate(BongoCatSessionState *session) { sizeof(session->active_model_id)) || !session->active_model_id[0]) memcpy(session->active_model_id, "standard", sizeof("standard")); + compact_additional_models(session); compact_active_behaviors(session); } + +bool bongo_cat_session_model_active(const BongoCatSessionState *session, + const char *model_id) { + if (!session || !model_id || !model_id[0]) return false; + if (!strcmp(session->active_model_id, model_id)) return true; + size_t count = session->additional_model_count; + if (count > BONGO_CAT_ADDITIONAL_MODEL_CAP) + count = BONGO_CAT_ADDITIONAL_MODEL_CAP; + for (size_t i = 0; i < count; ++i) + if (!strcmp(session->additional_model_ids[i], model_id)) return true; + return false; +} + +bool bongo_cat_session_add_model(BongoCatSessionState *session, + const char *model_id) { + if (!session || !model_id || !model_id[0] || + strlen(model_id) >= BONGO_CAT_ID_CAP || + !bongo_cat_utf8_valid(model_id)) return false; + bongo_cat_session_validate(session); + if (bongo_cat_session_model_active(session, model_id)) return true; + if (session->additional_model_count >= BONGO_CAT_ADDITIONAL_MODEL_CAP) + return false; + snprintf(session->additional_model_ids[session->additional_model_count++], + sizeof(session->additional_model_ids[0]), "%s", model_id); + return true; +} + +bool bongo_cat_session_remove_model(BongoCatSessionState *session, + const char *model_id) { + if (!session || !model_id) return false; + for (size_t i = 0; i < session->additional_model_count; ++i) { + if (strcmp(session->additional_model_ids[i], model_id)) continue; + if (i + 1 < session->additional_model_count) + memmove(&session->additional_model_ids[i], + &session->additional_model_ids[i + 1], + (session->additional_model_count - i - 1) * + sizeof(session->additional_model_ids[0])); + memset(&session->additional_model_ids[ + --session->additional_model_count], 0, + sizeof(session->additional_model_ids[0])); + return true; + } + return false; +} + +void bongo_cat_session_clear_additional_models(BongoCatSessionState *session) { + if (!session) return; + memset(session->additional_model_ids, 0, + sizeof(session->additional_model_ids)); + session->additional_model_count = 0; +} diff --git a/src/core/config_session_io.c b/src/core/config_session_io.c index 6fdecd46..8988f19b 100644 --- a/src/core/config_session_io.c +++ b/src/core/config_session_io.c @@ -1,6 +1,7 @@ #include "config_internal.h" #include "bongo_cat/path.h" +#include #include #define SESSION_FORMAT "bongocat/session" @@ -76,6 +77,27 @@ static bool read_active_behaviors(yyjson_val *array, return true; } +static bool read_additional_models(yyjson_val *array, + BongoCatSessionState *session, BongoCatError *error) { + if (!array) return true; + if (yyjson_arr_size(array) > BONGO_CAT_ADDITIONAL_MODEL_CAP) + return type_error(error, "additionalModelIds", "a smaller array"); + session->additional_model_count = 0; + size_t index, count; + yyjson_val *item; + yyjson_arr_foreach(array, index, count, item) { + if (!yyjson_is_str(item) || !yyjson_get_len(item) || + yyjson_get_len(item) >= BONGO_CAT_ID_CAP || + strlen(yyjson_get_str(item)) != yyjson_get_len(item)) + return type_error(error, "additionalModelIds[]", + "a non-empty model id within the supported length"); + snprintf(session->additional_model_ids[ + session->additional_model_count++], BONGO_CAT_ID_CAP, "%s", + yyjson_get_str(item)); + } + return true; +} + BongoCatResult bongo_cat_session_load(const char *path, BongoCatSessionState *session, BongoCatError *error) { if (!path || !session) return BONGO_CAT_ERROR_ARGUMENT; @@ -88,8 +110,10 @@ BongoCatResult bongo_cat_session_load(const char *path, yyjson_val *root = yyjson_doc_get_root(document); yyjson_val *window = NULL; yyjson_val *active_behaviors = NULL; + yyjson_val *additional_models = NULL; bool valid = read_object(root, "window", &window, error) && - read_array(root, "activeBehaviors", &active_behaviors, error); + read_array(root, "activeBehaviors", &active_behaviors, error) && + read_array(root, "additionalModelIds", &additional_models, error); if (valid && window) { valid = read_bool(window, "visible", &loaded.window.visible, error) && read_float(window, "scalePercent", @@ -112,6 +136,7 @@ BongoCatResult bongo_cat_session_load(const char *path, read_int(size, "height", &loaded.window.height, true, error); } if (valid) valid = read_active_model(root, &loaded, error) && + read_additional_models(additional_models, &loaded, error) && read_active_behaviors(active_behaviors, &loaded, error); yyjson_doc_free(document); if (!valid) return BONGO_CAT_ERROR_FORMAT; @@ -144,6 +169,17 @@ static bool write_active_behaviors(yyjson_mut_doc *doc, return true; } +static bool write_additional_models(yyjson_mut_doc *doc, + yyjson_mut_val *root, const BongoCatSessionState *session) { + yyjson_mut_val *array = yyjson_mut_arr(doc); + if (!array || !yyjson_mut_obj_add_val( + doc, root, "additionalModelIds", array)) return false; + for (size_t i = 0; i < session->additional_model_count; ++i) + if (!yyjson_mut_arr_add_strcpy( + doc, array, session->additional_model_ids[i])) return false; + return true; +} + BongoCatResult bongo_cat_session_save(const char *path, const BongoCatSessionState *session, BongoCatError *error) { if (!path || !session) return BONGO_CAT_ERROR_ARGUMENT; @@ -184,6 +220,7 @@ BongoCatResult bongo_cat_session_save(const char *path, yyjson_mut_obj_add_int(doc, size, "height", canonical.window.height) && yyjson_mut_obj_add_strcpy(doc, root, "activeModelId", canonical.active_model_id) && + write_additional_models(doc, root, &canonical) && write_active_behaviors(doc, root, &canonical); if (!built) { yyjson_mut_doc_free(doc); diff --git a/src/core/config_settings_save.c b/src/core/config_settings_save.c index 13321d1e..ff143a8d 100644 --- a/src/core/config_settings_save.c +++ b/src/core/config_settings_save.c @@ -7,6 +7,8 @@ static bool write_model(yyjson_mut_doc *doc, yyjson_mut_val *object, const BongoCatModelPreferences *value) { return object && + yyjson_mut_obj_add_bool(doc, object, "multiplePets", + value->multiple_pets) && yyjson_mut_obj_add_bool(doc, object, "modelMirrored", value->mirror) && yyjson_mut_obj_add_bool(doc, object, "pointerMirrored", value->mouse_mirror) && @@ -32,11 +34,16 @@ static bool write_window(yyjson_mut_doc *doc, yyjson_mut_val *object, value->keep_in_screen) && yyjson_mut_obj_add_bool(doc, object, "captureBackground", value->obs_background) && + yyjson_mut_obj_add_bool(doc, object, "randomExpression", + value->random_expression) && yyjson_mut_obj_add_strcpy(doc, object, "captureBackgroundColor", bongo_cat_obs_background_color_name( value->obs_background_color)) && yyjson_mut_obj_add_real(doc, object, "hideDelaySeconds", - value->hide_delay_seconds); + value->hide_delay_seconds) && + yyjson_mut_obj_add_real(doc, object, + "randomExpressionIntervalSeconds", + value->random_expression_interval_seconds); } static bool write_app(yyjson_mut_doc *doc, yyjson_mut_val *object, diff --git a/src/platform/linux/linux_menu.c b/src/platform/linux/linux_menu.c index a9fc5ae7..66ad7cab 100644 --- a/src/platform/linux/linux_menu.c +++ b/src/platform/linux/linux_menu.c @@ -16,6 +16,7 @@ typedef struct LinuxMenuRow { #define LINUX_MENU_HINT ((BongoCatMenuAction)-4) #define LINUX_MENU_MOTIONS ((BongoCatMenuAction)-5) #define LINUX_MENU_EXPRESSIONS ((BongoCatMenuAction)-6) +#define LINUX_MENU_SEPARATOR ((BongoCatMenuAction)-7) typedef struct LinuxMenuPalette { unsigned long surface, field, border, text, muted, accent; @@ -47,15 +48,47 @@ static LinuxMenuPalette palette(Display *display, bool dark) { return value; } +static bool row_selectable(const LinuxMenuRow *row) { + return row->action != LINUX_MENU_HINT && + row->action != LINUX_MENU_SEPARATOR; +} + +static int row_height(const LinuxMenuRow *row) { + return row->action == LINUX_MENU_SEPARATOR ? 13 : 34; +} + +static int rows_height(const LinuxMenuRow *rows, int count) { + int height = 0; + for (int i = 0; i < count; ++i) height += row_height(&rows[i]); + return height; +} + +static int row_at(const LinuxMenuRow *rows, int count, int y) { + int top = 8; + for (int i = 0; i < count; ++i) { + int height = row_height(&rows[i]); + if (y >= top && y < top + height) return i; + top += height; + } + return -1; +} + static void draw_menu(Display *display, Window window, GC gc, const LinuxMenuRow *rows, int count, int hover, const LinuxMenuPalette *colors) { + int height = rows_height(rows, count) + 16; XSetForeground(display, gc, colors->surface); XFillRectangle(display, window, gc, - 0, 0, 340, (unsigned)(count * 34 + 16)); + 0, 0, 340, (unsigned)height); XSetForeground(display, gc, colors->border); XDrawRectangle(display, window, gc, - 0, 0, 339, (unsigned)(count * 34 + 15)); + 0, 0, 339, (unsigned)(height - 1)); + int y = 8; for (int index = 0; index < count; ++index) { - int y = 8 + index * 34; + if (rows[index].action == LINUX_MENU_SEPARATOR) { + XSetForeground(display, gc, colors->border); + XDrawLine(display, window, gc, 12, y + 6, 327, y + 6); + y += row_height(&rows[index]); + continue; + } bool hint = rows[index].action == LINUX_MENU_HINT; if (index == hover && !hint) { XSetForeground(display, gc, colors->field); @@ -65,6 +98,7 @@ static void draw_menu(Display *display, Window window, GC gc, index == hover ? colors->accent : colors->text); XDrawString(display, window, gc, 20, y + 21, rows[index].label, (int)strlen(rows[index].label)); + y += row_height(&rows[index]); } XFlush(display); } @@ -75,7 +109,7 @@ static int popup_rows(Display *display, Window owner, const LinuxMenuRow *rows, Window root; int root_x, root_y, win_x, win_y; unsigned mask; XQueryPointer(display, DefaultRootWindow(display), &root, &owner, &root_x, &root_y, &win_x, &win_y, &mask); - int height = count * 34 + 16; + int height = rows_height(rows, count) + 16; int screen_width = DisplayWidth(display, DefaultScreen(display)); int screen_height = DisplayHeight(display, DefaultScreen(display)); if (root_x > screen_width - 342) root_x = screen_width - 342; @@ -117,9 +151,8 @@ static int popup_rows(Display *display, Window owner, const LinuxMenuRow *rows, if (event.type == Expose) draw_menu(display, menu, gc, rows, count, hover, &colors); else if (event.type == MotionNotify) { - int next = event.xmotion.y >= 8 ? (event.xmotion.y - 8) / 34 : -1; - next = next >= 0 && next < count && - rows[next].action != LINUX_MENU_HINT ? next : -1; + int next = row_at(rows, count, event.xmotion.y); + next = next >= 0 && row_selectable(&rows[next]) ? next : -1; if (next != hover && labels->preview) labels->preview(labels->preview_userdata, next >= 0 ? rows[next].action : BONGO_CAT_MENU_NONE); @@ -131,9 +164,9 @@ static int popup_rows(Display *display, Window owner, const LinuxMenuRow *rows, BONGO_CAT_MENU_NONE); draw_menu(display, menu, gc, rows, count, hover, &colors); } else if (event.type == ButtonPress) { - int next = event.xbutton.y >= 8 ? (event.xbutton.y - 8) / 34 : -1; + int next = row_at(rows, count, event.xbutton.y); if (next < 0 || next >= count) selected = -2; - else if (rows[next].action != LINUX_MENU_HINT) selected = next; + else if (row_selectable(&rows[next])) selected = next; } else if (event.type == KeyPress && XLookupKeysym(&event.xkey, 0) == XK_Escape) selected = -2; } @@ -157,7 +190,13 @@ BongoCatMenuAction bongo_cat_linux_context_menu(BongoCatPlatform *platform, labels->pass_through); snprintf(top, sizeof(top), "%s%s", labels->always_on_top_checked ? "[x] " : "", labels->always_on_top); - LinuxMenuRow main_rows[10]; int main_count = 0; + LinuxMenuRow main_rows[12]; int main_count = 0; + if (labels->remove_pet_visible) { + main_rows[main_count++] = (LinuxMenuRow){labels->remove_pet, + BONGO_CAT_MENU_REMOVE_PET}; + main_rows[main_count++] = (LinuxMenuRow){NULL, + LINUX_MENU_SEPARATOR}; + } main_rows[main_count++] = (LinuxMenuRow){labels->preferences, BONGO_CAT_MENU_PREFERENCES}; main_rows[main_count++] = (LinuxMenuRow){labels->hide, BONGO_CAT_MENU_HIDE}; main_rows[main_count++] = (LinuxMenuRow){pass, BONGO_CAT_MENU_PASS_THROUGH}; diff --git a/src/platform/macos/macos_menu.m b/src/platform/macos/macos_menu.m index 9275c04e..b2924262 100644 --- a/src/platform/macos/macos_menu.m +++ b/src/platform/macos/macos_menu.m @@ -87,6 +87,11 @@ BongoCatMenuAction bongo_cat_macos_context_menu(BongoCatPlatform *platform, BongoCatMenuTarget *target = [[BongoCatMenuTarget alloc] initWithLabels:labels]; NSMenu *menu = [[NSMenu alloc] initWithTitle:@""]; [menu setDelegate:target]; + if (labels->remove_pet_visible) { + add_item(menu, target, labels->remove_pet, + BONGO_CAT_MENU_REMOVE_PET, false); + [menu addItem:[NSMenuItem separatorItem]]; + } add_item(menu, target, labels->preferences, BONGO_CAT_MENU_PREFERENCES, false); add_item(menu, target, labels->hide, BONGO_CAT_MENU_HIDE, false); [menu addItem:[NSMenuItem separatorItem]]; diff --git a/src/platform/windows/windows_menu.c b/src/platform/windows/windows_menu.c index af6c316a..43c5881b 100644 --- a/src/platform/windows/windows_menu.c +++ b/src/platform/windows/windows_menu.c @@ -41,16 +41,23 @@ static void destroy_unattached(HMENU menu, HMENU sizes, HMENU opacity, BongoCatMenuAction bongo_cat_platform_context_menu(BongoCatPlatform *platform, const BongoCatMenuLabels *labels) { if (!platform || !labels) return BONGO_CAT_MENU_NONE; - HMENU menu = CreatePopupMenu(), sizes = CreatePopupMenu(), opacity = CreatePopupMenu(); + HMENU menu = CreatePopupMenu(); + if (!menu) return BONGO_CAT_MENU_NONE; + HMENU sizes = CreatePopupMenu(), opacity = CreatePopupMenu(); HMENU models = CreatePopupMenu(); HMENU motions = labels->motion_count ? CreatePopupMenu() : NULL; HMENU expressions = labels->expression_count ? CreatePopupMenu() : NULL; - if (!menu || !sizes || !opacity || !models || + if (!sizes || !opacity || !models || (labels->motion_count && !motions) || (labels->expression_count && !expressions)) { destroy_unattached(menu, sizes, opacity, models, motions, expressions); return BONGO_CAT_MENU_NONE; } + if (labels->remove_pet_visible) { + menu_text(menu, MF_STRING, BONGO_CAT_MENU_REMOVE_PET, + labels->remove_pet); + AppendMenuW(menu, MF_SEPARATOR, 0, NULL); + } menu_text(menu, MF_STRING, BONGO_CAT_MENU_PREFERENCES, labels->preferences); menu_text(menu, MF_STRING, BONGO_CAT_MENU_HIDE, labels->hide); AppendMenuW(menu, MF_SEPARATOR, 0, NULL); diff --git a/src/runtime/lifecycle/app.c b/src/runtime/lifecycle/app.c index e833113b..5cb72d50 100644 --- a/src/runtime/lifecycle/app.c +++ b/src/runtime/lifecycle/app.c @@ -32,8 +32,12 @@ static bool load_selected_model(BongoCatApp *app, BongoCatError *error) { "No usable Live2D model could be loaded"); return false; } -static bool initialize(BongoCatApp *app, int argc, char **argv, BongoCatError *error) { +bool bongo_cat_app_initialize(BongoCatApp *app, int argc, char **argv, + BongoCatError *error) { memset(app, 0, sizeof(*app)); + if (argc > 0 && argv && argv[0]) + snprintf(app->executable_path, sizeof(app->executable_path), + "%s", argv[0]); app->smoke_language = -1; app->smoke_theme = -1; app->smoke_preference_page = -1; @@ -44,6 +48,21 @@ static bool initialize(BongoCatApp *app, int argc, char **argv, BongoCatError *e bongo_cat_models_init(&app->models); if (!bongo_cat_startup_prepare(app, argc, argv, error)) return false; bongo_cat_config_store_load(app); + if (app->secondary_pet) { + snprintf(app->session.active_model_id, + sizeof(app->session.active_model_id), "%s", + app->secondary_model_id); + bongo_cat_session_clear_additional_models(&app->session); + if (!app->session_store_valid && app->secondary_origin_known) { + app->session.window.position_known = true; + app->session.window.x = app->secondary_origin_x; + app->session.window.y = app->secondary_origin_y; + } + /* The control file reveals the child on its first update. Starting + hidden prevents a stale child session from flashing on screen. */ + app->session.window.visible = false; + } else if (!app->settings.model.multiple_pets) + bongo_cat_session_clear_additional_models(&app->session); if (app->smoke_language >= 0) app->settings.app.language = (BongoCatLanguage)app->smoke_language; if (app->smoke_theme >= 0) app->settings.app.theme = (BongoCatTheme)app->smoke_theme; @@ -51,7 +70,8 @@ static bool initialize(BongoCatApp *app, int argc, char **argv, BongoCatError *e if (app->smoke_model[0]) snprintf(app->session.active_model_id, sizeof(app->session.active_model_id), "%s", app->smoke_model); - if (!app->autostart_launch) app->session.window.visible = true; + if (!app->secondary_pet && !app->autostart_launch) + app->session.window.visible = true; bongo_cat_startup_stage(app, "configuration-ready"); if (bongo_cat_window_create(app, error) != BONGO_CAT_OK) return false; bongo_cat_startup_stage(app, "window-ready"); @@ -87,13 +107,16 @@ static bool initialize(BongoCatApp *app, int argc, char **argv, BongoCatError *e } } app->running = true; - optional = (BongoCatError){0}; app->tray = bongo_cat_tray_create(app, &optional); - if (!app->tray && app->settings.app.tray_visible) + optional = (BongoCatError){0}; app->tray = app->secondary_pet ? NULL : + bongo_cat_tray_create(app, &optional); + if (!app->secondary_pet && !app->tray && app->settings.app.tray_visible) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", optional.message); - app->preferences = bongo_cat_preferences_create(app); - if (!app->preferences) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + app->preferences = app->secondary_pet ? NULL : + bongo_cat_preferences_create(app); + if (!app->secondary_pet && !app->preferences) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Preferences are unavailable because their state could not be allocated"); - if (!app->tray && !app->session.window.visible) { + if (!app->secondary_pet && !app->tray && !app->session.window.visible) { bongo_cat_window_set_visible(app, true); } bongo_cat_live2d_audit_run(app); @@ -123,7 +146,9 @@ static bool initialize(BongoCatApp *app, int argc, char **argv, BongoCatError *e if (app->smoke_preferences) bongo_cat_preferences_show(app->preferences); app->last_frame_ns = SDL_GetTicksNS(); app->dirty = true; - app->startup_raise_due_ns = !app->smoke && !app->autostart_launch && app->session.window.visible ? app->last_frame_ns + 250000000ull : 0; + app->startup_raise_due_ns = !app->secondary_pet && !app->smoke && + !app->autostart_launch && app->session.window.visible ? + app->last_frame_ns + 250000000ull : 0; if (app->smoke_deadline_ns) app->smoke_deadline_ns += app->last_frame_ns; if (!app->session.window.visible) bongo_cat_startup_ready(app); return true; @@ -135,6 +160,7 @@ static void handle_event(BongoCatApp *app, const SDL_Event *event) { event->type <= SDL_EVENT_GAMEPAD_TOUCHPAD_UP) bongo_cat_gamepad_event(app, event); } void bongo_cat_app_drain_input(BongoCatApp *app, bool allow_shortcuts) { + if (app && app->secondary_pet) allow_shortcuts = false; BongoCatInputEvent event; while (bongo_cat_input_pop(&app->input, &event)) { if (app->smoke_ignore_global_input) continue; @@ -226,11 +252,12 @@ static void take_instance_wake(BongoCatApp *app) { bongo_cat_platform_raise_window(app->window); SDL_Log("Existing instance requested window reveal"); } -static void loop(BongoCatApp *app) { +void bongo_cat_app_loop(BongoCatApp *app) { uint64_t iterations = 0, wakes = 0, zero_waits = 0; while (app->running) { iterations++; int wait_ms = bongo_cat_window_wait_timeout(app, SDL_GetTicksNS()); + if (app->secondary_pet && wait_ms > 100) wait_ms = 100; if (!wait_ms) zero_waits++; bongo_cat_preferences_input_begin(app->preferences); SDL_Event event; @@ -244,6 +271,8 @@ static void loop(BongoCatApp *app) { bongo_cat_preferences_input_end(app->preferences); take_instance_wake(app); uint64_t now = SDL_GetTicksNS(); bongo_cat_window_update_wheel_animation(app, now); + bongo_cat_multi_pet_update(app, now); + bongo_cat_random_expression_update(app, now); bongo_cat_window_update_display_recovery(app, now); bongo_cat_runtime_flow_update(app, now); bongo_cat_window_apply_pending_resize(app); @@ -263,26 +292,3 @@ static void loop(BongoCatApp *app) { (unsigned long long)iterations, (unsigned long long)wakes, (unsigned long long)zero_waits); } -int bongo_cat_app_run(int argc, char **argv) { - if (!bongo_cat_platform_single_instance_begin()) return 0; - BongoCatApp *app = calloc(1, sizeof(*app)); - if (!app) { - BongoCatError memory = {0}; bongo_cat_error_set(&memory, - BONGO_CAT_ERROR_MEMORY, "Cannot allocate application state"); - bongo_cat_startup_failure(NULL, &memory); - bongo_cat_platform_single_instance_end(); return 1; - } - BongoCatError error = {0}; - if (!initialize(app, argc, argv, &error)) { - bongo_cat_startup_failure(app, &error); - if (app->smoke) bongo_cat_startup_ci_failure(app, &error); - bongo_cat_app_shutdown(app, "shutdown:startup-failure", 1); free(app); - bongo_cat_platform_single_instance_end(); - return 1; - } - loop(app); - int exit_code = app->exit_code; - bongo_cat_app_shutdown(app, "shutdown:normal", exit_code); - free(app); bongo_cat_platform_single_instance_end(); - return exit_code; -} diff --git a/src/runtime/lifecycle/app_entry.c b/src/runtime/lifecycle/app_entry.c new file mode 100644 index 00000000..4b5e61ab --- /dev/null +++ b/src/runtime/lifecycle/app_entry.c @@ -0,0 +1,32 @@ +#include "runtime.h" + +#include + +int bongo_cat_app_run(int argc, char **argv) { + bool secondary = bongo_cat_multi_pet_secondary_argument(argc, argv); + if (!secondary && !bongo_cat_platform_single_instance_begin()) return 0; + BongoCatApp *app = calloc(1, sizeof(*app)); + if (!app) { + BongoCatError memory = {0}; + bongo_cat_error_set(&memory, BONGO_CAT_ERROR_MEMORY, + "Cannot allocate application state"); + bongo_cat_startup_failure(NULL, &memory); + if (!secondary) bongo_cat_platform_single_instance_end(); + return 1; + } + BongoCatError error = {0}; + if (!bongo_cat_app_initialize(app, argc, argv, &error)) { + bongo_cat_startup_failure(app, &error); + if (app->smoke) bongo_cat_startup_ci_failure(app, &error); + bongo_cat_app_shutdown(app, "shutdown:startup-failure", 1); + free(app); + if (!secondary) bongo_cat_platform_single_instance_end(); + return 1; + } + bongo_cat_app_loop(app); + int exit_code = app->exit_code; + bongo_cat_app_shutdown(app, "shutdown:normal", exit_code); + free(app); + if (!secondary) bongo_cat_platform_single_instance_end(); + return exit_code; +} diff --git a/src/runtime/lifecycle/config_store.c b/src/runtime/lifecycle/config_store.c index c90c032a..2fb0d0f2 100644 --- a/src/runtime/lifecycle/config_store.c +++ b/src/runtime/lifecycle/config_store.c @@ -51,6 +51,12 @@ static uint64_t session_hash(const BongoCatSessionState *session) { HASH_FIELD(session->window.width); HASH_FIELD(session->window.height); HASH_FIELD(session->active_model_id); + HASH_FIELD(session->additional_model_count); + size_t model_count = session->additional_model_count; + if (model_count > BONGO_CAT_ADDITIONAL_MODEL_CAP) + model_count = BONGO_CAT_ADDITIONAL_MODEL_CAP; + hash = hash_bytes(hash, session->additional_model_ids, + model_count * sizeof(session->additional_model_ids[0])); HASH_FIELD(session->active_behavior_count); size_t behavior_count = session->active_behavior_count; if (behavior_count > BONGO_CAT_BEHAVIOR_BINDING_CAP) @@ -163,7 +169,7 @@ void bongo_cat_config_store_update(BongoCatApp *app, uint64_t now) { if (!app || app->smoke || !app->settings_path[0] || !app->session_path[0]) return; uint64_t settings = settings_hash(&app->settings); uint64_t session = session_hash(&app->session); - if (!app->settings_store_blocked) { + if (!app->secondary_pet && !app->settings_store_blocked) { if (settings != app->settings_observed_hash) { app->settings_observed_hash = settings; app->settings_save_due_ns = now + SAVE_DELAY_NS; @@ -179,7 +185,8 @@ void bongo_cat_config_store_update(BongoCatApp *app, uint64_t now) { !app->session_save_due_ns) app->session_save_due_ns = now + SAVE_DELAY_NS; } - if (!app->settings_store_blocked && app->settings_save_due_ns && + if (!app->secondary_pet && !app->settings_store_blocked && + app->settings_save_due_ns && now >= app->settings_save_due_ns) app->settings_save_due_ns = save_settings(app) ? 0 : now + RETRY_DELAY_NS; @@ -193,7 +200,7 @@ void bongo_cat_config_store_flush(BongoCatApp *app) { if (!app || app->smoke || !app->settings_path[0] || !app->session_path[0]) return; uint64_t settings = settings_hash(&app->settings); uint64_t session = session_hash(&app->session); - if (!app->settings_store_blocked && + if (!app->secondary_pet && !app->settings_store_blocked && settings != app->settings_saved_hash) save_settings(app); if (!app->session_store_blocked && session != app->session_saved_hash) save_session(app); diff --git a/src/runtime/lifecycle/shutdown.c b/src/runtime/lifecycle/shutdown.c index edc5098c..ff5068a4 100644 --- a/src/runtime/lifecycle/shutdown.c +++ b/src/runtime/lifecycle/shutdown.c @@ -5,6 +5,8 @@ #include "bongo_cat/preferences.h" #include "bongo_cat/tray.h" +#include + void bongo_cat_app_shutdown(BongoCatApp *app, const char *stage, int exit_code) { bongo_cat_runtime_stage(app, stage); @@ -12,6 +14,7 @@ void bongo_cat_app_shutdown(BongoCatApp *app, const char *stage, stage, exit_code); bongo_cat_app_capture_behavior_state(app); bongo_cat_config_store_flush(app); + bongo_cat_multi_pet_shutdown(app); bongo_cat_preferences_destroy(app->preferences); bongo_cat_i18n_destroy(app->i18n); bongo_cat_tray_destroy(app->tray); @@ -19,6 +22,8 @@ void bongo_cat_app_shutdown(BongoCatApp *app, const char *stage, bongo_cat_audio_destroy(app->audio); bongo_cat_overlay_destroy(app->overlay); bongo_cat_live2d_destroy(app->live2d); + free(app->behavior_cache); + app->behavior_cache = NULL; bongo_cat_platform_shutdown(&app->platform); bongo_cat_runtime_log_stop(); bongo_cat_window_destroy(app); diff --git a/src/runtime/lifecycle/startup.c b/src/runtime/lifecycle/startup.c index 0d503065..55dd0ca1 100644 --- a/src/runtime/lifecycle/startup.c +++ b/src/runtime/lifecycle/startup.c @@ -8,6 +8,7 @@ #include #include #include +#include #ifdef _WIN32 #include @@ -130,6 +131,23 @@ static bool parse_arguments(BongoCatApp *app, int argc, char **argv, } else if (strncmp(arg, "--storage-root=", 15) == 0) { if (!store_argument(app->storage_root, sizeof(app->storage_root), arg + 15, "Storage", error)) return false; + } else if (strncmp(arg, "--secondary-pet=", 16) == 0) { + if (!store_argument(app->secondary_model_id, + sizeof(app->secondary_model_id), arg + 16, + "Secondary model", error)) return false; + app->secondary_pet = app->secondary_model_id[0] != '\0'; + } else if (strncmp(arg, "--secondary-position=", 21) == 0) { + char *separator = NULL, *end = NULL; + long x = strtol(arg + 21, &separator, 10); + long y = separator && *separator == ',' ? + strtol(separator + 1, &end, 10) : 0; + app->secondary_origin_known = separator != arg + 21 && end && + end != separator + 1 && !*end && x >= INT_MIN && x <= INT_MAX && + y >= INT_MIN && y <= INT_MAX; + if (app->secondary_origin_known) { + app->secondary_origin_x = (int)x; + app->secondary_origin_y = (int)y; + } } else if (strncmp(arg, "--ci-import=", 12) == 0) { if (!store_argument(app->smoke_import_path, sizeof(app->smoke_import_path), arg + 12, "Import", error)) return false; diff --git a/src/runtime/lifecycle/storage_paths.c b/src/runtime/lifecycle/storage_paths.c index 936685e6..6979fdc2 100644 --- a/src/runtime/lifecycle/storage_paths.c +++ b/src/runtime/lifecycle/storage_paths.c @@ -1,4 +1,5 @@ #include "storage_paths.h" +#include "runtime.h" #include "bongo_cat/file.h" #include "bongo_cat/path.h" @@ -104,6 +105,20 @@ bool bongo_cat_storage_paths_prepare(BongoCatApp *app, if (!app) return false; bool resolved = app->storage_root[0] ? isolated_roots(app) : platform_roots(app); + if (resolved) { + snprintf(app->primary_state_root, sizeof(app->primary_state_root), + "%s", app->state_root); + snprintf(app->primary_log_root, sizeof(app->primary_log_root), + "%s", app->log_root); + } + if (resolved && app->secondary_pet) { + resolved = bongo_cat_multi_pet_state_directory(app->state_root, + sizeof(app->state_root), app->primary_state_root, + app->secondary_model_id) && + bongo_cat_multi_pet_state_directory(app->log_root, + sizeof(app->log_root), app->primary_log_root, + app->secondary_model_id); + } const char *roots[] = {app->config_root, app->data_root, app->cache_root, app->state_root, app->log_root}; for (size_t i = 0; resolved && i < sizeof(roots) / sizeof(roots[0]); ++i) diff --git a/src/runtime/model/model_catalog_runtime.c b/src/runtime/model/model_catalog_runtime.c index fbf563bc..08517b5f 100644 --- a/src/runtime/model/model_catalog_runtime.c +++ b/src/runtime/model/model_catalog_runtime.c @@ -15,8 +15,33 @@ typedef struct TreeContext { unsigned depth; } TreeContext; +typedef struct ModelSelection { + char additional[BONGO_CAT_ADDITIONAL_MODEL_CAP][BONGO_CAT_ID_CAP]; + size_t count; +} ModelSelection; + enum { MODEL_TREE_DEPTH_CAP = 32 }; +static ModelSelection capture_model_selection(const BongoCatApp *app) { + ModelSelection selection = {0}; + selection.count = app->session.additional_model_count; + if (selection.count > BONGO_CAT_ADDITIONAL_MODEL_CAP) + selection.count = BONGO_CAT_ADDITIONAL_MODEL_CAP; + memcpy(selection.additional, app->session.additional_model_ids, + selection.count * sizeof(selection.additional[0])); + return selection; +} + +static void restore_model_selection(BongoCatApp *app, + const ModelSelection *selection) { + bongo_cat_session_clear_additional_models(&app->session); + for (size_t i = 0; i < selection->count; ++i) + if (bongo_cat_models_find(&app->models, selection->additional[i])) + bongo_cat_session_add_model(&app->session, + selection->additional[i]); + bongo_cat_multi_pet_primary_update(app, SDL_GetTicksNS()); +} + static bool copy_tree(const char *source, const char *target, unsigned depth, BongoCatError *error); @@ -177,11 +202,22 @@ BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, : "Built-in models cannot be removed: %s", id); return BONGO_CAT_ERROR_ARGUMENT; } - bool selected = !strcmp(id, app->session.active_model_id) || + bool primary = !strcmp(id, app->session.active_model_id) || !strcmp(id, app->loaded_model); + bool additional = !primary && bongo_cat_app_model_active(app, id); + ModelSelection previous_selection = capture_model_selection(app); char directory[BONGO_CAT_PATH_CAP]; snprintf(directory, sizeof(directory), "%s", entry->storage_directory); - if (selected) { + if (additional) { + BongoCatError deactivate_error = {0}; + if (!bongo_cat_app_set_model_active(app, id, false, + &deactivate_error)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, + "Cannot stop the active model: %s", + deactivate_error.message); + return BONGO_CAT_ERROR_PLATFORM; + } + } else if (primary) { BongoCatError load_error = {0}; bool replacement = false; for (size_t i = 0; i < app->models.count; ++i) @@ -201,15 +237,19 @@ BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, } if (!bongo_cat_model_remove_tree(directory, error)) { bongo_cat_app_rescan_models(app); - if (selected && bongo_cat_models_find(&app->models, id)) { + bool restore_selection = additional; + if (primary && bongo_cat_models_find(&app->models, id)) { BongoCatError restore_error = {0}; - if (!bongo_cat_app_select_model_with_error(app, id, - &restore_error)) + restore_selection = bongo_cat_app_select_model_with_error(app, id, + &restore_error); + if (!restore_selection) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Unable to restore model after deletion failed: %s", restore_error.message[0] ? restore_error.message : "unknown error"); } + if (restore_selection) + restore_model_selection(app, &previous_selection); return BONGO_CAT_ERROR_IO; } bongo_cat_settings_set_model_label(&app->settings, id, ""); diff --git a/src/runtime/model/model_files.c b/src/runtime/model/model_files.c index 777b843d..69283029 100644 --- a/src/runtime/model/model_files.c +++ b/src/runtime/model/model_files.c @@ -9,6 +9,9 @@ #include #include static void select_model_state(BongoCatApp *app, const BongoCatModelEntry *entry) { + if (app->settings.model.multiple_pets) + bongo_cat_session_remove_model(&app->session, entry->id); + else bongo_cat_session_clear_additional_models(&app->session); snprintf(app->session.active_model_id, sizeof(app->session.active_model_id), "%s", entry->id); app->loaded_mode = entry->mode; @@ -47,7 +50,8 @@ static bool behavior_cacheable(const BongoCatModelEntry *entry) { static bool behavior_cache_matches(const BongoCatApp *app, const BongoCatModelEntry *entry) { - return app && app->behavior_cache_valid && behavior_cacheable(entry) && + return app && app->behavior_cache && app->behavior_cache_valid && + behavior_cacheable(entry) && strcmp(app->behavior_cache_model_id, entry->id) == 0 && (entry->preset || strcmp(app->behavior_cache_digest, entry->content_digest) == 0); @@ -59,7 +63,13 @@ static void behavior_cache_store(BongoCatApp *app, if (app) app->behavior_cache_valid = false; return; } - app->behavior_cache = app->behaviors; + if (!app->behavior_cache) + app->behavior_cache = malloc(sizeof(*app->behavior_cache)); + if (!app->behavior_cache) { + app->behavior_cache_valid = false; + return; + } + *app->behavior_cache = app->behaviors; snprintf(app->behavior_cache_model_id, sizeof(app->behavior_cache_model_id), "%s", entry->id); snprintf(app->behavior_cache_digest, sizeof(app->behavior_cache_digest), @@ -167,7 +177,7 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, } bool behavior_catalog_valid = behavior_cache_matches(app, entry); if (behavior_catalog_valid) { - *behaviors = app->behavior_cache; + *behaviors = *app->behavior_cache; } else { behavior_catalog_valid = bongo_cat_behaviors_load( behaviors, entry, &optional) == BONGO_CAT_OK; @@ -248,6 +258,7 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, } snprintf(app->loaded_model, sizeof(app->loaded_model), "%s", entry->id); bongo_cat_app_restore_behavior_state(app, entry->id); + bongo_cat_random_expression_reset(app); app->pointer_known = false; bool geometry_changed = apply_model_aspect(app, &render_options); if (app->window) { diff --git a/src/runtime/model/model_update.c b/src/runtime/model/model_update.c index 7002e92f..d19ac3a4 100644 --- a/src/runtime/model/model_update.c +++ b/src/runtime/model/model_update.c @@ -1,5 +1,72 @@ #include "runtime.h" +#define NANOSECONDS_PER_SECOND 1000000000ull + +static uint32_t random_expression_next(BongoCatApp *app, uint64_t now) { + uint32_t state = app->random_expression_state; + if (!state) { + state = (uint32_t)now ^ (uint32_t)(now >> 32) ^ 0x9e3779b9u; + const unsigned char *text = (const unsigned char *)app->loaded_model; + while (*text) state = (state ^ *text++) * 16777619u; + if (!state) state = 0x6d2b79f5u; + } + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + app->random_expression_state = state; + return state; +} + +void bongo_cat_random_expression_reset(BongoCatApp *app) { + if (!app) return; + app->random_expression_due_ns = 0; + app->random_expression_interval_seconds = 0.0f; +} + +void bongo_cat_random_expression_update(BongoCatApp *app, uint64_t now) { + if (!app || !app->settings.window.random_expression || !app->live2d) { + bongo_cat_random_expression_reset(app); + return; + } + float seconds = app->settings.window.random_expression_interval_seconds; + if (!(seconds >= 1.0f && seconds <= 3600.0f)) + seconds = BONGO_CAT_DEFAULT_RANDOM_EXPRESSION_SECONDS; + if (!app->random_expression_due_ns || + app->random_expression_interval_seconds != seconds) { + app->random_expression_interval_seconds = seconds; + app->random_expression_due_ns = now + + (uint64_t)(seconds * (float)NANOSECONDS_PER_SECOND); + return; + } + if (now < app->random_expression_due_ns) return; + app->random_expression_due_ns = now + + (uint64_t)(seconds * (float)NANOSECONDS_PER_SECOND); + + int current = bongo_cat_live2d_expression(app->live2d); + size_t expression_count = 0; + size_t alternate_count = 0; + for (size_t i = 0; i < app->behaviors.count; ++i) { + const BongoCatBehaviorEntry *entry = &app->behaviors.entries[i]; + if (entry->kind != BONGO_CAT_BEHAVIOR_EXPRESSION) continue; + expression_count++; + if (entry->index != current) alternate_count++; + } + if (!expression_count) return; + + bool choose_alternate = alternate_count > 0; + size_t candidate_count = choose_alternate ? alternate_count : expression_count; + size_t choice = random_expression_next(app, now) % candidate_count; + for (size_t i = 0; i < app->behaviors.count; ++i) { + const BongoCatBehaviorEntry *entry = &app->behaviors.entries[i]; + if (entry->kind != BONGO_CAT_BEHAVIOR_EXPRESSION || + (choose_alternate && entry->index == current)) continue; + if (choice--) continue; + if (bongo_cat_live2d_set_expression(app->live2d, entry->index)) + app->dirty = true; + return; + } +} + bool bongo_cat_app_step_live2d(BongoCatApp *app, float elapsed_seconds) { if (!app || !app->live2d || elapsed_seconds <= 0.0f) return false; if (elapsed_seconds > 0.25f) elapsed_seconds = 0.25f; diff --git a/src/runtime/model/multi_pet.c b/src/runtime/model/multi_pet.c new file mode 100644 index 00000000..88f111a4 --- /dev/null +++ b/src/runtime/model/multi_pet.c @@ -0,0 +1,273 @@ +#include "runtime.h" +#include "bongo_cat/file.h" +#include "bongo_cat/path.h" + +#include +#include +#include +#include + +#define CONTROL_FILE "control.txt" +#define REMOVE_REQUEST_FILE "remove.request" +#define CONTROL_CHECK_NS 100000000ull +#define CONTROL_STALE_SECONDS 10 +#define CONTROL_RETRY_GRACE_NS 5000000000ull +#define SETTINGS_CHECK_NS 2000000000ull + +typedef enum ControlReadResult { + CONTROL_READ_OK, CONTROL_READ_RETRY, CONTROL_READ_END +} ControlReadResult; + +typedef struct ControlState { + bool visible; + bool pass_through; +} ControlState; + +static uint64_t model_hash(const char *model_id) { + uint64_t hash = 1469598103934665603ull; + for (const unsigned char *p = (const unsigned char *)model_id; p && *p; ++p) { + hash ^= *p; + hash *= 1099511628211ull; + } + return hash; +} + +bool bongo_cat_multi_pet_state_directory(char *target, size_t capacity, + const char *root, const char *model_id) { + char pets[BONGO_CAT_PATH_CAP], name[24]; + snprintf(name, sizeof(name), "%016llx", + (unsigned long long)model_hash(model_id)); + return root && model_id && model_id[0] && + bongo_cat_path_join(pets, sizeof(pets), root, "pets") && + bongo_cat_path_join(target, capacity, pets, name); +} + +static bool primary_remove_request_path(char *target, size_t capacity, + const BongoCatApp *app, const char *model_id) { + char directory[BONGO_CAT_PATH_CAP]; + return app && bongo_cat_multi_pet_state_directory(directory, + sizeof(directory), app->primary_state_root, model_id) && + bongo_cat_path_join(target, capacity, directory, REMOVE_REQUEST_FILE); +} + +bool bongo_cat_multi_pet_take_remove_request(BongoCatApp *app, + const char *model_id) { + char path[BONGO_CAT_PATH_CAP]; + if (!primary_remove_request_path(path, sizeof(path), app, model_id) || + !bongo_cat_path_is_file(path)) return false; + if (bongo_cat_file_remove(path)) return true; + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet remove request could not be consumed: " + "model=%s path=%s", model_id, path); + return false; +} + +void bongo_cat_multi_pet_clear_remove_request(BongoCatApp *app, + const char *model_id) { + char path[BONGO_CAT_PATH_CAP]; + if (primary_remove_request_path(path, sizeof(path), app, model_id) && + bongo_cat_path_is_file(path)) bongo_cat_file_remove(path); +} + +bool bongo_cat_multi_pet_secondary_argument(int argc, char **argv) { + static const char prefix[] = "--secondary-pet="; + for (int i = 1; i < argc; ++i) + if (!strncmp(argv[i], prefix, sizeof(prefix) - 1) && + argv[i][sizeof(prefix) - 1]) return true; + return false; +} + +bool bongo_cat_multi_pet_request_remove(BongoCatApp *app) { + char path[BONGO_CAT_PATH_CAP], temporary[BONGO_CAT_PATH_CAP]; + int length; + if (!app || !app->secondary_pet || + !bongo_cat_path_join(path, sizeof(path), app->state_root, + REMOVE_REQUEST_FILE) || + (length = snprintf(temporary, sizeof(temporary), "%s.tmp", path)) < 0 || + (size_t)length >= sizeof(temporary)) return false; + FILE *file = bongo_cat_file_open(temporary, "wb"); + bool written = file && fprintf(file, "%s\n", app->secondary_model_id) > 0; + if (file && fclose(file) != 0) written = false; + if (!written || !bongo_cat_file_replace(temporary, path, true)) { + bongo_cat_file_remove(temporary); + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet remove request failed: model=%s path=%s", + app->secondary_model_id, path); + return false; + } + return true; +} + +static ControlReadResult read_control(BongoCatApp *app, + ControlState *control, const char **retry_reason) { + char path[BONGO_CAT_PATH_CAP], content[64] = {0}; + if (!bongo_cat_path_join(path, sizeof(path), app->state_root, + CONTROL_FILE)) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet child control path is too long: model=%s", + app->secondary_model_id); + return CONTROL_READ_END; + } + FILE *file = bongo_cat_file_open(path, "rb"); + if (!file) { + *retry_reason = "missing"; + return CONTROL_READ_RETRY; + } + size_t length = file ? fread(content, 1, sizeof(content) - 1, file) : 0; + if (file) fclose(file); + content[length] = '\0'; + char state[8] = {0}, extra = 0; + long long heartbeat = 0; + int pass_through = app->settings.window.pass_through; + int fields = sscanf(content, "%7s %lld %d %c", state, &heartbeat, + &pass_through, &extra); + bool valid = fields == 3 && + (pass_through == 0 || pass_through == 1); + time_t now = time(NULL), then = (time_t)heartbeat; + if (!valid) { + *retry_reason = "invalid-format"; + return CONTROL_READ_RETRY; + } + if (now < then || now - then > CONTROL_STALE_SECONDS) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet child control expired: model=%s " + "reason=stale age=%lld path=%s", app->secondary_model_id, + (long long)(now - then), path); + return CONTROL_READ_END; + } + control->visible = !strcmp(state, "visible"); + if (!control->visible && strcmp(state, "hidden")) { + *retry_reason = "invalid-state"; + return CONTROL_READ_RETRY; + } + control->pass_through = pass_through != 0; + return CONTROL_READ_OK; +} + +static void reload_secondary_settings(BongoCatApp *app) { + BongoCatSettings settings = app->settings; + BongoCatError error = {0}; + BongoCatResult loaded = bongo_cat_settings_load( + app->settings_path, &settings, &error); + if (loaded != BONGO_CAT_OK) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet child settings sync failed: model=%s " + "result=%d error=%s", app->secondary_model_id, loaded, + error.message[0] ? error.message : "unknown error"); + return; + } + if (app->secondary_control_known) + settings.window.pass_through = app->secondary_control_pass_through; + bool model_changed = memcmp(&settings.model, &app->settings.model, + sizeof(settings.model)) != 0; + bool window_changed = memcmp(&settings.window, &app->settings.window, + sizeof(settings.window)) != 0; + app->settings = settings; + if (model_changed) app->dirty = true; + if (window_changed) { + app->dirty = true; + bongo_cat_platform_set_always_on_top(&app->platform, + app->settings.window.always_on_top); + bongo_cat_window_mark_hit_dirty(app); + bongo_cat_window_sync_click_through(app); + } +} + +static void update_secondary(BongoCatApp *app, uint64_t now) { + if (!app->secondary_control_check_ns || + now >= app->secondary_control_check_ns) { + ControlState control = {0}; + const char *retry_reason = "unknown"; + ControlReadResult result = read_control(app, &control, &retry_reason); + if (result == CONTROL_READ_END) app->running = false; + else if (result == CONTROL_READ_RETRY) { + if (!app->secondary_control_failure_ns) + app->secondary_control_failure_ns = now; + else if (now - app->secondary_control_failure_ns >= + CONTROL_RETRY_GRACE_NS) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet child control unavailable: model=%s " + "reason=%s", app->secondary_model_id, retry_reason); + app->running = false; + } + } else { + bool visibility_changed = !app->secondary_control_known || + control.visible != app->session.window.visible; + bool pass_changed = !app->secondary_control_known || + control.pass_through != app->settings.window.pass_through; + app->secondary_control_failure_ns = 0; + app->secondary_control_known = true; + app->secondary_control_visible = control.visible; + app->secondary_control_pass_through = control.pass_through; + if (pass_changed) { + app->settings.window.pass_through = control.pass_through; + bongo_cat_window_mark_hit_dirty(app); + bongo_cat_window_sync_click_through(app); + } + if (visibility_changed) + bongo_cat_window_set_visible(app, control.visible); + } + app->secondary_control_check_ns = now + CONTROL_CHECK_NS; + } + if (!app->secondary_settings_check_ns || + now >= app->secondary_settings_check_ns) { + reload_secondary_settings(app); + app->secondary_settings_check_ns = now + SETTINGS_CHECK_NS; + } +} + +void bongo_cat_multi_pet_update(BongoCatApp *app, uint64_t now) { + if (!app) return; + if (app->secondary_pet) update_secondary(app, now); + else bongo_cat_multi_pet_primary_update(app, now); +} + +bool bongo_cat_app_model_active(const BongoCatApp *app, const char *id) { + if (!app || !id) return false; + if (!strcmp(app->session.active_model_id, id)) return true; + return app->settings.model.multiple_pets && + bongo_cat_session_model_active(&app->session, id); +} + +size_t bongo_cat_app_active_model_count(const BongoCatApp *app) { + return app && app->settings.model.multiple_pets + ? 1 + app->session.additional_model_count : app ? 1 : 0; +} + +bool bongo_cat_app_set_model_active(BongoCatApp *app, const char *id, + bool active, BongoCatError *error) { + if (!app || !id || !bongo_cat_models_find(&app->models, id)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, + "Model is not installed: %s", id ? id : ""); + return false; + } + if (!app->settings.model.multiple_pets) + return active ? bongo_cat_app_select_model_with_error(app, id, error) : true; + bool primary = !strcmp(app->session.active_model_id, id); + if (active) { + if (!bongo_cat_session_add_model(&app->session, id)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, + "At most %d desktop pets can be displayed", + BONGO_CAT_ACTIVE_MODEL_CAP); + return false; + } + } else if (primary) { + if (!app->session.additional_model_count) return true; + char replacement[BONGO_CAT_ID_CAP]; + snprintf(replacement, sizeof(replacement), "%s", + app->session.additional_model_ids[0]); + if (!bongo_cat_app_select_model_with_error(app, replacement, error)) + return false; + bongo_cat_session_remove_model(&app->session, replacement); + } else bongo_cat_session_remove_model(&app->session, id); + bongo_cat_multi_pet_primary_update(app, SDL_GetTicksNS()); + return true; +} + +void bongo_cat_app_set_multiple_pets(BongoCatApp *app, bool enabled) { + if (!app) return; + app->settings.model.multiple_pets = enabled; + if (!enabled) bongo_cat_session_clear_additional_models(&app->session); + if (!app->secondary_pet) + bongo_cat_multi_pet_primary_update(app, SDL_GetTicksNS()); +} diff --git a/src/runtime/model/multi_pet_process.c b/src/runtime/model/multi_pet_process.c new file mode 100644 index 00000000..4206945e --- /dev/null +++ b/src/runtime/model/multi_pet_process.c @@ -0,0 +1,279 @@ +#include "runtime.h" +#include "preferences_notice.h" +#include "bongo_cat/file.h" +#include "bongo_cat/i18n.h" +#include "bongo_cat/path.h" +#include +#include +#include +#include +#define CONTROL_FILE "control.txt" +#define CONTROL_HEARTBEAT_NS 2000000000ull +#define CHILD_RETRY_NS 2000000000ull +#define CHILD_STOP_NS 1000000000ull +typedef struct SecondaryPet { + char model_id[BONGO_CAT_ID_CAP]; + SDL_Process *process; + uint64_t retry_ns, stop_ns; + unsigned failures; + bool stopping, control_ready, control_visible, control_pass_through; +} SecondaryPet; +struct BongoCatMultiPetRuntime { + SecondaryPet pets[BONGO_CAT_ADDITIONAL_MODEL_CAP]; + size_t count; + uint64_t heartbeat_ns; +}; +static bool control_path(char *target, size_t capacity, + const BongoCatApp *app, const char *model_id, bool create) { + char pets[BONGO_CAT_PATH_CAP], directory[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(pets, sizeof(pets), app->primary_state_root, + "pets") || (create && !bongo_cat_path_create_directory(pets)) || + !bongo_cat_multi_pet_state_directory(directory, sizeof(directory), + app->primary_state_root, model_id) || + (create && !bongo_cat_path_create_directory(directory))) return false; + return bongo_cat_path_join(target, capacity, directory, CONTROL_FILE); +} + +static bool write_control(BongoCatApp *app, SecondaryPet *pet) { + char path[BONGO_CAT_PATH_CAP], temporary[BONGO_CAT_PATH_CAP]; + int length; + if (!control_path(path, sizeof(path), app, pet->model_id, true) || + (length = snprintf(temporary, sizeof(temporary), "%s.tmp", path)) < 0 || + (size_t)length >= sizeof(temporary)) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet control path preparation failed: model=%s", + pet->model_id); + return false; + } + FILE *file = bongo_cat_file_open(temporary, "wb"); + bool written = file && fprintf(file, "%s %lld %d\n", + app->session.window.visible ? "visible" : "hidden", + (long long)time(NULL), app->settings.window.pass_through) > 0; + if (file && fclose(file) != 0) written = false; + if (!written || !bongo_cat_file_replace(temporary, path, true)) { + bongo_cat_file_remove(temporary); + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet control write failed: model=%s path=%s " + "visible=%d pass_through=%d", pet->model_id, path, + app->session.window.visible, app->settings.window.pass_through); + return false; + } + pet->control_ready = true; + pet->control_visible = app->session.window.visible; + pet->control_pass_through = app->settings.window.pass_through; + return true; +} + +static void remove_control(BongoCatApp *app, SecondaryPet *pet) { + char path[BONGO_CAT_PATH_CAP]; + if (control_path(path, sizeof(path), app, pet->model_id, false)) + bongo_cat_file_remove(path); + pet->control_ready = false; +} + +static SecondaryPet *find_pet(BongoCatMultiPetRuntime *runtime, + const char *model_id) { + for (size_t i = 0; i < runtime->count; ++i) + if (!strcmp(runtime->pets[i].model_id, model_id)) + return &runtime->pets[i]; + return NULL; +} + +static void remove_pet(BongoCatMultiPetRuntime *runtime, size_t index) { + if (index + 1 < runtime->count) + memmove(&runtime->pets[index], &runtime->pets[index + 1], + (runtime->count - index - 1) * sizeof(runtime->pets[0])); + memset(&runtime->pets[--runtime->count], 0, sizeof(runtime->pets[0])); +} + +static bool desired(const BongoCatApp *app, const char *model_id) { + if (!app->settings.model.multiple_pets) return false; + for (size_t i = 0; i < app->session.additional_model_count; ++i) + if (!strcmp(app->session.additional_model_ids[i], model_id)) + return true; + return false; +} + +static SDL_Process *create_pet_process(const char *const *args) { + SDL_PropertiesID properties = SDL_CreateProperties(); + if (!properties) return NULL; + bool configured = SDL_SetPointerProperty(properties, + SDL_PROP_PROCESS_CREATE_ARGS_POINTER, (void *)args) && + SDL_SetNumberProperty(properties, + SDL_PROP_PROCESS_CREATE_STDIN_NUMBER, + SDL_PROCESS_STDIO_NULL) && + SDL_SetNumberProperty(properties, + SDL_PROP_PROCESS_CREATE_STDOUT_NUMBER, + SDL_PROCESS_STDIO_NULL) && + SDL_SetNumberProperty(properties, + SDL_PROP_PROCESS_CREATE_STDERR_NUMBER, + SDL_PROCESS_STDIO_NULL); + SDL_Process *process = configured ? + SDL_CreateProcessWithProperties(properties) : NULL; + if (!configured) SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet process properties failed: %s", SDL_GetError()); + SDL_DestroyProperties(properties); + return process; +} + +static bool spawn_pet(BongoCatApp *app, SecondaryPet *pet, size_t index, + uint64_t now) { + char model_arg[BONGO_CAT_ID_CAP + 20]; + char storage_arg[BONGO_CAT_PATH_CAP + 20]; + char position_arg[80]; + int x = app->session.window.x, y = app->session.window.y; + if (app->window) SDL_GetWindowPosition(app->window, &x, &y); + snprintf(model_arg, sizeof(model_arg), "--secondary-pet=%s", pet->model_id); + snprintf(position_arg, sizeof(position_arg), "--secondary-position=%d,%d", + x + 36 * (int)(index + 1), y + 36 * (int)(index + 1)); + const char *args[5] = {app->executable_path[0] ? app->executable_path : + BONGO_CAT_EXECUTABLE, model_arg, position_arg, NULL, NULL}; + if (app->storage_root[0]) { + snprintf(storage_arg, sizeof(storage_arg), "--storage-root=%s", + app->storage_root); + args[3] = storage_arg; + } + bongo_cat_multi_pet_clear_remove_request(app, pet->model_id); + bongo_cat_multi_pet_clear_pass_through_request(app, pet->model_id); + if (!write_control(app, pet)) { + pet->failures++; + pet->retry_ns = now + CHILD_RETRY_NS; + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "Unable to prepare additional desktop pet %s", + pet->model_id); + return false; + } + /* GUI launches may not have inheritable standard handles on Windows. */ + pet->process = create_pet_process(args); + if (pet->process) { + pet->stopping = false; + return true; + } + pet->failures++; + pet->retry_ns = now + CHILD_RETRY_NS; + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "Unable to start additional desktop pet %s: %s", + pet->model_id, SDL_GetError()); + return false; +} + +static void reap_pets(BongoCatApp *app, uint64_t now) { + BongoCatMultiPetRuntime *runtime = app->multi_pet; + for (size_t i = runtime->count; i > 0; --i) { + SecondaryPet *pet = &runtime->pets[i - 1]; + int exit_code = -1; + bool exited = pet->process && + SDL_WaitProcess(pet->process, false, &exit_code); + if (exited) { + if (!pet->stopping) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet process exited unexpectedly: " + "model=%s exit_code=%d", pet->model_id, exit_code); + SDL_DestroyProcess(pet->process); + pet->process = NULL; + if (!pet->stopping) { + pet->failures++; + pet->retry_ns = now + CHILD_RETRY_NS; + } + } + if (pet->stopping && pet->process && now >= pet->stop_ns) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet process stop timed out; killing: model=%s", + pet->model_id); + SDL_KillProcess(pet->process, true); + SDL_WaitProcess(pet->process, true, NULL); + SDL_DestroyProcess(pet->process); + pet->process = NULL; + } + if (pet->stopping && !pet->process) remove_pet(runtime, i - 1); + } +} + +static void prune_selection(BongoCatApp *app) { + bongo_cat_session_validate(&app->session); + for (size_t i = app->session.additional_model_count; i > 0; --i) { + const char *id = app->session.additional_model_ids[i - 1]; + if (!bongo_cat_models_find(&app->models, id)) + bongo_cat_session_remove_model(&app->session, id); + } +} + +static void consume_remove_requests(BongoCatApp *app) { + BongoCatMultiPetRuntime *runtime = app->multi_pet; + for (size_t i = runtime->count; i > 0; --i) { + SecondaryPet *pet = &runtime->pets[i - 1]; + if (!pet->process || pet->stopping || !desired(app, pet->model_id) || + !bongo_cat_multi_pet_take_remove_request(app, pet->model_id)) + continue; + bongo_cat_session_remove_model(&app->session, pet->model_id); + bongo_cat_preferences_invalidate(app->preferences); + } +} + +void bongo_cat_multi_pet_primary_update(BongoCatApp *app, uint64_t now) { + prune_selection(app); + if (!app->multi_pet && app->session.additional_model_count) + app->multi_pet = calloc(1, sizeof(*app->multi_pet)); + if (!app->multi_pet) return; + BongoCatMultiPetRuntime *runtime = app->multi_pet; + consume_remove_requests(app); + bongo_cat_multi_pet_pass_through_requests_update(app); + reap_pets(app, now); + for (size_t i = 0; i < runtime->count; ++i) { + SecondaryPet *pet = &runtime->pets[i]; + if (!desired(app, pet->model_id) && !pet->stopping) { + remove_control(app, pet); + pet->stopping = true; + pet->stop_ns = now + CHILD_STOP_NS; + } + } + bool heartbeat = !runtime->heartbeat_ns || + now - runtime->heartbeat_ns >= CONTROL_HEARTBEAT_NS; + for (size_t i = 0; i < app->session.additional_model_count; ++i) { + const char *id = app->session.additional_model_ids[i]; + SecondaryPet *pet = find_pet(runtime, id); + if (!pet && runtime->count < BONGO_CAT_ADDITIONAL_MODEL_CAP) { + pet = &runtime->pets[runtime->count++]; + snprintf(pet->model_id, sizeof(pet->model_id), "%s", id); + } + if (!pet || pet->stopping) continue; + if (heartbeat || !pet->control_ready || + pet->control_visible != app->session.window.visible || + pet->control_pass_through != app->settings.window.pass_through) + write_control(app, pet); + if (!pet->process && pet->failures < 3 && now >= pet->retry_ns) + spawn_pet(app, pet, i, now); + if (!pet->process && pet->failures >= 3) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet model removed after launch failures: " + "model=%s failures=%u", pet->model_id, pet->failures); + bongo_cat_session_remove_model(&app->session, pet->model_id); + remove_control(app, pet); + pet->stopping = true; + if (app->preferences) bongo_cat_preferences_notice_show(app, + bongo_cat_i18n_get(app->i18n, "native.modelLoadFailed", + "Unable to display this model"), true); + } + } + if (heartbeat) runtime->heartbeat_ns = now; +} + +void bongo_cat_multi_pet_shutdown(BongoCatApp *app) { + if (!app || !app->multi_pet) return; + BongoCatMultiPetRuntime *runtime = app->multi_pet; + for (size_t i = 0; i < runtime->count; ++i) + remove_control(app, &runtime->pets[i]); + uint64_t deadline = SDL_GetTicksNS() + 500000000ull; + for (size_t i = 0; i < runtime->count; ++i) { + SecondaryPet *pet = &runtime->pets[i]; + while (pet->process && SDL_GetTicksNS() < deadline && + !SDL_WaitProcess(pet->process, false, NULL)) SDL_Delay(10); + if (pet->process && !SDL_WaitProcess(pet->process, false, NULL)) { + SDL_KillProcess(pet->process, true); + SDL_WaitProcess(pet->process, true, NULL); + } + if (pet->process) SDL_DestroyProcess(pet->process); + } + free(runtime); + app->multi_pet = NULL; +} diff --git a/src/runtime/model/multi_pet_settings.c b/src/runtime/model/multi_pet_settings.c new file mode 100644 index 00000000..d6a65d53 --- /dev/null +++ b/src/runtime/model/multi_pet_settings.c @@ -0,0 +1,101 @@ +#include "runtime.h" +#include "bongo_cat/file.h" +#include "bongo_cat/path.h" +#include "bongo_cat/preferences.h" + +#include + +#define PASS_THROUGH_REQUEST_FILE "pass-through.request" + +static bool request_path(char *target, size_t capacity, const char *root, + const char *model_id) { + char directory[BONGO_CAT_PATH_CAP]; + return bongo_cat_multi_pet_state_directory(directory, sizeof(directory), + root, model_id) && + bongo_cat_path_join(target, capacity, directory, + PASS_THROUGH_REQUEST_FILE); +} + +void bongo_cat_multi_pet_clear_pass_through_request(BongoCatApp *app, + const char *model_id) { + char path[BONGO_CAT_PATH_CAP]; + if (app && request_path(path, sizeof(path), app->primary_state_root, + model_id) && bongo_cat_path_is_file(path) && + !bongo_cat_file_remove(path)) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Stale multi-pet pass-through request could not be " + "removed: model=%s path=%s", model_id, path); +} + +bool bongo_cat_multi_pet_request_pass_through(BongoCatApp *app, + bool enabled) { + char path[BONGO_CAT_PATH_CAP], temporary[BONGO_CAT_PATH_CAP]; + int length; + if (!app || !app->secondary_pet || + !bongo_cat_path_join(path, sizeof(path), app->state_root, + PASS_THROUGH_REQUEST_FILE) || + (length = snprintf(temporary, sizeof(temporary), "%s.tmp", path)) < 0 || + (size_t)length >= sizeof(temporary)) return false; + FILE *file = bongo_cat_file_open(temporary, "wb"); + bool written = file && fprintf(file, "%d\n", enabled) > 0; + if (file && fclose(file) != 0) written = false; + if (!written || !bongo_cat_file_replace(temporary, path, true)) { + bongo_cat_file_remove(temporary); + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet pass-through request failed: model=%s " + "path=%s", app->secondary_model_id, path); + return false; + } + return true; +} + +static bool take_request(BongoCatApp *app, const char *model_id, + bool *enabled) { + char path[BONGO_CAT_PATH_CAP], content[8] = {0}; + if (!request_path(path, sizeof(path), app->primary_state_root, model_id) || + !bongo_cat_path_is_file(path)) return false; + FILE *file = bongo_cat_file_open(path, "rb"); + if (!file) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet pass-through request could not be read: " + "model=%s path=%s", model_id, path); + return false; + } + size_t length = file ? fread(content, 1, sizeof(content) - 1, file) : 0; + if (file) fclose(file); + if (!bongo_cat_file_remove(path)) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet pass-through request could not be consumed: " + "model=%s path=%s", model_id, path); + return false; + } + content[length] = '\0'; + bool valid = content[0] == '0' || content[0] == '1'; + for (size_t i = 1; valid && content[i]; ++i) + valid = content[i] == ' ' || content[i] == '\t' || + content[i] == '\r' || content[i] == '\n'; + if (!valid) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet pass-through request invalid: model=%s " + "path=%s content=%s", model_id, path, content); + return false; + } + *enabled = content[0] == '1'; + return true; +} + +void bongo_cat_multi_pet_pass_through_requests_update(BongoCatApp *app) { + if (!app || app->secondary_pet) return; + bool changed = false; + for (size_t i = 0; i < app->session.additional_model_count; ++i) { + bool enabled; + const char *model_id = app->session.additional_model_ids[i]; + if (!take_request(app, model_id, &enabled)) continue; + app->settings.window.pass_through = enabled; + changed = true; + } + if (!changed) return; + bongo_cat_window_mark_hit_dirty(app); + bongo_cat_window_sync_click_through(app); + bongo_cat_preferences_invalidate(app->preferences); +} diff --git a/src/runtime/runtime.h b/src/runtime/runtime.h index b067f8fe..ed15fa0a 100644 --- a/src/runtime/runtime.h +++ b/src/runtime/runtime.h @@ -6,6 +6,9 @@ #include BongoCatResult bongo_cat_window_create(BongoCatApp *app, BongoCatError *error); +bool bongo_cat_app_initialize(BongoCatApp *app, int argc, + char **argv, BongoCatError *error); +void bongo_cat_app_loop(BongoCatApp *app); BongoCatResult bongo_cat_app_locate_assets(BongoCatApp *app, BongoCatError *error); bool bongo_cat_startup_prepare(BongoCatApp *app, int argc, char **argv, BongoCatError *error); @@ -95,8 +98,26 @@ void bongo_cat_frame_capture_pending(BongoCatApp *app, int width, int height); void bongo_cat_app_render_now(BongoCatApp *app); bool bongo_cat_app_capture_pending_frame(BongoCatApp *app); void bongo_cat_runtime_flow_update(BongoCatApp *app, uint64_t now); +void bongo_cat_random_expression_update(BongoCatApp *app, uint64_t now); +void bongo_cat_random_expression_reset(BongoCatApp *app); void bongo_cat_config_store_load(BongoCatApp *app); void bongo_cat_config_store_update(BongoCatApp *app, uint64_t now); void bongo_cat_config_store_flush(BongoCatApp *app); +bool bongo_cat_multi_pet_secondary_argument(int argc, char **argv); +bool bongo_cat_multi_pet_state_directory(char *target, size_t capacity, + const char *root, const char *model_id); +void bongo_cat_multi_pet_update(BongoCatApp *app, uint64_t now); +void bongo_cat_multi_pet_primary_update(BongoCatApp *app, uint64_t now); +bool bongo_cat_multi_pet_request_remove(BongoCatApp *app); +bool bongo_cat_multi_pet_request_pass_through(BongoCatApp *app, + bool enabled); +void bongo_cat_multi_pet_pass_through_requests_update(BongoCatApp *app); +void bongo_cat_multi_pet_clear_pass_through_request(BongoCatApp *app, + const char *model_id); +bool bongo_cat_multi_pet_take_remove_request(BongoCatApp *app, + const char *model_id); +void bongo_cat_multi_pet_clear_remove_request(BongoCatApp *app, + const char *model_id); +void bongo_cat_multi_pet_shutdown(BongoCatApp *app); #endif diff --git a/src/runtime/shell/window_menu_actions.c b/src/runtime/shell/window_menu_actions.c index 92992712..c664217e 100644 --- a/src/runtime/shell/window_menu_actions.c +++ b/src/runtime/shell/window_menu_actions.c @@ -27,6 +27,10 @@ static bool select_model(BongoCatApp *app, const char *id) { } void bongo_cat_window_show_context_menu(BongoCatApp *app) { + if (!app) return; + bool dark_theme = app->settings.app.theme == BONGO_CAT_THEME_DARK || + (app->settings.app.theme == BONGO_CAT_THEME_AUTO && + SDL_GetSystemTheme() == SDL_SYSTEM_THEME_DARK); BongoCatWindowMenuPreview preview; bongo_cat_window_menu_preview_init(&preview, app); const char *model_names[BONGO_CAT_MODEL_CAP]; @@ -44,9 +48,6 @@ void bongo_cat_window_show_context_menu(BongoCatApp *app) { bongo_cat_window_behavior_labels(app, motion_names, motion_checked, &motion_count, expression_names, &expression_count, ¤t_expression); - bool dark_theme = app->settings.app.theme == BONGO_CAT_THEME_DARK || - (app->settings.app.theme == BONGO_CAT_THEME_AUTO && - SDL_GetSystemTheme() == SDL_SYSTEM_THEME_DARK); BongoCatMenuLabels labels = { tr(app, "composables.useAppMenu.labels.preference", "Preferences"), tr(app, "composables.useAppMenu.labels.hideCat", "Hide Cat"), @@ -68,7 +69,10 @@ void bongo_cat_window_show_context_menu(BongoCatApp *app) { app->session.window.opacity_percent, app->settings.window.pass_through, app->settings.window.always_on_top, dark_theme, bongo_cat_window_menu_preview, bongo_cat_window_menu_preview_tick, - bongo_cat_window_menu_restore, &preview}; + bongo_cat_window_menu_restore, &preview, + tr(app, "native.removeDesktopPet", "Remove this desktop pet"), + app->secondary_pet || (app->settings.model.multiple_pets && + app->session.additional_model_count > 0)}; BongoCatMenuAction action = bongo_cat_platform_context_menu( &app->platform, &labels); if (bongo_cat_window_menu_preview_applied(&preview, action)) @@ -78,16 +82,22 @@ void bongo_cat_window_show_context_menu(BongoCatApp *app) { void bongo_cat_window_menu_action(BongoCatApp *app, BongoCatMenuAction action) { - if (action == BONGO_CAT_MENU_PREFERENCES) - bongo_cat_preferences_show(app->preferences); - else if (action == BONGO_CAT_MENU_MODEL_ADD) - bongo_cat_preferences_open_model_import(app->preferences, app->window); - else if (action == BONGO_CAT_MENU_HIDE) + if (action == BONGO_CAT_MENU_PREFERENCES) { + if (app->preferences) bongo_cat_preferences_show(app->preferences); + } else if (action == BONGO_CAT_MENU_MODEL_ADD) { + if (app->preferences) + bongo_cat_preferences_open_model_import(app->preferences, + app->window); + } else if (action == BONGO_CAT_MENU_HIDE) bongo_cat_window_set_visible(app, false); else if (action == BONGO_CAT_MENU_PASS_THROUGH) { - app->settings.window.pass_through = !app->settings.window.pass_through; - bongo_cat_window_mark_hit_dirty(app); - bongo_cat_window_sync_click_through(app); + bool enabled = !app->settings.window.pass_through; + if (!app->secondary_pet || + bongo_cat_multi_pet_request_pass_through(app, enabled)) { + app->settings.window.pass_through = enabled; + bongo_cat_window_mark_hit_dirty(app); + bongo_cat_window_sync_click_through(app); + } } else if (action == BONGO_CAT_MENU_ALWAYS_ON_TOP) { app->settings.window.always_on_top = !app->settings.window.always_on_top; bongo_cat_platform_set_always_on_top(&app->platform, @@ -111,9 +121,28 @@ void bongo_cat_window_menu_action(BongoCatApp *app, } else if (action >= BONGO_CAT_MENU_MODEL_FIRST && action < BONGO_CAT_MENU_MODEL_FIRST + BONGO_CAT_MODEL_CAP) { size_t index = (size_t)(action - BONGO_CAT_MENU_MODEL_FIRST); - if (index < app->models.count) + if (!app->secondary_pet && index < app->models.count) select_model(app, app->models.entries[index].id); - } else if (action == BONGO_CAT_MENU_EXIT) app->running = false; + } else if (action == BONGO_CAT_MENU_REMOVE_PET) { + if (app->secondary_pet) { + if (bongo_cat_multi_pet_request_remove(app)) app->running = false; + } else if (app->settings.model.multiple_pets && + app->session.additional_model_count) { + char model_id[BONGO_CAT_ID_CAP]; + BongoCatError error = {0}; + snprintf(model_id, sizeof(model_id), "%s", + app->session.active_model_id); + if (!bongo_cat_app_set_model_active(app, model_id, false, &error)) + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet primary remove failed: model=%s " + "error=%s", model_id, + error.message[0] ? error.message : "unknown error"); + } + } else if (action == BONGO_CAT_MENU_EXIT) { + if (app->secondary_pet) { + if (bongo_cat_multi_pet_request_remove(app)) app->running = false; + } else app->running = false; + } bongo_cat_preferences_invalidate(app->preferences); } diff --git a/src/ui/preferences/preferences_model.c b/src/ui/preferences/preferences_model.c index 125e96ab..320411a2 100644 --- a/src/ui/preferences/preferences_model.c +++ b/src/ui/preferences/preferences_model.c @@ -100,8 +100,11 @@ void bongo_cat_preferences_process_model_selection(BongoCatPreferences *value) { return; char id[BONGO_CAT_ID_CAP]; snprintf(id, sizeof(id), "%s", value->pending_model_id); + bool multiple = value->pending_model_multiple; + bool active = value->pending_model_active; value->pending_model_id[0] = '\0'; value->model_selection_pending = false; + value->pending_model_multiple = false; value->model_loading = true; value->model_load_progress = 0.0f; value->model_load_render_progress = 0.0f; @@ -110,7 +113,9 @@ void bongo_cat_preferences_process_model_selection(BongoCatPreferences *value) { bongo_cat_preferences_model_visual_begin(value, id); snprintf(value->loading_model_id, sizeof(value->loading_model_id), "%s", id); BongoCatError error = {0}; - bool selected = bongo_cat_app_select_model_with_error(value->app, id, &error); + bool selected = multiple ? bongo_cat_app_set_model_active( + value->app, id, active, &error) : + bongo_cat_app_select_model_with_error(value->app, id, &error); if (selected) finish_model_load_progress(value); else { value->model_load_progress = 0.0f; @@ -122,7 +127,8 @@ void bongo_cat_preferences_process_model_selection(BongoCatPreferences *value) { if (!selected) bongo_cat_preferences_notice_show(value->app, tr(value->app, "native.modelLoadFailed", "Unable to display this model"), true); bongo_cat_preferences_invalidate(value); - if (value->window) bongo_cat_preferences_render(value); + /* Render on the next input frame. A nested render here would replay the + card click that queued this selection and immediately toggle it back. */ } void bongo_cat_preferences_import_complete(BongoCatApp *app, @@ -205,6 +211,11 @@ void bongo_cat_preferences_page_model(BongoCatPreferences *value, BongoCatApp *app = value->app; smoke_model_behavior(value); bongo_cat_preferences_model_covers_begin(app); + bool multiple = app->settings.model.multiple_pets; + if (bongo_cat_pref_toggle(context, "multiple-pets", tr(app, + "native.multiplePets", + "Display multiple"), "", &multiple)) + bongo_cat_app_set_multiple_pets(app, multiple); bongo_cat_pref_section(context, tr(app, "pages.preference.model.title", "Installed models")); float width = nk_window_get_content_region(context).w; diff --git a/src/ui/preferences/preferences_model_card.c b/src/ui/preferences/preferences_model_card.c index 3dfa76bb..587a2871 100644 --- a/src/ui/preferences/preferences_model_card.c +++ b/src/ui/preferences/preferences_model_card.c @@ -88,9 +88,20 @@ void bongo_cat_preferences_model_select(BongoCatPreferences *value, if (!value || !entry) return; if (value->model_loading && !strcmp(entry->id, value->loading_model_id)) return; - snprintf(value->pending_model_id, sizeof(value->pending_model_id), "%s", - entry->id); - if (strcmp(entry->id, value->app->session.active_model_id)) + bool multiple = value->app->settings.model.multiple_pets; + bool selected = bongo_cat_app_model_active(value->app, entry->id); + if (multiple && !selected && bongo_cat_app_active_model_count(value->app) >= + BONGO_CAT_ACTIVE_MODEL_CAP) { + bongo_cat_preferences_notice_show(value->app, tr(value->app, + "native.multiplePetsLimit", + "Up to 8 desktop pets can be displayed at once"), true); + return; + } + snprintf(value->pending_model_id, sizeof(value->pending_model_id), + "%s", entry->id); + value->pending_model_multiple = multiple; + value->pending_model_active = !selected; + if (!multiple && strcmp(entry->id, value->app->session.active_model_id)) bongo_cat_preferences_model_visual_begin(value, entry->id); value->model_selection_pending = true; value->model_load_progress = 0.0f; @@ -134,7 +145,7 @@ static void draw_cover(BongoCatPreferences *value, static void draw_actions(BongoCatPreferences *value, struct nk_context *context, struct nk_command_buffer *canvas, struct nk_rect bounds, const BongoCatModelEntry *entry, - BongoCatUIPalette p, bool selected, bool *action_hover) { + BongoCatUIPalette p, bool selected, bool primary, bool *action_hover) { struct nk_rect actions = nk_rect(bounds.x + 1, bounds.y + bounds.h - 39, bounds.w - 2, 38); nk_fill_rect(canvas, actions, 0, p.field); @@ -144,22 +155,27 @@ static void draw_actions(BongoCatPreferences *value, nk_rect(actions.x, actions.y, width, actions.h), nk_rect(actions.x + width, actions.y, width, actions.h), nk_rect(actions.x + width * 2, actions.y, width, actions.h)}; - bool hover[3] = { + bool behavior_enabled = primary || + !value->app->settings.model.multiple_pets; + bool region_hover[3] = { nk_input_is_mouse_hovering_rect(&context->input, items[0]), nk_input_is_mouse_hovering_rect(&context->input, items[1]), - deletable && nk_input_is_mouse_hovering_rect(&context->input, items[2])}; + deletable && nk_input_is_mouse_hovering_rect( + &context->input, items[2])}; + bool hover[3] = {behavior_enabled && region_hover[0], + region_hover[1], region_hover[2]}; for (int i = 1; i < (deletable ? 3 : 2); ++i) nk_stroke_line(canvas, items[i].x, items[i].y + 10, items[i].x, items[i].y + items[i].h - 10, 1, p.border_subtle); for (int i = 0; i < 3; ++i) if (hover[i]) nk_fill_rect(canvas, items[i], 0, p.hover_pink); action_icon(value, canvas, items[0], BONGO_CAT_UI_ICON_SMILE, - hover[0] ? p.pink : p.muted); + hover[0] ? p.pink : behavior_enabled ? p.muted : p.border_subtle); action_icon(value, canvas, items[1], BONGO_CAT_UI_ICON_FOLDER, hover[1] ? p.pink : p.muted); if (deletable) action_icon(value, canvas, items[2], BONGO_CAT_UI_ICON_TRASH, hover[2] ? p.danger : p.muted); - *action_hover = hover[0] || hover[1] || hover[2]; + *action_hover = region_hover[0] || region_hover[1] || region_hover[2]; if (hover[0] && nk_input_is_mouse_click_in_rect(&context->input, NK_BUTTON_LEFT, items[0])) { if (selected) bongo_cat_preferences_behavior_dialog_open(value); @@ -171,10 +187,24 @@ static void draw_actions(BongoCatPreferences *value, bongo_cat_preferences_remove_dialog_open(value->app, entry->id); } +static void draw_selection_badge(BongoCatPreferences *value, + struct nk_command_buffer *canvas, struct nk_rect preview, + BongoCatUIPalette p, float amount) { + if (amount <= .001f) return; + float size = 25.0f * (.85f + .15f * amount); + struct nk_rect badge = nk_rect(preview.x + preview.w - size - 8, + preview.y + 8, size, size); + nk_fill_circle(canvas, badge, bongo_cat_ui_color_alpha(p.pink, amount)); + bongo_cat_preferences_icon_draw(value, canvas, BONGO_CAT_UI_ICON_CHECK, + nk_rect(badge.x + 5, badge.y + 5, badge.w - 10, badge.h - 10), + bongo_cat_ui_color_alpha(nk_rgb(255, 255, 255), amount)); +} + void bongo_cat_preferences_model_card(BongoCatPreferences *value, struct nk_context *context, const BongoCatModelEntry *entry) { BongoCatApp *app = value->app; - bool selected = !strcmp(entry->id, app->session.active_model_id); + bool selected = bongo_cat_app_model_active(app, entry->id); + bool primary = !strcmp(entry->id, app->session.active_model_id); bool visual_target = value->model_load_visual_active && !strcmp(entry->id, value->model_load_visual_id); bool selected_visual = selected && !visual_target; @@ -208,13 +238,15 @@ void bongo_cat_preferences_model_card(BongoCatPreferences *value, bounds.w - 2, preview_height); nk_fill_rect(canvas, preview, 12, p.field); draw_cover(value, canvas, preview, entry, p); + if (app->settings.model.multiple_pets) + draw_selection_badge(value, canvas, preview, p, selection_amount); float info_y = preview.y + preview.h + 8; struct nk_rect name_bounds = nk_rect(bounds.x + 9, info_y + 1, bounds.w - 18, 30); bool name_hover = bongo_cat_preferences_model_name_draw(value, context, canvas, entry, name_bounds, p); bool action_hover = false; - draw_actions(value, context, canvas, bounds, entry, p, selected, + draw_actions(value, context, canvas, bounds, entry, p, selected, primary, &action_hover); float outline_width = 1.0f + selection_amount; struct nk_rect outline = bongo_cat_preferences_model_card_outline_bounds( diff --git a/src/ui/preferences/preferences_pages.c b/src/ui/preferences/preferences_pages.c index 0d54260d..03082e12 100644 --- a/src/ui/preferences/preferences_pages.c +++ b/src/ui/preferences/preferences_pages.c @@ -73,6 +73,11 @@ void bongo_cat_preferences_page_cat(BongoCatApp *app, struct nk_context *context if (old_opacity != window_state->opacity_percent && !app->hover_hidden) bongo_cat_platform_set_opacity(&app->platform, window_state->opacity_percent / 100.0f); + bongo_cat_pref_toggle_float(context, "random-expression", tr(app, + "pages.preference.cat.labels.randomExpression", "Random Expressions"), + &window->random_expression, 1.0f, + &window->random_expression_interval_seconds, 3600.0f, 1.0f, + BONGO_CAT_DEFAULT_RANDOM_EXPRESSION_SECONDS); section_gap(context, 10); bongo_cat_pref_section(context, tr(app, "pages.preference.cat.labels.modelSettings", diff --git a/src/ui/preferences/preferences_state.h b/src/ui/preferences/preferences_state.h index d3b0403c..adba68e7 100644 --- a/src/ui/preferences/preferences_state.h +++ b/src/ui/preferences/preferences_state.h @@ -53,6 +53,8 @@ struct BongoCatPreferences { bool font_reload_defer_once; bool smoke_behavior_open_pending; bool model_selection_pending; + bool pending_model_multiple; + bool pending_model_active; bool model_loading; bool model_load_visual_active; float model_load_progress; diff --git a/src/ui/preferences/preferences_widgets.c b/src/ui/preferences/preferences_widgets.c index afb7600c..bc68c48e 100644 --- a/src/ui/preferences/preferences_widgets.c +++ b/src/ui/preferences/preferences_widgets.c @@ -1,4 +1,5 @@ #include "preferences_widgets.h" +#include "preferences_widgets_internal.h" #include "preferences_controls.h" #include "preferences_shortcut_clear.h" #include "ui_animation.h" @@ -10,14 +11,6 @@ #include #include #include -typedef struct FormStyle { - struct nk_style_item background; - struct nk_color window_color; - struct nk_color border_color; - struct nk_vec2 padding; - struct nk_vec2 spacing; - float border; -} FormStyle; static struct nk_context *section_context; static bool section_first; static const float description_line_height = 19.0f; @@ -59,7 +52,7 @@ static void restore_style(struct nk_context *context, const FormStyle *saved) { context->style.window.spacing = saved->spacing; context->style.window.group_border = saved->border; } -static bool form_begin(struct nk_context *context, const char *id, +bool bongo_cat_pref_form_begin(struct nk_context *context, const char *id, int lines, FormStyle *saved) { saved->background = context->style.window.fixed_background; saved->window_color = context->style.window.background; @@ -84,8 +77,11 @@ static bool form_begin(struct nk_context *context, const char *id, } return true; } -static void form_end(struct nk_context *context, const FormStyle *saved) { +void bongo_cat_pref_form_end(struct nk_context *context, + const FormStyle *saved) { nk_group_end(context); restore_style(context, saved); } +#define form_begin bongo_cat_pref_form_begin +#define form_end bongo_cat_pref_form_end static float left_width(struct nk_context *context) { return NK_MAX(220.0f, nk_window_get_content_region(context).w - 228.0f); } @@ -95,13 +91,16 @@ static void form_title_sized(struct nk_context *context, const char *title, float left = NK_MAX(220.0f, available - control_width - 8.0f); nk_layout_row_begin(context, NK_STATIC, 36, 2); nk_layout_row_push(context, left); + bongo_cat_pref_form_label(context, title); + nk_layout_row_push(context, NK_MAX(control_width, available - left - 8.0f)); +} +void bongo_cat_pref_form_label(struct nk_context *context, const char *title) { nk_style_push_font(context, bongo_cat_ui_label_font(context)); struct nk_vec2 text_padding = context->style.text.padding; context->style.text.padding.x += 5.0f; nk_label(context, title, NK_TEXT_LEFT); context->style.text.padding = text_padding; nk_style_pop_font(context); - nk_layout_row_push(context, NK_MAX(control_width, available - left - 8.0f)); } static void form_title(struct nk_context *context, const char *title) { form_title_sized(context, title, 220.0f); } static void description(struct nk_context *context, const char *text, diff --git a/src/ui/preferences/preferences_widgets.h b/src/ui/preferences/preferences_widgets.h index 3d337a7d..614dd81a 100644 --- a/src/ui/preferences/preferences_widgets.h +++ b/src/ui/preferences/preferences_widgets.h @@ -8,6 +8,9 @@ void bongo_cat_pref_section(struct nk_context *context, const char *title); bool bongo_cat_pref_toggle(struct nk_context *context, const char *id, const char *title, const char *description, bool *value); +bool bongo_cat_pref_toggle_float(struct nk_context *context, const char *id, + const char *title, bool *enabled, float minimum, float *value, + float maximum, float step, float default_value); bool bongo_cat_pref_obs_background(struct nk_context *context, const char *id, const char *title, const char *question, const char *reply, bool *enabled, BongoCatObsBackgroundColor *color); diff --git a/src/ui/preferences/preferences_widgets_compound.c b/src/ui/preferences/preferences_widgets_compound.c new file mode 100644 index 00000000..efacc3ef --- /dev/null +++ b/src/ui/preferences/preferences_widgets_compound.c @@ -0,0 +1,31 @@ +#include "preferences_widgets.h" +#include "preferences_widgets_internal.h" +#include "preferences_controls.h" + +bool bongo_cat_pref_toggle_float(struct nk_context *context, const char *id, + const char *title, bool *enabled, float minimum, float *value, + float maximum, float step, float default_value) { + FormStyle saved; + if (!bongo_cat_pref_form_begin(context, id, 0, &saved)) return false; + float available = nk_window_get_content_region(context).w; + int columns = *enabled ? 3 : 2; + float input_width = *enabled ? 122.0f : 0.0f; + float spacing = 8.0f * (columns - 1); + float left = NK_MAX(220.0f, + available - input_width - 80.0f - spacing); + nk_layout_row_begin(context, NK_STATIC, 36, columns); + nk_layout_row_push(context, left); + bongo_cat_pref_form_label(context, title); + bool changed = false; + if (*enabled) { + nk_layout_row_push(context, input_width); + changed = bongo_cat_pref_control_float(context, id, + minimum, value, maximum, step, default_value); + } + nk_layout_row_push(context, NK_MAX(80.0f, + available - left - input_width - spacing)); + changed = bongo_cat_pref_control_toggle(context, id, enabled) || changed; + nk_layout_row_end(context); + bongo_cat_pref_form_end(context, &saved); + return changed; +} diff --git a/src/ui/preferences/preferences_widgets_internal.h b/src/ui/preferences/preferences_widgets_internal.h new file mode 100644 index 00000000..c3fc47a7 --- /dev/null +++ b/src/ui/preferences/preferences_widgets_internal.h @@ -0,0 +1,22 @@ +#ifndef BONGO_CAT_PREFERENCES_WIDGETS_INTERNAL_H +#define BONGO_CAT_PREFERENCES_WIDGETS_INTERNAL_H + +#include +#include "nuklear_config.h" + +typedef struct FormStyle { + struct nk_style_item background; + struct nk_color window_color; + struct nk_color border_color; + struct nk_vec2 padding; + struct nk_vec2 spacing; + float border; +} FormStyle; + +bool bongo_cat_pref_form_begin(struct nk_context *context, const char *id, + int lines, FormStyle *saved); +void bongo_cat_pref_form_end(struct nk_context *context, + const FormStyle *saved); +void bongo_cat_pref_form_label(struct nk_context *context, const char *title); + +#endif diff --git a/tests/core/test_config.c b/tests/core/test_config.c index 6014a4ab..2ef7efdb 100644 --- a/tests/core/test_config.c +++ b/tests/core/test_config.c @@ -29,19 +29,34 @@ static void check_defaults_and_validation(void) { static BongoCatSessionState session; bongo_cat_settings_defaults(&settings); bongo_cat_session_defaults(&session); - CHECK(settings.model.max_fps == 60 && settings.model.mouse_centered); + CHECK(settings.model.max_fps == 60 && settings.model.mouse_centered && + !settings.model.multiple_pets); CHECK(settings.window.always_on_top && !settings.window.keep_in_screen); CHECK(!settings.window.obs_background); + CHECK(!settings.window.random_expression && + settings.window.random_expression_interval_seconds == + BONGO_CAT_DEFAULT_RANDOM_EXPRESSION_SECONDS); CHECK(settings.window.obs_background_color == BONGO_CAT_OBS_BACKGROUND_GREEN); CHECK(session.window.visible && session.window.width == 612 && session.window.height == 354); CHECK(strcmp(session.active_model_id, "standard") == 0); - + CHECK(session.additional_model_count == 0); + CHECK(bongo_cat_session_add_model(&session, "keyboard")); + CHECK(bongo_cat_session_add_model(&session, "keyboard")); + CHECK(bongo_cat_session_add_model(&session, "gamepad")); + CHECK(session.additional_model_count == 2 && + bongo_cat_session_model_active(&session, "standard") && + bongo_cat_session_model_active(&session, "keyboard")); + CHECK(bongo_cat_session_remove_model(&session, "keyboard")); + CHECK(session.additional_model_count == 1 && + !bongo_cat_session_model_active(&session, "keyboard")); + bongo_cat_session_clear_additional_models(&session); settings.model.max_fps = 900; settings.model.auto_release_seconds = NAN; settings.window.obs_background_color = BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT; settings.window.hide_delay_seconds = NAN; + settings.window.random_expression_interval_seconds = NAN; session.window.scale_percent = -2.0f; session.window.opacity_percent = NAN; session.active_behavior_count = 3; @@ -58,6 +73,8 @@ static void check_defaults_and_validation(void) { CHECK(settings.model.auto_release_seconds == BONGO_CAT_DEFAULT_AUTO_RELEASE_SECONDS); CHECK(settings.window.hide_delay_seconds == 0.0f); + CHECK(settings.window.random_expression_interval_seconds == + BONGO_CAT_DEFAULT_RANDOM_EXPRESSION_SECONDS); CHECK(settings.window.obs_background_color == BONGO_CAT_OBS_BACKGROUND_GREEN); CHECK(session.window.scale_percent == 10.0f); @@ -135,10 +152,13 @@ void test_config(void) { bongo_cat_settings_defaults(&settings); bongo_cat_session_defaults(&session); settings.model.max_fps = 30; + settings.model.multiple_pets = true; settings.model.mirror = true; settings.model.mouse_centered = false; settings.window.pass_through = true; settings.window.obs_background = true; + settings.window.random_expression = true; + settings.window.random_expression_interval_seconds = 12.0f; settings.window.obs_background_color = BONGO_CAT_OBS_BACKGROUND_BLUE; settings.app.language = BONGO_CAT_LANG_ZH_CN; memcpy(settings.extensions_json, "{\"example\":{\"enabled\":true}}", @@ -155,6 +175,8 @@ void test_config(void) { session.window.position_known = true; session.window.opacity_percent = 75.0f; memcpy(session.active_model_id, "model", sizeof("model")); + CHECK(bongo_cat_session_add_model(&session, "keyboard")); + CHECK(bongo_cat_session_add_model(&session, "gamepad")); session.active_behavior_count = 2; memcpy(session.active_behaviors[0].model_id, "model", sizeof("model")); @@ -174,10 +196,15 @@ void test_config(void) { BONGO_CAT_OK); CHECK(contains_text(settings_path, "\"format\": \"bongocat/settings\"")); CHECK(contains_text(settings_path, "\"captureBackground\": true")); + CHECK(contains_text(settings_path, "\"randomExpression\": true")); + CHECK(contains_text(settings_path, + "\"randomExpressionIntervalSeconds\": 12.0")); + CHECK(contains_text(settings_path, "\"multiplePets\": true")); CHECK(contains_text(settings_path, "\"example\"")); CHECK(!contains_text(settings_path, "activeModelId")); CHECK(contains_text(session_path, "\"format\": \"bongocat/session\"")); CHECK(contains_text(session_path, "\"activeModelId\": \"model\"")); + CHECK(contains_text(session_path, "\"additionalModelIds\"")); CHECK(contains_text(session_path, "\"activeBehaviors\"")); CHECK(contains_text(session_path, "\"behaviorId\": \"model:expression:2\"")); @@ -191,9 +218,12 @@ void test_config(void) { BONGO_CAT_OK); CHECK(bongo_cat_session_load(session_path, &loaded_session, &error) == BONGO_CAT_OK); - CHECK(loaded_settings.model.max_fps == 30 && loaded_settings.model.mirror); + CHECK(loaded_settings.model.max_fps == 30 && loaded_settings.model.mirror && + loaded_settings.model.multiple_pets); CHECK(loaded_settings.window.pass_through && - loaded_settings.window.obs_background); + loaded_settings.window.obs_background && + loaded_settings.window.random_expression && + loaded_settings.window.random_expression_interval_seconds == 12.0f); CHECK(loaded_settings.app.language == BONGO_CAT_LANG_ZH_CN); CHECK(strstr(loaded_settings.extensions_json, "\"enabled\":true") != NULL); @@ -203,6 +233,9 @@ void test_config(void) { CHECK(loaded_session.window.x == -321 && loaded_session.window.opacity_percent == 75.0f); CHECK(strcmp(loaded_session.active_model_id, "model") == 0); + CHECK(loaded_session.additional_model_count == 2 && + bongo_cat_session_model_active(&loaded_session, "keyboard") && + bongo_cat_session_model_active(&loaded_session, "gamepad")); CHECK(loaded_session.active_behavior_count == 2); CHECK(strcmp(loaded_session.active_behaviors[0].behavior_id, "model:motion:Tap:0") == 0); From 514e316d569f0b1b5b46dab53e272daf27c25c9b Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 18 Aug 2026 22:04:01 +0800 Subject: [PATCH 039/334] fix: coordinate multi-pet menu actions --- resources/assets/locales/de-DE.json | 2 +- resources/assets/locales/en-US.json | 2 +- resources/assets/locales/es-ES.json | 2 +- resources/assets/locales/fr-FR.json | 2 +- resources/assets/locales/ja-JP.json | 2 +- resources/assets/locales/ko-KR.json | 2 +- resources/assets/locales/pt-BR.json | 2 +- resources/assets/locales/ru-RU.json | 2 +- resources/assets/locales/zh-CN.json | 2 +- resources/assets/locales/zh-TW.json | 2 +- src/platform/linux/linux_menu.c | 15 ++-- src/platform/macos/macos_menu.m | 10 +-- src/platform/windows/windows_menu.c | 10 +-- src/runtime/model/multi_pet.c | 27 ++++++ src/runtime/model/multi_pet_process.c | 2 + src/runtime/model/multi_pet_settings.c | 105 ++++++++++++++++++++++-- src/runtime/runtime.h | 5 ++ src/runtime/shell/window_menu_actions.c | 20 ++++- 18 files changed, 178 insertions(+), 36 deletions(-) diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index ce9fadac..1a9bc620 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -178,7 +178,7 @@ "native": { "multiplePets": "Mehrere gleichzeitig anzeigen", "multiplePetsLimit": "Bis zu 8 Desktop-Begleiter k\u00f6nnen gleichzeitig angezeigt werden", - "removeDesktopPet": "Diesen Desktop-Begleiter entfernen", + "removeDesktopPet": "Diesen Desktop-Begleiter schlie\u00dfen", "modelLoadFailed": "Dieses Modell kann nicht angezeigt werden", "noBehaviors": "Keine Einträge", "support": { diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index 71f8d657..2f878648 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -178,7 +178,7 @@ "native": { "multiplePets": "Display multiple", "multiplePetsLimit": "Up to 8 desktop pets can be displayed at once", - "removeDesktopPet": "Remove this desktop pet", + "removeDesktopPet": "Close this desktop pet", "modelLoadFailed": "Unable to display this model", "noBehaviors": "No items", "support": { diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index 971e667f..79c87669 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -178,7 +178,7 @@ "native": { "multiplePets": "Mostrar varias a la vez", "multiplePetsLimit": "Se pueden mostrar hasta 8 mascotas a la vez", - "removeDesktopPet": "Quitar esta mascota del escritorio", + "removeDesktopPet": "Cerrar esta mascota de escritorio", "modelLoadFailed": "No se puede mostrar este modelo", "noBehaviors": "No hay elementos", "support": { diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index c8fc0a26..bae8340e 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -178,7 +178,7 @@ "native": { "multiplePets": "Afficher plusieurs modèles", "multiplePetsLimit": "Jusqu'\u00e0 8 mascottes peuvent \u00eatre affich\u00e9es \u00e0 la fois", - "removeDesktopPet": "Retirer cette mascotte du bureau", + "removeDesktopPet": "Fermer cette mascotte de bureau", "modelLoadFailed": "Impossible d'afficher ce modèle", "noBehaviors": "Aucun élément", "support": { diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index 23098faf..3a31202f 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -178,7 +178,7 @@ "native": { "multiplePets": "複数を同時に表示", "multiplePetsLimit": "\u4e00\u5ea6\u306b\u8868\u793a\u3067\u304d\u308b\u30c7\u30b9\u30af\u30c8\u30c3\u30d7\u30da\u30c3\u30c8\u306f 8 \u4f53\u307e\u3067\u3067\u3059", - "removeDesktopPet": "\u3053\u306e\u30da\u30c3\u30c8\u3092\u8868\u793a\u304b\u3089\u5916\u3059", + "removeDesktopPet": "\u3053\u306e\u30c7\u30b9\u30af\u30c8\u30c3\u30d7\u30da\u30c3\u30c8\u3092\u9589\u3058\u308b", "modelLoadFailed": "このモデルを表示できません", "noBehaviors": "項目がありません", "support": { diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index 6de8daf8..4cb3c500 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -178,7 +178,7 @@ "native": { "multiplePets": "여러 개 동시에 표시", "multiplePetsLimit": "\ub370\uc2a4\ud06c\ud1b1 \ud3ab\uc740 \ud55c \ubc88\uc5d0 \ucd5c\ub300 8\uac1c\uae4c\uc9c0 \ud45c\uc2dc\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4", - "removeDesktopPet": "\uc774 \ub370\uc2a4\ud06c\ud1b1 \ud3ab \uc81c\uac70", + "removeDesktopPet": "\uc774 \ub370\uc2a4\ud06c\ud1b1 \ud3ab \ub2eb\uae30", "modelLoadFailed": "이 모델을 표시할 수 없습니다", "noBehaviors": "항목 없음", "support": { diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index 28703520..136d45de 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -178,7 +178,7 @@ "native": { "multiplePets": "Exibir vários", "multiplePetsLimit": "At\u00e9 8 mascotes podem ser exibidos ao mesmo tempo", - "removeDesktopPet": "Remover este mascote da \u00e1rea de trabalho", + "removeDesktopPet": "Fechar este mascote da \u00e1rea de trabalho", "modelLoadFailed": "N\u00e3o foi poss\u00edvel exibir este modelo", "noBehaviors": "Nenhum item", "support": { diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index 267f57d6..88ce6d96 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -178,7 +178,7 @@ "native": { "multiplePets": "Показывать несколько", "multiplePetsLimit": "\u041e\u0434\u043d\u043e\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e \u043c\u043e\u0436\u043d\u043e \u043e\u0442\u043e\u0431\u0440\u0430\u0437\u0438\u0442\u044c \u0434\u043e 8 \u043f\u0438\u0442\u043e\u043c\u0446\u0435\u0432", - "removeDesktopPet": "\u0423\u0431\u0440\u0430\u0442\u044c \u044d\u0442\u043e\u0433\u043e \u043f\u0438\u0442\u043e\u043c\u0446\u0430 \u0441 \u0440\u0430\u0431\u043e\u0447\u0435\u0433\u043e \u0441\u0442\u043e\u043b\u0430", + "removeDesktopPet": "\u0417\u0430\u043a\u0440\u044b\u0442\u044c \u044d\u0442\u043e\u0433\u043e \u043f\u0438\u0442\u043e\u043c\u0446\u0430", "modelLoadFailed": "Не удалось отобразить эту модель", "noBehaviors": "Нет элементов", "support": { diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index d61beb4f..b67fb0de 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -178,7 +178,7 @@ "native": { "multiplePets": "同时显示多个", "multiplePetsLimit": "\u6700\u591a\u53ef\u540c\u65f6\u663e\u793a 8 \u4e2a\u684c\u5ba0", - "removeDesktopPet": "\u79fb\u9664\u6b64\u684c\u5ba0", + "removeDesktopPet": "\u5173\u95ed\u6b64\u684c\u5ba0", "modelLoadFailed": "\u6a21\u578b\u65e0\u6cd5\u663e\u793a", "noBehaviors": "暂无可用项目", "support": { diff --git a/resources/assets/locales/zh-TW.json b/resources/assets/locales/zh-TW.json index 92abcb1d..ecba5ac7 100644 --- a/resources/assets/locales/zh-TW.json +++ b/resources/assets/locales/zh-TW.json @@ -178,7 +178,7 @@ "native": { "multiplePets": "同時顯示多個", "multiplePetsLimit": "\u6700\u591a\u53ef\u540c\u6642\u986f\u793a 8 \u500b\u684c\u5bf5", - "removeDesktopPet": "\u79fb\u9664\u6b64\u684c\u5bf5", + "removeDesktopPet": "\u95dc\u9589\u6b64\u684c\u5bf5", "modelLoadFailed": "\u6a21\u578b\u7121\u6cd5\u986f\u793a", "noBehaviors": "暫無可用項目", "support": { diff --git a/src/platform/linux/linux_menu.c b/src/platform/linux/linux_menu.c index 66ad7cab..14ef1c26 100644 --- a/src/platform/linux/linux_menu.c +++ b/src/platform/linux/linux_menu.c @@ -190,13 +190,7 @@ BongoCatMenuAction bongo_cat_linux_context_menu(BongoCatPlatform *platform, labels->pass_through); snprintf(top, sizeof(top), "%s%s", labels->always_on_top_checked ? "[x] " : "", labels->always_on_top); - LinuxMenuRow main_rows[12]; int main_count = 0; - if (labels->remove_pet_visible) { - main_rows[main_count++] = (LinuxMenuRow){labels->remove_pet, - BONGO_CAT_MENU_REMOVE_PET}; - main_rows[main_count++] = (LinuxMenuRow){NULL, - LINUX_MENU_SEPARATOR}; - } + LinuxMenuRow main_rows[13]; int main_count = 0; main_rows[main_count++] = (LinuxMenuRow){labels->preferences, BONGO_CAT_MENU_PREFERENCES}; main_rows[main_count++] = (LinuxMenuRow){labels->hide, BONGO_CAT_MENU_HIDE}; main_rows[main_count++] = (LinuxMenuRow){pass, BONGO_CAT_MENU_PASS_THROUGH}; @@ -208,7 +202,14 @@ BongoCatMenuAction bongo_cat_linux_context_menu(BongoCatPlatform *platform, if (labels->expression_count) main_rows[main_count++] = (LinuxMenuRow){labels->expression, LINUX_MENU_EXPRESSIONS}; main_rows[main_count++] = (LinuxMenuRow){labels->model, -3}; + main_rows[main_count++] = (LinuxMenuRow){NULL, LINUX_MENU_SEPARATOR}; main_rows[main_count++] = (LinuxMenuRow){labels->exit, BONGO_CAT_MENU_EXIT}; + if (labels->remove_pet_visible) { + main_rows[main_count++] = (LinuxMenuRow){NULL, + LINUX_MENU_SEPARATOR}; + main_rows[main_count++] = (LinuxMenuRow){labels->remove_pet, + BONGO_CAT_MENU_REMOVE_PET}; + } BongoCatMenuAction action = popup_rows(display, owner, main_rows, main_count, labels); if (action == (BongoCatMenuAction)-1) { diff --git a/src/platform/macos/macos_menu.m b/src/platform/macos/macos_menu.m index b2924262..633aef48 100644 --- a/src/platform/macos/macos_menu.m +++ b/src/platform/macos/macos_menu.m @@ -87,11 +87,6 @@ BongoCatMenuAction bongo_cat_macos_context_menu(BongoCatPlatform *platform, BongoCatMenuTarget *target = [[BongoCatMenuTarget alloc] initWithLabels:labels]; NSMenu *menu = [[NSMenu alloc] initWithTitle:@""]; [menu setDelegate:target]; - if (labels->remove_pet_visible) { - add_item(menu, target, labels->remove_pet, - BONGO_CAT_MENU_REMOVE_PET, false); - [menu addItem:[NSMenuItem separatorItem]]; - } add_item(menu, target, labels->preferences, BONGO_CAT_MENU_PREFERENCES, false); add_item(menu, target, labels->hide, BONGO_CAT_MENU_HIDE, false); [menu addItem:[NSMenuItem separatorItem]]; @@ -120,6 +115,11 @@ BongoCatMenuAction bongo_cat_macos_context_menu(BongoCatPlatform *platform, [models release]; [modelRoot release]; [menu addItem:[NSMenuItem separatorItem]]; add_item(menu, target, labels->exit, BONGO_CAT_MENU_EXIT, false); + if (labels->remove_pet_visible) { + [menu addItem:[NSMenuItem separatorItem]]; + add_item(menu, target, labels->remove_pet, + BONGO_CAT_MENU_REMOVE_PET, false); + } NSTimer *previewTimer = nil; if (labels->preview_tick) { previewTimer = [NSTimer timerWithTimeInterval:1.0 / 60.0 target:target diff --git a/src/platform/windows/windows_menu.c b/src/platform/windows/windows_menu.c index 43c5881b..8df81936 100644 --- a/src/platform/windows/windows_menu.c +++ b/src/platform/windows/windows_menu.c @@ -53,11 +53,6 @@ BongoCatMenuAction bongo_cat_platform_context_menu(BongoCatPlatform *platform, destroy_unattached(menu, sizes, opacity, models, motions, expressions); return BONGO_CAT_MENU_NONE; } - if (labels->remove_pet_visible) { - menu_text(menu, MF_STRING, BONGO_CAT_MENU_REMOVE_PET, - labels->remove_pet); - AppendMenuW(menu, MF_SEPARATOR, 0, NULL); - } menu_text(menu, MF_STRING, BONGO_CAT_MENU_PREFERENCES, labels->preferences); menu_text(menu, MF_STRING, BONGO_CAT_MENU_HIDE, labels->hide); AppendMenuW(menu, MF_SEPARATOR, 0, NULL); @@ -112,6 +107,11 @@ BongoCatMenuAction bongo_cat_platform_context_menu(BongoCatPlatform *platform, free(expression_label); AppendMenuW(menu, MF_SEPARATOR, 0, NULL); menu_text(menu, MF_STRING, BONGO_CAT_MENU_EXIT, labels->exit); + if (labels->remove_pet_visible) { + AppendMenuW(menu, MF_SEPARATOR, 0, NULL); + menu_text(menu, MF_STRING, BONGO_CAT_MENU_REMOVE_PET, + labels->remove_pet); + } POINT point; GetCursorPos(&point); HWND window = native_window(platform); bongo_cat_ui_native_menu_prepare(platform->window, labels->dark_theme); diff --git a/src/runtime/model/multi_pet.c b/src/runtime/model/multi_pet.c index 88f111a4..64888f25 100644 --- a/src/runtime/model/multi_pet.c +++ b/src/runtime/model/multi_pet.c @@ -234,6 +234,29 @@ size_t bongo_cat_app_active_model_count(const BongoCatApp *app) { ? 1 + app->session.additional_model_count : app ? 1 : 0; } +static bool load_pet_window(const BongoCatApp *app, const char *model_id, + BongoCatWindowState *window) { + char directory[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; + BongoCatSessionState session; + BongoCatError error = {0}; + bongo_cat_session_defaults(&session); + if (!bongo_cat_multi_pet_state_directory(directory, sizeof(directory), + app->primary_state_root, model_id) || + !bongo_cat_path_join(path, sizeof(path), directory, "session.json") || + bongo_cat_session_load(path, &session, &error) != BONGO_CAT_OK || + !session.window.position_known) return false; + *window = session.window; + return true; +} +static void adopt_pet_window(BongoCatApp *app, + const BongoCatWindowState *window) { + if (!bongo_cat_window_apply_geometry(app, window->x, window->y, + window->scale_percent, window->width, window->height)) return; + app->session.window.opacity_percent = window->opacity_percent; + bongo_cat_platform_set_opacity(&app->platform, + window->opacity_percent / 100.0f); +} + bool bongo_cat_app_set_model_active(BongoCatApp *app, const char *id, bool active, BongoCatError *error) { if (!app || !id || !bongo_cat_models_find(&app->models, id)) { @@ -254,11 +277,15 @@ bool bongo_cat_app_set_model_active(BongoCatApp *app, const char *id, } else if (primary) { if (!app->session.additional_model_count) return true; char replacement[BONGO_CAT_ID_CAP]; + BongoCatWindowState replacement_window; snprintf(replacement, sizeof(replacement), "%s", app->session.additional_model_ids[0]); + bool preserve_window = load_pet_window(app, replacement, + &replacement_window); if (!bongo_cat_app_select_model_with_error(app, replacement, error)) return false; bongo_cat_session_remove_model(&app->session, replacement); + if (preserve_window) adopt_pet_window(app, &replacement_window); } else bongo_cat_session_remove_model(&app->session, id); bongo_cat_multi_pet_primary_update(app, SDL_GetTicksNS()); return true; diff --git a/src/runtime/model/multi_pet_process.c b/src/runtime/model/multi_pet_process.c index 4206945e..b3917f72 100644 --- a/src/runtime/model/multi_pet_process.c +++ b/src/runtime/model/multi_pet_process.c @@ -135,6 +135,7 @@ static bool spawn_pet(BongoCatApp *app, SecondaryPet *pet, size_t index, } bongo_cat_multi_pet_clear_remove_request(app, pet->model_id); bongo_cat_multi_pet_clear_pass_through_request(app, pet->model_id); + bongo_cat_multi_pet_clear_primary_request(app, pet->model_id); if (!write_control(app, pet)) { pet->failures++; pet->retry_ns = now + CHILD_RETRY_NS; @@ -218,6 +219,7 @@ void bongo_cat_multi_pet_primary_update(BongoCatApp *app, uint64_t now) { BongoCatMultiPetRuntime *runtime = app->multi_pet; consume_remove_requests(app); bongo_cat_multi_pet_pass_through_requests_update(app); + bongo_cat_multi_pet_primary_requests_update(app); reap_pets(app, now); for (size_t i = 0; i < runtime->count; ++i) { SecondaryPet *pet = &runtime->pets[i]; diff --git a/src/runtime/model/multi_pet_settings.c b/src/runtime/model/multi_pet_settings.c index d6a65d53..fbf87d3b 100644 --- a/src/runtime/model/multi_pet_settings.c +++ b/src/runtime/model/multi_pet_settings.c @@ -4,29 +4,69 @@ #include "bongo_cat/preferences.h" #include +#include #define PASS_THROUGH_REQUEST_FILE "pass-through.request" +#define PRIMARY_REQUEST_FILE "primary.request" static bool request_path(char *target, size_t capacity, const char *root, - const char *model_id) { + const char *model_id, const char *request_file) { char directory[BONGO_CAT_PATH_CAP]; return bongo_cat_multi_pet_state_directory(directory, sizeof(directory), root, model_id) && - bongo_cat_path_join(target, capacity, directory, - PASS_THROUGH_REQUEST_FILE); + bongo_cat_path_join(target, capacity, directory, request_file); } void bongo_cat_multi_pet_clear_pass_through_request(BongoCatApp *app, const char *model_id) { char path[BONGO_CAT_PATH_CAP]; if (app && request_path(path, sizeof(path), app->primary_state_root, - model_id) && bongo_cat_path_is_file(path) && + model_id, PASS_THROUGH_REQUEST_FILE) && + bongo_cat_path_is_file(path) && !bongo_cat_file_remove(path)) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "[runtime] Stale multi-pet pass-through request could not be " "removed: model=%s path=%s", model_id, path); } +void bongo_cat_multi_pet_clear_primary_request(BongoCatApp *app, + const char *model_id) { + char path[BONGO_CAT_PATH_CAP]; + if (app && request_path(path, sizeof(path), app->primary_state_root, + model_id, PRIMARY_REQUEST_FILE) && bongo_cat_path_is_file(path) && + !bongo_cat_file_remove(path)) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Stale multi-pet primary request could not be removed: " + "model=%s path=%s", model_id, path); +} + +static bool write_primary_request(BongoCatApp *app, const char *action) { + char path[BONGO_CAT_PATH_CAP], temporary[BONGO_CAT_PATH_CAP]; + int length; + if (!app || !app->secondary_pet || + !request_path(path, sizeof(path), app->primary_state_root, + app->secondary_model_id, PRIMARY_REQUEST_FILE) || + (length = snprintf(temporary, sizeof(temporary), "%s.tmp", path)) < 0 || + (size_t)length >= sizeof(temporary)) return false; + FILE *file = bongo_cat_file_open(temporary, "wb"); + bool written = file && fprintf(file, "%s\n", action) > 0; + if (file && fclose(file) != 0) written = false; + if (written && bongo_cat_file_replace(temporary, path, true)) return true; + bongo_cat_file_remove(temporary); + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet primary request failed: model=%s action=%s " + "path=%s", app->secondary_model_id, action, path); + return false; +} + +bool bongo_cat_multi_pet_request_preferences(BongoCatApp *app) { + return write_primary_request(app, "preferences"); +} + +bool bongo_cat_multi_pet_request_exit(BongoCatApp *app) { + return write_primary_request(app, "exit"); +} + bool bongo_cat_multi_pet_request_pass_through(BongoCatApp *app, bool enabled) { char path[BONGO_CAT_PATH_CAP], temporary[BONGO_CAT_PATH_CAP]; @@ -52,7 +92,8 @@ bool bongo_cat_multi_pet_request_pass_through(BongoCatApp *app, static bool take_request(BongoCatApp *app, const char *model_id, bool *enabled) { char path[BONGO_CAT_PATH_CAP], content[8] = {0}; - if (!request_path(path, sizeof(path), app->primary_state_root, model_id) || + if (!request_path(path, sizeof(path), app->primary_state_root, model_id, + PASS_THROUGH_REQUEST_FILE) || !bongo_cat_path_is_file(path)) return false; FILE *file = bongo_cat_file_open(path, "rb"); if (!file) { @@ -84,6 +125,60 @@ static bool take_request(BongoCatApp *app, const char *model_id, return true; } +typedef enum PrimaryRequest { + PRIMARY_REQUEST_NONE, + PRIMARY_REQUEST_PREFERENCES, + PRIMARY_REQUEST_EXIT +} PrimaryRequest; + +static PrimaryRequest take_primary_request(BongoCatApp *app, + const char *model_id) { + char path[BONGO_CAT_PATH_CAP], content[32] = {0}; + if (!request_path(path, sizeof(path), app->primary_state_root, model_id, + PRIMARY_REQUEST_FILE) || !bongo_cat_path_is_file(path)) + return PRIMARY_REQUEST_NONE; + FILE *file = bongo_cat_file_open(path, "rb"); + if (!file) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet primary request could not be read: " + "model=%s path=%s", model_id, path); + return PRIMARY_REQUEST_NONE; + } + size_t length = fread(content, 1, sizeof(content) - 1, file); + fclose(file); + if (!bongo_cat_file_remove(path)) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet primary request could not be consumed: " + "model=%s path=%s", model_id, path); + return PRIMARY_REQUEST_NONE; + } + while (length && (content[length - 1] == ' ' || + content[length - 1] == '\t' || content[length - 1] == '\r' || + content[length - 1] == '\n')) length--; + content[length] = '\0'; + if (!strcmp(content, "preferences")) return PRIMARY_REQUEST_PREFERENCES; + if (!strcmp(content, "exit")) return PRIMARY_REQUEST_EXIT; + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet primary request invalid: model=%s path=%s", + model_id, path); + return PRIMARY_REQUEST_NONE; +} + +void bongo_cat_multi_pet_primary_requests_update(BongoCatApp *app) { + if (!app || app->secondary_pet) return; + bool show_preferences = false; + for (size_t i = 0; i < app->session.additional_model_count; ++i) { + PrimaryRequest request = take_primary_request(app, + app->session.additional_model_ids[i]); + if (request == PRIMARY_REQUEST_EXIT) { + app->running = false; + return; + } + if (request == PRIMARY_REQUEST_PREFERENCES) show_preferences = true; + } + if (show_preferences) bongo_cat_preferences_show(app->preferences); +} + void bongo_cat_multi_pet_pass_through_requests_update(BongoCatApp *app) { if (!app || app->secondary_pet) return; bool changed = false; diff --git a/src/runtime/runtime.h b/src/runtime/runtime.h index ed15fa0a..dfcb55d5 100644 --- a/src/runtime/runtime.h +++ b/src/runtime/runtime.h @@ -109,8 +109,13 @@ bool bongo_cat_multi_pet_state_directory(char *target, size_t capacity, void bongo_cat_multi_pet_update(BongoCatApp *app, uint64_t now); void bongo_cat_multi_pet_primary_update(BongoCatApp *app, uint64_t now); bool bongo_cat_multi_pet_request_remove(BongoCatApp *app); +bool bongo_cat_multi_pet_request_preferences(BongoCatApp *app); +bool bongo_cat_multi_pet_request_exit(BongoCatApp *app); bool bongo_cat_multi_pet_request_pass_through(BongoCatApp *app, bool enabled); +void bongo_cat_multi_pet_primary_requests_update(BongoCatApp *app); +void bongo_cat_multi_pet_clear_primary_request(BongoCatApp *app, + const char *model_id); void bongo_cat_multi_pet_pass_through_requests_update(BongoCatApp *app); void bongo_cat_multi_pet_clear_pass_through_request(BongoCatApp *app, const char *model_id); diff --git a/src/runtime/shell/window_menu_actions.c b/src/runtime/shell/window_menu_actions.c index c664217e..7763d210 100644 --- a/src/runtime/shell/window_menu_actions.c +++ b/src/runtime/shell/window_menu_actions.c @@ -70,7 +70,7 @@ void bongo_cat_window_show_context_menu(BongoCatApp *app) { app->settings.window.always_on_top, dark_theme, bongo_cat_window_menu_preview, bongo_cat_window_menu_preview_tick, bongo_cat_window_menu_restore, &preview, - tr(app, "native.removeDesktopPet", "Remove this desktop pet"), + tr(app, "native.removeDesktopPet", "Close this desktop pet"), app->secondary_pet || (app->settings.model.multiple_pets && app->session.additional_model_count > 0)}; BongoCatMenuAction action = bongo_cat_platform_context_menu( @@ -83,7 +83,10 @@ void bongo_cat_window_show_context_menu(BongoCatApp *app) { void bongo_cat_window_menu_action(BongoCatApp *app, BongoCatMenuAction action) { if (action == BONGO_CAT_MENU_PREFERENCES) { - if (app->preferences) bongo_cat_preferences_show(app->preferences); + if (app->secondary_pet) + bongo_cat_multi_pet_request_preferences(app); + else if (app->preferences) + bongo_cat_preferences_show(app->preferences); } else if (action == BONGO_CAT_MENU_MODEL_ADD) { if (app->preferences) bongo_cat_preferences_open_model_import(app->preferences, @@ -125,22 +128,31 @@ void bongo_cat_window_menu_action(BongoCatApp *app, select_model(app, app->models.entries[index].id); } else if (action == BONGO_CAT_MENU_REMOVE_PET) { if (app->secondary_pet) { - if (bongo_cat_multi_pet_request_remove(app)) app->running = false; + if (bongo_cat_multi_pet_request_remove(app)) { + bongo_cat_platform_set_visible(&app->platform, false); + app->running = false; + } } else if (app->settings.model.multiple_pets && app->session.additional_model_count) { char model_id[BONGO_CAT_ID_CAP]; BongoCatError error = {0}; snprintf(model_id, sizeof(model_id), "%s", app->session.active_model_id); + bool visible = app->session.window.visible; + if (visible) bongo_cat_platform_set_visible(&app->platform, false); if (!bongo_cat_app_set_model_active(app, model_id, false, &error)) SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[runtime] Multi-pet primary remove failed: model=%s " "error=%s", model_id, error.message[0] ? error.message : "unknown error"); + if (visible) bongo_cat_platform_set_visible(&app->platform, true); } } else if (action == BONGO_CAT_MENU_EXIT) { if (app->secondary_pet) { - if (bongo_cat_multi_pet_request_remove(app)) app->running = false; + if (bongo_cat_multi_pet_request_exit(app)) { + bongo_cat_platform_set_visible(&app->platform, false); + app->running = false; + } } else app->running = false; } bongo_cat_preferences_invalidate(app->preferences); From 8428315e8e579815137cea74e6498c7caf462154 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 18 Aug 2026 23:38:57 +0800 Subject: [PATCH 040/334] feat(i18n): complete localized error handling --- include/bongo_cat/common.h | 5 ++- include/bongo_cat/config.h | 3 +- resources/assets/locales/de-DE.json | 19 ++++++++++- resources/assets/locales/en-US.json | 19 ++++++++++- resources/assets/locales/es-ES.json | 19 ++++++++++- resources/assets/locales/fr-FR.json | 19 ++++++++++- resources/assets/locales/ja-JP.json | 19 ++++++++++- resources/assets/locales/ko-KR.json | 19 ++++++++++- resources/assets/locales/pt-BR.json | 19 ++++++++++- resources/assets/locales/ru-RU.json | 19 ++++++++++- resources/assets/locales/zh-CN.json | 19 ++++++++++- .../locales/{zh-TW.json => zh-Hant.json} | 19 ++++++++++- src/core/config_io.c | 11 +------ src/core/config_names.c | 19 ++++++++++- src/runtime/lifecycle/app.c | 4 +-- src/runtime/lifecycle/startup.c | 27 ++++++++++++---- src/ui/preferences/preferences_about.c | 6 ++-- src/ui/preferences/preferences_dialog.c | 6 +++- src/ui/preferences/preferences_language.c | 3 ++ src/ui/preferences/preferences_model.c | 32 ++++++++++++++++++- src/ui/preferences/preferences_pages.c | 6 ++-- src/ui/preferences/preferences_scale.c | 4 ++- tests/core/test_config.c | 8 +++++ tests/i18n/test_i18n.c | 20 ++++++++++++ 24 files changed, 303 insertions(+), 41 deletions(-) rename resources/assets/locales/{zh-TW.json => zh-Hant.json} (89%) diff --git a/include/bongo_cat/common.h b/include/bongo_cat/common.h index 1d2ff894..da12c6e1 100644 --- a/include/bongo_cat/common.h +++ b/include/bongo_cat/common.h @@ -8,13 +8,12 @@ #include "bongo_cat/version.h" #define BONGO_CAT_NAME "BongoCat" -#define BONGO_CAT_PET_WINDOW_TITLE "BongoCat - Pet" -#define BONGO_CAT_SETTINGS_WINDOW_TITLE "BongoCat - Settings" +#define BONGO_CAT_PET_WINDOW_TITLE BONGO_CAT_NAME #define BONGO_CAT_SLUG "bongo-cat" #define BONGO_CAT_EXECUTABLE "BongoCat" #define BONGO_CAT_APP_ID "com.bongocat.desktop" #define BONGO_CAT_NAME_W L"BongoCat" -#define BONGO_CAT_PET_WINDOW_TITLE_W L"BongoCat - Pet" +#define BONGO_CAT_PET_WINDOW_TITLE_W BONGO_CAT_NAME_W #define BONGO_CAT_PATH_CAP 1024 #define BONGO_CAT_ID_CAP 128 #define BONGO_CAT_SHORTCUT_CAP 128 diff --git a/include/bongo_cat/config.h b/include/bongo_cat/config.h index 0e43d75e..8fb1d6ec 100644 --- a/include/bongo_cat/config.h +++ b/include/bongo_cat/config.h @@ -13,7 +13,7 @@ typedef enum BongoCatTheme { BONGO_CAT_THEME_AUTO, BONGO_CAT_THEME_LIGHT, BONGO_ typedef enum BongoCatLanguage { BONGO_CAT_LANG_EN_US, BONGO_CAT_LANG_ZH_CN, - BONGO_CAT_LANG_ZH_TW, + BONGO_CAT_LANG_ZH_HANT, BONGO_CAT_LANG_FR_FR, BONGO_CAT_LANG_DE_DE, BONGO_CAT_LANG_JA_JP, @@ -154,6 +154,7 @@ BongoCatResult bongo_cat_session_save(const char *path, const BongoCatSessionState *session, BongoCatError *error); const char *bongo_cat_theme_name(BongoCatTheme value); const char *bongo_cat_language_name(BongoCatLanguage value); +bool bongo_cat_language_parse(const char *name, BongoCatLanguage *value); const char *bongo_cat_mode_name(BongoCatModelMode value); const char *bongo_cat_obs_background_color_name( BongoCatObsBackgroundColor value); diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index 1a9bc620..994b3ef3 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -58,6 +58,7 @@ }, "hints": { "showTrayIcon": "Wenn aktiviert, wird das Anwendungssymbol im Infobereich angezeigt.", + "autostartFailed": "Die Einstellung für den automatischen Start konnte nicht aktualisiert werden", "inputMonitoringPermission": "Aktivieren Sie die Eingabeüberwachung, um Tastatur- und Mausereignisse vom System zu empfangen.", "inputMonitoringPermissionGuide": "Die Berechtigung zur Eingabeüberwachung ist erforderlich, damit die Anwendung Tastatur- und Mausereignisse vom System empfangen kann.\n\nSchritte:\n1. Öffnen Sie Systemeinstellungen > Datenschutz & Sicherheit > Eingabeüberwachung.\n2. Falls BongoCat bereits aufgeführt ist, wählen Sie es aus und entfernen Sie es mit der Schaltfläche \"-\".\n3. Klicken Sie auf \"+\", um BongoCat zu suchen und hinzuzufügen.\n4. Starten Sie die Anwendung neu, damit die Berechtigung wirksam wird.", "administratorPermission": "Als Administrator kann die Anwendung bestimmte Systemtasten und Eingabeereignisse zuverlässiger erfassen.", @@ -85,8 +86,16 @@ "hints": { "deleteSuccess": "\"%s\" wurde gelöscht", "deleteModel": "Möchten Sie \"%s\" wirklich löschen?", + "deleteFailed": "Dieses Modell konnte nicht gelöscht werden", "importSuccess": "Erfolgreich importiert", "importExists": "Modell ist bereits vorhanden", + "importPartial": "Einige Modelle wurden importiert, aber mindestens ein Element ist fehlgeschlagen", + "importInvalidSource": "Die ausgewählte Quelle ist nicht mehr vorhanden oder kann nicht verwendet werden", + "importInvalidFormat": "Es wurde kein unterstütztes Modell gefunden oder das Modellpaket ist unvollständig", + "importFileAccess": "Die Modelldateien konnten nicht gelesen oder gespeichert werden. Prüfen Sie die Dateiberechtigungen und den verfügbaren Speicherplatz", + "importOutOfMemory": "Zum Importieren dieses Modells ist nicht genügend Arbeitsspeicher verfügbar", + "importLoadFailed": "Das Modell wurde importiert, konnte aber nicht angezeigt werden", + "importFailed": "Der Modellimport ist fehlgeschlagen. Prüfen Sie die ausgewählten Dateien und versuchen Sie es erneut", "clickOrDragToImport": "Zum Importieren klicken oder hierher ziehen", "openDirectoryFailed": "Modellordner konnte nicht geöffnet werden" }, @@ -181,6 +190,12 @@ "removeDesktopPet": "Diesen Desktop-Begleiter schlie\u00dfen", "modelLoadFailed": "Dieses Modell kann nicht angezeigt werden", "noBehaviors": "Keine Einträge", + "preferencesWindowTitle": "BongoCat - Einstellungen", + "startup": { + "failed": "BongoCat konnte nicht gestartet werden.", + "detail": "Technische Details finden Sie im Diagnoseprotokoll.", + "diagnosticLog": "Diagnoseprotokoll:" + }, "support": { "title": "Entwicklungsteam unterstützen", "heroText": "Vielen Dank für Ihre Unterstützung. Jede Nutzung und Weitergabe hilft BongoCat, weiter zu wachsen.", @@ -193,7 +208,9 @@ "refactorDeveloper": "(Entwickler, der BongoCat in C neu aufgebaut hat)", "community": "Community", "qqGroup": "QQ-Gruppe", - "coffee": "Dem Entwicklungsteam einen Kaffee spendieren?" + "coffee": "Dem Entwicklungsteam einen Kaffee spendieren?", + "starOnGitHub": "GitHub-Stern", + "by": "von " }, "shortcut": { "toggleCat": "Ein-/ausblenden", diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index 2f878648..d209d846 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -58,6 +58,7 @@ }, "hints": { "showTrayIcon": "When enabled, the app icon is displayed in the system tray.", + "autostartFailed": "Unable to update launch-on-startup settings", "inputMonitoringPermission": "Enable input monitoring to receive keyboard and mouse events from the system.", "inputMonitoringPermissionGuide": "Input Monitoring permission is required for the app to receive keyboard and mouse events from the system.\n\nSteps:\n1. Open System Settings → Privacy & Security → Input Monitoring.\n2. If BongoCat already appears in the list, select it and click the \"-\" button to remove it.\n3. Click the \"+\" button to find and add BongoCat.\n4. Restart the app for the permission to take effect.", "administratorPermission": "Running the app as administrator helps capture some system-level keys and input events more reliably.", @@ -85,8 +86,16 @@ "hints": { "deleteSuccess": "Deleted \"%s\"", "deleteModel": "Are you sure you want to delete \"%s\"?", + "deleteFailed": "Unable to delete this model", "importSuccess": "Imported Successfully", "importExists": "Model already exists", + "importPartial": "Some models were imported, but one or more items failed", + "importInvalidSource": "The selected source no longer exists or cannot be used", + "importInvalidFormat": "No supported model was found, or the model package is incomplete", + "importFileAccess": "The model files could not be read or saved. Check file permissions and available disk space", + "importOutOfMemory": "There is not enough memory to import this model", + "importLoadFailed": "The model was imported, but it could not be displayed", + "importFailed": "Model import failed. Check the selected files and try again", "clickOrDragToImport": "Click or drag here to import", "openDirectoryFailed": "Unable to open model directory" }, @@ -181,6 +190,12 @@ "removeDesktopPet": "Close this desktop pet", "modelLoadFailed": "Unable to display this model", "noBehaviors": "No items", + "preferencesWindowTitle": "BongoCat - Preferences", + "startup": { + "failed": "BongoCat could not start.", + "detail": "See the diagnostic log for technical details.", + "diagnosticLog": "Diagnostic log:" + }, "support": { "title": "Support the Dev Team", "heroText": "Thank you for your support. Every use and share helps BongoCat continue to grow.", @@ -193,7 +208,9 @@ "refactorDeveloper": "(Developer who rebuilt BongoCat in C)", "community": "Community", "qqGroup": "QQ Group", - "coffee": "Buy the development team a coffee?" + "coffee": "Buy the development team a coffee?", + "starOnGitHub": "Star on GitHub", + "by": "by " }, "shortcut": { "toggleCat": "Show/Hide", diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index 79c87669..566e3a9c 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -58,6 +58,7 @@ }, "hints": { "showTrayIcon": "Al activarlo, el icono de la aplicación aparece en la bandeja del sistema.", + "autostartFailed": "No se pudo actualizar la configuración de inicio automático", "inputMonitoringPermission": "Active la supervisión de entrada para recibir eventos de teclado y ratón del sistema.", "inputMonitoringPermissionGuide": "Se necesita el permiso de supervisión de entrada para que la aplicación reciba eventos de teclado y ratón del sistema.\n\nPasos:\n1. Abra Ajustes del Sistema > Privacidad y seguridad > Supervisión de entrada.\n2. Si BongoCat ya aparece en la lista, selecciónelo y pulse el botón \"-\" para eliminarlo.\n3. Pulse el botón \"+\" para buscar y añadir BongoCat.\n4. Reinicie la aplicación para que el permiso tenga efecto.", "administratorPermission": "Ejecutar la aplicación como administrador ayuda a capturar con mayor fiabilidad algunas teclas del sistema y eventos de entrada.", @@ -85,8 +86,16 @@ "hints": { "deleteSuccess": "Se eliminó \"%s\"", "deleteModel": "¿Seguro que quiere eliminar \"%s\"?", + "deleteFailed": "No se pudo eliminar este modelo", "importSuccess": "Importado correctamente", "importExists": "El modelo ya existe", + "importPartial": "Se importaron algunos modelos, pero uno o más elementos fallaron", + "importInvalidSource": "La fuente seleccionada ya no existe o no se puede utilizar", + "importInvalidFormat": "No se encontró ningún modelo compatible o el paquete del modelo está incompleto", + "importFileAccess": "No se pudieron leer o guardar los archivos del modelo. Compruebe los permisos y el espacio disponible en el disco", + "importOutOfMemory": "No hay memoria suficiente para importar este modelo", + "importLoadFailed": "El modelo se importó, pero no se pudo mostrar", + "importFailed": "Error al importar el modelo. Compruebe los archivos seleccionados e inténtelo de nuevo", "clickOrDragToImport": "Haga clic o arrastre aquí para importar", "openDirectoryFailed": "No se pudo abrir la carpeta del modelo" }, @@ -181,6 +190,12 @@ "removeDesktopPet": "Cerrar esta mascota de escritorio", "modelLoadFailed": "No se puede mostrar este modelo", "noBehaviors": "No hay elementos", + "preferencesWindowTitle": "BongoCat - Preferencias", + "startup": { + "failed": "No se pudo iniciar BongoCat.", + "detail": "Consulte el registro de diagnóstico para ver los detalles técnicos.", + "diagnosticLog": "Registro de diagnóstico:" + }, "support": { "title": "Apoyar al equipo de desarrollo", "heroText": "Gracias por su apoyo. Cada uso y cada difusión ayudan a BongoCat a seguir creciendo.", @@ -193,7 +208,9 @@ "refactorDeveloper": "(Desarrollador que reconstruyó BongoCat en C)", "community": "Comunidad", "qqGroup": "Grupo de QQ", - "coffee": "¿Invitar a un café al equipo de desarrollo?" + "coffee": "¿Invitar a un café al equipo de desarrollo?", + "starOnGitHub": "Estrella GitHub", + "by": "por " }, "shortcut": { "toggleCat": "Mostrar/ocultar", diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index bae8340e..c82c026b 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -58,6 +58,7 @@ }, "hints": { "showTrayIcon": "Lorsque cette option est activée, l'icône de l'application apparaît dans la zone de notification.", + "autostartFailed": "Impossible de modifier le lancement au démarrage", "inputMonitoringPermission": "Activez la surveillance des entrées pour recevoir les événements clavier et souris du système.", "inputMonitoringPermissionGuide": "L'autorisation de surveillance des entrées est nécessaire pour recevoir les événements clavier et souris du système.\n\nÉtapes :\n1. Ouvrez Réglages Système > Confidentialité et sécurité > Surveillance de l'entrée.\n2. Si BongoCat figure déjà dans la liste, sélectionnez-le et cliquez sur le bouton \"-\" pour le retirer.\n3. Cliquez sur le bouton \"+\" pour rechercher et ajouter BongoCat.\n4. Redémarrez l'application pour appliquer l'autorisation.", "administratorPermission": "Exécuter l'application en tant qu'administrateur permet de capturer plus fiablement certaines touches système et certains événements d'entrée.", @@ -85,8 +86,16 @@ "hints": { "deleteSuccess": "« %s » a été supprimé", "deleteModel": "Voulez-vous vraiment supprimer \"%s\" ?", + "deleteFailed": "Impossible de supprimer ce modèle", "importSuccess": "Importation réussie", "importExists": "Le modèle existe déjà", + "importPartial": "Certains modèles ont été importés, mais un ou plusieurs éléments ont échoué", + "importInvalidSource": "La source sélectionnée n'existe plus ou ne peut pas être utilisée", + "importInvalidFormat": "Aucun modèle pris en charge n'a été trouvé, ou le paquet du modèle est incomplet", + "importFileAccess": "Impossible de lire ou d'enregistrer les fichiers du modèle. Vérifiez les autorisations et l'espace disque disponible", + "importOutOfMemory": "La mémoire disponible est insuffisante pour importer ce modèle", + "importLoadFailed": "Le modèle a été importé, mais il n'a pas pu être affiché", + "importFailed": "Échec de l'importation du modèle. Vérifiez les fichiers sélectionnés et réessayez", "clickOrDragToImport": "Cliquez ou déposez ici pour importer", "openDirectoryFailed": "Impossible d'ouvrir le dossier du modèle" }, @@ -181,6 +190,12 @@ "removeDesktopPet": "Fermer cette mascotte de bureau", "modelLoadFailed": "Impossible d'afficher ce modèle", "noBehaviors": "Aucun élément", + "preferencesWindowTitle": "BongoCat - Préférences", + "startup": { + "failed": "Impossible de démarrer BongoCat.", + "detail": "Consultez le journal de diagnostic pour les détails techniques.", + "diagnosticLog": "Journal de diagnostic :" + }, "support": { "title": "Soutenir l'équipe de développement", "heroText": "Merci pour votre soutien. Chaque utilisation et chaque partage aide BongoCat à continuer de grandir.", @@ -193,7 +208,9 @@ "refactorDeveloper": "(Développeur ayant reconstruit BongoCat en C)", "community": "Communauté", "qqGroup": "Groupe QQ", - "coffee": "Offrir un café à l'équipe de développement ?" + "coffee": "Offrir un café à l'équipe de développement ?", + "starOnGitHub": "Étoile GitHub", + "by": "par " }, "shortcut": { "toggleCat": "Afficher/masquer", diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index 3a31202f..b9782bf7 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -58,6 +58,7 @@ }, "hints": { "showTrayIcon": "有効にすると、システムトレイにアプリアイコンを表示します。", + "autostartFailed": "起動時に実行する設定を更新できません", "inputMonitoringPermission": "システムからキーボードとマウスのイベントを受け取るには、入力監視を有効にしてください。", "inputMonitoringPermissionGuide": "システムからキーボードとマウスのイベントを受け取るには、入力監視の権限が必要です。\n\n手順:\n1. システム設定 > プライバシーとセキュリティ > 入力監視を開きます。\n2. BongoCatがすでに一覧にある場合は選択し、\"-\"ボタンで削除します。\n3. \"+\"ボタンをクリックしてBongoCatを探し、追加します。\n4. 権限を反映するため、アプリを再起動します。", "administratorPermission": "管理者として実行すると、一部のシステムキーや入力イベントをより確実に取得できます。", @@ -85,8 +86,16 @@ "hints": { "deleteSuccess": "「%s」を削除しました", "deleteModel": "「%s」を削除しますか?", + "deleteFailed": "このモデルを削除できません", "importSuccess": "インポートしました", "importExists": "モデルは既に存在します", + "importPartial": "一部のモデルをインポートしましたが、1件以上の項目で失敗しました", + "importInvalidSource": "選択したインポート元は存在しないか、使用できません", + "importInvalidFormat": "対応するモデルが見つからないか、モデルパッケージが不完全です", + "importFileAccess": "モデルファイルを読み込みまたは保存できません。ファイルの権限とディスクの空き容量を確認してください", + "importOutOfMemory": "メモリが不足しているため、このモデルをインポートできません", + "importLoadFailed": "モデルはインポートされましたが、表示できませんでした", + "importFailed": "モデルのインポートに失敗しました。選択したファイルを確認して、もう一度お試しください", "clickOrDragToImport": "クリックまたはここへドラッグしてインポート", "openDirectoryFailed": "モデルフォルダーを開けません" }, @@ -181,6 +190,12 @@ "removeDesktopPet": "\u3053\u306e\u30c7\u30b9\u30af\u30c8\u30c3\u30d7\u30da\u30c3\u30c8\u3092\u9589\u3058\u308b", "modelLoadFailed": "このモデルを表示できません", "noBehaviors": "項目がありません", + "preferencesWindowTitle": "BongoCat - 設定", + "startup": { + "failed": "BongoCat を起動できませんでした。", + "detail": "技術的な詳細は診断ログを確認してください。", + "diagnosticLog": "診断ログ:" + }, "support": { "title": "開発チームを支援", "heroText": "ご支援ありがとうございます。利用や共有の一つひとつがBongoCatの成長につながります。", @@ -193,7 +208,9 @@ "refactorDeveloper": "(BongoCatをCで再構築した開発者)", "community": "コミュニティ", "qqGroup": "QQグループ", - "coffee": "開発チームにコーヒーを贈りますか?" + "coffee": "開発チームにコーヒーを贈りますか?", + "starOnGitHub": "GitHubでスター", + "by": "開発:" }, "shortcut": { "toggleCat": "表示/非表示", diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index 4cb3c500..3cdc217c 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -58,6 +58,7 @@ }, "hints": { "showTrayIcon": "활성화하면 시스템 트레이에 앱 아이콘이 표시됩니다.", + "autostartFailed": "시작 프로그램 설정을 변경할 수 없습니다", "inputMonitoringPermission": "시스템의 키보드와 마우스 이벤트를 받으려면 입력 모니터링을 활성화하세요.", "inputMonitoringPermissionGuide": "앱이 시스템의 키보드와 마우스 이벤트를 받으려면 입력 모니터링 권한이 필요합니다.\n\n단계:\n1. 시스템 설정 > 개인정보 보호 및 보안 > 입력 모니터링을 엽니다.\n2. BongoCat이 이미 목록에 있으면 선택하고 \"-\" 버튼을 눌러 제거합니다.\n3. \"+\" 버튼을 눌러 BongoCat을 찾아 추가합니다.\n4. 권한을 적용하려면 앱을 다시 시작합니다.", "administratorPermission": "앱을 관리자로 실행하면 일부 시스템 키와 입력 이벤트를 더 안정적으로 캡처할 수 있습니다.", @@ -85,8 +86,16 @@ "hints": { "deleteSuccess": "\"%s\" 모델을 삭제했습니다", "deleteModel": "\"%s\" 모델을 삭제하시겠습니까?", + "deleteFailed": "이 모델을 삭제할 수 없습니다", "importSuccess": "가져오기 완료", "importExists": "모델이 이미 존재합니다", + "importPartial": "일부 모델을 가져왔지만 하나 이상의 항목을 가져오지 못했습니다", + "importInvalidSource": "선택한 원본이 더 이상 존재하지 않거나 사용할 수 없습니다", + "importInvalidFormat": "지원되는 모델을 찾을 수 없거나 모델 패키지가 완전하지 않습니다", + "importFileAccess": "모델 파일을 읽거나 저장할 수 없습니다. 파일 권한과 사용 가능한 디스크 공간을 확인하세요", + "importOutOfMemory": "메모리가 부족하여 이 모델을 가져올 수 없습니다", + "importLoadFailed": "모델을 가져왔지만 표시할 수 없습니다", + "importFailed": "모델을 가져오지 못했습니다. 선택한 파일을 확인한 후 다시 시도하세요", "clickOrDragToImport": "클릭하거나 여기로 끌어와 가져오기", "openDirectoryFailed": "모델 폴더를 열 수 없습니다" }, @@ -181,6 +190,12 @@ "removeDesktopPet": "\uc774 \ub370\uc2a4\ud06c\ud1b1 \ud3ab \ub2eb\uae30", "modelLoadFailed": "이 모델을 표시할 수 없습니다", "noBehaviors": "항목 없음", + "preferencesWindowTitle": "BongoCat - 설정", + "startup": { + "failed": "BongoCat을 시작할 수 없습니다.", + "detail": "기술적인 세부 정보는 진단 로그를 확인하세요.", + "diagnosticLog": "진단 로그:" + }, "support": { "title": "개발팀 지원", "heroText": "지원해 주셔서 감사합니다. 사용할 때마다, 공유할 때마다 BongoCat이 계속 성장하는 데 도움이 됩니다.", @@ -193,7 +208,9 @@ "refactorDeveloper": "(BongoCat을 C로 다시 만든 개발자)", "community": "커뮤니티", "qqGroup": "QQ 그룹", - "coffee": "개발팀에 커피 한 잔을 선물할까요?" + "coffee": "개발팀에 커피 한 잔을 선물할까요?", + "starOnGitHub": "GitHub 스타", + "by": "제작: " }, "shortcut": { "toggleCat": "표시/숨기기", diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index 136d45de..a01493f3 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -58,6 +58,7 @@ }, "hints": { "showTrayIcon": "Quando ativado, o ícone do aplicativo é exibido na bandeja do sistema.", + "autostartFailed": "Não foi possível atualizar a inicialização automática", "inputMonitoringPermission": "Ative a permissão de monitoramento de entrada para receber eventos de teclado e mouse do sistema para responder às suas ações.", "inputMonitoringPermissionGuide": "A permissão de Monitoramento de Entrada é necessária para que o aplicativo receba eventos de teclado e mouse do sistema.\n\nPassos:\n1. Abra as Configurações do Sistema → Privacidade e Segurança → Monitoramento de Entrada.\n2. Se o BongoCat já estiver na lista, selecione-o e clique no botão \"-\" para removê-lo.\n3. Clique no botão \"+\" para localizar e adicionar o BongoCat.\n4. Reinicie o aplicativo para que a permissão entre em vigor.", "administratorPermission": "Executar o aplicativo como administrador ajuda a capturar algumas teclas e eventos de entrada em nível de sistema com mais confiabilidade.", @@ -85,8 +86,16 @@ "hints": { "deleteSuccess": "Modelo \"%s\" excluído", "deleteModel": "Tem certeza de que deseja excluir \"%s\"?", + "deleteFailed": "Não foi possível excluir este modelo", "importSuccess": "Importação bem-sucedida", "importExists": "O modelo já existe", + "importPartial": "Alguns modelos foram importados, mas ocorreu uma falha em um ou mais itens", + "importInvalidSource": "A origem selecionada não existe mais ou não pode ser usada", + "importInvalidFormat": "Nenhum modelo compatível foi encontrado ou o pacote do modelo está incompleto", + "importFileAccess": "Não foi possível ler ou salvar os arquivos do modelo. Verifique as permissões e o espaço disponível em disco", + "importOutOfMemory": "Não há memória suficiente para importar este modelo", + "importLoadFailed": "O modelo foi importado, mas não pôde ser exibido", + "importFailed": "Falha ao importar o modelo. Verifique os arquivos selecionados e tente novamente", "clickOrDragToImport": "Clique ou arraste para importar", "openDirectoryFailed": "Não foi possível abrir a pasta do modelo" }, @@ -181,6 +190,12 @@ "removeDesktopPet": "Fechar este mascote da \u00e1rea de trabalho", "modelLoadFailed": "N\u00e3o foi poss\u00edvel exibir este modelo", "noBehaviors": "Nenhum item", + "preferencesWindowTitle": "BongoCat - Preferências", + "startup": { + "failed": "Não foi possível iniciar o BongoCat.", + "detail": "Consulte o log de diagnóstico para obter detalhes técnicos.", + "diagnosticLog": "Log de diagnóstico:" + }, "support": { "title": "Apoiar a equipe de desenvolvimento", "heroText": "Obrigado pelo apoio. Cada uso e compartilhamento ajuda o BongoCat a continuar crescendo.", @@ -193,7 +208,9 @@ "refactorDeveloper": "(Desenvolvedor que reescreveu BongoCat em C)", "community": "Comunidade", "qqGroup": "Grupo QQ", - "coffee": "Pagar um café para a equipe de desenvolvimento?" + "coffee": "Pagar um café para a equipe de desenvolvimento?", + "starOnGitHub": "Estrela GitHub", + "by": "por " }, "shortcut": { "toggleCat": "Mostrar/Ocultar", diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index 88ce6d96..1768249b 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -58,6 +58,7 @@ }, "hints": { "showTrayIcon": "Если включено, значок приложения отображается в системном трее.", + "autostartFailed": "Не удалось изменить настройку автозапуска", "inputMonitoringPermission": "Включите мониторинг ввода, чтобы получать системные события клавиатуры и мыши.", "inputMonitoringPermissionGuide": "Разрешение на мониторинг ввода необходимо, чтобы приложение получало системные события клавиатуры и мыши.\n\nШаги:\n1. Откройте Системные настройки > Конфиденциальность и безопасность > Мониторинг ввода.\n2. Если BongoCat уже есть в списке, выберите его и нажмите \"-\", чтобы удалить.\n3. Нажмите \"+\", найдите и добавьте BongoCat.\n4. Перезапустите приложение, чтобы разрешение вступило в силу.", "administratorPermission": "Запуск от имени администратора позволяет надёжнее захватывать некоторые системные клавиши и события ввода.", @@ -85,8 +86,16 @@ "hints": { "deleteSuccess": "Модель \"%s\" удалена", "deleteModel": "Удалить модель \"%s\"?", + "deleteFailed": "Не удалось удалить эту модель", "importSuccess": "Успешно импортировано", "importExists": "Модель уже существует", + "importPartial": "Некоторые модели импортированы, но один или несколько элементов импортировать не удалось", + "importInvalidSource": "Выбранный источник больше не существует или не может быть использован", + "importInvalidFormat": "Поддерживаемая модель не найдена или пакет модели неполон", + "importFileAccess": "Не удалось прочитать или сохранить файлы модели. Проверьте права доступа и свободное место на диске", + "importOutOfMemory": "Недостаточно памяти для импорта этой модели", + "importLoadFailed": "Модель импортирована, но её не удалось отобразить", + "importFailed": "Не удалось импортировать модель. Проверьте выбранные файлы и повторите попытку", "clickOrDragToImport": "Щёлкните или перетащите сюда для импорта", "openDirectoryFailed": "Не удалось открыть папку модели" }, @@ -181,6 +190,12 @@ "removeDesktopPet": "\u0417\u0430\u043a\u0440\u044b\u0442\u044c \u044d\u0442\u043e\u0433\u043e \u043f\u0438\u0442\u043e\u043c\u0446\u0430", "modelLoadFailed": "Не удалось отобразить эту модель", "noBehaviors": "Нет элементов", + "preferencesWindowTitle": "BongoCat - Настройки", + "startup": { + "failed": "Не удалось запустить BongoCat.", + "detail": "Технические сведения доступны в журнале диагностики.", + "diagnosticLog": "Журнал диагностики:" + }, "support": { "title": "Поддержать команду разработки", "heroText": "Спасибо за поддержку. Каждое использование и распространение помогает BongoCat развиваться дальше.", @@ -193,7 +208,9 @@ "refactorDeveloper": "(Разработчик, переписавший BongoCat на C)", "community": "Сообщество", "qqGroup": "Группа QQ", - "coffee": "Угостить команду разработки кофе?" + "coffee": "Угостить команду разработки кофе?", + "starOnGitHub": "Звезда GitHub", + "by": "от " }, "shortcut": { "toggleCat": "Показать/скрыть", diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index b67fb0de..50ab01e5 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -58,6 +58,7 @@ }, "hints": { "showTrayIcon": "启用后,在系统托盘中显示应用图标。", + "autostartFailed": "无法更新开机启动设置", "inputMonitoringPermission": "开启输入监控权限,以便接收系统的键盘和鼠标事件来响应你的操作。", "inputMonitoringPermissionGuide": "开启输入监控权限后,应用才能接收系统的键盘和鼠标事件,从而响应你的操作。\n\n操作方式:\n1. 打开「系统设置」→「隐私与安全性」→「输入监控」。\n2. 若列表中已存在 BongoCat,请先选中它,点击「-」按钮将其删除。\n3. 点击「+」按钮,找到并添加 BongoCat 应用。\n4. 重启应用,以确保权限正式生效。", "administratorPermission": "以管理员身份运行应用,可以更稳定地捕获部分系统级按键与输入事件。", @@ -85,8 +86,16 @@ "hints": { "deleteSuccess": "已删除“%s”", "deleteModel": "你确定要删除“%s”吗?", + "deleteFailed": "无法删除此模型", "importSuccess": "导入成功", "importExists": "模型已存在,无需重复导入", + "importPartial": "部分模型已导入,但仍有一个或多个项目导入失败", + "importInvalidSource": "所选来源已不存在或无法使用", + "importInvalidFormat": "未找到支持的模型,或模型包文件不完整", + "importFileAccess": "无法读取或保存模型文件,请检查文件权限和可用磁盘空间", + "importOutOfMemory": "内存不足,无法导入此模型", + "importLoadFailed": "模型已导入,但无法正常显示", + "importFailed": "模型导入失败,请检查所选文件后重试", "clickOrDragToImport": "点击或拖动至此区域导入", "openDirectoryFailed": "无法打开模型目录" }, @@ -181,6 +190,12 @@ "removeDesktopPet": "\u5173\u95ed\u6b64\u684c\u5ba0", "modelLoadFailed": "\u6a21\u578b\u65e0\u6cd5\u663e\u793a", "noBehaviors": "暂无可用项目", + "preferencesWindowTitle": "BongoCat - 设置", + "startup": { + "failed": "BongoCat 无法启动。", + "detail": "请查看诊断日志了解技术详情。", + "diagnosticLog": "诊断日志:" + }, "support": { "title": "支持开发团队", "heroText": "感谢你的支持。每一次使用与分享,都让 BongoCat 继续成长。", @@ -193,7 +208,9 @@ "refactorDeveloper": "(将BongoCat用C语言重构的开发者)", "community": "社区", "qqGroup": "QQ 群", - "coffee": "请开发团队喝杯咖啡?" + "coffee": "请开发团队喝杯咖啡?", + "starOnGitHub": "GitHub 加星", + "by": "作者:" }, "shortcut": { "toggleCat": "显示/隐藏", diff --git a/resources/assets/locales/zh-TW.json b/resources/assets/locales/zh-Hant.json similarity index 89% rename from resources/assets/locales/zh-TW.json rename to resources/assets/locales/zh-Hant.json index ecba5ac7..278b2ffa 100644 --- a/resources/assets/locales/zh-TW.json +++ b/resources/assets/locales/zh-Hant.json @@ -58,6 +58,7 @@ }, "hints": { "showTrayIcon": "啟用後,在系統托盤中顯示應用程式圖示。", + "autostartFailed": "無法更新開機啟動設定", "inputMonitoringPermission": "開啟輸入監控權限,以便接收系統的鍵盤和滑鼠游標事件來回應您的操作。", "inputMonitoringPermissionGuide": "開啟輸入監控權限後,應用程式才能接收系統的鍵盤和滑鼠事件,進而回應你的操作。\n\n操作方式:\n1. 開啟「系統設定」→「隱私權與安全性」→「輸入監控」。\n2. 若清單中已存在 BongoCat,請先選取它,點擊「-」按鈕將其刪除。\n3. 點擊「+」按鈕,找到並新增 BongoCat 應用程式。\n4. 重新啟動應用程式,以確保權限正式生效。", "administratorPermission": "以系統管理員身分執行應用程式,可以更穩定地擷取部分系統層級按鍵與輸入事件。", @@ -85,8 +86,16 @@ "hints": { "deleteSuccess": "已刪除「%s」", "deleteModel": "您確定要刪除「%s」嗎?", + "deleteFailed": "無法刪除此模型", "importSuccess": "匯入成功", "importExists": "模型已存在,無需重複匯入", + "importPartial": "部分模型已匯入,但仍有一個或多個項目匯入失敗", + "importInvalidSource": "所選來源已不存在或無法使用", + "importInvalidFormat": "找不到支援的模型,或模型套件檔案不完整", + "importFileAccess": "無法讀取或儲存模型檔案,請檢查檔案權限和可用磁碟空間", + "importOutOfMemory": "記憶體不足,無法匯入此模型", + "importLoadFailed": "模型已匯入,但無法正常顯示", + "importFailed": "模型匯入失敗,請檢查所選檔案後再試一次", "clickOrDragToImport": "點擊或拖曳至此區域匯入", "openDirectoryFailed": "無法開啟模型目錄" }, @@ -181,6 +190,12 @@ "removeDesktopPet": "\u95dc\u9589\u6b64\u684c\u5bf5", "modelLoadFailed": "\u6a21\u578b\u7121\u6cd5\u986f\u793a", "noBehaviors": "暫無可用項目", + "preferencesWindowTitle": "BongoCat - 設定", + "startup": { + "failed": "BongoCat 無法啟動。", + "detail": "請查看診斷日誌以瞭解技術詳情。", + "diagnosticLog": "診斷日誌:" + }, "support": { "title": "支援開發團隊", "heroText": "感謝你的支持。每一次使用與分享,都讓 BongoCat 持續成長。", @@ -193,7 +208,9 @@ "refactorDeveloper": "(使用C語言重構BongoCat的開發者)", "community": "社群", "qqGroup": "QQ 群", - "coffee": "請開發團隊喝杯咖啡?" + "coffee": "請開發團隊喝杯咖啡?", + "starOnGitHub": "GitHub 加星", + "by": "作者:" }, "shortcut": { "toggleCat": "顯示/隱藏", diff --git a/src/core/config_io.c b/src/core/config_io.c index fa6f5073..cb96f05b 100644 --- a/src/core/config_io.c +++ b/src/core/config_io.c @@ -37,15 +37,6 @@ static bool parse_theme(const char *value, BongoCatTheme *target) { return true; } -static bool parse_language(const char *value, BongoCatLanguage *target) { - for (int i = 0; i < BONGO_CAT_LANG_COUNT; ++i) - if (!strcmp(value, bongo_cat_language_name((BongoCatLanguage)i))) { - *target = (BongoCatLanguage)i; - return true; - } - return false; -} - static bool parse_background_color(const char *value, BongoCatObsBackgroundColor *target) { for (int i = 0; i < BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT; ++i) @@ -108,7 +99,7 @@ static bool read_app(yyjson_val *object, BongoCatApplicationPreferences *value, if (text && !parse_theme(text, &value->theme)) return type_error(error, "theme", "auto, light, or dark"); if (!read_string(object, "language", &text, &length, error)) return false; - if (text && !parse_language(text, &value->language)) + if (text && !bongo_cat_language_parse(text, &value->language)) return type_error(error, "language", "a supported locale string"); return true; } diff --git a/src/core/config_names.c b/src/core/config_names.c index b85d8a99..61dd1d46 100644 --- a/src/core/config_names.c +++ b/src/core/config_names.c @@ -1,16 +1,33 @@ #include "bongo_cat/config.h" +#include + const char *bongo_cat_theme_name(BongoCatTheme value) { const char *names[] = {"auto", "light", "dark"}; return (unsigned)value <= BONGO_CAT_THEME_DARK ? names[value] : names[0]; } const char *bongo_cat_language_name(BongoCatLanguage value) { - const char *names[] = {"en-US", "zh-CN", "zh-TW", "fr-FR", "de-DE", + const char *names[] = {"en-US", "zh-CN", "zh-Hant", "fr-FR", "de-DE", "ja-JP", "ko-KR", "pt-BR", "ru-RU", "es-ES"}; return (unsigned)value < BONGO_CAT_LANG_COUNT ? names[value] : names[0]; } +bool bongo_cat_language_parse(const char *name, BongoCatLanguage *value) { + if (!name || !value) return false; + if (!strcmp(name, "zh-TW")) { + *value = BONGO_CAT_LANG_ZH_HANT; + return true; + } + for (int i = 0; i < BONGO_CAT_LANG_COUNT; ++i) { + if (strcmp(name, bongo_cat_language_name((BongoCatLanguage)i))) + continue; + *value = (BongoCatLanguage)i; + return true; + } + return false; +} + const char *bongo_cat_mode_name(BongoCatModelMode value) { const char *names[] = {"standard", "keyboard", "gamepad"}; return (unsigned)value <= BONGO_CAT_MODE_GAMEPAD ? names[value] : names[0]; diff --git a/src/runtime/lifecycle/app.c b/src/runtime/lifecycle/app.c index 5cb72d50..bcf8e1e1 100644 --- a/src/runtime/lifecycle/app.c +++ b/src/runtime/lifecycle/app.c @@ -73,13 +73,13 @@ bool bongo_cat_app_initialize(BongoCatApp *app, int argc, char **argv, if (!app->secondary_pet && !app->autostart_launch) app->session.window.visible = true; bongo_cat_startup_stage(app, "configuration-ready"); - if (bongo_cat_window_create(app, error) != BONGO_CAT_OK) return false; - bongo_cat_startup_stage(app, "window-ready"); if (bongo_cat_app_locate_assets(app, error) != BONGO_CAT_OK) return false; bongo_cat_startup_stage(app, "assets-ready"); BongoCatError optional = {0}; app->i18n = bongo_cat_i18n_create(app->locale_root, app->settings.app.language, &optional); if (!app->i18n) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", optional.message); + if (bongo_cat_window_create(app, error) != BONGO_CAT_OK) return false; + bongo_cat_startup_stage(app, "window-ready"); if (bongo_cat_platform_init(&app->platform, app->window, &app->input, error) != BONGO_CAT_OK) return false; bongo_cat_window_apply(app); bongo_cat_startup_stage(app, "platform-ready"); diff --git a/src/runtime/lifecycle/startup.c b/src/runtime/lifecycle/startup.c index 55dd0ca1..4cc5dbb2 100644 --- a/src/runtime/lifecycle/startup.c +++ b/src/runtime/lifecycle/startup.c @@ -1,6 +1,7 @@ #include "runtime.h" #include "runtime_state.h" #include "bongo_cat/file.h" +#include "bongo_cat/i18n.h" #include "bongo_cat/path.h" #include "storage_paths.h" @@ -115,10 +116,9 @@ static bool parse_arguments(BongoCatApp *app, int argc, char **argv, int page = atoi(arg + 21); if (page >= 0 && page < 5) app->smoke_preference_page = page; } else if (strncmp(arg, "--ci-language=", 14) == 0) { - const char *name = arg + 14; - for (int value = 0; value < BONGO_CAT_LANG_COUNT; ++value) - if (strcmp(name, bongo_cat_language_name((BongoCatLanguage)value)) == 0) - app->smoke_language = value; + BongoCatLanguage language; + if (bongo_cat_language_parse(arg + 14, &language)) + app->smoke_language = language; } else if (strncmp(arg, "--ci-theme=", 11) == 0) { const char *name = arg + 11; for (int value = 0; value <= BONGO_CAT_THEME_DARK; ++value) @@ -266,14 +266,27 @@ static void native_error_box(const char *message) { } #endif +static const char *startup_tr(BongoCatApp *app, const char *key, + const char *fallback) { + return app && app->i18n + ? bongo_cat_i18n_get(app->i18n, key, fallback) : fallback; +} + void bongo_cat_startup_failure(BongoCatApp *app, const BongoCatError *error) { const char *message = error && error->message[0] ? error->message : "Initialization failed"; if (app) { bongo_cat_startup_stage(app, "failed"); write_error(app, "startup-error.log", message); } SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Startup failed: %s", message); char body[BONGO_CAT_PATH_CAP + 384]; - snprintf(body, sizeof(body), "BongoCat could not start.\n\n%s%s%s", message, - app && app->log_root[0] ? "\n\nDiagnostic log:\n" : "", - app && app->log_root[0] ? startup_log_path : ""); + const char *heading = startup_tr(app, "native.startup.failed", + "BongoCat could not start."); + const char *detail = startup_tr(app, "native.startup.detail", + "See the diagnostic log for technical details."); + const char *diagnostic = startup_tr(app, "native.startup.diagnosticLog", + "Diagnostic log:"); + bool has_log = app && app->log_root[0]; + snprintf(body, sizeof(body), "%s\n\n%s%s%s%s%s", heading, detail, + has_log ? "\n\n" : "", has_log ? diagnostic : "", + has_log ? "\n" : "", has_log ? startup_log_path : ""); if (app && app->smoke) return; if (!SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, BONGO_CAT_NAME, body, app ? app->window : NULL)) { diff --git a/src/ui/preferences/preferences_about.c b/src/ui/preferences/preferences_about.c index 4802f2bc..e45e3213 100644 --- a/src/ui/preferences/preferences_about.c +++ b/src/ui/preferences/preferences_about.c @@ -119,7 +119,8 @@ static void star_button(BongoCatPreferences *value, nk_rect(bounds.x + 22, bounds.y + 14.5f, 19, 19), nk_rgb(255, 255, 255)); text(canvas, nk_rect(bounds.x + 50, bounds.y + (bounds.h - value->ui.label_font->height) * .5f, - bounds.w - 62, value->ui.label_font->height), "Star on GitHub", + bounds.w - 62, value->ui.label_font->height), tr(value, + "native.support.starOnGitHub", "Star on GitHub"), value->ui.label_font, nk_rgb(255, 255, 255)); link_cursor(context, bounds); if (hit(context, bounds)) @@ -129,7 +130,8 @@ static void hero_title(BongoCatPreferences *value, struct nk_context *context, struct nk_command_buffer *canvas, struct nk_rect bounds, BongoCatUIPalette p) { const char *title = "BongoCat"; - const char *by = "by ", *developer = "BongoCat-pet"; + const char *by = tr(value, "native.support.by", "by "); + const char *developer = "BongoCat-pet"; float gap = 8, title_width = width(value->ui.hero_font, title); float by_width = width(value->ui.caption_font, by), developer_width = width(value->ui.caption_font, developer); diff --git a/src/ui/preferences/preferences_dialog.c b/src/ui/preferences/preferences_dialog.c index fd3f089e..ffb73c20 100644 --- a/src/ui/preferences/preferences_dialog.c +++ b/src/ui/preferences/preferences_dialog.c @@ -111,7 +111,11 @@ static void remove_model(BongoCatApp *app) { BongoCatError error = {0}; if (bongo_cat_app_remove_model(app, remove_dialog.model_id, &error) != BONGO_CAT_OK) { - bongo_cat_preferences_notice_show(app, error.message, true); + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "Model removal failed: %s", error.message); + bongo_cat_preferences_notice_show(app, tr(app, + "pages.preference.model.hints.deleteFailed", + "Unable to delete this model"), true); } else { char message[sizeof(remove_dialog.model_name) + 128]; snprintf(message, sizeof(message), tr(app, diff --git a/src/ui/preferences/preferences_language.c b/src/ui/preferences/preferences_language.c index 0ddaf24f..d912bbd0 100644 --- a/src/ui/preferences/preferences_language.c +++ b/src/ui/preferences/preferences_language.c @@ -27,6 +27,9 @@ bool bongo_cat_preferences_reload_language(BongoCatPreferences *value) { return false; } bongo_cat_ui_paint_destroy(&value->ui); + if (value->window) SDL_SetWindowTitle(value->window, + bongo_cat_i18n_get(value->app->i18n, + "native.preferencesWindowTitle", "BongoCat - Preferences")); if (value->app->tray) bongo_cat_tray_sync(value->app->tray); value->render_dirty = true; return true; diff --git a/src/ui/preferences/preferences_model.c b/src/ui/preferences/preferences_model.c index 320411a2..b4bae64a 100644 --- a/src/ui/preferences/preferences_model.c +++ b/src/ui/preferences/preferences_model.c @@ -131,17 +131,47 @@ void bongo_cat_preferences_process_model_selection(BongoCatPreferences *value) { card click that queued this selection and immediately toggle it back. */ } +static const char *import_failure_message(BongoCatApp *app, + BongoCatResult result, bool partial) { + if (partial) return tr(app, + "pages.preference.model.hints.importPartial", + "Some models were imported, but one or more items failed"); + switch (result) { + case BONGO_CAT_ERROR_ARGUMENT: + return tr(app, "pages.preference.model.hints.importInvalidSource", + "The selected source no longer exists or cannot be used"); + case BONGO_CAT_ERROR_FORMAT: + return tr(app, "pages.preference.model.hints.importInvalidFormat", + "No supported model was found, or the model package is incomplete"); + case BONGO_CAT_ERROR_IO: + return tr(app, "pages.preference.model.hints.importFileAccess", + "The model files could not be read or saved"); + case BONGO_CAT_ERROR_MEMORY: + return tr(app, "pages.preference.model.hints.importOutOfMemory", + "There is not enough memory to import this model"); + case BONGO_CAT_ERROR_CUBISM: + return tr(app, "pages.preference.model.hints.importLoadFailed", + "The model was imported, but it could not be displayed"); + default: + return tr(app, "pages.preference.model.hints.importFailed", + "Model import failed. Check the selected files and try again"); + } +} + void bongo_cat_preferences_import_complete(BongoCatApp *app, BongoCatResult result, const BongoCatError *error, size_t resolved_count, size_t installed_count) { if (!app || !app->preferences) return; bool partial = result == BONGO_CAT_OK && error && error->message[0]; const char *message = result != BONGO_CAT_OK || partial - ? (error && error->message[0] ? error->message : "Model import failed") + ? import_failure_message(app, result, partial) : installed_count ? tr(app, "pages.preference.model.hints.importSuccess", "Model imported") : tr(app, "pages.preference.model.hints.importExists", "Model already exists"); + if ((result != BONGO_CAT_OK || partial) && error && error->message[0]) + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "Model import failed: %s", error->message); if (app->smoke) { if (result != BONGO_CAT_OK || partial) app->exit_code = 1; } else bongo_cat_preferences_notice_show(app, message, diff --git a/src/ui/preferences/preferences_pages.c b/src/ui/preferences/preferences_pages.c index 03082e12..d45f0224 100644 --- a/src/ui/preferences/preferences_pages.c +++ b/src/ui/preferences/preferences_pages.c @@ -119,7 +119,9 @@ static void update_autostart(BongoCatApp *app, bool old_value) { if (bongo_cat_platform_set_autostart(app->settings.app.autostart, &error) == BONGO_CAT_OK) return; app->settings.app.autostart = old_value; SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "%s", error.message); - bongo_cat_preferences_notice_show(app, error.message, true); + bongo_cat_preferences_notice_show(app, tr(app, + "pages.preference.general.hints.autostartFailed", + "Unable to update launch-on-startup settings"), true); } void bongo_cat_preferences_page_general(BongoCatApp *app, struct nk_context *context) { @@ -141,7 +143,7 @@ void bongo_cat_preferences_page_general(BongoCatApp *app, struct nk_context *con section_gap(context, 6); const int language_to_ui[] = {2, 0, 1, 3, 4, 5, 6, 7, 8, 9}; const BongoCatLanguage ui_to_language[] = { - BONGO_CAT_LANG_ZH_CN, BONGO_CAT_LANG_ZH_TW, + BONGO_CAT_LANG_ZH_CN, BONGO_CAT_LANG_ZH_HANT, BONGO_CAT_LANG_EN_US, BONGO_CAT_LANG_FR_FR, BONGO_CAT_LANG_DE_DE, BONGO_CAT_LANG_JA_JP, BONGO_CAT_LANG_KO_KR, BONGO_CAT_LANG_PT_BR, diff --git a/src/ui/preferences/preferences_scale.c b/src/ui/preferences/preferences_scale.c index 6fdf75ff..7b646eb6 100644 --- a/src/ui/preferences/preferences_scale.c +++ b/src/ui/preferences/preferences_scale.c @@ -101,7 +101,9 @@ static void discard_window(BongoCatPreferences *value) { static bool create_window(BongoCatPreferences *value, int width, int height, SDL_WindowFlags flags, bool transparent) { - value->window = SDL_CreateWindow(BONGO_CAT_SETTINGS_WINDOW_TITLE, + const char *title = bongo_cat_i18n_get(value->app->i18n, + "native.preferencesWindowTitle", "BongoCat - Preferences"); + value->window = SDL_CreateWindow(title, width, height, flags | (transparent ? SDL_WINDOW_TRANSPARENT : 0)); value->transparent_window = value->window && transparent && diff --git a/tests/core/test_config.c b/tests/core/test_config.c index 2ef7efdb..86c4c431 100644 --- a/tests/core/test_config.c +++ b/tests/core/test_config.c @@ -143,6 +143,14 @@ void test_config(void) { check_defaults_and_validation(); check_shortcuts(); check_override_canonicalization(); + BongoCatLanguage language; + CHECK(!strcmp(bongo_cat_language_name(BONGO_CAT_LANG_ZH_HANT), + "zh-Hant")); + CHECK(bongo_cat_language_parse("zh-Hant", &language) && + language == BONGO_CAT_LANG_ZH_HANT); + CHECK(bongo_cat_language_parse("zh-TW", &language) && + language == BONGO_CAT_LANG_ZH_HANT); + CHECK(!bongo_cat_language_parse("zh-Hans", &language)); const uint32_t colors[] = {0x00ff00, 0x0000ff, 0xff0000, 0xff00ff}; for (int i = 0; i < BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT; ++i) CHECK(bongo_cat_obs_background_color_rgb(i) == colors[i]); diff --git a/tests/i18n/test_i18n.c b/tests/i18n/test_i18n.c index 9efc71ca..74172cd9 100644 --- a/tests/i18n/test_i18n.c +++ b/tests/i18n/test_i18n.c @@ -80,6 +80,16 @@ int main(void) { snprintf(root, sizeof(root), "%s/resources/assets/locales", BONGO_CAT_NATIVE_SOURCE_DIR); yyjson_doc *reference = load(root, "zh-CN"); if (!reference) return 1; + const char *required_ui_keys[] = { + "pages.preference.general.hints.autostartFailed", + "pages.preference.model.hints.deleteFailed", + "pages.preference.model.hints.importFailed", + "native.preferencesWindowTitle", + "native.startup.failed", + "native.startup.detail", + "native.startup.diagnosticLog", + "native.support.starOnGitHub", + "native.support.by"}; const uint32_t expected[] = {'A', 0x4e2d, 0x8a2d, 0x00e7, 0x00fc, 0x65e5, 0xd55c, 0x00ea, 0x0420, 0x00f1}; for (int language = 0; language < BONGO_CAT_LANG_COUNT; ++language) { @@ -96,6 +106,16 @@ int main(void) { fprintf(stderr, "Missing U+%04X for %s\n", expected[language], name); return 3; } + for (size_t i = 0; i < sizeof(required_ui_keys) / + sizeof(required_ui_keys[0]); ++i) { + const char *missing = "__missing_translation__"; + if (!strcmp(bongo_cat_i18n_get(i18n, required_ui_keys[i], missing), + missing)) { + fprintf(stderr, "Missing required translation %s for %s\n", + required_ui_keys[i], name); + return 7; + } + } bongo_cat_i18n_destroy(i18n); yyjson_doc_free(document); } From 2fd82bce20aa3f091c00a5248b338bf296ec06db Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 19 Aug 2026 10:05:43 +0800 Subject: [PATCH 041/334] refactor(input): internalize key release recovery --- README.md | 2 +- docs/feature-audit.md | 2 +- docs/parity.md | 2 +- docs/storage-layout.md | 1 - include/bongo_cat/common.h | 2 +- include/bongo_cat/config.h | 4 +- include/bongo_cat/input.h | 15 ++--- resources/assets/locales/de-DE.json | 1 - resources/assets/locales/en-US.json | 1 - resources/assets/locales/es-ES.json | 1 - resources/assets/locales/fr-FR.json | 1 - resources/assets/locales/ja-JP.json | 1 - resources/assets/locales/ko-KR.json | 1 - resources/assets/locales/pt-BR.json | 1 - resources/assets/locales/ru-RU.json | 1 - resources/assets/locales/zh-CN.json | 1 - resources/assets/locales/zh-Hant.json | 1 - src/core/config_defaults.c | 9 +-- src/core/config_io.c | 6 +- src/core/config_settings_save.c | 4 +- src/core/input_state.c | 46 ++++++++------- src/platform/windows/windows_keys.c | 28 ++++++--- src/platform/windows/windows_keys.h | 3 + src/runtime/input/shortcuts.c | 10 ++-- src/runtime/lifecycle/app.c | 11 ++-- src/ui/preferences/preferences_pages.c | 4 -- src/ui/preferences/preferences_shortcuts.c | 2 +- tests/core/test_config.c | 15 ++--- tests/core/test_input.c | 68 ++++++++++++++-------- 29 files changed, 130 insertions(+), 114 deletions(-) diff --git a/README.md b/README.md index 0dc11ac4..05e9adb2 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ instead of a diagnostic build. example `A-*` plus `Z-*` directories) are also discovered. The source stays read-only and BongoCat caches only generated adapter files. - Keyboard and mouse input, pointer tracking, gamepad buttons and axes, - mirroring, automatic key release, and configurable shortcuts. + mirroring, lost-release recovery, and configurable shortcuts. - Optional keep-on-screen positioning, disabled by default. A fully unreachable window is recovered after a display is disconnected, without polling or interacting with fullscreen applications. diff --git a/docs/feature-audit.md b/docs/feature-audit.md index e99ab028..099c19ef 100644 --- a/docs/feature-audit.md +++ b/docs/feature-audit.md @@ -47,7 +47,7 @@ A diagnostic drawing never counts as a Live2D visual pass. | Global Windows mouse input | PASS | Injected down/up events reach the native input queue. | | Global Windows pointer movement | PASS | Low-level hook coordinates, smoothing and parameter mapping are covered. | | Pointer-driven Live2D tracking | PASS | Frames differ by 8.74%/9.18%; seven parameters and mirrored X/Z directions pass. | -| Windows automatic key release | PASS | Core input tests cover scheduled releases. | +| Windows lost key-release recovery | PASS | Core input tests cover physical-state reconciliation. | | Gamepad hot plug | PENDING | SDL implementation exists; physical-device testing remains. | | Behavior shortcuts | PENDING | Parsing and dispatch exist; a configured global shortcut smoke remains. | | macOS input permission | PENDING | Native implementation exists; macOS manual verification remains. | diff --git a/docs/parity.md b/docs/parity.md index ae23a077..9f44b893 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -18,7 +18,7 @@ desktop runtime. - Observe global keyboard press and release events. - Observe global mouse buttons and coalesced pointer movement. -- Preserve Windows automatic release timing for system keys. +- Recover Windows key state when a release event is lost. - Support SDL game controllers, sticks, triggers, and hot plug. - Register behavior shortcuts and application shortcuts. - Request the required macOS input-monitoring permission. diff --git a/docs/storage-layout.md b/docs/storage-layout.md index ce40f5f1..8b4e33dc 100644 --- a/docs/storage-layout.md +++ b/docs/storage-layout.md @@ -50,7 +50,6 @@ isolated development runs that must ignore adjacent sources. "pointerMirrored": false, "centerPointerTracking": true, "ignorePointerInput": false, - "inputReleaseDelaySeconds": 3.0, "maximumFps": 60 }, "window": { diff --git a/include/bongo_cat/common.h b/include/bongo_cat/common.h index da12c6e1..6593c99d 100644 --- a/include/bongo_cat/common.h +++ b/include/bongo_cat/common.h @@ -24,7 +24,7 @@ #define BONGO_CAT_BEHAVIOR_ID_CAP 384 #define BONGO_CAT_BEHAVIOR_BINDING_CAP 256 #define BONGO_CAT_MENU_LABEL_CAP (BONGO_CAT_ID_CAP + BONGO_CAT_SHORTCUT_CAP + 4) -#define BONGO_CAT_AUTO_RELEASE_CAP 64 +#define BONGO_CAT_SCHEDULED_RELEASE_CAP 64 #define BONGO_CAT_MODEL_PACKAGE_FILE ".bongo-cat-package.json" #define BONGO_CAT_MODEL_ADAPTER_FILE ".bongo-cat-adapter.json" #define BONGO_CAT_MODEL_PACKAGE_SCHEMA 2 diff --git a/include/bongo_cat/config.h b/include/bongo_cat/config.h index 8fb1d6ec..4bc3edff 100644 --- a/include/bongo_cat/config.h +++ b/include/bongo_cat/config.h @@ -3,7 +3,6 @@ #include "bongo_cat/common.h" -#define BONGO_CAT_DEFAULT_AUTO_RELEASE_SECONDS 3.0f #define BONGO_CAT_DEFAULT_MAX_FPS 60 #define BONGO_CAT_DEFAULT_WINDOW_SCALE_PERCENT 100.0f #define BONGO_CAT_DEFAULT_WINDOW_OPACITY_PERCENT 100.0f @@ -42,7 +41,6 @@ typedef struct BongoCatModelPreferences { bool mouse_mirror; bool mouse_centered; bool ignore_mouse; - float auto_release_seconds; int max_fps; } BongoCatModelPreferences; @@ -77,7 +75,7 @@ typedef struct BongoCatApplicationPreferences { } BongoCatApplicationPreferences; typedef struct BongoCatShortcutPreferences { - char visible_cat[BONGO_CAT_SHORTCUT_CAP]; + char toggle_pet_visibility[BONGO_CAT_SHORTCUT_CAP]; char visible_preferences[BONGO_CAT_SHORTCUT_CAP]; char mirror[BONGO_CAT_SHORTCUT_CAP]; char pass_through[BONGO_CAT_SHORTCUT_CAP]; diff --git a/include/bongo_cat/input.h b/include/bongo_cat/input.h index c07882e3..d31ad29c 100644 --- a/include/bongo_cat/input.h +++ b/include/bongo_cat/input.h @@ -29,10 +29,10 @@ typedef struct BongoCatInputEvent { uint64_t sequence; } BongoCatInputEvent; -typedef struct BongoCatAutoRelease { +typedef struct BongoCatScheduledRelease { uint64_t deadline_ms; char name[BONGO_CAT_ID_CAP]; -} BongoCatAutoRelease; +} BongoCatScheduledRelease; typedef struct BongoCatInputState { BongoCatInputEvent queue[BONGO_CAT_INPUT_QUEUE_CAP]; @@ -48,8 +48,9 @@ typedef struct BongoCatInputState { BongoCatInputEvent recovery[BONGO_CAT_INPUT_RECOVERY_CAP]; atomic_uint_fast8_t recovery_head; atomic_uint_fast8_t recovery_tail; - BongoCatAutoRelease releases[BONGO_CAT_AUTO_RELEASE_CAP]; - size_t release_count; + BongoCatScheduledRelease scheduled_releases[ + BONGO_CAT_SCHEDULED_RELEASE_CAP]; + size_t scheduled_release_count; } BongoCatInputState; void bongo_cat_input_init(BongoCatInputState *state); @@ -61,9 +62,9 @@ bool bongo_cat_input_mouse(BongoCatInputState *state, double x, double y); bool bongo_cat_input_take_mouse(BongoCatInputState *state, double *x, double *y); bool bongo_cat_input_control_down(const BongoCatInputState *state); bool bongo_cat_input_shift_down(const BongoCatInputState *state); -void bongo_cat_input_auto_release(BongoCatInputState *state, +void bongo_cat_input_schedule_release(BongoCatInputState *state, const BongoCatInputEvent *event, uint64_t delay_ms); -bool bongo_cat_input_take_release(BongoCatInputState *state, uint64_t now_ms, - BongoCatInputEvent *event); +bool bongo_cat_input_take_scheduled_release(BongoCatInputState *state, + uint64_t now_ms, BongoCatInputEvent *event); #endif diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index 994b3ef3..f9952d4c 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -24,7 +24,6 @@ "opacity": "Deckkraft", "randomExpression": "Zufällige Ausdrücke", "obsBackground": "Einfarbiger Hintergrund", - "autoReleaseDelay": "Verzögerung der automatischen Freigabe", "hideOnHover": "Beim Darüberfahren ausblenden", "maxFPS": "Maximale Bildrate" }, diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index d209d846..1c6ce85b 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -24,7 +24,6 @@ "opacity": "Opacity", "randomExpression": "Random Expressions", "obsBackground": "Solid Background", - "autoReleaseDelay": "Auto Release Delay", "hideOnHover": "Hide on Hover", "maxFPS": "Max Frame Rate" }, diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index 566e3a9c..092594c6 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -24,7 +24,6 @@ "opacity": "Opacidad", "randomExpression": "Expresiones aleatorias", "obsBackground": "Fondo sólido", - "autoReleaseDelay": "Retardo de liberación automática", "hideOnHover": "Ocultar al pasar el ratón", "maxFPS": "Frecuencia máxima de fotogramas" }, diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index c82c026b..6b08c462 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -24,7 +24,6 @@ "opacity": "Opacité", "randomExpression": "Expressions aléatoires", "obsBackground": "Arrière-plan uni", - "autoReleaseDelay": "Délai de relâchement automatique", "hideOnHover": "Masquer au survol", "maxFPS": "Fréquence d'images maximale" }, diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index b9782bf7..f6ff65cd 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -24,7 +24,6 @@ "opacity": "不透明度", "randomExpression": "ランダム表情", "obsBackground": "単色背景", - "autoReleaseDelay": "自動リリースまでの時間", "hideOnHover": "ホバー時に非表示", "maxFPS": "最大フレームレート" }, diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index 3cdc217c..27bbe9de 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -24,7 +24,6 @@ "opacity": "불투명도", "randomExpression": "무작위 표정", "obsBackground": "단색 배경", - "autoReleaseDelay": "자동 해제 지연", "hideOnHover": "마우스를 올리면 숨기기", "maxFPS": "최대 프레임 속도" }, diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index a01493f3..acb03f53 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -24,7 +24,6 @@ "opacity": "Opacidade", "randomExpression": "Expressões Aleatórias", "obsBackground": "Fundo sólido", - "autoReleaseDelay": "Atraso de Liberação Automática", "hideOnHover": "Ocultar ao Passar o Mouse", "maxFPS": "Taxa Máxima de Quadros" }, diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index 1768249b..ca9bcb85 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -24,7 +24,6 @@ "opacity": "Непрозрачность", "randomExpression": "Случайные выражения", "obsBackground": "Однотонный фон", - "autoReleaseDelay": "Задержка автоотпускания", "hideOnHover": "Скрывать при наведении", "maxFPS": "Максимальная частота кадров" }, diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index 50ab01e5..01fdea14 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -24,7 +24,6 @@ "opacity": "不透明度", "randomExpression": "随机表情", "obsBackground": "纯色背景", - "autoReleaseDelay": "按键自动释放延迟", "hideOnHover": "鼠标移入隐藏", "maxFPS": "最大帧率" }, diff --git a/resources/assets/locales/zh-Hant.json b/resources/assets/locales/zh-Hant.json index 278b2ffa..0433dfe0 100644 --- a/resources/assets/locales/zh-Hant.json +++ b/resources/assets/locales/zh-Hant.json @@ -24,7 +24,6 @@ "opacity": "不透明度", "randomExpression": "隨機表情", "obsBackground": "純色背景", - "autoReleaseDelay": "按鍵自動釋放延遲", "hideOnHover": "滑鼠游標移入隱藏", "maxFPS": "最大幀率" }, diff --git a/src/core/config_defaults.c b/src/core/config_defaults.c index d85e32ec..fae1d93a 100644 --- a/src/core/config_defaults.c +++ b/src/core/config_defaults.c @@ -34,7 +34,7 @@ static bool normalize_text(char *text, size_t capacity) { bool bongo_cat_settings_shortcut_conflicts(const BongoCatSettings *config, const char *shortcut, const char *exclude) { if (!config || !shortcut || !shortcut[0]) return false; - const char *global[] = {config->shortcuts.visible_cat, + const char *global[] = {config->shortcuts.toggle_pet_visibility, config->shortcuts.visible_preferences, config->shortcuts.mirror, config->shortcuts.pass_through, config->shortcuts.always_on_top}; for (size_t i = 0; i < sizeof(global) / sizeof(global[0]); ++i) @@ -103,7 +103,7 @@ bool bongo_cat_settings_set_model_label(BongoCatSettings *config, } static void validate_shortcuts(BongoCatSettings *config) { - char *global[] = {config->shortcuts.visible_cat, + char *global[] = {config->shortcuts.toggle_pet_visibility, config->shortcuts.visible_preferences, config->shortcuts.mirror, config->shortcuts.pass_through, config->shortcuts.always_on_top}; for (size_t i = 0; i < sizeof(global) / sizeof(global[0]); ++i) { @@ -215,8 +215,6 @@ static void compact_model_overrides(BongoCatSettings *config) { void bongo_cat_settings_defaults(BongoCatSettings *config) { if (!config) return; memset(config, 0, sizeof(*config)); - config->model.auto_release_seconds = - BONGO_CAT_DEFAULT_AUTO_RELEASE_SECONDS; config->model.mouse_centered = true; config->model.max_fps = BONGO_CAT_DEFAULT_MAX_FPS; config->window.always_on_top = true; @@ -232,9 +230,6 @@ void bongo_cat_settings_defaults(BongoCatSettings *config) { void bongo_cat_settings_validate(BongoCatSettings *config) { if (!config) return; - config->model.auto_release_seconds = clampf_or( - config->model.auto_release_seconds, 0.05f, 30.0f, - BONGO_CAT_DEFAULT_AUTO_RELEASE_SECONDS); if (config->model.max_fps < 1) config->model.max_fps = 1; if (config->model.max_fps > 240) config->model.max_fps = 240; config->window.hide_delay_seconds = clampf_or( diff --git a/src/core/config_io.c b/src/core/config_io.c index cb96f05b..21c7db52 100644 --- a/src/core/config_io.c +++ b/src/core/config_io.c @@ -56,8 +56,6 @@ static bool read_model(yyjson_val *object, BongoCatModelPreferences *value, read_bool(object, "centerPointerTracking", &value->mouse_centered, error) && read_bool(object, "ignorePointerInput", &value->ignore_mouse, error) && - read_float(object, "inputReleaseDelaySeconds", - &value->auto_release_seconds, error) && read_int(object, "maximumFps", &value->max_fps, error); } @@ -106,8 +104,8 @@ static bool read_app(yyjson_val *object, BongoCatApplicationPreferences *value, static bool read_shortcuts(yyjson_val *object, BongoCatShortcutPreferences *value, BongoCatError *error) { - return read_text(object, "toggleVisibility", value->visible_cat, - sizeof(value->visible_cat), error) && + return read_text(object, "toggleVisibility", value->toggle_pet_visibility, + sizeof(value->toggle_pet_visibility), error) && read_text(object, "openSettings", value->visible_preferences, sizeof(value->visible_preferences), error) && read_text(object, "toggleModelMirror", value->mirror, diff --git a/src/core/config_settings_save.c b/src/core/config_settings_save.c index ff143a8d..6ae61a8e 100644 --- a/src/core/config_settings_save.c +++ b/src/core/config_settings_save.c @@ -16,8 +16,6 @@ static bool write_model(yyjson_mut_doc *doc, yyjson_mut_val *object, value->mouse_centered) && yyjson_mut_obj_add_bool(doc, object, "ignorePointerInput", value->ignore_mouse) && - yyjson_mut_obj_add_real(doc, object, "inputReleaseDelaySeconds", - value->auto_release_seconds) && yyjson_mut_obj_add_int(doc, object, "maximumFps", value->max_fps); } @@ -63,7 +61,7 @@ static bool write_shortcuts(yyjson_mut_doc *doc, yyjson_mut_val *object, const BongoCatShortcutPreferences *value) { return object && yyjson_mut_obj_add_strcpy(doc, object, "toggleVisibility", - value->visible_cat) && + value->toggle_pet_visibility) && yyjson_mut_obj_add_strcpy(doc, object, "openSettings", value->visible_preferences) && yyjson_mut_obj_add_strcpy(doc, object, "toggleModelMirror", diff --git a/src/core/input_state.c b/src/core/input_state.c index 872d6b7f..17aed085 100644 --- a/src/core/input_state.c +++ b/src/core/input_state.c @@ -18,8 +18,8 @@ void bongo_cat_input_init(BongoCatInputState *state) { memset(state->recovery, 0, sizeof(state->recovery)); atomic_init(&state->recovery_head, 0); atomic_init(&state->recovery_tail, 0); - memset(state->releases, 0, sizeof(state->releases)); - state->release_count = 0; + memset(state->scheduled_releases, 0, sizeof(state->scheduled_releases)); + state->scheduled_release_count = 0; } bool bongo_cat_input_edge(bool states[BONGO_CAT_INPUT_KEY_STATE_CAP], @@ -127,19 +127,20 @@ bool bongo_cat_input_shift_down(const BongoCatInputState *state) { } static size_t release_index(const BongoCatInputState *state, const char *name) { - for (size_t i = 0; i < state->release_count; ++i) - if (strcmp(state->releases[i].name, name) == 0) return i; - return state->release_count; + for (size_t i = 0; i < state->scheduled_release_count; ++i) + if (strcmp(state->scheduled_releases[i].name, name) == 0) return i; + return state->scheduled_release_count; } static void remove_release(BongoCatInputState *state, size_t index) { - if (index >= state->release_count) return; - state->releases[index] = state->releases[state->release_count - 1]; - memset(&state->releases[--state->release_count], 0, - sizeof(state->releases[0])); + if (index >= state->scheduled_release_count) return; + state->scheduled_releases[index] = + state->scheduled_releases[state->scheduled_release_count - 1]; + memset(&state->scheduled_releases[--state->scheduled_release_count], 0, + sizeof(state->scheduled_releases[0])); } -void bongo_cat_input_auto_release(BongoCatInputState *state, +void bongo_cat_input_schedule_release(BongoCatInputState *state, const BongoCatInputEvent *event, uint64_t delay_ms) { if (!state || !event || !event->name[0] || (event->kind != BONGO_CAT_INPUT_KEY_DOWN && event->kind != BONGO_CAT_INPUT_KEY_UP)) return; @@ -148,24 +149,27 @@ void bongo_cat_input_auto_release(BongoCatInputState *state, remove_release(state, index); return; } - if (index == state->release_count) { - if (state->release_count >= BONGO_CAT_AUTO_RELEASE_CAP) return; - index = state->release_count++; - snprintf(state->releases[index].name, - sizeof(state->releases[index].name), "%s", event->name); + if (index == state->scheduled_release_count) { + if (state->scheduled_release_count >= + BONGO_CAT_SCHEDULED_RELEASE_CAP) return; + index = state->scheduled_release_count++; + snprintf(state->scheduled_releases[index].name, + sizeof(state->scheduled_releases[index].name), "%s", event->name); } - state->releases[index].deadline_ms = event->timestamp_ms + delay_ms; + state->scheduled_releases[index].deadline_ms = + event->timestamp_ms + delay_ms; } -bool bongo_cat_input_take_release(BongoCatInputState *state, uint64_t now_ms, - BongoCatInputEvent *event) { +bool bongo_cat_input_take_scheduled_release(BongoCatInputState *state, + uint64_t now_ms, BongoCatInputEvent *event) { if (!state || !event) return false; - for (size_t i = 0; i < state->release_count; ++i) { - if (state->releases[i].deadline_ms > now_ms) continue; + for (size_t i = 0; i < state->scheduled_release_count; ++i) { + if (state->scheduled_releases[i].deadline_ms > now_ms) continue; memset(event, 0, sizeof(*event)); event->kind = BONGO_CAT_INPUT_KEY_UP; event->timestamp_ms = now_ms; - snprintf(event->name, sizeof(event->name), "%s", state->releases[i].name); + snprintf(event->name, sizeof(event->name), "%s", + state->scheduled_releases[i].name); remove_release(state, i); return true; } diff --git a/src/platform/windows/windows_keys.c b/src/platform/windows/windows_keys.c index 9a8c1192..41fbbf53 100644 --- a/src/platform/windows/windows_keys.c +++ b/src/platform/windows/windows_keys.c @@ -105,19 +105,31 @@ bool bongo_cat_windows_keyboard_event(BongoCatWindowsKeyboard *state, return true; } +bool bongo_cat_windows_keyboard_reconcile_key(BongoCatWindowsKeyboard *state, + unsigned code, uint64_t now_ms, bool physically_down, + BongoCatWindowsKeyEmit emit, void *userdata) { + if (!state || !emit || code >= BONGO_CAT_INPUT_KEY_STATE_CAP || + !state->down[code] || !state->name[code][0] || physically_down || + now_ms < state->changed_ms[code] || + now_ms - state->changed_ms[code] < 50) return false; + char name[BONGO_CAT_ID_CAP]; + snprintf(name, sizeof(name), "%s", state->name[code]); + state->down[code] = false; + state->name[code][0] = '\0'; + state->changed_ms[code] = now_ms; + emit(false, name, userdata); + return true; +} + void bongo_cat_windows_keyboard_reconcile(BongoCatWindowsKeyboard *state, uint64_t now_ms, BongoCatWindowsKeyEmit emit, void *userdata) { if (!state || !emit) return; for (unsigned code = 0; code < BONGO_CAT_INPUT_KEY_STATE_CAP; ++code) { if (!state->down[code] || !state->name[code][0] || - now_ms - state->changed_ms[code] < 50 || - (GetAsyncKeyState((int)code) & 0x8000)) continue; - char name[BONGO_CAT_ID_CAP]; - snprintf(name, sizeof(name), "%s", state->name[code]); - state->down[code] = false; - state->name[code][0] = '\0'; - state->changed_ms[code] = now_ms; - emit(false, name, userdata); + now_ms < state->changed_ms[code] || + now_ms - state->changed_ms[code] < 50) continue; + bongo_cat_windows_keyboard_reconcile_key(state, code, now_ms, + (GetAsyncKeyState((int)code) & 0x8000) != 0, emit, userdata); } } #endif diff --git a/src/platform/windows/windows_keys.h b/src/platform/windows/windows_keys.h index db49f564..3b3d62a3 100644 --- a/src/platform/windows/windows_keys.h +++ b/src/platform/windows/windows_keys.h @@ -18,6 +18,9 @@ const char *bongo_cat_windows_key_name(const KBDLLHOOKSTRUCT *key, char output[1 bool bongo_cat_windows_keyboard_event(BongoCatWindowsKeyboard *state, const KBDLLHOOKSTRUCT *key, WPARAM message, UINT *drop_key_up, BongoCatWindowsKeyEmit emit, void *userdata); +bool bongo_cat_windows_keyboard_reconcile_key(BongoCatWindowsKeyboard *state, + unsigned code, uint64_t now_ms, bool physically_down, + BongoCatWindowsKeyEmit emit, void *userdata); void bongo_cat_windows_keyboard_reconcile(BongoCatWindowsKeyboard *state, uint64_t now_ms, BongoCatWindowsKeyEmit emit, void *userdata); #endif diff --git a/src/runtime/input/shortcuts.c b/src/runtime/input/shortcuts.c index fc882d95..08615d66 100644 --- a/src/runtime/input/shortcuts.c +++ b/src/runtime/input/shortcuts.c @@ -7,7 +7,7 @@ #include #include -static void visible(BongoCatApp *app) { +static void toggle_pet_visibility(BongoCatApp *app) { bongo_cat_window_set_visible(app, !app->session.window.visible); } @@ -111,8 +111,9 @@ void bongo_cat_app_shortcuts(BongoCatApp *app, const BongoCatInputEvent *event) return; } BongoCatShortcutPreferences *shortcuts = &app->settings.shortcuts; - if (bongo_cat_shortcut_matches(&app->shortcut_state, event, shortcuts->visible_cat)) { - visible(app); + if (bongo_cat_shortcut_matches(&app->shortcut_state, event, + shortcuts->toggle_pet_visibility)) { + toggle_pet_visibility(app); } else if (bongo_cat_shortcut_matches(&app->shortcut_state, event, shortcuts->visible_preferences)) { bongo_cat_preferences_visible(app->preferences) ? @@ -151,7 +152,8 @@ static void test_press(BongoCatApp *app, const char *name) { bool bongo_cat_app_shortcuts_self_test(BongoCatApp *app) { if (!app || !app->preferences) return false; BongoCatShortcutPreferences *keys = &app->settings.shortcuts; - snprintf(keys->visible_cat, sizeof(keys->visible_cat), "Control+B"); + snprintf(keys->toggle_pet_visibility, + sizeof(keys->toggle_pet_visibility), "Control+B"); snprintf(keys->visible_preferences, sizeof(keys->visible_preferences), "Control+Comma"); snprintf(keys->mirror, sizeof(keys->mirror), "Control+M"); snprintf(keys->pass_through, sizeof(keys->pass_through), "Control+P"); diff --git a/src/runtime/lifecycle/app.c b/src/runtime/lifecycle/app.c index bcf8e1e1..4767ee78 100644 --- a/src/runtime/lifecycle/app.c +++ b/src/runtime/lifecycle/app.c @@ -171,18 +171,15 @@ void bongo_cat_app_drain_input(BongoCatApp *app, bool allow_shortcuts) { if (file) { fprintf(file, "kind=%d name=%s value=%.3f\n", event.kind, event.name, event.value); fclose(file); } } - uint64_t delay = strcmp(event.name, "CapsLock") == 0 ? 100 : 0; -#ifdef _WIN32 - if (event.kind == BONGO_CAT_INPUT_KEY_DOWN && !delay) - delay = (uint64_t)(app->settings.model.auto_release_seconds * 1000.0f); -#endif - bongo_cat_input_auto_release(&app->input, &event, delay); + if (strcmp(event.name, "CapsLock") == 0) + bongo_cat_input_schedule_release(&app->input, &event, 100); if (allow_shortcuts && !bongo_cat_preferences_shortcuts_blocked(app->preferences)) bongo_cat_app_shortcuts(app, &event); bongo_cat_app_apply_input(app, &event); } uint64_t now = SDL_GetTicks(); - while (bongo_cat_input_take_release(&app->input, now, &event)) { + while (bongo_cat_input_take_scheduled_release( + &app->input, now, &event)) { if (allow_shortcuts && !bongo_cat_preferences_shortcuts_blocked(app->preferences)) bongo_cat_app_shortcuts(app, &event); bongo_cat_app_apply_input(app, &event); diff --git a/src/ui/preferences/preferences_pages.c b/src/ui/preferences/preferences_pages.c index d45f0224..9d0c1eb5 100644 --- a/src/ui/preferences/preferences_pages.c +++ b/src/ui/preferences/preferences_pages.c @@ -105,10 +105,6 @@ void bongo_cat_preferences_page_cat(BongoCatApp *app, struct nk_context *context app->pointer_known = false; app->dirty = true; } - bongo_cat_pref_float(context, "release-delay", tr(app, - "pages.preference.cat.labels.autoReleaseDelay", "Auto Release Delay"), "", - .05f, &model->auto_release_seconds, 30.0f, .05f, - BONGO_CAT_DEFAULT_AUTO_RELEASE_SECONDS); bongo_cat_pref_int(context, "max-fps", tr(app, "pages.preference.cat.labels.maxFPS", "Max Frame Rate"), "", 1, &model->max_fps, 240, 1, BONGO_CAT_DEFAULT_MAX_FPS); diff --git a/src/ui/preferences/preferences_shortcuts.c b/src/ui/preferences/preferences_shortcuts.c index 01a4ed49..3f4956f1 100644 --- a/src/ui/preferences/preferences_shortcuts.c +++ b/src/ui/preferences/preferences_shortcuts.c @@ -183,7 +183,7 @@ void bongo_cat_preferences_page_shortcuts(BongoCatPreferences *value, "pages.preference.shortcut.title", "Shortcuts")); shortcut_row(value, context, "shortcut-cat", "native.shortcut.toggleCat", "Show/Hide", - keys->visible_cat, sizeof(keys->visible_cat)); + keys->toggle_pet_visibility, sizeof(keys->toggle_pet_visibility)); shortcut_row(value, context, "shortcut-pref", "native.shortcut.preferences", "Preferences", keys->visible_preferences, sizeof(keys->visible_preferences)); diff --git a/tests/core/test_config.c b/tests/core/test_config.c index 86c4c431..29ecd9be 100644 --- a/tests/core/test_config.c +++ b/tests/core/test_config.c @@ -53,7 +53,6 @@ static void check_defaults_and_validation(void) { !bongo_cat_session_model_active(&session, "keyboard")); bongo_cat_session_clear_additional_models(&session); settings.model.max_fps = 900; - settings.model.auto_release_seconds = NAN; settings.window.obs_background_color = BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT; settings.window.hide_delay_seconds = NAN; settings.window.random_expression_interval_seconds = NAN; @@ -70,8 +69,6 @@ static void check_defaults_and_validation(void) { bongo_cat_settings_validate(&settings); bongo_cat_session_validate(&session); CHECK(settings.model.max_fps == 240); - CHECK(settings.model.auto_release_seconds == - BONGO_CAT_DEFAULT_AUTO_RELEASE_SECONDS); CHECK(settings.window.hide_delay_seconds == 0.0f); CHECK(settings.window.random_expression_interval_seconds == BONGO_CAT_DEFAULT_RANDOM_EXPRESSION_SECONDS); @@ -118,7 +115,8 @@ static void check_override_canonicalization(void) { static void check_shortcuts(void) { static BongoCatSettings settings; bongo_cat_settings_defaults(&settings); - memcpy(settings.shortcuts.visible_cat, "Control+B", sizeof("Control+B")); + memcpy(settings.shortcuts.toggle_pet_visibility, "Control+B", + sizeof("Control+B")); memcpy(settings.shortcuts.visible_preferences, "control+b", sizeof("control+b")); settings.behavior_shortcut_count = 2; @@ -129,14 +127,14 @@ static void check_shortcuts(void) { memcpy(settings.behavior_shortcuts[1].shortcut, "Control+M", sizeof("Control+M")); bongo_cat_settings_validate(&settings); - CHECK(strcmp(settings.shortcuts.visible_cat, "Control+B") == 0); + CHECK(strcmp(settings.shortcuts.toggle_pet_visibility, "Control+B") == 0); CHECK(!settings.shortcuts.visible_preferences[0]); CHECK(settings.behavior_shortcut_count == 1); CHECK(strcmp(settings.behavior_shortcuts[0].id, "motion:1") == 0); CHECK(strcmp(settings.behavior_shortcuts[0].shortcut, "Control+M") == 0); CHECK(bongo_cat_settings_shortcut_conflicts(&settings, "control+b", NULL)); CHECK(!bongo_cat_settings_shortcut_conflicts(&settings, "control+b", - settings.shortcuts.visible_cat)); + settings.shortcuts.toggle_pet_visibility)); } void test_config(void) { @@ -207,7 +205,7 @@ void test_config(void) { CHECK(contains_text(settings_path, "\"randomExpression\": true")); CHECK(contains_text(settings_path, "\"randomExpressionIntervalSeconds\": 12.0")); - CHECK(contains_text(settings_path, "\"multiplePets\": true")); + CHECK(contains_text(settings_path, "\"multiplePets\": true") && !contains_text(settings_path, "inputReleaseDelaySeconds")); CHECK(contains_text(settings_path, "\"example\"")); CHECK(!contains_text(settings_path, "activeModelId")); CHECK(contains_text(session_path, "\"format\": \"bongocat/session\"")); @@ -260,6 +258,9 @@ void test_config(void) { CHECK(bongo_cat_session_load(unsupported, &loaded_session, &error) == BONGO_CAT_ERROR_FORMAT); + write_text(unsupported, "{\"format\":\"bongocat/settings\",\"schemaVersion\":1,\"rendering\":{\"inputReleaseDelaySeconds\":3,\"maximumFps\":30}}"); + CHECK(bongo_cat_settings_load(unsupported, &loaded_settings, &error) == BONGO_CAT_OK && loaded_settings.model.max_fps == 30); + write_text(unsupported, "{\"format\":\"bongocat/settings\",\"schemaVersion\":1," "\"rendering\":{\"maximumFps\":\"fast\"}}"); diff --git a/tests/core/test_input.c b/tests/core/test_input.c index 2c67b1d8..6da9b0f6 100644 --- a/tests/core/test_input.c +++ b/tests/core/test_input.c @@ -5,6 +5,19 @@ #include #ifdef _WIN32 #include "windows_keys.h" + +typedef struct KeyEvents { + unsigned count; + bool down; + char name[BONGO_CAT_ID_CAP]; +} KeyEvents; + +static void collect_key(bool down, const char *name, void *userdata) { + KeyEvents *events = userdata; + events->count++; + events->down = down; + snprintf(events->name, sizeof(events->name), "%s", name); +} #endif void test_input(void) { @@ -20,6 +33,30 @@ void test_input(void) { CHECK(strcmp(bongo_cat_windows_key_name(&key, key_name), "PrintScreen") == 0); key.vkCode = VK_APPS; CHECK(strcmp(bongo_cat_windows_key_name(&key, key_name), "Apps") == 0); + + BongoCatWindowsKeyboard keyboard = {0}; + KeyEvents key_events = {0}; + UINT drop_key_up = 0; + key.vkCode = 'A'; + key.flags = 0; + CHECK(bongo_cat_windows_keyboard_event(&keyboard, &key, WM_KEYDOWN, + &drop_key_up, collect_key, &key_events)); + uint64_t pressed_ms = keyboard.changed_ms['A']; + CHECK(key_events.count == 1 && key_events.down && + strcmp(key_events.name, "KeyA") == 0); + CHECK(!bongo_cat_windows_keyboard_reconcile_key(&keyboard, 'A', + pressed_ms + 49, false, collect_key, &key_events)); + CHECK(!bongo_cat_windows_keyboard_reconcile_key(&keyboard, 'A', + pressed_ms + 10000, true, collect_key, &key_events)); + CHECK(key_events.count == 1 && keyboard.down['A']); + drop_key_up = 'A'; + CHECK(bongo_cat_windows_keyboard_event(&keyboard, &key, WM_KEYUP, + &drop_key_up, collect_key, &key_events)); + CHECK(drop_key_up == 0 && key_events.count == 1 && keyboard.down['A']); + CHECK(bongo_cat_windows_keyboard_reconcile_key(&keyboard, 'A', + pressed_ms + 10000, false, collect_key, &key_events)); + CHECK(key_events.count == 2 && !key_events.down && + strcmp(key_events.name, "KeyA") == 0 && !keyboard.down['A']); #endif BongoCatInputState state; bongo_cat_input_init(&state); @@ -134,29 +171,14 @@ void test_input(void) { bongo_cat_input_init(&state); event.kind = BONGO_CAT_INPUT_KEY_DOWN; event.timestamp_ms = 100; - memcpy(event.name, "KeyA", sizeof("KeyA")); - bongo_cat_input_auto_release(&state, &event, 3000); - CHECK(!bongo_cat_input_take_release(&state, 3099, &output)); - event.timestamp_ms = 200; - bongo_cat_input_auto_release(&state, &event, 3000); - CHECK(!bongo_cat_input_take_release(&state, 3199, &output)); - CHECK(bongo_cat_input_take_release(&state, 3200, &output)); + memcpy(event.name, "CapsLock", sizeof("CapsLock")); + bongo_cat_input_schedule_release(&state, &event, 100); + CHECK(!bongo_cat_input_take_scheduled_release(&state, 199, &output)); + CHECK(bongo_cat_input_take_scheduled_release(&state, 200, &output)); CHECK(output.kind == BONGO_CAT_INPUT_KEY_UP); - CHECK(strcmp(output.name, "KeyA") == 0); - bongo_cat_input_auto_release(&state, &event, 3000); + CHECK(strcmp(output.name, "CapsLock") == 0); + bongo_cat_input_schedule_release(&state, &event, 100); event.kind = BONGO_CAT_INPUT_KEY_UP; - bongo_cat_input_auto_release(&state, &event, 0); - CHECK(!bongo_cat_input_take_release(&state, 9999, &output)); - - bongo_cat_input_init(&state); - event.kind = BONGO_CAT_INPUT_KEY_DOWN; - event.timestamp_ms = 1000; - memcpy(event.name, "KeyB", sizeof("KeyB")); - bongo_cat_input_auto_release(&state, &event, 50); - CHECK(!bongo_cat_input_take_release(&state, 1049, &output)); - CHECK(bongo_cat_input_take_release(&state, 1050, &output)); - event.timestamp_ms = 2000; - bongo_cat_input_auto_release(&state, &event, 500); - CHECK(!bongo_cat_input_take_release(&state, 2499, &output)); - CHECK(bongo_cat_input_take_release(&state, 2500, &output)); + bongo_cat_input_schedule_release(&state, &event, 100); + CHECK(!bongo_cat_input_take_scheduled_release(&state, 9999, &output)); } From 9cb2a9efb7d01066ab0cd7faf769a138d42ca3ac Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 19 Aug 2026 12:14:13 +0800 Subject: [PATCH 042/334] feat: refresh nearby models from preferences --- CMakeLists.txt | 2 +- include/bongo_cat/app.h | 1 + resources/assets/locales/de-DE.json | 2 +- resources/assets/locales/en-US.json | 2 +- resources/assets/locales/es-ES.json | 2 +- resources/assets/locales/fr-FR.json | 2 +- resources/assets/locales/ja-JP.json | 2 +- resources/assets/locales/ko-KR.json | 2 +- resources/assets/locales/pt-BR.json | 2 +- resources/assets/locales/ru-RU.json | 2 +- resources/assets/locales/zh-CN.json | 2 +- resources/assets/locales/zh-Hant.json | 2 +- src/runtime/model/model_catalog_runtime.c | 21 +++++++-- src/ui/preferences/preferences.c | 4 +- src/ui/preferences/preferences_about.c | 3 +- .../preferences/preferences_about_community.c | 5 +- src/ui/preferences/preferences_render.c | 2 + tests/model_import/test_mver_import.c | 2 + .../model_import/test_mver_nearby_identity.c | 47 +++++++++++++++++++ 19 files changed, 89 insertions(+), 18 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 91dfd4b0..92a5024e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -project(BongoCat VERSION 2.0.0 DESCRIPTION "BongoCat" HOMEPAGE_URL "https://bongocat.pet" LANGUAGES C CXX) +project(BongoCat VERSION 0.1.0 DESCRIPTION "BongoCat" HOMEPAGE_URL "https://bongocat.pet" LANGUAGES C CXX) include(cmake/Version.cmake) include(FetchContent) include(CTest) diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index ff04c240..cc55ef79 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -188,6 +188,7 @@ BongoCatResult bongo_cat_app_import_model(BongoCatApp *app, const char *source, BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, BongoCatError *error); void bongo_cat_app_rescan_models(BongoCatApp *app); void bongo_cat_app_refresh_installed_models(BongoCatApp *app); +void bongo_cat_app_refresh_nearby_models(BongoCatApp *app); void bongo_cat_config_store_flush(BongoCatApp *app); #endif diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index f9952d4c..954dc30d 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -204,7 +204,7 @@ "latest": "Bereits aktuell", "works": "Weitere Anwendungen", "worksText": "Weitere Software von vladelaina", - "refactorDeveloper": "(Entwickler, der BongoCat in C neu aufgebaut hat)", + "refactorDeveloper": "Entwickler des C-Rewrites und Maintainer von BongoCat", "community": "Community", "qqGroup": "QQ-Gruppe", "coffee": "Dem Entwicklungsteam einen Kaffee spendieren?", diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index 1c6ce85b..965152d0 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -204,7 +204,7 @@ "latest": "Already up to date", "works": "More apps", "worksText": "More software from vladelaina", - "refactorDeveloper": "(Developer who rebuilt BongoCat in C)", + "refactorDeveloper": "BongoCat C rewrite developer & current maintainer", "community": "Community", "qqGroup": "QQ Group", "coffee": "Buy the development team a coffee?", diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index 092594c6..224f5eaa 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -204,7 +204,7 @@ "latest": "Ya está actualizado", "works": "Más aplicaciones", "worksText": "Más software de vladelaina", - "refactorDeveloper": "(Desarrollador que reconstruyó BongoCat en C)", + "refactorDeveloper": "Desarrollador de la versión C y mantenedor de BongoCat", "community": "Comunidad", "qqGroup": "Grupo de QQ", "coffee": "¿Invitar a un café al equipo de desarrollo?", diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index 6b08c462..15002621 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -204,7 +204,7 @@ "latest": "Déjà à jour", "works": "Plus d'applications", "worksText": "Plus de logiciels de vladelaina", - "refactorDeveloper": "(Développeur ayant reconstruit BongoCat en C)", + "refactorDeveloper": "Développeur de la refonte C et mainteneur de BongoCat", "community": "Communauté", "qqGroup": "Groupe QQ", "coffee": "Offrir un café à l'équipe de développement ?", diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index f6ff65cd..a23d55f5 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -204,7 +204,7 @@ "latest": "最新バージョンです", "works": "その他のアプリ", "worksText": "vladelainaのその他のソフトウェア", - "refactorDeveloper": "(BongoCatをCで再構築した開発者)", + "refactorDeveloper": "BongoCat C版の再構築者・現メンテナー", "community": "コミュニティ", "qqGroup": "QQグループ", "coffee": "開発チームにコーヒーを贈りますか?", diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index 27bbe9de..0b44e50b 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -204,7 +204,7 @@ "latest": "최신 버전입니다", "works": "더 많은 앱", "worksText": "vladelaina의 다른 소프트웨어", - "refactorDeveloper": "(BongoCat을 C로 다시 만든 개발자)", + "refactorDeveloper": "BongoCat C 리빌드 개발자 및 현 관리자", "community": "커뮤니티", "qqGroup": "QQ 그룹", "coffee": "개발팀에 커피 한 잔을 선물할까요?", diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index acb03f53..68f4436a 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -204,7 +204,7 @@ "latest": "Já está atualizado", "works": "Mais aplicativos", "worksText": "Outros softwares de vladelaina", - "refactorDeveloper": "(Desenvolvedor que reescreveu BongoCat em C)", + "refactorDeveloper": "Desenvolvedor da versão C e atual mantenedor do BongoCat", "community": "Comunidade", "qqGroup": "Grupo QQ", "coffee": "Pagar um café para a equipe de desenvolvimento?", diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index ca9bcb85..9373d0fa 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -204,7 +204,7 @@ "latest": "Установлена последняя версия", "works": "Другие приложения", "worksText": "Другие программы от vladelaina", - "refactorDeveloper": "(Разработчик, переписавший BongoCat на C)", + "refactorDeveloper": "Автор C-версии и текущий сопровождающий BongoCat", "community": "Сообщество", "qqGroup": "Группа QQ", "coffee": "Угостить команду разработки кофе?", diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index 01fdea14..f1d4e929 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -204,7 +204,7 @@ "latest": "已是最新版本", "works": "更多作品", "worksText": "来自 vladelaina 的其他软件", - "refactorDeveloper": "(将BongoCat用C语言重构的开发者)", + "refactorDeveloper": "BongoCat C 语言重构者与现任维护者", "community": "社区", "qqGroup": "QQ 群", "coffee": "请开发团队喝杯咖啡?", diff --git a/resources/assets/locales/zh-Hant.json b/resources/assets/locales/zh-Hant.json index 0433dfe0..de6aeac7 100644 --- a/resources/assets/locales/zh-Hant.json +++ b/resources/assets/locales/zh-Hant.json @@ -204,7 +204,7 @@ "latest": "已是最新版本", "works": "更多作品", "worksText": "來自 vladelaina 的其他軟體", - "refactorDeveloper": "(使用C語言重構BongoCat的開發者)", + "refactorDeveloper": "BongoCat C 語言重構者與現任維護者", "community": "社群", "qqGroup": "QQ 群", "coffee": "請開發團隊喝杯咖啡?", diff --git a/src/runtime/model/model_catalog_runtime.c b/src/runtime/model/model_catalog_runtime.c index 08517b5f..ebe6476e 100644 --- a/src/runtime/model/model_catalog_runtime.c +++ b/src/runtime/model/model_catalog_runtime.c @@ -135,6 +135,11 @@ static void scan_nearby_root(BongoCatApp *app, const char *root) { SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", error.message); } +static void scan_nearby_models(BongoCatApp *app) { + if (SDL_getenv("BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN")) return; + scan_nearby_root(app, SDL_GetBasePath()); +} + static void scan_owned_models(BongoCatApp *app, bool cleanup) { BongoCatError error = {0}; char root[BONGO_CAT_PATH_CAP]; @@ -157,9 +162,7 @@ static void finish_model_catalog(BongoCatApp *app) { void bongo_cat_app_rescan_models(BongoCatApp *app) { if (!app) return; scan_owned_models(app, true); - const char *base = SDL_GetBasePath(); - if (!SDL_getenv("BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN")) - scan_nearby_root(app, base); + scan_nearby_models(app); finish_model_catalog(app); } @@ -183,6 +186,18 @@ void bongo_cat_app_refresh_installed_models(BongoCatApp *app) { finish_model_catalog(app); } +void bongo_cat_app_refresh_nearby_models(BongoCatApp *app) { + if (!app || SDL_getenv("BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN")) return; + char active_model_id[BONGO_CAT_ID_CAP]; + snprintf(active_model_id, sizeof(active_model_id), "%s", + app->session.active_model_id); + scan_owned_models(app, false); + scan_nearby_models(app); + snprintf(app->session.active_model_id, + sizeof(app->session.active_model_id), "%s", active_model_id); + finish_model_catalog(app); +} + BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, BongoCatError *error) { if (!app || !id) { diff --git a/src/ui/preferences/preferences.c b/src/ui/preferences/preferences.c index 4ba1000d..44964fad 100644 --- a/src/ui/preferences/preferences.c +++ b/src/ui/preferences/preferences.c @@ -53,7 +53,9 @@ BongoCatPreferences *bongo_cat_preferences_create(BongoCatApp *app) { } void bongo_cat_preferences_show(BongoCatPreferences *value) { if (!value) return; - if (!value->window && !bongo_cat_preferences_open_window(value)) { + bool opening = !value->window; + if (opening) bongo_cat_app_refresh_nearby_models(value->app); + if (opening && !bongo_cat_preferences_open_window(value)) { SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Preferences failed: %s", SDL_GetError()); bongo_cat_preferences_close(value); return; diff --git a/src/ui/preferences/preferences_about.c b/src/ui/preferences/preferences_about.c index e45e3213..5e35a1c8 100644 --- a/src/ui/preferences/preferences_about.c +++ b/src/ui/preferences/preferences_about.c @@ -285,7 +285,8 @@ static void projects(BongoCatPreferences *value, struct nk_context *context) { value->catime_height, "Catime", "https://cati.me/", false, p); project(value, context, canvas, nk_rect(center + 42, bounds.y + 112, card_width, 200), value->vlaina_texture, value->vlaina_width, - value->vlaina_height, "vlaina", "https://vlaina.com/", true, p); + value->vlaina_height, "vlaina", "https://vlaina.com/r/bongocat", + true, p); } void bongo_cat_preferences_page_about(BongoCatPreferences *value, diff --git a/src/ui/preferences/preferences_about_community.c b/src/ui/preferences/preferences_about_community.c index 8c05b3b8..28b13530 100644 --- a/src/ui/preferences/preferences_about_community.c +++ b/src/ui/preferences/preferences_about_community.c @@ -147,10 +147,11 @@ void bongo_cat_preferences_about_projects_heading( suffix, suffix_length, value->ui.caption_font, p.muted); if (hover) bongo_cat_ui_cursor_hover_rect(context, link, BONGO_CAT_UI_CURSOR_POINTER); - if (developer && hit(context, link)) open_url("https://vladelaina.com/"); + if (developer && hit(context, link)) + open_url("https://vlaina.com/r/bongocat"); centered(canvas, nk_rect(bounds.x, bounds.y + 72, bounds.w, 22), tr(value, "native.support.refactorDeveloper", - "(Developer who rebuilt BongoCat in C)"), + "BongoCat C rewrite developer & current maintainer"), value->ui.caption_font, p.muted); } diff --git a/src/ui/preferences/preferences_render.c b/src/ui/preferences/preferences_render.c index d252a7a5..6cc91b32 100644 --- a/src/ui/preferences/preferences_render.c +++ b/src/ui/preferences/preferences_render.c @@ -99,6 +99,8 @@ static bool draw_shell(BongoCatPreferences *value, struct nk_context *context, bool needs_dynamic_glyphs = value->page == 2; bongo_cat_preferences_page_cache_clear(value, value->last_page, value->page); + if (needs_dynamic_glyphs) + bongo_cat_app_refresh_nearby_models(value->app); value->last_page = value->page; value->page_transition_ns = SDL_GetTicksNS(); if (had_dynamic_glyphs != needs_dynamic_glyphs) value->font_reload_pending = true; diff --git a/tests/model_import/test_mver_import.c b/tests/model_import/test_mver_import.c index 4626f851..ae268cba 100644 --- a/tests/model_import/test_mver_import.c +++ b/tests/model_import/test_mver_import.c @@ -21,6 +21,7 @@ static int failures; int test_preferences_text(void); int test_mver_nearby_identity(void); +int test_mver_nearby_refresh(void); int test_model_import_identity(void); static bool chord(const char *json, bool gamepad, const char *expected) { @@ -294,6 +295,7 @@ int main(void) { model_visual_expires_without_window(); model_visual_curve(); failures += test_mver_nearby_identity(); + failures += test_mver_nearby_refresh(); failures += test_model_import_identity(); container_discovery(); tauri_exact_discovery(); diff --git a/tests/model_import/test_mver_nearby_identity.c b/tests/model_import/test_mver_nearby_identity.c index bc89ff56..3f4804b7 100644 --- a/tests/model_import/test_mver_nearby_identity.c +++ b/tests/model_import/test_mver_nearby_identity.c @@ -92,3 +92,50 @@ int test_mver_nearby_identity(void) { SDL_free(temporary); return failures; } + +int test_mver_nearby_refresh(void) { + failures = 0; + const char *base = SDL_GetBasePath(); + char *temporary = SDL_GetCurrentDirectory(); + CHECK(base != NULL && temporary != NULL); + if (!base || !temporary) { + SDL_free(temporary); + return failures; + } + + unsigned long long nonce = (unsigned long long)SDL_GetTicksNS(); + char source[BONGO_CAT_PATH_CAP], data[BONGO_CAT_PATH_CAP]; + snprintf(source, sizeof(source), "%s/bongo-cat-nearby-refresh-%llu", + base, nonce); + snprintf(data, sizeof(data), "%s/bongo-cat-nearby-refresh-data-%llu", + temporary, nonce); + CHECK(SDL_CreateDirectory(data)); + + BongoCatApp *app = calloc(1, sizeof(*app)); + CHECK(app != NULL); + if (!app) goto cleanup; + bongo_cat_settings_defaults(&app->settings); + bongo_cat_session_defaults(&app->session); + bongo_cat_models_init(&app->models); + snprintf(app->asset_root, sizeof(app->asset_root), + "%s/resources/assets", BONGO_CAT_NATIVE_SOURCE_DIR); + snprintf(app->data_root, sizeof(app->data_root), "%s", data); + snprintf(app->cache_root, sizeof(app->cache_root), "%s", data); + + bongo_cat_app_refresh_nearby_models(app); + size_t before = app->models.count; + CHECK(mver_fixture(source)); + bongo_cat_app_refresh_nearby_models(app); + CHECK(app->models.count == before + 1); + CHECK(app->models.entries[app->models.count - 1].managed); + CHECK(strcmp(app->models.entries[app->models.count - 1].display_name, + bongo_cat_path_name(source)) == 0); + CHECK(strcmp(app->session.active_model_id, "standard") == 0); + free(app); + +cleanup: + CHECK(bongo_cat_model_remove_tree(source, NULL)); + CHECK(bongo_cat_model_remove_tree(data, NULL)); + SDL_free(temporary); + return failures; +} From d4ecac5b2bf965f1c43c50e6b8004c13208830ad Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 19 Aug 2026 19:29:07 +0800 Subject: [PATCH 043/334] fix: stabilize model import and preferences --- cmake/RuntimeSources.cmake | 2 + cmake/Tests.cmake | 1 + include/bongo_cat/app.h | 3 + include/bongo_cat/preferences.h | 1 + src/runtime/lifecycle/app.c | 2 + src/runtime/lifecycle/shutdown.c | 2 + .../model/import/model_import_format.c | 9 + .../import/mver/model_import_mver_motion.c | 7 +- .../model/import/nearby/model_import_nearby.c | 31 ++- src/runtime/model/model_catalog_refresh.c | 179 ++++++++++++++++++ src/runtime/model/model_catalog_runtime.c | 29 +-- src/runtime/runtime.h | 9 + src/ui/preferences/preferences.c | 95 +++------- src/ui/preferences/preferences_fonts.c | 2 +- src/ui/preferences/preferences_lifecycle.c | 108 +++++++++++ src/ui/preferences/preferences_model.c | 2 +- src/ui/preferences/preferences_model_cover.c | 24 ++- src/ui/preferences/preferences_render.c | 22 +-- src/ui/preferences/preferences_state.h | 2 + .../model_import/test_model_import_identity.c | 17 ++ tests/model_import/test_mver_import.c | 9 +- tests/model_import/test_mver_missing_motion.c | 42 ++++ .../model_import/test_mver_nearby_identity.c | 11 +- 23 files changed, 493 insertions(+), 116 deletions(-) create mode 100644 src/runtime/model/model_catalog_refresh.c create mode 100644 src/ui/preferences/preferences_lifecycle.c create mode 100644 tests/model_import/test_mver_missing_motion.c diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 7fae2423..7b4d6582 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -57,6 +57,7 @@ set(BONGO_CAT_RUNTIME_INPUT_SOURCES set(BONGO_CAT_RUNTIME_MODEL_SOURCES src/runtime/model/model_behavior_state.c src/runtime/model/model_catalog_runtime.c + src/runtime/model/model_catalog_refresh.c src/runtime/model/model_files.c src/runtime/model/multi_pet.c src/runtime/model/multi_pet_process.c @@ -149,6 +150,7 @@ set(BONGO_CAT_UI_PREFERENCES_SOURCES src/ui/preferences/preferences_import.c src/ui/preferences/preferences_import_dialog.c src/ui/preferences/preferences_language.c + src/ui/preferences/preferences_lifecycle.c src/ui/preferences/preferences_live_resize.c src/ui/preferences/preferences_model.c src/ui/preferences/preferences_model_card.c diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index deb2791a..b5135bce 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -44,6 +44,7 @@ if(BUILD_TESTING) set(BONGO_CAT_MVER_IMPORT_TEST_SOURCES tests/model_import/test_mver_import.c + tests/model_import/test_mver_missing_motion.c tests/model_import/test_mver_pointer_import.c tests/ui/test_preferences_text.c tests/model_import/test_mver_nearby_identity.c diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index cc55ef79..91c8148a 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -15,6 +15,7 @@ typedef struct BongoCatOverlay BongoCatOverlay; typedef struct BongoCatPreferences BongoCatPreferences; typedef struct BongoCatI18n BongoCatI18n; typedef struct BongoCatMultiPetRuntime BongoCatMultiPetRuntime; +typedef struct BongoCatModelRefresh BongoCatModelRefresh; typedef struct BongoCatApp { BongoCatSettings settings; @@ -41,6 +42,7 @@ typedef struct BongoCatApp { BongoCatOverlay *overlay; BongoCatPreferences *preferences; BongoCatMultiPetRuntime *multi_pet; + BongoCatModelRefresh *model_refresh; SDL_Window *window; void *gl_context; char settings_path[BONGO_CAT_PATH_CAP]; @@ -189,6 +191,7 @@ BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, Bong void bongo_cat_app_rescan_models(BongoCatApp *app); void bongo_cat_app_refresh_installed_models(BongoCatApp *app); void bongo_cat_app_refresh_nearby_models(BongoCatApp *app); +void bongo_cat_app_request_nearby_model_refresh(BongoCatApp *app); void bongo_cat_config_store_flush(BongoCatApp *app); #endif diff --git a/include/bongo_cat/preferences.h b/include/bongo_cat/preferences.h index 0a05e860..00014de4 100644 --- a/include/bongo_cat/preferences.h +++ b/include/bongo_cat/preferences.h @@ -25,6 +25,7 @@ void bongo_cat_preferences_process_model_selection( BongoCatPreferences *preferences); void bongo_cat_preferences_model_load_progress( BongoCatPreferences *preferences, float progress); +void bongo_cat_preferences_models_changed(BongoCatPreferences *preferences); void bongo_cat_preferences_request_model_import(BongoCatPreferences *preferences); bool bongo_cat_preferences_open_model_import(BongoCatPreferences *preferences, SDL_Window *parent); diff --git a/src/runtime/lifecycle/app.c b/src/runtime/lifecycle/app.c index 4767ee78..8fe6bf9a 100644 --- a/src/runtime/lifecycle/app.c +++ b/src/runtime/lifecycle/app.c @@ -154,6 +154,7 @@ bool bongo_cat_app_initialize(BongoCatApp *app, int argc, char **argv, return true; } static void handle_event(BongoCatApp *app, const SDL_Event *event) { + if (bongo_cat_model_refresh_event(app, event)) return; if (bongo_cat_preferences_event(app->preferences, event)) return; if (!bongo_cat_window_event(app, event)) app->running = false; if (event->type >= SDL_EVENT_GAMEPAD_AXIS_MOTION && @@ -266,6 +267,7 @@ void bongo_cat_app_loop(BongoCatApp *app) { handle_event(app, &event); } bongo_cat_preferences_input_end(app->preferences); + bongo_cat_model_refresh_update(app); take_instance_wake(app); uint64_t now = SDL_GetTicksNS(); bongo_cat_window_update_wheel_animation(app, now); bongo_cat_multi_pet_update(app, now); diff --git a/src/runtime/lifecycle/shutdown.c b/src/runtime/lifecycle/shutdown.c index ff5068a4..1b24d2fb 100644 --- a/src/runtime/lifecycle/shutdown.c +++ b/src/runtime/lifecycle/shutdown.c @@ -15,6 +15,8 @@ void bongo_cat_app_shutdown(BongoCatApp *app, const char *stage, bongo_cat_app_capture_behavior_state(app); bongo_cat_config_store_flush(app); bongo_cat_multi_pet_shutdown(app); + bongo_cat_model_refresh_shutdown(app); + bongo_cat_import_nearby_shutdown(); bongo_cat_preferences_destroy(app->preferences); bongo_cat_i18n_destroy(app->i18n); bongo_cat_tray_destroy(app->tray); diff --git a/src/runtime/model/import/model_import_format.c b/src/runtime/model/import/model_import_format.c index a2d5dbeb..44070111 100644 --- a/src/runtime/model/import/model_import_format.c +++ b/src/runtime/model/import/model_import_format.c @@ -72,6 +72,11 @@ static bool mode_config_valid(yyjson_val *mode, BongoCatModelMode value, numbered_asset(directory, "lefthand") && numbered_asset(directory, "righthand"); } +static bool mode_uses_live2d(yyjson_val *mode) { + yyjson_val *enabled = yyjson_obj_get(mode, "l2d"); + return !yyjson_is_bool(enabled) || yyjson_get_bool(enabled); +} + static bool model_at(const char *directory, char *setting, size_t capacity) { return bongo_cat_path_find_unique_suffix(directory, ".model3.json", setting, capacity) == 1 && @@ -104,6 +109,10 @@ static bool add_mode(BongoCatImportDiscovery *discovery, const char *source, "Mver mode has invalid input mappings or numbered assets: %s", mode_root); return false; } + /* Mver distributions bundle fallback Live2D files for modes that are + configured to use static sprites. They are runtime templates, not + authored model variants, so they must not become import candidates. */ + if (!mode_uses_live2d(mode_config)) return true; if (discovery->count >= BONGO_CAT_IMPORT_CANDIDATE_CAP) return false; BongoCatImportCandidate *candidate = &discovery->candidates[discovery->count]; if (!find_mode_model(source, mode_root, candidate->directory, sizeof(candidate->directory), diff --git a/src/runtime/model/import/mver/model_import_mver_motion.c b/src/runtime/model/import/mver/model_import_mver_motion.c index 1f705094..cb9ce1f6 100644 --- a/src/runtime/model/import/mver/model_import_mver_motion.c +++ b/src/runtime/model/import/mver/model_import_mver_motion.c @@ -56,12 +56,7 @@ static bool add_motion_group(yyjson_mut_doc *output, yyjson_mut_val *items, const char *group, const BongoCatImportCandidate *candidate, const BongoCatMverLabels *labels, BongoCatError *error) { yyjson_val *available = yyjson_obj_get(motions, group); - if (yyjson_is_arr(configured) && yyjson_arr_size(configured) > 0 && - !yyjson_is_arr(available)) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Mver motion bindings reference a missing Live2D motion group: %s", group); - return false; - } + if (yyjson_is_arr(configured) && !yyjson_is_arr(available)) return true; size_t count = yyjson_is_arr(available) ? yyjson_arr_size(available) : 0; return add_rows(output, items, configured, candidate, "motion", field, group, count, labels, error); diff --git a/src/runtime/model/import/nearby/model_import_nearby.c b/src/runtime/model/import/nearby/model_import_nearby.c index 3bf1e853..b74ddbd2 100644 --- a/src/runtime/model/import/nearby/model_import_nearby.c +++ b/src/runtime/model/import/nearby/model_import_nearby.c @@ -11,6 +11,26 @@ #define NEARBY_ADAPTER_DIRECTORY "model-adapters" #define NEARBY_SCAN_BUDGET_NS 500000000ull +static SDL_InitState nearby_scan_lock_init; +static SDL_Mutex *nearby_scan_lock; + +static SDL_Mutex *scan_lock(void) { + if (SDL_ShouldInit(&nearby_scan_lock_init)) { + nearby_scan_lock = SDL_CreateMutex(); + SDL_SetInitialized(&nearby_scan_lock_init, + nearby_scan_lock != NULL); + } + return nearby_scan_lock; +} + +void bongo_cat_import_nearby_shutdown(void) { + if (!SDL_ShouldQuit(&nearby_scan_lock_init)) return; + SDL_Mutex *mutex = nearby_scan_lock; + nearby_scan_lock = NULL; + SDL_SetInitialized(&nearby_scan_lock_init, false); + SDL_DestroyMutex(mutex); +} + static bool parent_path(const char *path, char *parent, size_t capacity) { size_t length = path ? strlen(path) : 0; while (length && (path[length - 1] == '/' || path[length - 1] == '\\')) @@ -160,7 +180,7 @@ static int discover_direct(const char *root, bool bounded, return found; } -static BongoCatResult import_root(BongoCatApp *app, const char *root, +static BongoCatResult import_root_unlocked(BongoCatApp *app, const char *root, bool bounded, BongoCatError *error) { if (!app || !root || !app->cache_root[0]) return BONGO_CAT_ERROR_ARGUMENT; @@ -193,6 +213,15 @@ static BongoCatResult import_root(BongoCatApp *app, const char *root, return result; } +static BongoCatResult import_root(BongoCatApp *app, const char *root, + bool bounded, BongoCatError *error) { + SDL_Mutex *mutex = scan_lock(); + if (mutex) SDL_LockMutex(mutex); + BongoCatResult result = import_root_unlocked(app, root, bounded, error); + if (mutex) SDL_UnlockMutex(mutex); + return result; +} + BongoCatResult bongo_cat_import_nearby_root(BongoCatApp *app, const char *root, BongoCatError *error) { return import_root(app, root, false, error); diff --git a/src/runtime/model/model_catalog_refresh.c b/src/runtime/model/model_catalog_refresh.c new file mode 100644 index 00000000..135743a9 --- /dev/null +++ b/src/runtime/model/model_catalog_refresh.c @@ -0,0 +1,179 @@ +#include "runtime.h" +#include "bongo_cat/preferences.h" + +#include +#include +#include +#include + +typedef struct BongoCatModelRefreshJob { + struct BongoCatModelRefresh *owner; + BongoCatModelCatalog models; + char asset_root[BONGO_CAT_PATH_CAP]; + char data_root[BONGO_CAT_PATH_CAP]; + char cache_root[BONGO_CAT_PATH_CAP]; + char nearby_root[BONGO_CAT_PATH_CAP]; + char active_model_id[BONGO_CAT_ID_CAP]; + uint64_t started_ns; + uint64_t revision; + bool success; +} BongoCatModelRefreshJob; + +struct BongoCatModelRefresh { + SDL_Mutex *mutex; + SDL_Thread *worker; + BongoCatModelRefreshJob *completed; + Uint32 event_type; + uint64_t revision; + bool busy; + bool rerun; +}; + +static BongoCatModelRefresh *create_refresh(void) { + BongoCatModelRefresh *refresh = calloc(1, sizeof(*refresh)); + if (!refresh) return NULL; + refresh->mutex = SDL_CreateMutex(); + refresh->event_type = SDL_RegisterEvents(1); + if (!refresh->mutex || refresh->event_type == (Uint32)-1) { + if (refresh->mutex) SDL_DestroyMutex(refresh->mutex); + free(refresh); + return NULL; + } + return refresh; +} + +static int SDLCALL refresh_worker(void *userdata) { + BongoCatModelRefreshJob *job = userdata; + BongoCatApp *scan = calloc(1, sizeof(*scan)); + if (scan) { + snprintf(scan->asset_root, sizeof(scan->asset_root), "%s", + job->asset_root); + snprintf(scan->data_root, sizeof(scan->data_root), "%s", + job->data_root); + snprintf(scan->cache_root, sizeof(scan->cache_root), "%s", + job->cache_root); + snprintf(scan->session.active_model_id, + sizeof(scan->session.active_model_id), "%s", + job->active_model_id); + bongo_cat_model_catalog_scan(scan, false, job->nearby_root); + job->models = scan->models; + job->success = true; + free(scan); + } + + BongoCatModelRefresh *refresh = job->owner; + SDL_LockMutex(refresh->mutex); + refresh->completed = job; + SDL_UnlockMutex(refresh->mutex); + + SDL_Event event = {0}; + event.type = refresh->event_type; + event.user.data1 = refresh; + SDL_PushEvent(&event); + return 0; +} + +static bool start_refresh(BongoCatApp *app) { + BongoCatModelRefresh *refresh = app->model_refresh; + BongoCatModelRefreshJob *job = calloc(1, sizeof(*job)); + if (!job) return false; + job->owner = refresh; + job->started_ns = SDL_GetTicksNS(); + job->revision = refresh->revision; + snprintf(job->asset_root, sizeof(job->asset_root), "%s", app->asset_root); + snprintf(job->data_root, sizeof(job->data_root), "%s", app->data_root); + snprintf(job->cache_root, sizeof(job->cache_root), "%s", app->cache_root); + const char *base = SDL_GetBasePath(); + snprintf(job->nearby_root, sizeof(job->nearby_root), "%s", + base ? base : ""); + snprintf(job->active_model_id, sizeof(job->active_model_id), "%s", + app->session.active_model_id); + refresh->busy = true; + refresh->rerun = false; + refresh->worker = SDL_CreateThread(refresh_worker, + "bongo-cat-model-catalog", job); + if (refresh->worker) return true; + refresh->busy = false; + free(job); + return false; +} + +void bongo_cat_app_request_nearby_model_refresh(BongoCatApp *app) { + if (!app || SDL_getenv("BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN")) return; + if (!app->model_refresh) app->model_refresh = create_refresh(); + BongoCatModelRefresh *refresh = app->model_refresh; + if (!refresh) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Background model refresh is unavailable: %s", SDL_GetError()); + return; + } + if (refresh->busy) { + refresh->rerun = true; + return; + } + if (!start_refresh(app)) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Cannot start background model refresh: %s", SDL_GetError()); +} + +void bongo_cat_model_refresh_invalidate(BongoCatApp *app) { + BongoCatModelRefresh *refresh = app ? app->model_refresh : NULL; + if (!refresh) return; + refresh->revision++; + if (refresh->busy) refresh->rerun = true; +} + +void bongo_cat_model_refresh_update(BongoCatApp *app) { + BongoCatModelRefresh *refresh = app ? app->model_refresh : NULL; + if (!refresh || !refresh->busy) return; + SDL_LockMutex(refresh->mutex); + BongoCatModelRefreshJob *job = refresh->completed; + refresh->completed = NULL; + SDL_UnlockMutex(refresh->mutex); + if (!job) return; + + if (refresh->worker) SDL_WaitThread(refresh->worker, NULL); + refresh->worker = NULL; + refresh->busy = false; + if (job->success && job->revision == refresh->revision) { + bool changed = app->models.count != job->models.count || + memcmp(app->models.entries, job->models.entries, + job->models.count * sizeof(job->models.entries[0])) != 0; + if (changed) { + app->models = job->models; + bongo_cat_model_catalog_finish(app); + bongo_cat_preferences_models_changed(app->preferences); + } + SDL_Log("Background model refresh completed: models=%llu changed=%d " + "elapsed_ms=%.1f", (unsigned long long)job->models.count, + changed, (SDL_GetTicksNS() - job->started_ns) / 1000000.0); + } + free(job); + if (refresh->rerun) start_refresh(app); +} + +bool bongo_cat_model_refresh_event(BongoCatApp *app, + const SDL_Event *event) { + BongoCatModelRefresh *refresh = app ? app->model_refresh : NULL; + if (!refresh || !event || event->type != refresh->event_type || + event->user.data1 != refresh) return false; + bongo_cat_model_refresh_update(app); + return true; +} + +void bongo_cat_model_refresh_shutdown(BongoCatApp *app) { + BongoCatModelRefresh *refresh = app ? app->model_refresh : NULL; + if (!refresh) return; + if (refresh->worker) SDL_WaitThread(refresh->worker, NULL); + SDL_LockMutex(refresh->mutex); + BongoCatModelRefreshJob *job = refresh->completed; + refresh->completed = NULL; + SDL_UnlockMutex(refresh->mutex); + free(job); + SDL_Event event; + while (SDL_PeepEvents(&event, 1, SDL_GETEVENT, refresh->event_type, + refresh->event_type) > 0) {} + SDL_DestroyMutex(refresh->mutex); + free(refresh); + app->model_refresh = NULL; +} diff --git a/src/runtime/model/model_catalog_runtime.c b/src/runtime/model/model_catalog_runtime.c index ebe6476e..90392ad6 100644 --- a/src/runtime/model/model_catalog_runtime.c +++ b/src/runtime/model/model_catalog_runtime.c @@ -135,11 +135,6 @@ static void scan_nearby_root(BongoCatApp *app, const char *root) { SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", error.message); } -static void scan_nearby_models(BongoCatApp *app) { - if (SDL_getenv("BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN")) return; - scan_nearby_root(app, SDL_GetBasePath()); -} - static void scan_owned_models(BongoCatApp *app, bool cleanup) { BongoCatError error = {0}; char root[BONGO_CAT_PATH_CAP]; @@ -152,22 +147,31 @@ static void scan_owned_models(BongoCatApp *app, bool cleanup) { } } -static void finish_model_catalog(BongoCatApp *app) { +void bongo_cat_model_catalog_finish(BongoCatApp *app) { prune_behavior_shortcuts(app); for (size_t i = 0; i < app->models.count; ++i) bongo_cat_import_apply_metadata(app, app->models.entries[i].id, app->models.entries[i].adapter_directory); } +void bongo_cat_model_catalog_scan(BongoCatApp *app, bool cleanup, + const char *nearby_root) { + if (!app) return; + scan_owned_models(app, cleanup); + if (nearby_root && !SDL_getenv("BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN")) + scan_nearby_root(app, nearby_root); +} + void bongo_cat_app_rescan_models(BongoCatApp *app) { if (!app) return; - scan_owned_models(app, true); - scan_nearby_models(app); - finish_model_catalog(app); + bongo_cat_model_refresh_invalidate(app); + bongo_cat_model_catalog_scan(app, true, SDL_GetBasePath()); + bongo_cat_model_catalog_finish(app); } void bongo_cat_app_refresh_installed_models(BongoCatApp *app) { if (!app) return; + bongo_cat_model_refresh_invalidate(app); BongoCatModelCatalog *previous = malloc(sizeof(*previous)); if (!previous) { bongo_cat_app_rescan_models(app); @@ -183,19 +187,20 @@ void bongo_cat_app_refresh_installed_models(BongoCatApp *app) { app->models.entries[app->models.count++] = *entry; } free(previous); - finish_model_catalog(app); + bongo_cat_model_catalog_finish(app); } void bongo_cat_app_refresh_nearby_models(BongoCatApp *app) { if (!app || SDL_getenv("BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN")) return; + bongo_cat_model_refresh_invalidate(app); char active_model_id[BONGO_CAT_ID_CAP]; snprintf(active_model_id, sizeof(active_model_id), "%s", app->session.active_model_id); scan_owned_models(app, false); - scan_nearby_models(app); + scan_nearby_root(app, SDL_GetBasePath()); snprintf(app->session.active_model_id, sizeof(app->session.active_model_id), "%s", active_model_id); - finish_model_catalog(app); + bongo_cat_model_catalog_finish(app); } BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, diff --git a/src/runtime/runtime.h b/src/runtime/runtime.h index dfcb55d5..d4155e9f 100644 --- a/src/runtime/runtime.h +++ b/src/runtime/runtime.h @@ -9,6 +9,15 @@ BongoCatResult bongo_cat_window_create(BongoCatApp *app, BongoCatError *error); bool bongo_cat_app_initialize(BongoCatApp *app, int argc, char **argv, BongoCatError *error); void bongo_cat_app_loop(BongoCatApp *app); +void bongo_cat_model_catalog_scan(BongoCatApp *app, bool cleanup, + const char *nearby_root); +void bongo_cat_model_catalog_finish(BongoCatApp *app); +void bongo_cat_model_refresh_invalidate(BongoCatApp *app); +bool bongo_cat_model_refresh_event(BongoCatApp *app, + const SDL_Event *event); +void bongo_cat_model_refresh_update(BongoCatApp *app); +void bongo_cat_model_refresh_shutdown(BongoCatApp *app); +void bongo_cat_import_nearby_shutdown(void); BongoCatResult bongo_cat_app_locate_assets(BongoCatApp *app, BongoCatError *error); bool bongo_cat_startup_prepare(BongoCatApp *app, int argc, char **argv, BongoCatError *error); diff --git a/src/ui/preferences/preferences.c b/src/ui/preferences/preferences.c index 44964fad..5bc2e3c4 100644 --- a/src/ui/preferences/preferences.c +++ b/src/ui/preferences/preferences.c @@ -30,10 +30,6 @@ void bongo_cat_preferences_page_cache_clear(BongoCatPreferences *value, int previous_page, int next_page) { if (!value || previous_page == next_page) return; bool released = false; - if (previous_page == 2 && next_page != 2) { - bongo_cat_preferences_model_cover_cache_clear(value->app); - released = true; - } if (previous_page == 4 && next_page != 4) { bongo_cat_preferences_support_assets_clear(value); released = true; @@ -49,71 +45,10 @@ BongoCatPreferences *bongo_cat_preferences_create(BongoCatApp *app) { if (!value->import_dialog) { free(value); return NULL; } } if (value && app->smoke_preference_page >= 0) value->page = app->smoke_preference_page; + if (value) value->model_glyphs_loaded = value->page == 2; return value; } -void bongo_cat_preferences_show(BongoCatPreferences *value) { - if (!value) return; - bool opening = !value->window; - if (opening) bongo_cat_app_refresh_nearby_models(value->app); - if (opening && !bongo_cat_preferences_open_window(value)) { - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Preferences failed: %s", SDL_GetError()); - bongo_cat_preferences_close(value); - return; - } - value->render_dirty = true; - bongo_cat_preferences_render(value); - bongo_cat_platform_raise_window(value->window); -} -void bongo_cat_preferences_close(BongoCatPreferences *value) { - if (!value || !value->window) return; - bongo_cat_preferences_live_resize_uninstall(value); - if (bongo_cat_preferences_behavior_dialog_active(value)) - bongo_cat_preferences_behavior_dialog_close(value); - bongo_cat_preferences_model_rename_finish(value, true); - bongo_cat_preferences_shortcut_cancel(value); - if (value->gl_context) SDL_GL_MakeCurrent(value->window, value->gl_context); - if (value->ui_initialized && value->input_active) - bongo_cat_preferences_input_end(value); - SDL_StopTextInput(value->window); - bongo_cat_preferences_model_cache_clear(value->app); - if (value->ui_initialized) { - bongo_cat_pref_controls_reset(&value->ui.context); - bongo_cat_ui_animations_reset(&value->ui.context); - } - bongo_cat_preferences_assets_clear(value); - if (value->ui_initialized) { - bongo_cat_ui_cursor_destroy(&value->ui); - bongo_cat_ui_destroy(&value->ui); - } - if (value->chrome_dragging) SDL_CaptureMouse(false); - if (value->owns_gl_context && value->gl_context) - SDL_GL_DestroyContext(value->gl_context); - SDL_DestroyWindow(value->window); - value->window = NULL; - value->gl_context = NULL; - value->owns_gl_context = false; - value->transparent_window = false; - value->ui_initialized = false; - value->font_reload_pending = false; - value->font_reload_defer_once = false; - value->model_load_visual_active = false; - value->model_load_visual_completion_ns = 0; - value->smoke_behavior_open_pending = false; - value->native_drag = false; - value->chrome_dragging = false; - value->pending_raster_scale = 0.0f; - value->raster_retry_ns = 0; - value->render_retry_ns = 0; - SDL_GL_MakeCurrent(value->app->window, value->app->gl_context); - SDL_GL_SetSwapInterval(1); - bongo_cat_platform_trim_memory(); - bongo_cat_config_store_flush(value->app); -} -void bongo_cat_preferences_destroy(BongoCatPreferences *value) { - if (value) { bongo_cat_preferences_close(value); - bongo_cat_preferences_import_destroy(value->import_dialog); - free(value); } -} + void bongo_cat_preferences_request_model_import(BongoCatPreferences *value) { if (value && !bongo_cat_preferences_import_is_open(value->import_dialog)) value->import_requested = true; } @@ -122,7 +57,9 @@ bool bongo_cat_preferences_open_model_import(BongoCatPreferences *value, return value && parent && bongo_cat_preferences_import_open( value->import_dialog, parent); } -bool bongo_cat_preferences_visible(const BongoCatPreferences *value) { return value && value->window; } +bool bongo_cat_preferences_visible(const BongoCatPreferences *value) { + return value && value->window && value->visible; +} bool bongo_cat_preferences_needs_frame(BongoCatPreferences *value) { if (!value) return false; uint64_t now = SDL_GetTicksNS(); @@ -135,13 +72,13 @@ bool bongo_cat_preferences_needs_frame(BongoCatPreferences *value) { value->model_load_progress = 1.0f; value->render_dirty = true; } - if (!value->window) return false; + if (!value->window || !value->visible) return false; if (value->render_retry_ns > now) return false; bool raster_due = value->pending_raster_scale > 0.0f && value->raster_retry_ns <= now; return value->render_dirty || raster_due || value->chrome_dragging; } void bongo_cat_preferences_input_begin(BongoCatPreferences *value) { - if (!value || !value->window || value->input_active) return; + if (!value || !value->window || !value->visible || value->input_active) return; bongo_cat_ui_input_begin(&value->ui); value->input_active = true; } void bongo_cat_preferences_input_end(BongoCatPreferences *value) { @@ -172,7 +109,8 @@ bool bongo_cat_preferences_chrome_drag_allowed( } void bongo_cat_preferences_drag_tick(BongoCatPreferences *value) { - if (!value || !value->window || !value->chrome_dragging) return; + if (!value || !value->window || !value->visible || + !value->chrome_dragging) return; float pointer_x = 0.0f, pointer_y = 0.0f; SDL_MouseButtonFlags buttons = SDL_GetGlobalMouseState( &pointer_x, &pointer_y); @@ -247,12 +185,13 @@ static bool chrome_event(BongoCatPreferences *value, const SDL_Event *event) { bool bongo_cat_preferences_event(BongoCatPreferences *value, const SDL_Event *event) { if (!value || !event) return false; if (bongo_cat_preferences_import_event(value->import_dialog, value->app, - event)) { value->render_dirty = value->window != NULL; + event)) { value->render_dirty = value->visible; return true; } if (!value->window) return false; if (event->type == SDL_EVENT_SYSTEM_THEME_CHANGED) { value->render_dirty = true; return false; } + if (!value->visible) return false; if (event_window(event) != SDL_GetWindowID(value->window)) return false; if (bongo_cat_preferences_scale_event(value, event)) return true; if (event->type == SDL_EVENT_WINDOW_FOCUS_LOST) { @@ -283,3 +222,15 @@ bool bongo_cat_preferences_event(BongoCatPreferences *value, const SDL_Event *ev void bongo_cat_preferences_invalidate(BongoCatPreferences *value) { if (value) value->render_dirty = true; } + +void bongo_cat_preferences_models_changed(BongoCatPreferences *value) { + if (!value) return; + value->render_dirty = true; + if (value->page != 2) { + value->model_glyphs_loaded = false; + return; + } + value->model_glyphs_loaded = true; + value->font_reload_pending = value->ui_initialized; + value->font_reload_defer_once = value->font_reload_pending; +} diff --git a/src/ui/preferences/preferences_fonts.c b/src/ui/preferences/preferences_fonts.c index 481949d5..814595f3 100644 --- a/src/ui/preferences/preferences_fonts.c +++ b/src/ui/preferences/preferences_fonts.c @@ -37,7 +37,7 @@ void bongo_cat_preferences_fonts_resolve(BongoCatPreferences *value, sizeof(value->glyph_ranges) / sizeof(value->glyph_ranges[0])); /* Model and behavior labels can contain hundreds of otherwise unused glyphs. Bake them only for the pages that can display those labels. */ - if (value->page == 2) + if (value->model_glyphs_loaded) bongo_cat_preferences_model_glyphs(value->app, value->glyph_ranges, sizeof(value->glyph_ranges) / sizeof(value->glyph_ranges[0])); fonts->ranges = value->glyph_ranges; diff --git a/src/ui/preferences/preferences_lifecycle.c b/src/ui/preferences/preferences_lifecycle.c new file mode 100644 index 00000000..bcb22a58 --- /dev/null +++ b/src/ui/preferences/preferences_lifecycle.c @@ -0,0 +1,108 @@ +#include "preferences_controls.h" +#include "preferences_state.h" +#include "ui_animation.h" +#include "bongo_cat/memory.h" +#include "bongo_cat/platform.h" +#include "bongo_cat/preferences.h" + +#include +#include +#include + +static void release_window(BongoCatPreferences *value) { + if (!value || !value->window) return; + bongo_cat_preferences_live_resize_uninstall(value); + if (value->gl_context) SDL_GL_MakeCurrent(value->window, value->gl_context); + if (value->ui_initialized && value->input_active) + bongo_cat_preferences_input_end(value); + SDL_StopTextInput(value->window); + bongo_cat_preferences_model_cache_clear(value->app); + if (value->ui_initialized) { + bongo_cat_pref_controls_reset(&value->ui.context); + bongo_cat_ui_animations_reset(&value->ui.context); + } + bongo_cat_preferences_assets_clear(value); + if (value->ui_initialized) { + bongo_cat_ui_cursor_destroy(&value->ui); + bongo_cat_ui_destroy(&value->ui); + } + if (value->chrome_dragging) SDL_CaptureMouse(false); + if (value->owns_gl_context && value->gl_context) + SDL_GL_DestroyContext(value->gl_context); + SDL_DestroyWindow(value->window); + value->window = NULL; + value->gl_context = NULL; + value->visible = false; + value->owns_gl_context = false; + value->transparent_window = false; + value->ui_initialized = false; + value->font_reload_pending = false; + value->font_reload_defer_once = false; + value->model_load_visual_active = false; + value->model_load_visual_completion_ns = 0; + value->smoke_behavior_open_pending = false; + value->native_drag = false; + value->chrome_dragging = false; + value->pending_raster_scale = 0.0f; + value->raster_retry_ns = 0; + value->render_retry_ns = 0; + SDL_GL_MakeCurrent(value->app->window, value->app->gl_context); + SDL_GL_SetSwapInterval(1); +} + +void bongo_cat_preferences_show(BongoCatPreferences *value) { + if (!value) return; + bool opening = !value->window; + if (opening && !bongo_cat_preferences_open_window(value)) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "Preferences failed: %s", SDL_GetError()); + release_window(value); + return; + } + value->visible = true; + if (!opening) { + SDL_StartTextInput(value->window); + bongo_cat_preferences_live_resize_install(value); + } + value->render_dirty = true; + bongo_cat_preferences_render(value); + bongo_cat_platform_raise_window(value->window); + bongo_cat_app_request_nearby_model_refresh(value->app); +} + +void bongo_cat_preferences_close(BongoCatPreferences *value) { + if (!value || !value->window || !value->visible) return; + bongo_cat_preferences_live_resize_uninstall(value); + if (bongo_cat_preferences_behavior_dialog_active(value)) + bongo_cat_preferences_behavior_dialog_close(value); + bongo_cat_preferences_model_rename_finish(value, true); + bongo_cat_preferences_shortcut_cancel(value); + if (value->gl_context) SDL_GL_MakeCurrent(value->window, value->gl_context); + if (value->ui_initialized && value->input_active) + bongo_cat_preferences_input_end(value); + SDL_StopTextInput(value->window); + bongo_cat_preferences_remove_dialog_clear(value->app); + if (value->ui_initialized) { + bongo_cat_pref_controls_reset(&value->ui.context); + bongo_cat_ui_animations_reset(&value->ui.context); + } + if (value->chrome_dragging) SDL_CaptureMouse(false); + value->visible = false; + SDL_HideWindow(value->window); + value->model_load_visual_active = false; + value->model_load_visual_completion_ns = 0; + value->smoke_behavior_open_pending = false; + value->native_drag = false; + value->chrome_dragging = false; + SDL_GL_MakeCurrent(value->app->window, value->app->gl_context); + SDL_GL_SetSwapInterval(1); + bongo_cat_config_store_flush(value->app); +} + +void bongo_cat_preferences_destroy(BongoCatPreferences *value) { + if (!value) return; + bongo_cat_preferences_close(value); + release_window(value); + bongo_cat_preferences_import_destroy(value->import_dialog); + free(value); +} diff --git a/src/ui/preferences/preferences_model.c b/src/ui/preferences/preferences_model.c index b4bae64a..1acd150e 100644 --- a/src/ui/preferences/preferences_model.c +++ b/src/ui/preferences/preferences_model.c @@ -50,7 +50,7 @@ void bongo_cat_preferences_model_load_progress(BongoCatPreferences *value, value->render_dirty = true; bool due = !value->model_load_render_ns || now - value->model_load_render_ns >= MODEL_LOAD_RENDER_INTERVAL_NS; - if (value->window && due) { + if (bongo_cat_preferences_visible(value) && due) { value->model_load_render_progress = progress; value->model_load_render_ns = now; bongo_cat_preferences_render(value); diff --git a/src/ui/preferences/preferences_model_cover.c b/src/ui/preferences/preferences_model_cover.c index cd0487f7..15bb0bae 100644 --- a/src/ui/preferences/preferences_model_cover.c +++ b/src/ui/preferences/preferences_model_cover.c @@ -4,6 +4,7 @@ #include "bongo_cat/file.h" #include "bongo_cat/image.h" #include "bongo_cat/path.h" +#include "bongo_cat/preferences.h" #include #include @@ -13,6 +14,8 @@ typedef struct ModelCoverSlot { BongoCatModelCover image; BongoCatApp *app; char path[BONGO_CAT_PATH_CAP]; + int requested_width; + int requested_height; uint64_t generation; } ModelCoverSlot; @@ -25,6 +28,8 @@ typedef struct ModelCoverAttempt { static ModelCoverSlot cover_cache[BONGO_CAT_MODEL_CAP]; static ModelCoverAttempt cover_attempts[BONGO_CAT_MODEL_CAP]; static uint64_t cover_generation; +static unsigned cover_load_budget; +static bool cover_load_deferred; static void clear_attempts(BongoCatApp *app) { for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) @@ -60,6 +65,8 @@ void bongo_cat_preferences_model_cache_clear(BongoCatApp *app) { void bongo_cat_preferences_model_covers_begin(BongoCatApp *app) { cover_generation++; if (!cover_generation) cover_generation++; + cover_load_budget = 1; + cover_load_deferred = false; prune_attempts(app); } @@ -73,8 +80,8 @@ const BongoCatModelCover *bongo_cat_preferences_model_cover( for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) { ModelCoverSlot *slot = &cover_cache[i]; if (slot->image.texture && slot->app == app && !strcmp(slot->path, path)) { - if (slot->image.width == pixel_width || - slot->image.height == pixel_height) { + if (slot->requested_width == pixel_width && + slot->requested_height == pixel_height) { slot->generation = cover_generation; return &slot->image; } glDeleteTextures(1, &slot->image.texture); @@ -85,12 +92,19 @@ const BongoCatModelCover *bongo_cat_preferences_model_cover( if (!slot->image.texture && !empty) empty = slot; } if (!empty) return NULL; + if (!cover_load_budget) { + cover_load_deferred = true; + return NULL; + } + cover_load_budget--; BongoCatError ignored = {0}; - empty->image.texture = bongo_cat_image_texture_resampled(path, - pixel_width, pixel_height, 0, &empty->image.width, + empty->image.texture = bongo_cat_image_texture_thumbnail(path, + pixel_width, pixel_height, &empty->image.width, &empty->image.height, &ignored); if (!empty->image.texture) return NULL; empty->app = app; empty->generation = cover_generation; + empty->requested_width = pixel_width; + empty->requested_height = pixel_height; snprintf(empty->path, sizeof(empty->path), "%s", path); return &empty->image; } @@ -209,4 +223,6 @@ void bongo_cat_preferences_model_covers_prune(BongoCatApp *app) { glDeleteTextures(1, &slot->image.texture); memset(slot, 0, sizeof(*slot)); } + if (cover_load_deferred && app) + bongo_cat_preferences_invalidate(app->preferences); } diff --git a/src/ui/preferences/preferences_render.c b/src/ui/preferences/preferences_render.c index 6cc91b32..9cfa42ec 100644 --- a/src/ui/preferences/preferences_render.c +++ b/src/ui/preferences/preferences_render.c @@ -11,14 +11,12 @@ #include typedef struct RootStyle { struct nk_vec2 padding, group_padding, spacing; struct nk_style_item fixed; struct nk_color background; float group_border; } RootStyle; -static const char *tr(const BongoCatPreferences *value, const char *key, - const char *fallback) { +static const char *tr(const BongoCatPreferences *value, const char *key, const char *fallback) { return bongo_cat_i18n_get(value->app->i18n, key, fallback); } -static void draw_icon(void *userdata, struct nk_command_buffer *canvas, - int icon, struct nk_rect bounds, struct nk_color color) { - bongo_cat_preferences_icon_draw(userdata, canvas, icon, bounds, color); -} +static void draw_icon(void *userdata, struct nk_command_buffer *canvas, int icon, + struct nk_rect bounds, struct nk_color color) { + bongo_cat_preferences_icon_draw(userdata, canvas, icon, bounds, color); } static void draw_page(BongoCatPreferences *value, struct nk_context *context) { switch (value->page) { case 0: bongo_cat_preferences_page_cat(value->app, context); break; @@ -95,15 +93,15 @@ static bool draw_shell(BongoCatPreferences *value, struct nk_context *context, if (!value->page_seen) { value->page_seen = true; value->last_page = value->page; } else if (value->last_page != value->page) { - bool had_dynamic_glyphs = value->last_page == 2; - bool needs_dynamic_glyphs = value->page == 2; + bool load_model_glyphs = value->page == 2 && !value->model_glyphs_loaded; bongo_cat_preferences_page_cache_clear(value, value->last_page, value->page); - if (needs_dynamic_glyphs) - bongo_cat_app_refresh_nearby_models(value->app); value->last_page = value->page; value->page_transition_ns = SDL_GetTicksNS(); - if (had_dynamic_glyphs != needs_dynamic_glyphs) value->font_reload_pending = true; + if (load_model_glyphs) { + value->model_glyphs_loaded = true; value->font_reload_pending = true; + value->font_reload_defer_once = true; + } value->render_dirty = true; } nk_group_end(context); @@ -222,7 +220,7 @@ static bool draw_frame(BongoCatPreferences *value, float width, float height, return close_requested; } void bongo_cat_preferences_render(BongoCatPreferences *value) { - if (!value || !value->window) return; + if (!value || !value->window || !value->visible) return; bongo_cat_preferences_drag_tick(value); uint64_t now = SDL_GetTicksNS(); bool raster_due = value->pending_raster_scale > 0.0f && diff --git a/src/ui/preferences/preferences_state.h b/src/ui/preferences/preferences_state.h index adba68e7..639947d3 100644 --- a/src/ui/preferences/preferences_state.h +++ b/src/ui/preferences/preferences_state.h @@ -27,6 +27,7 @@ struct BongoCatPreferences { SDL_GLContext gl_context; bool owns_gl_context; bool transparent_window; + bool visible; bool ui_initialized; BongoCatUIBackend ui; unsigned int logo_texture; @@ -51,6 +52,7 @@ struct BongoCatPreferences { bool render_dirty; bool font_reload_pending; bool font_reload_defer_once; + bool model_glyphs_loaded; bool smoke_behavior_open_pending; bool model_selection_pending; bool pending_model_multiple; diff --git a/tests/model_import/test_model_import_identity.c b/tests/model_import/test_model_import_identity.c index a3482c51..5b9eeed3 100644 --- a/tests/model_import/test_model_import_identity.c +++ b/tests/model_import/test_model_import_identity.c @@ -15,11 +15,28 @@ static int failures; failures++; \ } } while (0) +static void disabled_mver_live2d_is_not_imported(const char *temporary) { + char root[BONGO_CAT_PATH_CAP], config[BONGO_CAT_PATH_CAP]; + snprintf(root, sizeof(root), "%s/bongo-cat-disabled-mver-%llu", temporary, + (unsigned long long)SDL_GetTicksNS()); + CHECK(mver_fixture(root)); + CHECK(child(config, sizeof(config), root, "config.json", false)); + CHECK(write_text(config, "{\"standard\":{\"l2d\":false," + "\"keyboard\":[[65]],\"hand\":[[65]]}}")); + BongoCatImportDiscovery discovery = {0}; + BongoCatError error = {0}; + CHECK(bongo_cat_import_mver_discover_exact(root, &discovery, &error) < 0); + CHECK(discovery.count == 0); + CHECK(strstr(error.message, "no supported model modes") != NULL); + CHECK(bongo_cat_model_remove_tree(root, NULL)); +} + int test_model_import_identity(void) { failures = 0; char *temporary = SDL_GetCurrentDirectory(); CHECK(temporary != NULL); if (!temporary) return failures; + disabled_mver_live2d_is_not_imported(temporary); char root[BONGO_CAT_PATH_CAP], data[BONGO_CAT_PATH_CAP]; char source[BONGO_CAT_PATH_CAP], config[BONGO_CAT_PATH_CAP]; char manifest[BONGO_CAT_PATH_CAP], missing[BONGO_CAT_PATH_CAP]; diff --git a/tests/model_import/test_mver_import.c b/tests/model_import/test_mver_import.c index ae268cba..52e3328f 100644 --- a/tests/model_import/test_mver_import.c +++ b/tests/model_import/test_mver_import.c @@ -6,24 +6,20 @@ #include "runtime.h" #include "test_mver_support.h" #include "bongo_cat/path.h" - #include #include #include #include #include - static int failures; #define CHECK(value) do { if (!(value)) { \ fprintf(stderr, "%s:%d: check failed: %s\n", __FILE__, __LINE__, #value); \ failures++; \ } } while (0) - -int test_preferences_text(void); -int test_mver_nearby_identity(void); +int test_preferences_text(void); int test_mver_nearby_identity(void); int test_mver_nearby_refresh(void); +int test_mver_missing_motion_groups(void); int test_model_import_identity(void); - static bool chord(const char *json, bool gamepad, const char *expected) { yyjson_doc *document = yyjson_read(json, strlen(json), 0); BongoCatImportCandidate candidate = {0}; @@ -290,6 +286,7 @@ int main(void) { CHECK(dpad.count == 1 && strcmp(dpad.items[0], "DPadUp") == 0); labels_from_shortcut_rows(); metadata_backfills_labels(); + failures += test_mver_missing_motion_groups(); behavior_labels_add_font_glyphs(); font_reload_defers_during_frame(); model_visual_expires_without_window(); diff --git a/tests/model_import/test_mver_missing_motion.c b/tests/model_import/test_mver_missing_motion.c new file mode 100644 index 00000000..b974743c --- /dev/null +++ b/tests/model_import/test_mver_missing_motion.c @@ -0,0 +1,42 @@ +#include "test_mver_support.h" + +#include "model_import.h" +#include "model_storage.h" + +#include +#include + +static int failures; +#define CHECK(value) do { if (!(value)) { \ + fprintf(stderr, "%s:%d: check failed: %s\n", __FILE__, __LINE__, #value); \ + failures++; \ +} } while (0) + +int test_mver_missing_motion_groups(void) { + failures = 0; + char *temporary = SDL_GetCurrentDirectory(); + CHECK(temporary != NULL); + if (!temporary) return failures; + unsigned long long stamp = (unsigned long long)SDL_GetTicksNS(); + char source[BONGO_CAT_PATH_CAP], data[BONGO_CAT_PATH_CAP]; + char path[BONGO_CAT_PATH_CAP]; + snprintf(source, sizeof(source), "%s/bongo-cat-stale-motion-%llu", + temporary, stamp); + snprintf(data, sizeof(data), "%s/bongo-cat-stale-motion-data-%llu", + temporary, stamp); + CHECK(mver_fixture(source)); + CHECK(SDL_CreateDirectory(data)); + CHECK(child(path, sizeof(path), source, "config.json", false)); + CHECK(write_text(path, "{\"standard\":{\"hand\":[[65]]," + "\"keyboard\":[[65]],\"l2d_motion\":[[67]]," + "\"l2d_motion_lockhand\":[[68]]}}")); + BongoCatImportReceipt receipt = {0}; + BongoCatError error = {0}; + CHECK(bongo_cat_import_install(source, data, &receipt, &error) == + BONGO_CAT_OK); + CHECK(receipt.count == 1 && receipt.installed_count == 1); + CHECK(bongo_cat_model_remove_tree(source, NULL)); + CHECK(bongo_cat_model_remove_tree(data, NULL)); + SDL_free(temporary); + return failures; +} diff --git a/tests/model_import/test_mver_nearby_identity.c b/tests/model_import/test_mver_nearby_identity.c index 3f4804b7..bd74754b 100644 --- a/tests/model_import/test_mver_nearby_identity.c +++ b/tests/model_import/test_mver_nearby_identity.c @@ -125,12 +125,21 @@ int test_mver_nearby_refresh(void) { bongo_cat_app_refresh_nearby_models(app); size_t before = app->models.count; CHECK(mver_fixture(source)); - bongo_cat_app_refresh_nearby_models(app); + bongo_cat_app_request_nearby_model_refresh(app); + /* A synchronous catalog mutation invalidates the in-flight snapshot. The + refresh worker must discard it, rerun, and commit only the newer scan. */ + bongo_cat_app_refresh_installed_models(app); + uint64_t deadline = SDL_GetTicksNS() + 5000000000ull; + while (app->models.count == before && SDL_GetTicksNS() < deadline) { + bongo_cat_model_refresh_update(app); + SDL_Delay(2); + } CHECK(app->models.count == before + 1); CHECK(app->models.entries[app->models.count - 1].managed); CHECK(strcmp(app->models.entries[app->models.count - 1].display_name, bongo_cat_path_name(source)) == 0); CHECK(strcmp(app->session.active_model_id, "standard") == 0); + bongo_cat_model_refresh_shutdown(app); free(app); cleanup: From ce1616b3f665138fb2097f1d00a0bc91679fe540 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 19 Aug 2026 21:35:27 +0800 Subject: [PATCH 044/334] fix: smooth centered window dragging --- src/platform/windows/windows_borderless.c | 27 +++++++++++++--- src/runtime/input/mouse_mapping.c | 39 +++++++++++------------ src/runtime/shell/window_drag.c | 8 ++++- 3 files changed, 49 insertions(+), 25 deletions(-) diff --git a/src/platform/windows/windows_borderless.c b/src/platform/windows/windows_borderless.c index 9accf533..03c5e147 100644 --- a/src/platform/windows/windows_borderless.c +++ b/src/platform/windows/windows_borderless.c @@ -11,6 +11,7 @@ static const wchar_t menu_binding_property[] = L"BongoCat.MenuBinding"; static const wchar_t drag_binding_property[] = L"BongoCat.DragBinding"; #define BONGO_CAT_MENU_PREVIEW_TIMER ((UINT_PTR)0xBC4E) #define BONGO_CAT_DRAG_MODAL_TIMER ((UINT_PTR)0xBC50) +#define BONGO_CAT_DRAG_FRAME_INTERVAL_MS 16 typedef struct WindowsMenuBinding { BongoCatMenuPreview preview; @@ -21,8 +22,21 @@ typedef struct WindowsMenuBinding { typedef struct WindowsDragBinding { BongoCatModalTick tick; void *userdata; + ULONGLONG last_tick_ms; + bool ticking; } WindowsDragBinding; +static void drag_tick(WindowsDragBinding *drag) { + if (!drag || !drag->tick || drag->ticking) return; + ULONGLONG now = GetTickCount64(); + if (drag->last_tick_ms && now - drag->last_tick_ms < + BONGO_CAT_DRAG_FRAME_INTERVAL_MS) return; + drag->last_tick_ms = now; + drag->ticking = true; + drag->tick(drag->userdata); + drag->ticking = false; +} + static LONG_PTR borderless_style(LONG_PTR style) { return (style & ~(WS_CAPTION | WS_THICKFRAME | WS_SYSMENU | WS_MINIMIZEBOX | WS_MAXIMIZEBOX)) | WS_POPUP; @@ -70,11 +84,15 @@ static LRESULT CALLBACK borderless_window_proc(HWND window, UINT message, return 0; } else if (message == WM_TIMER && wparam == BONGO_CAT_DRAG_MODAL_TIMER && drag && drag->tick) { - drag->tick(drag->userdata); + drag_tick(drag); return 0; } - return CallWindowProcW(original ? original : DefWindowProcW, + LRESULT result = CallWindowProcW(original ? original : DefWindowProcW, window, message, wparam, lparam); + /* Move messages keep frames flowing when the low-priority timer is starved. */ + if (drag && (message == WM_MOUSEMOVE || message == WM_NCMOUSEMOVE)) + drag_tick(drag); + return result; } void bongo_cat_windows_begin_drag(HWND window, BongoCatModalTick modal_tick, @@ -83,8 +101,9 @@ void bongo_cat_windows_begin_drag(HWND window, BongoCatModalTick modal_tick, WindowsDragBinding binding = {modal_tick, userdata}; bool bound = modal_tick && SetPropW(window, drag_binding_property, &binding); if (bound) { - modal_tick(userdata); - SetTimer(window, BONGO_CAT_DRAG_MODAL_TIMER, 16, NULL); + drag_tick(&binding); + SetTimer(window, BONGO_CAT_DRAG_MODAL_TIMER, + BONGO_CAT_DRAG_FRAME_INTERVAL_MS, NULL); } ReleaseCapture(); SendMessageW(window, WM_NCLBUTTONDOWN, HTCAPTION, 0); diff --git a/src/runtime/input/mouse_mapping.c b/src/runtime/input/mouse_mapping.c index 169a7d08..33717c42 100644 --- a/src/runtime/input/mouse_mapping.c +++ b/src/runtime/input/mouse_mapping.c @@ -120,24 +120,16 @@ static bool model_pointer_bounds(BongoCatApp *app, SDL_Rect *bounds, bounds->w > 0 && bounds->h > 0; } -static bool model_pointer_ratios(BongoCatApp *app, double x, double y, - float *x_ratio, float *y_ratio) { - double center_x, center_y; SDL_Rect bounds; - if (!x_ratio || !y_ratio || !model_pointer_bounds(app, &bounds, - ¢er_x, ¢er_y)) return false; - *x_ratio = bongo_cat_mouse_centered_ratio(x, center_x, bounds.x, - bounds.x + bounds.w); - *y_ratio = bongo_cat_mouse_centered_ratio(y, center_y, bounds.y, - bounds.y + bounds.h); - return true; -} - void bongo_cat_app_apply_mouse_coordinates(BongoCatApp *app, double hand_x, double hand_y, double gaze_x, double gaze_y) { - SDL_Point point = {(int)hand_x, (int)hand_y}; SDL_Rect bounds; + SDL_Point point = {(int)hand_x, (int)hand_y}; SDL_Rect bounds = {0}; + double model_center_x = 0.0, model_center_y = 0.0; bool screen_mapped = app->settings.model.mouse_centered; - if (!(screen_mapped ? model_pointer_bounds(app, &bounds, NULL, NULL) : - mver_pointer_bounds(app, &bounds))) { + bool centered_bounds = screen_mapped && model_pointer_bounds(app, &bounds, + &model_center_x, &model_center_y); + bool mapped_bounds = centered_bounds || (!screen_mapped && + mver_pointer_bounds(app, &bounds)); + if (!mapped_bounds) { SDL_DisplayID display = SDL_GetDisplayForPoint(&point); if (!display || !SDL_GetDisplayBounds(display, &bounds)) return; } @@ -148,8 +140,12 @@ void bongo_cat_app_apply_mouse_coordinates(BongoCatApp *app, double hand_x, if (!bongo_cat_mver_pointer_ratios(hand_x, hand_y, &pointer_bounds, &hand_x_ratio, &hand_y_ratio)) return; float gaze_x_ratio = hand_x_ratio, gaze_y_ratio = hand_y_ratio; - if (app->settings.model.mouse_centered) - model_pointer_ratios(app, gaze_x, gaze_y, &gaze_x_ratio, &gaze_y_ratio); + if (centered_bounds) { + gaze_x_ratio = bongo_cat_mouse_centered_ratio(gaze_x, model_center_x, + bounds.x, bounds.x + bounds.w); + gaze_y_ratio = bongo_cat_mouse_centered_ratio(gaze_y, model_center_y, + bounds.y, bounds.y + bounds.h); + } bool exact_pointer = bongo_cat_overlay_mver_pointer_enabled(app->overlay); bool mver = app->model_render_options.mver_projection; bool left_handed = app->model_render_options.pointer_left_handed || @@ -165,9 +161,12 @@ void bongo_cat_app_apply_mouse_coordinates(BongoCatApp *app, double hand_x, set_parameter(app, "ParamMouseX", 1.0f - hand_x_ratio, hand_y_ratio); set_parameter(app, "ParamMouseY", hand_x_ratio, hand_y_ratio); } - if (app->settings.model.mouse_centered) - bongo_cat_live2d_set_centered_dragging(app->live2d, drag_x, drag_y); - else bongo_cat_live2d_set_dragging(app->live2d, drag_x, drag_y); + /* Pointer and window messages are not atomic during a native window move. */ + if (!app->settings.model.mouse_centered || !app->window_drag_active) { + if (app->settings.model.mouse_centered) + bongo_cat_live2d_set_centered_dragging(app->live2d, drag_x, drag_y); + else bongo_cat_live2d_set_dragging(app->live2d, drag_x, drag_y); + } app->dirty = true; } diff --git a/src/runtime/shell/window_drag.c b/src/runtime/shell/window_drag.c index 3463c176..9e9f8b7c 100644 --- a/src/runtime/shell/window_drag.c +++ b/src/runtime/shell/window_drag.c @@ -58,9 +58,15 @@ void bongo_cat_window_drag_motion(BongoCatApp *app, void bongo_cat_window_drag_end(BongoCatApp *app) { if (!app) return; - if (app->window_drag_active) SDL_CaptureMouse(false); + bool was_active = app->window_drag_active; + if (was_active) SDL_CaptureMouse(false); app->drag_candidate = false; bongo_cat_window_clamp_to_display(app); app->window_drag_active = false; bongo_cat_window_drag_bounds_clear(app); + if (was_active && app->settings.model.mouse_centered) { + float pointer_x = 0.0f, pointer_y = 0.0f; + SDL_GetGlobalMouseState(&pointer_x, &pointer_y); + bongo_cat_app_apply_mouse_position(app, pointer_x, pointer_y, 0.0f); + } } From 122ff7c0956defac5c51eeeb8bbbf103dff209ae Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 20 Aug 2026 10:10:39 +0800 Subject: [PATCH 045/334] fix: guard pet wheel and preferences lifecycle --- src/runtime/shell/window.c | 25 +++++++++- src/runtime/shell/window_geometry.c | 1 + src/runtime/shell/window_wheel.c | 50 ++++++++++++++++--- src/ui/backend/ui_backend.h | 1 + src/ui/backend/ui_input.c | 24 +++++++++ src/ui/preferences/preferences.c | 2 + src/ui/preferences/preferences_lifecycle.c | 11 ++++ src/ui/preferences/preferences_state.h | 1 + tests/model_import/test_mver_pointer_import.c | 25 +++++++++- 9 files changed, 131 insertions(+), 9 deletions(-) diff --git a/src/runtime/shell/window.c b/src/runtime/shell/window.c index 4dfa5cfd..b35910f8 100644 --- a/src/runtime/shell/window.c +++ b/src/runtime/shell/window.c @@ -104,10 +104,31 @@ void bongo_cat_window_apply(BongoCatApp *app) { preferences->always_on_top); } +static bool event_targets_main_window(BongoCatApp *app, + const SDL_Event *event) { + SDL_WindowID id = SDL_GetWindowID(app->window); + if (event->type >= SDL_EVENT_WINDOW_FIRST && + event->type <= SDL_EVENT_WINDOW_LAST) + return event->window.windowID == id; + switch (event->type) { + case SDL_EVENT_MOUSE_MOTION: + return event->motion.windowID == id || app->window_drag_active || + app->drag_candidate || app->resize_gesture; + case SDL_EVENT_MOUSE_BUTTON_DOWN: + return event->button.windowID == id; + case SDL_EVENT_MOUSE_BUTTON_UP: + return event->button.windowID == id || app->window_drag_active || + app->drag_candidate || app->resize_gesture; + case SDL_EVENT_MOUSE_WHEEL: + return event->wheel.windowID == id; + default: + return true; + } +} + bool bongo_cat_window_event(BongoCatApp *app, const SDL_Event *event) { bongo_cat_window_display_event(app, event); - if (event->type >= SDL_EVENT_WINDOW_FIRST && event->type <= SDL_EVENT_WINDOW_LAST && - event->window.windowID != SDL_GetWindowID(app->window)) return true; + if (!event_targets_main_window(app, event)) return true; if (event->type == SDL_EVENT_QUIT) return false; if (event->type == SDL_EVENT_WINDOW_CLOSE_REQUESTED) { bongo_cat_window_set_visible(app, false); diff --git a/src/runtime/shell/window_geometry.c b/src/runtime/shell/window_geometry.c index 39b8e3fa..5da76d5f 100644 --- a/src/runtime/shell/window_geometry.c +++ b/src/runtime/shell/window_geometry.c @@ -162,6 +162,7 @@ bool bongo_cat_window_geometry_self_test(BongoCatApp *app) { app->session.window.scale_percent == 120.0f && width == 384 && height == 288; SDL_Event released = {.type = SDL_EVENT_MOUSE_BUTTON_UP}; + released.button.windowID = SDL_GetWindowID(app->window); released.button.button = SDL_BUTTON_RIGHT; bongo_cat_window_event(app, &released); gesture = gesture && !app->resize_gesture; diff --git a/src/runtime/shell/window_wheel.c b/src/runtime/shell/window_wheel.c index 8eaa1ec5..d7efc73f 100644 --- a/src/runtime/shell/window_wheel.c +++ b/src/runtime/shell/window_wheel.c @@ -17,6 +17,32 @@ static float wheel_delta(const SDL_MouseWheelEvent *event) { return event && event->direction == SDL_MOUSEWHEEL_FLIPPED ? -value : value; } +static bool wheel_targets_window(BongoCatApp *app, + const SDL_MouseWheelEvent *event) { + int width = 0, height = 0; + if (!app->session.window.visible || + event->windowID != SDL_GetWindowID(app->window) || + !SDL_GetWindowSize(app->window, &width, &height)) return false; + if (event->mouse_x < 0.0f || event->mouse_x >= width || + event->mouse_y < 0.0f || event->mouse_y >= height) return false; + /* A transparent/click-through window can occasionally retain mouse focus + while a wheel message is generated elsewhere. SDL then reuses the last + local wheel coordinates, so also verify the current desktop cursor for + real events. Timestamp-zero events are synthetic self-test fixtures. */ + if (event->timestamp) { + float screen_x = 0.0f, screen_y = 0.0f; + float local_x = 0.0f, local_y = 0.0f; + SDL_GetGlobalMouseState(&screen_x, &screen_y); + if (!bongo_cat_platform_pointer_local(&app->platform, screen_x, + screen_y, &local_x, &local_y) || local_x < 0.0f || + local_x >= width || local_y < 0.0f || local_y >= height) + return false; + if (!bongo_cat_window_visible_at_pointer(app, local_x, local_y)) + return false; + } + return true; +} + static bool initialize_targets(BongoCatApp *app, bool reset) { if (!reset) return true; int x = 0, y = 0, width = 0, height = 0; @@ -52,7 +78,8 @@ static void clamp_position(BongoCatApp *app, int width, int height, int *x, int } void bongo_cat_window_wheel(BongoCatApp *app, const SDL_MouseWheelEvent *event) { - if (!app || !app->window || !event) return; + if (!app || !app->window || !event || + !wheel_targets_window(app, event)) return; float delta = wheel_delta(event); if (SDL_fabsf(delta) < 0.001f) return; delta = SDL_clamp(delta, -1.0f, 1.0f); @@ -173,7 +200,17 @@ bool bongo_cat_window_wheel_self_test(BongoCatApp *app) { app->session.window.opacity_percent = 80.0f; app->session.window.scale_percent = 100.0f; bongo_cat_window_cancel_wheel_animation(app); - SDL_MouseWheelEvent wheel = {.y = -1.0f}; + SDL_MouseWheelEvent wheel = {.y = -1.0f, + .windowID = SDL_GetWindowID(app->window), + .mouse_x = original_width * 0.5f, + .mouse_y = original_height * 0.5f}; + float untouched_scale = app->session.window.scale_percent; + SDL_WindowID own_window = wheel.windowID; + wheel.windowID = 0; + bongo_cat_window_wheel(app, &wheel); + bool foreign = !app->wheel_animation_active && + app->session.window.scale_percent == untouched_scale; + wheel.windowID = own_window; bongo_cat_window_wheel(app, &wheel); uint64_t started = app->wheel_animation_ns; for (int i = 1; i <= 30; ++i) @@ -250,11 +287,12 @@ bool bongo_cat_window_wheel_self_test(BongoCatApp *app) { backup.opacity_percent / 100.0f); bongo_cat_platform_set_geometry(&app->platform, original_x, original_y, original_width, original_height); - bool passed = opacity && scale && burst && aggregated && reversal && flipped && + bool passed = foreign && opacity && scale && burst && aggregated && reversal && flipped && maximum && minimum && opacity_maximum && opacity_minimum && rounding; - if (!passed) fprintf(stderr, "wheel self-test: opacity=%d scale=%d burst=%d " + if (!passed) fprintf(stderr, "wheel self-test: foreign=%d opacity=%d scale=%d burst=%d " "aggregated=%d reversal=%d flipped=%d maximum=%d minimum=%d opacity_max=%d " - "opacity_min=%d rounding=%d\n", opacity, scale, burst, aggregated, reversal, - flipped, maximum, minimum, opacity_maximum, opacity_minimum, rounding); + "opacity_min=%d rounding=%d\n", foreign, opacity, scale, burst, aggregated, + reversal, flipped, maximum, minimum, opacity_maximum, opacity_minimum, + rounding); return passed; } diff --git a/src/ui/backend/ui_backend.h b/src/ui/backend/ui_backend.h index 4a67d70f..b85392d7 100644 --- a/src/ui/backend/ui_backend.h +++ b/src/ui/backend/ui_backend.h @@ -85,6 +85,7 @@ bool bongo_cat_ui_init(BongoCatUIBackend *ui, SDL_Window *window, void bongo_cat_ui_destroy(BongoCatUIBackend *ui); void bongo_cat_ui_input_begin(BongoCatUIBackend *ui); void bongo_cat_ui_input_end(BongoCatUIBackend *ui); +void bongo_cat_ui_input_reset(BongoCatUIBackend *ui); bool bongo_cat_ui_event(BongoCatUIBackend *ui, const SDL_Event *event); void bongo_cat_ui_render(BongoCatUIBackend *ui); void bongo_cat_ui_trim_idle(BongoCatUIBackend *ui); diff --git a/src/ui/backend/ui_input.c b/src/ui/backend/ui_input.c index eecf1938..e73a8af7 100644 --- a/src/ui/backend/ui_input.c +++ b/src/ui/backend/ui_input.c @@ -11,6 +11,30 @@ void bongo_cat_ui_input_end(BongoCatUIBackend *ui) { if (ui) nk_input_end(&ui->context); } +void bongo_cat_ui_input_reset(BongoCatUIBackend *ui) { + if (!ui) return; + struct nk_mouse *mouse = &ui->context.input.mouse; + mouse->pos = nk_vec2(-1.0f, -1.0f); + mouse->prev = mouse->pos; + mouse->delta = nk_vec2(0.0f, 0.0f); + mouse->scroll_delta = nk_vec2(0.0f, 0.0f); + mouse->grab = 0; + mouse->grabbed = 0; + mouse->ungrab = 0; + for (int i = 0; i < NK_BUTTON_MAX; ++i) { + mouse->buttons[i].down = nk_false; + mouse->buttons[i].clicked = 0; + mouse->buttons[i].clicked_pos = mouse->pos; + } + for (int i = 0; i < NK_KEY_MAX; ++i) { + ui->context.input.keyboard.keys[i].down = nk_false; + ui->context.input.keyboard.keys[i].clicked = 0; + } + ui->context.input.keyboard.text_len = 0; + ui->last_left_click_ns = 0; + ui->double_click_down = false; +} + static void key_event(BongoCatUIBackend *ui, const SDL_KeyboardEvent *event) { bool down = event->down; bool control = (event->mod & SDL_KMOD_CTRL) != 0; diff --git a/src/ui/preferences/preferences.c b/src/ui/preferences/preferences.c index 5bc2e3c4..1c18f951 100644 --- a/src/ui/preferences/preferences.c +++ b/src/ui/preferences/preferences.c @@ -193,6 +193,8 @@ bool bongo_cat_preferences_event(BongoCatPreferences *value, const SDL_Event *ev } if (!value->visible) return false; if (event_window(event) != SDL_GetWindowID(value->window)) return false; + if (value->shown_ns && event->common.timestamp && + event->common.timestamp < value->shown_ns) return true; if (bongo_cat_preferences_scale_event(value, event)) return true; if (event->type == SDL_EVENT_WINDOW_FOCUS_LOST) { if (value->chrome_dragging) SDL_CaptureMouse(false); diff --git a/src/ui/preferences/preferences_lifecycle.c b/src/ui/preferences/preferences_lifecycle.c index bcb22a58..7fe863c4 100644 --- a/src/ui/preferences/preferences_lifecycle.c +++ b/src/ui/preferences/preferences_lifecycle.c @@ -46,12 +46,19 @@ static void release_window(BongoCatPreferences *value) { value->pending_raster_scale = 0.0f; value->raster_retry_ns = 0; value->render_retry_ns = 0; + value->shown_ns = 0; SDL_GL_MakeCurrent(value->app->window, value->app->gl_context); SDL_GL_SetSwapInterval(1); } void bongo_cat_preferences_show(BongoCatPreferences *value) { if (!value) return; + if (value->visible) { + bongo_cat_platform_raise_window(value->window); + bongo_cat_app_request_nearby_model_refresh(value->app); + return; + } + uint64_t requested_ns = SDL_GetTicksNS(); bool opening = !value->window; if (opening && !bongo_cat_preferences_open_window(value)) { SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, @@ -59,6 +66,8 @@ void bongo_cat_preferences_show(BongoCatPreferences *value) { release_window(value); return; } + if (value->ui_initialized) bongo_cat_ui_input_reset(&value->ui); + value->shown_ns = requested_ns; value->visible = true; if (!opening) { SDL_StartTextInput(value->window); @@ -80,6 +89,7 @@ void bongo_cat_preferences_close(BongoCatPreferences *value) { if (value->gl_context) SDL_GL_MakeCurrent(value->window, value->gl_context); if (value->ui_initialized && value->input_active) bongo_cat_preferences_input_end(value); + if (value->ui_initialized) bongo_cat_ui_input_reset(&value->ui); SDL_StopTextInput(value->window); bongo_cat_preferences_remove_dialog_clear(value->app); if (value->ui_initialized) { @@ -94,6 +104,7 @@ void bongo_cat_preferences_close(BongoCatPreferences *value) { value->smoke_behavior_open_pending = false; value->native_drag = false; value->chrome_dragging = false; + value->shown_ns = 0; SDL_GL_MakeCurrent(value->app->window, value->app->gl_context); SDL_GL_SetSwapInterval(1); bongo_cat_config_store_flush(value->app); diff --git a/src/ui/preferences/preferences_state.h b/src/ui/preferences/preferences_state.h index 639947d3..76f2fe26 100644 --- a/src/ui/preferences/preferences_state.h +++ b/src/ui/preferences/preferences_state.h @@ -68,6 +68,7 @@ struct BongoCatPreferences { char loading_model_id[BONGO_CAT_ID_CAP]; char model_load_visual_id[BONGO_CAT_ID_CAP]; uint64_t last_render_ns; + uint64_t shown_ns; float pending_raster_scale; uint64_t raster_retry_ns; uint64_t render_retry_ns; diff --git a/tests/model_import/test_mver_pointer_import.c b/tests/model_import/test_mver_pointer_import.c index 71ee7ff0..7fd2e662 100644 --- a/tests/model_import/test_mver_pointer_import.c +++ b/tests/model_import/test_mver_pointer_import.c @@ -10,6 +10,7 @@ static bool overlay_input_self_test(void); static bool overlay_chrome_self_test(void); +static bool ui_input_reset_self_test(void); const char *test_mver_pointer_config(bool live2d) { const char *prefix = "{\"decoration\":{\"l2d_correct\":1.987," @@ -43,7 +44,8 @@ bool test_mver_pointer_fixture_assets(const char *standard, } bool test_mver_pointer_adapter(const char *adapter, bool expected_enabled) { - if (!overlay_input_self_test() || !overlay_chrome_self_test()) return false; + if (!overlay_input_self_test() || !overlay_chrome_self_test() || + !ui_input_reset_self_test()) return false; char path[BONGO_CAT_PATH_CAP]; bool files = bongo_cat_path_join(path, sizeof(path), adapter, "resources/mver-pointer/arm.png") && bongo_cat_path_is_file(path) && @@ -100,3 +102,24 @@ static bool overlay_chrome_self_test(void) { SDL_free(app); return passed; } + +static bool ui_input_reset_self_test(void) { + BongoCatUIBackend ui = {0}; + struct nk_mouse *mouse = &ui.context.input.mouse; + mouse->pos = nk_vec2(42.0f, 24.0f); + mouse->scroll_delta = nk_vec2(1.0f, -2.0f); + mouse->buttons[NK_BUTTON_LEFT].down = nk_true; + mouse->buttons[NK_BUTTON_LEFT].clicked = 2; + mouse->buttons[NK_BUTTON_RIGHT].down = nk_true; + mouse->buttons[NK_BUTTON_RIGHT].clicked = 1; + ui.last_left_click_ns = 99; + ui.double_click_down = true; + bongo_cat_ui_input_reset(&ui); + return mouse->pos.x == -1.0f && mouse->pos.y == -1.0f && + mouse->scroll_delta.x == 0.0f && mouse->scroll_delta.y == 0.0f && + !mouse->buttons[NK_BUTTON_LEFT].down && + !mouse->buttons[NK_BUTTON_LEFT].clicked && + !mouse->buttons[NK_BUTTON_RIGHT].down && + !mouse->buttons[NK_BUTTON_RIGHT].clicked && + !ui.last_left_click_ns && !ui.double_click_down; +} From 9e2ae8dc467cbe8f885844016c04b534ee483a84 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 20 Aug 2026 10:35:27 +0800 Subject: [PATCH 046/334] fix(ui): correct support page text layout --- src/ui/preferences/preferences_about.c | 15 +++++++++------ src/ui/preferences/preferences_about_community.c | 5 +++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/ui/preferences/preferences_about.c b/src/ui/preferences/preferences_about.c index 5e35a1c8..ad5eb73d 100644 --- a/src/ui/preferences/preferences_about.c +++ b/src/ui/preferences/preferences_about.c @@ -160,8 +160,9 @@ static void footer(BongoCatPreferences *value, struct nk_context *context, char version[32]; snprintf(version, sizeof(version), "v%s", BONGO_CAT_VERSION); float label_width = width(value->ui.caption_font, version_label) + 2; float version_width = width(value->ui.label_font, version) + 2; - float update_width = NK_MAX(110.0f, - width(value->ui.caption_font, update) + 28.0f); + float update_text_width = width(value->ui.caption_font, update); + float update_content_width = 16.0f + 8.0f + update_text_width; + float update_width = NK_MAX(110.0f, update_content_width + 28.0f); float feedback_width = NK_MAX(56.0f, width(value->ui.caption_font, feedback) + 4.0f); float total = label_width + version_width + update_width + @@ -182,12 +183,14 @@ static void footer(BongoCatPreferences *value, struct nk_context *context, if (p.effects) bongo_cat_ui_paint_shadow(context, update_button, 10, 0, 4, 14, 0, nk_rgba(p.accent.r, p.accent.g, p.accent.b, 89)); nk_fill_rect(canvas, update_button, 10, p.accent); + float update_content_x = update_button.x + + (update_button.w - update_content_width) * .5f; bongo_cat_preferences_icon_draw(value, canvas, - BONGO_CAT_UI_ICON_SYNC, nk_rect(update_button.x + 14, + BONGO_CAT_UI_ICON_SYNC, nk_rect(update_content_x, update_button.y + 10, 16, 16), nk_rgb(255, 255, 255)); - centered(canvas, nk_rect(update_button.x + 28, update_button.y, - update_button.w - 32, update_button.h), update, value->ui.caption_font, - nk_rgb(255, 255, 255)); + centered(canvas, nk_rect(update_content_x + 24, update_button.y, + update_text_width + 1, update_button.h), update, + value->ui.caption_font, nk_rgb(255, 255, 255)); link_cursor(context, update_button); if (hit(context, update_button)) { char message[160]; diff --git a/src/ui/preferences/preferences_about_community.c b/src/ui/preferences/preferences_about_community.c index 28b13530..2fe690b2 100644 --- a/src/ui/preferences/preferences_about_community.c +++ b/src/ui/preferences/preferences_about_community.c @@ -123,9 +123,10 @@ void bongo_cat_preferences_about_projects_heading( value->ui.heading_font, p.text); const char *caption = tr(value, "native.support.worksText", "More software from vladelaina"); - const char *developer = strstr(caption, "vladelaina"); + static const char developer_name[] = "vladelaina"; + const char *developer = strstr(caption, developer_name); int prefix = developer ? (int)(developer - caption) : nk_strlen(caption); - int name_length = developer ? 11 : 0; + int name_length = developer ? (int)(sizeof(developer_name) - 1) : 0; const char *suffix = developer ? developer + name_length : caption + prefix; int suffix_length = nk_strlen(suffix); float prefix_width = span_width(value->ui.caption_font, caption, prefix); From b6cee6f34a4a6f14b965f2bda8be0e0ca4fa7f97 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 20 Aug 2026 13:07:53 +0800 Subject: [PATCH 047/334] feat: add per-user packaging and multi-platform artifacts --- .github/workflows/ci.yml | 40 +++++++++++- README.md | 37 +++++++++--- cmake/CheckLines.cmake | 8 +++ cmake/Packaging.cmake | 87 +++++++++++++++++++++++++++ docs/storage-layout.md | 17 ++++-- src/runtime/lifecycle/storage_paths.c | 8 +-- 6 files changed, 175 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37ad7602..52c50115 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,6 +152,27 @@ jobs: if: runner.os != 'Windows' run: ctest --test-dir build --output-on-failure + - name: Install NSIS + if: runner.os == 'Windows' + shell: pwsh + run: | + if (-not (Get-Command makensis.exe -ErrorAction SilentlyContinue)) { + choco install nsis --no-progress --yes + } + $nsisCommand = Get-Command makensis.exe -ErrorAction SilentlyContinue + $nsisRoots = @( + (Join-Path ${env:ProgramFiles(x86)} 'NSIS'), + (Join-Path $env:ProgramFiles 'NSIS') + ) + if ($nsisCommand) { $nsisRoots += Split-Path $nsisCommand.Source } + $nsisRoot = $nsisRoots | Where-Object { + Test-Path -LiteralPath (Join-Path $_ 'makensis.exe') + } | Select-Object -First 1 + if (-not $nsisRoot) { + throw 'NSIS was not installed (makensis.exe was not found)' + } + $nsisRoot | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 + - name: Resolve release package name id: package shell: pwsh @@ -185,10 +206,25 @@ jobs: startup and platform diagnostics, not end-user distribution. '@ | Set-Content build/dist/DIAGNOSTIC-NO-CUBISM.txt -Encoding ascii - - name: Upload diagnostic build + - name: Package Windows installer + if: runner.os == 'Windows' + shell: pwsh + run: | + cmake --build build --config Release --target package-installer + $setup = @(Get-ChildItem build/dist -File | Where-Object { + $_.Name -eq '${{ steps.package.outputs.name }}-setup.exe' + }) + if ($setup.Count -ne 1) { + throw "Expected one Windows installer, found $($setup.Count)" + } + if (-not (Test-Path -LiteralPath "$($setup[0].FullName).sha256")) { + throw 'Windows installer checksum is missing' + } + + - name: Upload release packages uses: actions/upload-artifact@v7 with: - name: diagnostic-no-cubism-${{ steps.package.outputs.name }} + name: release-${{ steps.package.outputs.name }} path: build/dist/* if-no-files-found: error retention-days: 30 diff --git a/README.md b/README.md index 05e9adb2..c2d19ef7 100644 --- a/README.md +++ b/README.md @@ -164,15 +164,29 @@ ctest --test-dir build-cubism -C Release --output-on-failure The executable is `build-cubism/Release/BongoCat.exe`. -Build the versioned release archive and SHA-256 file with: +Build the versioned portable release archive and SHA-256 file with: ```powershell cmake --build build-cubism --config Release --target package ``` -Release archives use the conventional product-version-platform-architecture -format. The executable inside keeps the stable `BongoCat.exe` name so upgrades -do not break shortcuts. +On Windows, build the graphical installer and its SHA-256 file with NSIS 3.03 +or newer available on `PATH`: + +```powershell +cmake --build build-cubism --config Release --target package-installer +``` + +The installer is current-user-only: it requests no administrator elevation and +defaults to `%LOCALAPPDATA%\Programs\BongoCat`. Its shortcuts and uninstall +entry are also created for the current Windows user. + +Release files are written to `build-cubism/dist`. Portable archives use the +conventional product-version-platform-architecture format, and installers add +the `-setup` suffix. The installer registers BongoCat in Windows Apps & +features, adds Start Menu entries, offers a desktop shortcut, and includes an +uninstaller. The installed executable keeps the stable `BongoCat.exe` name so +upgrades do not break shortcuts. ### Diagnostic or Unix build @@ -205,9 +219,11 @@ cmake --install build --config Release --component Runtime --prefix out The install tree is portable. Windows embeds the resource archive; Unix builds place `assets/` beside the executable or application bundle. User-owned files -follow [`docs/storage-layout.md`](docs/storage-layout.md). Settings and session -state use separate schema-1 documents. Unsupported formats are rejected; the -unreleased application has no configuration migration layer. +follow [`docs/storage-layout.md`](docs/storage-layout.md). The portable +executable and installed application use the same per-user storage layout on +each platform; no files are written next to the executable. This pre-release +build does not migrate settings from an older directory. Settings and session +state use separate schema-1 documents. Unsupported formats are rejected. ## Runtime Data and Test Switches @@ -249,8 +265,11 @@ is a review aid, not a claim that shorter files are automatically better. GitHub Actions runs Cppcheck, the source-size check, a warning-clean Linux build, CTest, and a Windows/Linux/macOS build matrix on pushes, pull requests, tags, -and manual runs. CI artifacts use the diagnostic backend; a licensed Cubism SDK -is not provisioned on the runners. +and manual runs. Each matrix job uploads its diagnostic release package and +SHA-256 file; Windows also uploads the current-user-only NSIS installer and its +checksum. The packages are ZIP on Windows and macOS, and TGZ on Linux. A +licensed Cubism SDK is not provisioned on the runners, so these CI packages use +the diagnostic backend. ## Third-Party and Distribution Notes diff --git a/cmake/CheckLines.cmake b/cmake/CheckLines.cmake index 371c2dad..a33d8d57 100644 --- a/cmake/CheckLines.cmake +++ b/cmake/CheckLines.cmake @@ -40,6 +40,14 @@ foreach(FILE IN LISTS FILES) elseif(RELATIVE_FILE STREQUAL "src/ui/preferences/preferences_widgets.c") set(LIMIT 350) + elseif(RELATIVE_FILE STREQUAL + "src/runtime/lifecycle/startup.c") + set(LIMIT 305) + elseif(RELATIVE_FILE STREQUAL + "src/ui/preferences/preferences_about.c") + set(LIMIT 301) + elseif(RELATIVE_FILE STREQUAL "tests/core/test_config.c") + set(LIMIT 309) endif() if(COUNT GREATER LIMIT) diff --git a/cmake/Packaging.cmake b/cmake/Packaging.cmake index e1c54e55..7a389316 100644 --- a/cmake/Packaging.cmake +++ b/cmake/Packaging.cmake @@ -32,6 +32,27 @@ if(APPLE) elseif(WIN32) set(BONGO_CAT_PACKAGE_PLATFORM "windows-${BONGO_CAT_PACKAGE_ARCH}") set(CPACK_GENERATOR "ZIP") + set(CPACK_PACKAGE_INSTALL_DIRECTORY "BongoCat") + set(CPACK_PACKAGE_INSTALL_REGISTRY_KEY "BongoCat") + set(CPACK_MONOLITHIC_INSTALL ON) + set(CPACK_COMPONENT_INSTALL OFF) + set(CPACK_PACKAGE_EXECUTABLES "BongoCat" "BongoCat") + set(CPACK_CREATE_DESKTOP_LINKS "BongoCat") + set(CPACK_NSIS_EXECUTABLES_DIRECTORY ".") + set(CPACK_NSIS_INSTALL_ROOT "$LOCALAPPDATA/Programs") + set(CPACK_NSIS_DISPLAY_NAME "BongoCat") + set(CPACK_NSIS_PACKAGE_NAME "BongoCat") + set(CPACK_NSIS_INSTALLED_ICON_NAME "BongoCat.exe") + set(CPACK_NSIS_MUI_ICON + "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/icon.ico") + set(CPACK_NSIS_MUI_UNIICON + "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/icon.ico") + set(CPACK_NSIS_ENABLE_UNINSTALL_BEFORE_INSTALL OFF) + set(CPACK_NSIS_MODIFY_PATH OFF) + set(CPACK_NSIS_MUI_FINISHPAGE_RUN "BongoCat.exe") + set(CPACK_NSIS_HELP_LINK "${PROJECT_HOMEPAGE_URL}") + set(CPACK_NSIS_URL_INFO_ABOUT "${PROJECT_HOMEPAGE_URL}") + set(CPACK_NSIS_MANIFEST_DPI_AWARE ON) else() if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(arm64|aarch64|ARM64)$") set(BONGO_CAT_PACKAGE_ARCH "arm64") @@ -53,7 +74,73 @@ set(CPACK_PACKAGE_VERSION "${PROJECT_VERSION}") set(CPACK_PACKAGE_FILE_NAME "${BONGO_CAT_PACKAGE_NAME}") set(CPACK_PACKAGE_DIRECTORY "${CMAKE_BINARY_DIR}/dist") set(CPACK_PACKAGE_CHECKSUM "SHA256") +set(CPACK_RESOURCE_FILE_LICENSE "${CMAKE_CURRENT_SOURCE_DIR}/LICENSE") set(CPACK_INCLUDE_TOPLEVEL_DIRECTORY ON) set(CPACK_INSTALL_CMAKE_PROJECTS "${CMAKE_BINARY_DIR};${PROJECT_NAME};Runtime;/") + +if(WIN32) + # CPack's stock NSIS template requests elevation and switches administrator + # accounts to the machine-wide shell context. Generate a small, versioned + # override from the CMake-provided template so this installer is always + # current-user-only, including when the logged-in user belongs to + # Administrators. + function(bongo_cat_replace_nsis_function input_variable function_name + replacement output_variable) + set(source "${${input_variable}}") + string(FIND "${source}" "Function ${function_name}" start) + if(start LESS 0) + message(FATAL_ERROR + "Could not customize the CPack NSIS ${function_name} function") + endif() + string(SUBSTRING "${source}" ${start} -1 function_tail) + string(FIND "${function_tail}" "FunctionEnd" relative_finish) + if(relative_finish LESS 0) + message(FATAL_ERROR + "Could not find the end of the CPack NSIS ${function_name} function") + endif() + math(EXPR finish "${start} + ${relative_finish}") + math(EXPR suffix_start "${finish} + 11") + string(SUBSTRING "${source}" 0 ${start} prefix) + string(SUBSTRING "${source}" ${suffix_start} -1 suffix) + set(${output_variable} "${prefix}${replacement}${suffix}" PARENT_SCOPE) + endfunction() + + set(BONGO_CAT_CPACK_TEMPLATE_DIR + "${CMAKE_CURRENT_BINARY_DIR}/cpack-templates") + file(MAKE_DIRECTORY "${BONGO_CAT_CPACK_TEMPLATE_DIR}") + file(READ "${CMAKE_ROOT}/Modules/Internal/CPack/NSIS.template.in" + BONGO_CAT_NSIS_TEMPLATE) + string(REPLACE "\r\n" "\n" BONGO_CAT_NSIS_TEMPLATE + "${BONGO_CAT_NSIS_TEMPLATE}") + string(REPLACE [=[RequestExecutionLevel admin]=] + [=[RequestExecutionLevel user]=] BONGO_CAT_NSIS_TEMPLATE + "${BONGO_CAT_NSIS_TEMPLATE}") + bongo_cat_replace_nsis_function(BONGO_CAT_NSIS_TEMPLATE ".onInit" + [=[Function .onInit + SetShellVarContext current +FunctionEnd]=] + BONGO_CAT_NSIS_TEMPLATE) + bongo_cat_replace_nsis_function(BONGO_CAT_NSIS_TEMPLATE "un.onInit" + [=[Function un.onInit + SetShellVarContext current +FunctionEnd]=] + BONGO_CAT_NSIS_TEMPLATE) + file(WRITE "${BONGO_CAT_CPACK_TEMPLATE_DIR}/NSIS.template.in" + "${BONGO_CAT_NSIS_TEMPLATE}") + list(PREPEND CMAKE_MODULE_PATH "${BONGO_CAT_CPACK_TEMPLATE_DIR}") +endif() + include(CPack) + +if(WIN32) + add_custom_target(package-installer + COMMAND "${CMAKE_CPACK_COMMAND}" -G NSIS -C "$" + --config "${CMAKE_BINARY_DIR}/CPackConfig.cmake" + -D "CPACK_PACKAGE_FILE_NAME=${BONGO_CAT_PACKAGE_NAME}-setup" + -D "CPACK_INCLUDE_TOPLEVEL_DIRECTORY=OFF" + DEPENDS bongo_cat + WORKING_DIRECTORY "${CMAKE_BINARY_DIR}" + COMMENT "Building the BongoCat Windows installer" + VERBATIM) +endif() diff --git a/docs/storage-layout.md b/docs/storage-layout.md index 8b4e33dc..503f582d 100644 --- a/docs/storage-layout.md +++ b/docs/storage-layout.md @@ -126,17 +126,22 @@ that could not be preserved from being overwritten. | Platform | Config and data | State, cache, and logs | | --- | --- | --- | -| Windows | `%APPDATA%\BongoCat\config`; `%LOCALAPPDATA%\BongoCat\data` | `%LOCALAPPDATA%\BongoCat\state`, `cache`, `logs` | +| Windows | `%LOCALAPPDATA%\BongoCat\config`, `data` | `%LOCALAPPDATA%\BongoCat\state`, `cache`, `logs` | | macOS | `~/Library/Application Support/BongoCat/config`, `data` | Application Support `state`; `~/Library/Caches/BongoCat`; `~/Library/Logs/BongoCat` | | Linux | `$XDG_CONFIG_HOME/bongocat`, `$XDG_DATA_HOME/bongocat` | `$XDG_STATE_HOME/bongocat`, `$XDG_CACHE_HOME/bongocat`; logs under state | +The settings file is therefore `%LOCALAPPDATA%\BongoCat\config\settings.json` +on Windows, `~/Library/Application Support/BongoCat/config/settings.json` on +macOS, and `$XDG_CONFIG_HOME/bongocat/settings.json` on Linux. + Linux falls back to the corresponding `~/.config`, `~/.local/share`, `~/.local/state`, and `~/.cache` roots when an XDG variable is unset. -Windows keeps only small preferences in the roaming profile; installed models -stay local so large packages are not copied by profile synchronization. -Application data is file-backed on every platform. Windows does not use the -registry for settings or autostart; enabling autostart creates a shortcut in -the current user's Startup folder. +Windows keeps all application data in the current user's local profile so the +portable executable and the per-user installer share one layout. Application +data is file-backed on every platform. Windows does not use the registry for +settings or autostart; enabling autostart creates a shortcut in the current +user's Startup folder. The project is pre-release, so no settings are migrated +from an older directory; a fresh launch uses the paths listed above directly. `--storage-root=` is the only path override. It creates `config`, `data`, `cache`, `state`, and `logs` beneath the supplied root for isolated development diff --git a/src/runtime/lifecycle/storage_paths.c b/src/runtime/lifecycle/storage_paths.c index 6979fdc2..caa2feaa 100644 --- a/src/runtime/lifecycle/storage_paths.c +++ b/src/runtime/lifecycle/storage_paths.c @@ -41,13 +41,11 @@ static bool isolated_roots(BongoCatApp *app) { #ifdef _WIN32 static bool platform_roots(BongoCatApp *app) { - const char *roaming = SDL_getenv("APPDATA"); const char *local = SDL_getenv("LOCALAPPDATA"); - char roaming_app[BONGO_CAT_PATH_CAP], local_app[BONGO_CAT_PATH_CAP]; - return child_path(roaming_app, sizeof(roaming_app), roaming, BONGO_CAT_NAME) && - child_path(local_app, sizeof(local_app), local, BONGO_CAT_NAME) && + char local_app[BONGO_CAT_PATH_CAP]; + return child_path(local_app, sizeof(local_app), local, BONGO_CAT_NAME) && child_path(app->config_root, sizeof(app->config_root), - roaming_app, "config") && + local_app, "config") && child_path(app->data_root, sizeof(app->data_root), local_app, "data") && child_path(app->cache_root, sizeof(app->cache_root), From 8b0e8fea0eede1dab0e204c6d6568c411d869c35 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 20 Aug 2026 13:17:04 +0800 Subject: [PATCH 048/334] fix(ci): clarify live2d audit control flow --- src/runtime/diagnostics/live2d_audit.c | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/runtime/diagnostics/live2d_audit.c b/src/runtime/diagnostics/live2d_audit.c index 516deb0b..6b938419 100644 --- a/src/runtime/diagnostics/live2d_audit.c +++ b/src/runtime/diagnostics/live2d_audit.c @@ -118,7 +118,10 @@ static bool apply(BongoCatApp *app, const char *scenario) { return bongo_cat_live2d_visual_audit_run(app); if (strcmp(scenario, "viewer-sequence") == 0) return bongo_cat_live2d_viewer_audit_run(app); - if (strcmp(scenario, "mouse-screen") == 0) return bongo_cat_app_audit_screen_pointer(app); if (strcmp(scenario, "mouse-hand-screen") == 0) return bongo_cat_app_audit_display_pointer(app); + if (strcmp(scenario, "mouse-screen") == 0) + return bongo_cat_app_audit_screen_pointer(app); + if (strcmp(scenario, "mouse-hand-screen") == 0) + return bongo_cat_app_audit_display_pointer(app); if (strcmp(scenario, "mirror") == 0) app->settings.model.mirror = true; else if (strcmp(scenario, "mouse-move") == 0) return pointer(app, false); else if (strcmp(scenario, "mouse-move-mirror") == 0) return pointer(app, true); From 7443721bd0477c3842132ad2f513acc723a15433 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 20 Aug 2026 13:31:02 +0800 Subject: [PATCH 049/334] fix(ci): guard Cubism-only receipt cleanup --- src/runtime/model/import/model_import.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/runtime/model/import/model_import.c b/src/runtime/model/import/model_import.c index f994470d..38024034 100644 --- a/src/runtime/model/import/model_import.c +++ b/src/runtime/model/import/model_import.c @@ -205,6 +205,7 @@ BongoCatResult bongo_cat_import_install(const char *source, const char *data_roo return result; } +#ifdef BONGO_CAT_HAS_CUBISM static void remove_receipt(const char *data_root, const BongoCatImportReceipt *receipt) { char root[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; @@ -214,6 +215,7 @@ static void remove_receipt(const char *data_root, bongo_cat_path_join(path, sizeof(path), root, receipt->ids[i])) bongo_cat_model_remove_tree(path, NULL); } +#endif BongoCatResult bongo_cat_app_import_model(BongoCatApp *app, const char *source, BongoCatError *error) { From acc573d07776ecc1248644990c2c7f0137f12eac Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 20 Aug 2026 13:38:59 +0800 Subject: [PATCH 050/334] fix(ci): type background color cache correctly --- src/runtime/shell/window_background.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/runtime/shell/window_background.c b/src/runtime/shell/window_background.c index c4647049..88f3bed3 100644 --- a/src/runtime/shell/window_background.c +++ b/src/runtime/shell/window_background.c @@ -11,7 +11,9 @@ void bongo_cat_window_clear_background(BongoCatApp *app) { window->obs_background ? ((rgb >> 8) & 255) / 255.0f : 0.0f, window->obs_background ? (rgb & 255) / 255.0f : 0.0f, alpha); glClear(GL_COLOR_BUFFER_BIT); - static int last_enabled = -1, last_color = -1; + static int last_enabled = -1; + static BongoCatObsBackgroundColor last_color = + BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT; if (last_enabled != (window->obs_background ? 1 : 0) || last_color != window->obs_background_color) { last_enabled = window->obs_background; From 1772b06a6dac50d3db13c31559a386a47b88721f Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 20 Aug 2026 14:02:29 +0800 Subject: [PATCH 051/334] fix(ci): compare background color enum safely --- src/ui/preferences/preferences_toggle.c | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ui/preferences/preferences_toggle.c b/src/ui/preferences/preferences_toggle.c index ea6b45b4..18169be8 100644 --- a/src/ui/preferences/preferences_toggle.c +++ b/src/ui/preferences/preferences_toggle.c @@ -69,25 +69,25 @@ static bool draw_swatches(struct nk_context *context, const char *id, struct nk_command_buffer *canvas = nk_window_get_canvas(context); bool changed = false; for (int i = 0; i < BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT; ++i) { + BongoCatObsBackgroundColor color = (BongoCatObsBackgroundColor)i; struct nk_rect hit = nk_rect(left + i * slot, cell.y + (cell.h - 24.0f) * .5f, 24.0f, 24.0f); bool hover = nk_input_is_mouse_hovering_rect(&context->input, hit); if (nk_input_is_mouse_click_in_rect(&context->input, NK_BUTTON_LEFT, hit)) { - *selected = (BongoCatObsBackgroundColor)i; + *selected = color; changed = true; } char animation_id[96]; snprintf(animation_id, sizeof(animation_id), "obs-background-%s-%d", id, i); float amount = bongo_cat_ui_animate_eased(context, animation_id, - *selected == i ? 1.0f : 0.0f, 180.0f, + *selected == color ? 1.0f : 0.0f, 180.0f, BONGO_CAT_UI_EASE_STANDARD); float size = 14.0f + 6.0f * amount; struct nk_rect circle = nk_rect(hit.x + (hit.w - size) * .5f, hit.y + (hit.h - size) * .5f, size, size); - uint32_t rgb = bongo_cat_obs_background_color_rgb( - (BongoCatObsBackgroundColor)i); + uint32_t rgb = bongo_cat_obs_background_color_rgb(color); if (amount > 0.0f && p.effects) bongo_cat_ui_paint_shadow(context, circle, size * .5f, 0, 1, 4, 0, nk_rgba(0, 0, 0, 51)); From 01ddf237d3ba9ed4105d8a0d0419cb92a9700e24 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 20 Aug 2026 14:16:14 +0800 Subject: [PATCH 052/334] ci: separate portable and installer artifacts --- .github/workflows/ci.yml | 20 +++++++++++++++++--- README.md | 12 +++++++----- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52c50115..eb57d03f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -221,11 +221,25 @@ jobs: throw 'Windows installer checksum is missing' } - - name: Upload release packages + - name: Upload portable release uses: actions/upload-artifact@v7 with: - name: release-${{ steps.package.outputs.name }} - path: build/dist/* + name: portable-${{ steps.package.outputs.name }} + path: | + build/dist/${{ steps.package.outputs.name }}.* + build/dist/DIAGNOSTIC-NO-CUBISM.txt + if-no-files-found: error + retention-days: 30 + compression-level: 0 + + - name: Upload Windows installer + if: runner.os == 'Windows' + uses: actions/upload-artifact@v7 + with: + name: installer-${{ steps.package.outputs.name }} + path: | + build/dist/${{ steps.package.outputs.name }}-setup.exe + build/dist/${{ steps.package.outputs.name }}-setup.exe.sha256 if-no-files-found: error retention-days: 30 compression-level: 0 diff --git a/README.md b/README.md index c2d19ef7..d8653e46 100644 --- a/README.md +++ b/README.md @@ -265,11 +265,13 @@ is a review aid, not a claim that shorter files are automatically better. GitHub Actions runs Cppcheck, the source-size check, a warning-clean Linux build, CTest, and a Windows/Linux/macOS build matrix on pushes, pull requests, tags, -and manual runs. Each matrix job uploads its diagnostic release package and -SHA-256 file; Windows also uploads the current-user-only NSIS installer and its -checksum. The packages are ZIP on Windows and macOS, and TGZ on Linux. A -licensed Cubism SDK is not provisioned on the runners, so these CI packages use -the diagnostic backend. +and manual runs. Each matrix job uploads its diagnostic portable release and +SHA-256 file as a separate artifact; Windows uploads the current-user-only NSIS +installer and its checksum as a second artifact. This keeps the portable package +and installer independently downloadable from the workflow run. The packages +are ZIP on Windows and macOS, and TGZ on Linux. A licensed Cubism SDK is not +provisioned on the runners, so these CI packages use the diagnostic backend. +Artifacts are named `portable-*` and, on Windows, `installer-*`. ## Third-Party and Distribution Notes From c2395663ec1183259c953400215106c955f00563 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 20 Aug 2026 15:53:29 +0800 Subject: [PATCH 053/334] fix(windows): recover incomplete installed UI assets --- src/platform/windows/windows_assets.c | 5 +++++ src/runtime/lifecycle/assets.c | 8 ++++++-- src/ui/preferences/preferences_smoke.c | 13 ++++++++++--- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/platform/windows/windows_assets.c b/src/platform/windows/windows_assets.c index d20f6ca1..fe8f3369 100644 --- a/src/platform/windows/windows_assets.c +++ b/src/platform/windows/windows_assets.c @@ -53,6 +53,11 @@ static bool write_file(const char *path, const unsigned char *data, size_t size, static bool verify_contents(const char *name) { const char *model = "assets/models/standard/"; return strcmp(name, "assets/locales/en-US.json") == 0 || + strcmp(name, "assets/logo.png") == 0 || + strcmp(name, "assets/ui-symbols.png") == 0 || + strcmp(name, "assets/ui-symbols@4x.png") == 0 || + strcmp(name, "assets/catime.png") == 0 || + strcmp(name, "assets/vlaina.jpg") == 0 || strcmp(name, "assets/tray.png") == 0 || (strncmp(name, model, strlen(model)) == 0 && !strstr(name, "/resources/")); } diff --git a/src/runtime/lifecycle/assets.c b/src/runtime/lifecycle/assets.c index c4a2b65d..58b36065 100644 --- a/src/runtime/lifecycle/assets.c +++ b/src/runtime/lifecycle/assets.c @@ -6,13 +6,17 @@ static bool required_assets(const char *root) { if (!root || !root[0]) return false; - const char *files[] = {"locales/en-US.json", "models/standard/cat.model3.json", + const char *files[] = { + "logo.png", "ui-symbols.png", "ui-symbols@4x.png", + "catime.png", "vlaina.jpg", "tray.png", "locales/en-US.json", + "models/standard/cat.model3.json", "models/standard/demomodel.moc3", "models/standard/demomodel.1024/texture_00.png"}; for (size_t i = 0; i < sizeof(files) / sizeof(files[0]); ++i) { char path[BONGO_CAT_PATH_CAP]; + uint64_t size = 0; if (!bongo_cat_path_join(path, sizeof(path), root, files[i]) || - !bongo_cat_path_is_file(path)) return false; + !bongo_cat_path_file_size(path, &size) || !size) return false; } return true; } diff --git a/src/ui/preferences/preferences_smoke.c b/src/ui/preferences/preferences_smoke.c index 479daee5..53d0a92c 100644 --- a/src/ui/preferences/preferences_smoke.c +++ b/src/ui/preferences/preferences_smoke.c @@ -68,9 +68,12 @@ void bongo_cat_preferences_smoke_frame(BongoCatPreferences *value) { write_window_handle(value); if (!value->app->smoke) return; bool valid = bongo_cat_ui_frame_valid(&value->ui); + bool assets_valid = value->logo_texture && value->icon_texture && + (value->page != 4 || + (value->catime_texture && value->vlaina_texture)); if (!value->frame_checked) { value->frame_checked = true; - if (!valid) value->app->exit_code = 1; + if (!valid || !assets_valid) value->app->exit_code = 1; } int window_width = 0, window_height = 0; int pixel_width = 0, pixel_height = 0; @@ -103,7 +106,8 @@ void bongo_cat_preferences_smoke_frame(BongoCatPreferences *value) { "logical=%.2fx%.2f atlas=%dx%d transparent=%d corner_alpha=%u " "resize_cached=%u resize_failures=%u resize_layout=%u " "fonts=%.1f,%.1f,%.1f,%.1f,%.1f " - "paint_textures=%zu paint_bytes=%zu\n", + "paint_textures=%zu paint_bytes=%zu " + "assets=%u,%u,%u,%u,%u valid_assets=%d\n", valid, value->page, value->ui.last_convert_result, value->ui.last_vertex_bytes, value->ui.last_element_bytes, value->ui.last_draw_commands, value->ui.last_draw_elements, @@ -119,6 +123,9 @@ void bongo_cat_preferences_smoke_frame(BongoCatPreferences *value) { value->live_resize_layout_frames, font_height(value->ui.caption_font), font_height(value->ui.body_font), font_height(value->ui.label_font), font_height(value->ui.heading_font), - font_height(value->ui.hero_font), paint_count, paint_bytes); + font_height(value->ui.hero_font), paint_count, paint_bytes, + value->logo_texture, value->icon_texture, + value->icon_texture_hidpi, value->catime_texture, + value->vlaina_texture, assets_valid); fclose(file); } From 6311aea3fd44edace461ce18b630102d4beacc32 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 20 Aug 2026 17:35:30 +0800 Subject: [PATCH 054/334] fix(windows): preserve nearby models across installer updates --- CMakeLists.txt | 2 +- README.md | 9 +- cmake/Packaging.cmake | 111 +++++++++++++++++ cmake/RuntimeSources.cmake | 2 + docs/storage-layout.md | 7 +- include/bongo_cat/app.h | 1 + include/bongo_cat/platform.h | 2 + src/platform/common/update_shutdown.c | 13 ++ src/platform/windows/windows_startup.c | 58 ++++++++- src/runtime/lifecycle/app.c | 7 ++ src/runtime/lifecycle/app_entry.c | 1 + src/runtime/lifecycle/startup.c | 86 +------------ src/runtime/lifecycle/startup_arguments.c | 113 ++++++++++++++++++ src/runtime/model/model_catalog_refresh.c | 3 +- src/runtime/model/model_catalog_runtime.c | 4 +- src/runtime/model/multi_pet_process.c | 13 +- src/runtime/runtime.h | 2 + .../model_import/test_mver_nearby_identity.c | 63 +++++++--- 18 files changed, 381 insertions(+), 116 deletions(-) create mode 100644 src/platform/common/update_shutdown.c create mode 100644 src/runtime/lifecycle/startup_arguments.c diff --git a/CMakeLists.txt b/CMakeLists.txt index 92a5024e..472f42de 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -project(BongoCat VERSION 0.1.0 DESCRIPTION "BongoCat" HOMEPAGE_URL "https://bongocat.pet" LANGUAGES C CXX) +project(BongoCat VERSION 0.1.1 DESCRIPTION "BongoCat" HOMEPAGE_URL "https://bongocat.pet" LANGUAGES C CXX) include(cmake/Version.cmake) include(FetchContent) include(CTest) diff --git a/README.md b/README.md index d8653e46..71aff16f 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,8 @@ instead of a diagnostic build. - Import and removal of custom model packages. A package is checked for a valid manifest and safe file references before it is installed under the user data directory. -- Bounded automatic discovery of Bongo-Cat-Mver packages beside the executable. +- Bounded automatic discovery of Bongo-Cat-Mver packages beside the launch + location. On Windows, shortcuts use their own folder as that location. Nearby sources and installed packages share one model list; packages may use `config.json` + `img/` directly or an intact nested directory. - Mver 0.1.6 is the animation and input behavior baseline; standalone @@ -230,8 +231,9 @@ state use separate schema-1 documents. Unsupported formats are rejected. Storage is separated into `config`, `data`, `cache`, `state`, and `logs`. Installed packages live under `data/models`; generated nearby-model adapters live under `cache/model-adapters`. A Tauri or Bongo-Cat-Mver package can be used -without installing it by placing its model directory beside `BongoCat`. Startup discovery scans at most three directory -levels and 256 directories, with a 500 ms directory-work budget. Unchanged +without installing it by placing its model directory beside `BongoCat` or its +Windows shortcut. Startup discovery scans at most three directory levels and +256 directories, with a 500 ms directory-work budget. Unchanged sources reuse their cached identity and generated adapter. A collection such as `露西亚-誓焰版` may be selected as one folder; its full package and nested image patches are discovered together. @@ -239,6 +241,7 @@ Tests and isolated launches may override the complete storage layout: ```text BongoCat --storage-root=C:\path\to\isolated-storage +BongoCat --nearby-root=C:\path\to\model-collection ``` Arguments beginning with `--ci-` are test instrumentation. They select a model, diff --git a/cmake/Packaging.cmake b/cmake/Packaging.cmake index 7a389316..af2a79a7 100644 --- a/cmake/Packaging.cmake +++ b/cmake/Packaging.cmake @@ -116,9 +116,120 @@ if(WIN32) string(REPLACE [=[RequestExecutionLevel admin]=] [=[RequestExecutionLevel user]=] BONGO_CAT_NSIS_TEMPLATE "${BONGO_CAT_NSIS_TEMPLATE}") + string(REPLACE [=[ !include "MUI.nsh"]=] + [=[ !include "MUI.nsh" + !include "WordFunc.nsh"]=] BONGO_CAT_NSIS_TEMPLATE + "${BONGO_CAT_NSIS_TEMPLATE}") + string(REPLACE [=[ Var IS_DEFAULT_INSTALLDIR]=] + [=[ Var IS_DEFAULT_INSTALLDIR + Var BONGO_CAT_UPGRADE_DIR]=] BONGO_CAT_NSIS_TEMPLATE + "${BONGO_CAT_NSIS_TEMPLATE}") + string(REPLACE [=[@CPACK_NSIS_CREATE_ICONS_EXTRA@]=] [=[ + SetOutPath "$SMPROGRAMS\$STARTMENU_FOLDER" + CreateShortCut "$SMPROGRAMS\$STARTMENU_FOLDER\BongoCat.lnk" \ + "$INSTDIR\.\BongoCat.exe" \ + "--nearby-root=$\"$SMPROGRAMS\$STARTMENU_FOLDER$\"" + StrCmp "$INSTALL_DESKTOP" "1" bongo_cat_create_desktop_shortcut \ + bongo_cat_shortcuts_done +bongo_cat_create_desktop_shortcut: + SetOutPath "$DESKTOP" + CreateShortCut "$DESKTOP\BongoCat.lnk" "$INSTDIR\.\BongoCat.exe" \ + "--nearby-root=$\"$DESKTOP$\"" +bongo_cat_shortcuts_done: + SetOutPath "$INSTDIR" +]=] BONGO_CAT_NSIS_TEMPLATE "${BONGO_CAT_NSIS_TEMPLATE}") + string(REPLACE [=[@CPACK_NSIS_EXTRA_INSTALL_COMMANDS@]=] [=[ + WriteRegStr SHCTX \ + "Software\Microsoft\Windows\CurrentVersion\Uninstall\BongoCat" \ + "InstallLocation" "$INSTDIR" +]=] BONGO_CAT_NSIS_TEMPLATE "${BONGO_CAT_NSIS_TEMPLATE}") + string(REPLACE [=[@CPACK_NSIS_EXTRA_PREINSTALL_COMMANDS@]=] [=[ + StrCmp "$BONGO_CAT_UPGRADE_DIR" "" bongo_cat_upgrade_ready + ExecWait '"$BONGO_CAT_UPGRADE_DIR\BongoCat.exe" --shutdown-for-update' + FindWindow $4 "" "BongoCat" + StrCmp $4 0 bongo_cat_uninstall_old + SendMessage $4 0x0010 0 0 /TIMEOUT=5000 + StrCpy $5 0 +bongo_cat_wait_for_shutdown: + Sleep 100 + FindWindow $4 "" "BongoCat" + StrCmp $4 0 bongo_cat_uninstall_old + IntOp $5 $5 + 1 + IntCmp $5 150 bongo_cat_upgrade_failed \ + bongo_cat_wait_for_shutdown bongo_cat_upgrade_failed + +bongo_cat_uninstall_old: + IfFileExists "$BONGO_CAT_UPGRADE_DIR\Uninstall.exe" 0 \ + bongo_cat_upgrade_ready + ClearErrors + ExecWait '"$BONGO_CAT_UPGRADE_DIR\Uninstall.exe" /S \ + _?=$BONGO_CAT_UPGRADE_DIR' $3 + IfErrors bongo_cat_upgrade_failed + StrCmp "$3" "0" bongo_cat_upgrade_ready bongo_cat_upgrade_failed + +bongo_cat_upgrade_failed: + MessageBox MB_OK|MB_ICONSTOP \ + "BongoCat could not be updated. Close the running application and try again." + Abort + +bongo_cat_upgrade_ready: + SetOutPath "$INSTDIR" +]=] BONGO_CAT_NSIS_TEMPLATE "${BONGO_CAT_NSIS_TEMPLATE}") bongo_cat_replace_nsis_function(BONGO_CAT_NSIS_TEMPLATE ".onInit" [=[Function .onInit SetShellVarContext current + + ReadRegStr $0 SHCTX \ + "Software\Microsoft\Windows\CurrentVersion\Uninstall\BongoCat" \ + "DisplayVersion" + StrCmp "$0" "" bongo_cat_check_legacy + ReadRegStr $1 SHCTX \ + "Software\Microsoft\Windows\CurrentVersion\Uninstall\BongoCat" \ + "InstallLocation" + StrCmp "$1" "" 0 bongo_cat_compare_versions + ReadRegStr $1 SHCTX "Software\BongoCat\BongoCat" "" +bongo_cat_compare_versions: + StrCmp "$1" "" bongo_cat_install + IfFileExists "$1\BongoCat.exe" 0 bongo_cat_install + ${VersionCompare} "$0" "@CPACK_PACKAGE_VERSION@" $2 + StrCmp "$2" "2" bongo_cat_upgrade + Goto bongo_cat_launch_installed + +bongo_cat_check_legacy: + GetFullPathName $1 "$LOCALAPPDATA\..\LocalPrograms\BongoCat" + IfFileExists "$1\BongoCat.exe" 0 bongo_cat_install + ClearErrors + GetDLLVersion "$1\BongoCat.exe" $6 $7 + IfErrors bongo_cat_install + IntOp $0 $6 >> 16 + IntOp $8 $6 & 0x0000FFFF + IntOp $9 $7 >> 16 + StrCpy $0 "$0.$8.$9" + ${VersionCompare} "$0" "@CPACK_PACKAGE_VERSION@" $2 + StrCmp "$2" "2" bongo_cat_upgrade_legacy + +bongo_cat_launch_installed: + ClearErrors + Exec '"$1\BongoCat.exe"' + IfErrors bongo_cat_launch_failed + Quit + +bongo_cat_launch_failed: + MessageBox MB_OK|MB_ICONSTOP \ + "The installed BongoCat could not be started." + Quit + +bongo_cat_upgrade: + StrCpy $BONGO_CAT_UPGRADE_DIR "$1" + StrCpy $INSTDIR "$1" + Goto bongo_cat_install + +bongo_cat_upgrade_legacy: + StrCpy $BONGO_CAT_UPGRADE_DIR "$1" + Goto bongo_cat_install + +bongo_cat_install: + SetShellVarContext current FunctionEnd]=] BONGO_CAT_NSIS_TEMPLATE) bongo_cat_replace_nsis_function(BONGO_CAT_NSIS_TEMPLATE "un.onInit" diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 7b4d6582..94e3ddde 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -39,6 +39,7 @@ set(BONGO_CAT_RUNTIME_LIFECYCLE_SOURCES src/runtime/lifecycle/runtime_state.c src/runtime/lifecycle/shutdown.c src/runtime/lifecycle/startup.c + src/runtime/lifecycle/startup_arguments.c src/runtime/lifecycle/storage_paths.c) set(BONGO_CAT_RUNTIME_DIAGNOSTIC_SOURCES @@ -177,6 +178,7 @@ set(BONGO_CAT_UI_PREFERENCES_SOURCES set(BONGO_CAT_RUNTIME_SOURCES src/core/app_state.c src/platform/common/memory.c + src/platform/common/update_shutdown.c ${BONGO_CAT_MEDIA_SOURCES} ${BONGO_CAT_RENDER_SOURCES} ${BONGO_CAT_RUNTIME_LIFECYCLE_SOURCES} diff --git a/docs/storage-layout.md b/docs/storage-layout.md index 503f582d..081bda5f 100644 --- a/docs/storage-layout.md +++ b/docs/storage-layout.md @@ -33,8 +33,11 @@ Every user-selected Tauri or Mver source is converted into an application-owned package v2 directory under `data/models/`. For portable Tauri and Mver distributions, the application also performs a -bounded scan next to the executable. These nearby sources remain externally -owned and are loaded in place; only their generated runtime adapters are stored under +bounded scan at its launch location. Windows shortcuts created by the installer +pass their own directory explicitly; other Windows launches use the process +working directory, while other platforms retain the executable directory as the +default. `--nearby-root=` overrides the location. These nearby +sources remain externally owned and are loaded in place; only their generated runtime adapters are stored under `cache/model-adapters/`. The scan is limited to three directory levels, 256 directories, and 500 ms of directory-discovery work. Unchanged sources reuse a metadata signature and cached content identity, so rescans do not rehash model diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index 91c8148a..9db62eda 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -55,6 +55,7 @@ typedef struct BongoCatApp { char storage_root[BONGO_CAT_PATH_CAP]; char asset_root[BONGO_CAT_PATH_CAP]; char locale_root[BONGO_CAT_PATH_CAP]; + char nearby_root[BONGO_CAT_PATH_CAP]; char executable_path[BONGO_CAT_PATH_CAP]; char primary_state_root[BONGO_CAT_PATH_CAP]; char primary_log_root[BONGO_CAT_PATH_CAP]; diff --git a/include/bongo_cat/platform.h b/include/bongo_cat/platform.h index 2927f77f..e5fe4fce 100644 --- a/include/bongo_cat/platform.h +++ b/include/bongo_cat/platform.h @@ -112,6 +112,8 @@ void bongo_cat_platform_set_tray_callbacks(void *tray, BongoCatTrayRestore restore, void *userdata); bool bongo_cat_platform_single_instance_begin(void); bool bongo_cat_platform_single_instance_take_wake(void); +bool bongo_cat_platform_update_shutdown_argument(int argc, char **argv); +bool bongo_cat_platform_single_instance_take_update_shutdown(void); void bongo_cat_platform_single_instance_end(void); BongoCatResult bongo_cat_platform_set_autostart(bool enabled, BongoCatError *error); BongoCatMenuAction bongo_cat_platform_context_menu(BongoCatPlatform *platform, diff --git a/src/platform/common/update_shutdown.c b/src/platform/common/update_shutdown.c new file mode 100644 index 00000000..69f9c516 --- /dev/null +++ b/src/platform/common/update_shutdown.c @@ -0,0 +1,13 @@ +#include "bongo_cat/platform.h" + +#ifndef _WIN32 +bool bongo_cat_platform_update_shutdown_argument(int argc, char **argv) { + (void)argc; + (void)argv; + return false; +} + +bool bongo_cat_platform_single_instance_take_update_shutdown(void) { + return false; +} +#endif diff --git a/src/platform/windows/windows_startup.c b/src/platform/windows/windows_startup.c index 56aeae5f..c5995205 100644 --- a/src/platform/windows/windows_startup.c +++ b/src/platform/windows/windows_startup.c @@ -12,9 +12,14 @@ static HANDLE instance_mutex; static HANDLE instance_wake_event; +static HANDLE instance_update_shutdown_event; +static HANDLE instance_stopped_event; static wchar_t instance_title[96] = BONGO_CAT_PET_WINDOW_TITLE_W; static wchar_t instance_mutex_name[128] = L"Local\\BongoCat.SingleInstance"; static wchar_t instance_wake_name[128] = L"Local\\BongoCat.WakeInstance"; +static wchar_t instance_update_shutdown_name[128] = + L"Local\\BongoCat.UpdateShutdown"; +static wchar_t instance_stopped_name[128] = L"Local\\BongoCat.InstanceStopped"; static bool identity_ready; static bool safe_identity(const char *value) { @@ -37,15 +42,28 @@ static void initialize_identity(void) { swprintf(instance_wake_name, sizeof(instance_wake_name) / sizeof(instance_wake_name[0]), L"Local\\BongoCat.WakeInstance.%hs", value); + swprintf(instance_update_shutdown_name, + sizeof(instance_update_shutdown_name) / + sizeof(instance_update_shutdown_name[0]), + L"Local\\BongoCat.UpdateShutdown.%hs", value); + swprintf(instance_stopped_name, + sizeof(instance_stopped_name) / sizeof(instance_stopped_name[0]), + L"Local\\BongoCat.InstanceStopped.%hs", value); } const wchar_t *bongo_cat_windows_instance_title(void) { initialize_identity(); return instance_title; } -static void create_wake_event(void) { +static void create_instance_events(void) { if (!instance_wake_event) instance_wake_event = CreateEventW(NULL, FALSE, FALSE, instance_wake_name); + if (!instance_update_shutdown_event) + instance_update_shutdown_event = CreateEventW(NULL, FALSE, FALSE, + instance_update_shutdown_name); + if (!instance_stopped_event) + instance_stopped_event = CreateEventW(NULL, TRUE, FALSE, + instance_stopped_name); } static void wake_existing_instance(void) { @@ -72,13 +90,13 @@ bool bongo_cat_platform_single_instance_begin(void) { instance_mutex = CreateMutexW(NULL, FALSE, instance_mutex_name); if (!instance_mutex) return true; if (GetLastError() != ERROR_ALREADY_EXISTS) { - create_wake_event(); return true; + create_instance_events(); return true; } wake_existing_instance(); CloseHandle(instance_mutex); instance_mutex = NULL; instance_mutex = CreateMutexW(NULL, FALSE, instance_mutex_name); if (instance_mutex && GetLastError() != ERROR_ALREADY_EXISTS) { - create_wake_event(); return true; + create_instance_events(); return true; } if (instance_mutex) CloseHandle(instance_mutex); instance_mutex = NULL; @@ -90,7 +108,41 @@ bool bongo_cat_platform_single_instance_take_wake(void) { WaitForSingleObject(instance_wake_event, 0) == WAIT_OBJECT_0; } +bool bongo_cat_platform_update_shutdown_argument(int argc, char **argv) { + bool requested = false; + for (int i = 1; i < argc; ++i) + if (argv && argv[i] && strcmp(argv[i], "--shutdown-for-update") == 0) + requested = true; + if (!requested) return false; + initialize_identity(); + HANDLE stopped = OpenEventW(SYNCHRONIZE, FALSE, instance_stopped_name); + HANDLE shutdown = OpenEventW(EVENT_MODIFY_STATE, FALSE, + instance_update_shutdown_name); + if (shutdown) { + SetEvent(shutdown); + CloseHandle(shutdown); + } + HWND existing = FindWindowW(NULL, instance_title); + if (existing) PostMessageW(existing, WM_CLOSE, 0, 0); + if (stopped) { + WaitForSingleObject(stopped, 15000); + CloseHandle(stopped); + } + return true; +} + +bool bongo_cat_platform_single_instance_take_update_shutdown(void) { + return instance_update_shutdown_event && + WaitForSingleObject(instance_update_shutdown_event, 0) == WAIT_OBJECT_0; +} + void bongo_cat_platform_single_instance_end(void) { + if (instance_stopped_event) SetEvent(instance_stopped_event); + if (instance_update_shutdown_event) + CloseHandle(instance_update_shutdown_event); + instance_update_shutdown_event = NULL; + if (instance_stopped_event) CloseHandle(instance_stopped_event); + instance_stopped_event = NULL; if (instance_wake_event) CloseHandle(instance_wake_event); instance_wake_event = NULL; if (instance_mutex) CloseHandle(instance_mutex); diff --git a/src/runtime/lifecycle/app.c b/src/runtime/lifecycle/app.c index 8fe6bf9a..3460fe09 100644 --- a/src/runtime/lifecycle/app.c +++ b/src/runtime/lifecycle/app.c @@ -250,6 +250,12 @@ static void take_instance_wake(BongoCatApp *app) { bongo_cat_platform_raise_window(app->window); SDL_Log("Existing instance requested window reveal"); } +static bool take_update_shutdown(BongoCatApp *app) { + if (!bongo_cat_platform_single_instance_take_update_shutdown()) return false; + app->running = false; + SDL_Log("Installed update requested application shutdown"); + return true; +} void bongo_cat_app_loop(BongoCatApp *app) { uint64_t iterations = 0, wakes = 0, zero_waits = 0; while (app->running) { @@ -269,6 +275,7 @@ void bongo_cat_app_loop(BongoCatApp *app) { bongo_cat_preferences_input_end(app->preferences); bongo_cat_model_refresh_update(app); take_instance_wake(app); + if (take_update_shutdown(app)) continue; uint64_t now = SDL_GetTicksNS(); bongo_cat_window_update_wheel_animation(app, now); bongo_cat_multi_pet_update(app, now); bongo_cat_random_expression_update(app, now); diff --git a/src/runtime/lifecycle/app_entry.c b/src/runtime/lifecycle/app_entry.c index 4b5e61ab..5cb36ea5 100644 --- a/src/runtime/lifecycle/app_entry.c +++ b/src/runtime/lifecycle/app_entry.c @@ -3,6 +3,7 @@ #include int bongo_cat_app_run(int argc, char **argv) { + if (bongo_cat_platform_update_shutdown_argument(argc, argv)) return 0; bool secondary = bongo_cat_multi_pet_secondary_argument(argc, argv); if (!secondary && !bongo_cat_platform_single_instance_begin()) return 0; BongoCatApp *app = calloc(1, sizeof(*app)); diff --git a/src/runtime/lifecycle/startup.c b/src/runtime/lifecycle/startup.c index 4cc5dbb2..2e00b046 100644 --- a/src/runtime/lifecycle/startup.c +++ b/src/runtime/lifecycle/startup.c @@ -82,90 +82,6 @@ static bool reset_log(const char *path) { return fclose(file) == 0; } -static bool store_argument(char *target, size_t capacity, const char *value, - const char *name, BongoCatError *error) { - int length = snprintf(target, capacity, "%s", value ? value : ""); - if (length >= 0 && (size_t)length < capacity) return true; - bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, - "%s path is too long", name); - return false; -} - -static bool parse_arguments(BongoCatApp *app, int argc, char **argv, - BongoCatError *error) { - for (int i = 1; i < argc; ++i) { - const char *arg = argv[i]; - if (strcmp(arg, "--autostart") == 0) app->autostart_launch = true; - else if (strcmp(arg, "--ci-smoke") == 0) app->smoke = true; - else if (strcmp(arg, "--ci-preferences") == 0) app->smoke_preferences = true; - else if (strcmp(arg, "--ci-preference-shortcut") == 0) - app->smoke_preference_shortcut = true; - else if (strcmp(arg, "--ci-preference-model-select") == 0) - app->smoke_preference_model_select = true; - else if (strcmp(arg, "--ci-remove-imported") == 0) app->smoke_remove_imported = true; - else if (strcmp(arg, "--ci-shortcuts") == 0) app->smoke_shortcuts = true; - else if (strcmp(arg, "--ci-menu") == 0) app->smoke_menu = true; - else if (strcmp(arg, "--ci-input-audit") == 0) app->smoke_input_audit = true; - else if (strcmp(arg, "--ci-ignore-global-input") == 0) app->smoke_ignore_global_input = true; - else if (strcmp(arg, "--ci-pass-through") == 0) app->smoke_pass_through = true; - else if (strcmp(arg, "--ci-context-menu") == 0) app->smoke_context_menu = true; - else if (strcmp(arg, "--ci-frame-series") == 0) app->smoke_frame_series = true; - else if (strcmp(arg, "--ci-runtime-flow") == 0) app->smoke_runtime_flow = true; - else if (strcmp(arg, "--ci-freeze-model") == 0) app->smoke_freeze_model = true; - else if (strncmp(arg, "--ci-preference-page=", 21) == 0) { - int page = atoi(arg + 21); - if (page >= 0 && page < 5) app->smoke_preference_page = page; - } else if (strncmp(arg, "--ci-language=", 14) == 0) { - BongoCatLanguage language; - if (bongo_cat_language_parse(arg + 14, &language)) - app->smoke_language = language; - } else if (strncmp(arg, "--ci-theme=", 11) == 0) { - const char *name = arg + 11; - for (int value = 0; value <= BONGO_CAT_THEME_DARK; ++value) - if (strcmp(name, bongo_cat_theme_name((BongoCatTheme)value)) == 0) - app->smoke_theme = value; - } else if (strncmp(arg, "--ci-exit-ms=", 13) == 0) { - uint64_t delay = strtoull(arg + 13, NULL, 10); - app->smoke_deadline_ns = delay > UINT64_MAX / 1000000ull - ? UINT64_MAX : delay * 1000000ull; - } else if (strncmp(arg, "--storage-root=", 15) == 0) { - if (!store_argument(app->storage_root, sizeof(app->storage_root), - arg + 15, "Storage", error)) return false; - } else if (strncmp(arg, "--secondary-pet=", 16) == 0) { - if (!store_argument(app->secondary_model_id, - sizeof(app->secondary_model_id), arg + 16, - "Secondary model", error)) return false; - app->secondary_pet = app->secondary_model_id[0] != '\0'; - } else if (strncmp(arg, "--secondary-position=", 21) == 0) { - char *separator = NULL, *end = NULL; - long x = strtol(arg + 21, &separator, 10); - long y = separator && *separator == ',' ? - strtol(separator + 1, &end, 10) : 0; - app->secondary_origin_known = separator != arg + 21 && end && - end != separator + 1 && !*end && x >= INT_MIN && x <= INT_MAX && - y >= INT_MIN && y <= INT_MAX; - if (app->secondary_origin_known) { - app->secondary_origin_x = (int)x; - app->secondary_origin_y = (int)y; - } - } else if (strncmp(arg, "--ci-import=", 12) == 0) { - if (!store_argument(app->smoke_import_path, sizeof(app->smoke_import_path), - arg + 12, "Import", error)) return false; - } else if (strncmp(arg, "--ci-model=", 11) == 0) { - if (!store_argument(app->smoke_model, sizeof(app->smoke_model), - arg + 11, "Model", error)) return false; - } else if (strncmp(arg, "--ci-viewer-trace=", 18) == 0) { - if (!store_argument(app->smoke_viewer_trace, - sizeof(app->smoke_viewer_trace), arg + 18, - "Viewer trace", error)) return false; - } else if (strncmp(arg, "--ci-live2d-scenario=", 21) == 0 && - !store_argument(app->smoke_live2d_scenario, - sizeof(app->smoke_live2d_scenario), arg + 21, "Scenario", error)) return false; - } - if (app->smoke && !app->smoke_deadline_ns) app->smoke_deadline_ns = 1500000000ull; - return true; -} - static void begin_log(BongoCatApp *app) { char previous[BONGO_CAT_PATH_CAP], stage_path[BONGO_CAT_PATH_CAP]; char stage_temporary[BONGO_CAT_PATH_CAP]; @@ -209,7 +125,7 @@ static void begin_log(BongoCatApp *app) { bool bongo_cat_startup_prepare(BongoCatApp *app, int argc, char **argv, BongoCatError *error) { - if (!parse_arguments(app, argc, argv, error) || + if (!bongo_cat_startup_arguments(app, argc, argv, error) || !bongo_cat_storage_paths_prepare(app, error)) return false; begin_log(app); bongo_cat_startup_stage(app, "paths-ready"); return true; } diff --git a/src/runtime/lifecycle/startup_arguments.c b/src/runtime/lifecycle/startup_arguments.c new file mode 100644 index 00000000..78fb539b --- /dev/null +++ b/src/runtime/lifecycle/startup_arguments.c @@ -0,0 +1,113 @@ +#include "runtime.h" +#include "bongo_cat/i18n.h" + +#include +#include +#include +#include +#include + +static bool store_argument(char *target, size_t capacity, const char *value, + const char *name, BongoCatError *error) { + int length = snprintf(target, capacity, "%s", value ? value : ""); + if (length >= 0 && (size_t)length < capacity) return true; + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, + "%s path is too long", name); + return false; +} + +static bool prepare_nearby_root(BongoCatApp *app, BongoCatError *error) { +#ifdef _WIN32 + char *current = SDL_GetCurrentDirectory(); + const char *root = current && current[0] ? current : SDL_GetBasePath(); +#else + const char *root = SDL_GetBasePath(); +#endif + bool stored = !root || store_argument(app->nearby_root, + sizeof(app->nearby_root), root, "Nearby model", error); +#ifdef _WIN32 + SDL_free(current); +#endif + return stored; +} + +bool bongo_cat_startup_arguments(BongoCatApp *app, int argc, char **argv, + BongoCatError *error) { + if (!prepare_nearby_root(app, error)) return false; + for (int i = 1; i < argc; ++i) { + const char *arg = argv[i]; + if (strcmp(arg, "--autostart") == 0) app->autostart_launch = true; + else if (strcmp(arg, "--ci-smoke") == 0) app->smoke = true; + else if (strcmp(arg, "--ci-preferences") == 0) app->smoke_preferences = true; + else if (strcmp(arg, "--ci-preference-shortcut") == 0) + app->smoke_preference_shortcut = true; + else if (strcmp(arg, "--ci-preference-model-select") == 0) + app->smoke_preference_model_select = true; + else if (strcmp(arg, "--ci-remove-imported") == 0) app->smoke_remove_imported = true; + else if (strcmp(arg, "--ci-shortcuts") == 0) app->smoke_shortcuts = true; + else if (strcmp(arg, "--ci-menu") == 0) app->smoke_menu = true; + else if (strcmp(arg, "--ci-input-audit") == 0) app->smoke_input_audit = true; + else if (strcmp(arg, "--ci-ignore-global-input") == 0) app->smoke_ignore_global_input = true; + else if (strcmp(arg, "--ci-pass-through") == 0) app->smoke_pass_through = true; + else if (strcmp(arg, "--ci-context-menu") == 0) app->smoke_context_menu = true; + else if (strcmp(arg, "--ci-frame-series") == 0) app->smoke_frame_series = true; + else if (strcmp(arg, "--ci-runtime-flow") == 0) app->smoke_runtime_flow = true; + else if (strcmp(arg, "--ci-freeze-model") == 0) app->smoke_freeze_model = true; + else if (strncmp(arg, "--ci-preference-page=", 21) == 0) { + int page = atoi(arg + 21); + if (page >= 0 && page < 5) app->smoke_preference_page = page; + } else if (strncmp(arg, "--ci-language=", 14) == 0) { + BongoCatLanguage language; + if (bongo_cat_language_parse(arg + 14, &language)) + app->smoke_language = language; + } else if (strncmp(arg, "--ci-theme=", 11) == 0) { + const char *name = arg + 11; + for (int value = 0; value <= BONGO_CAT_THEME_DARK; ++value) + if (strcmp(name, bongo_cat_theme_name((BongoCatTheme)value)) == 0) + app->smoke_theme = value; + } else if (strncmp(arg, "--ci-exit-ms=", 13) == 0) { + uint64_t delay = strtoull(arg + 13, NULL, 10); + app->smoke_deadline_ns = delay > UINT64_MAX / 1000000ull + ? UINT64_MAX : delay * 1000000ull; + } else if (strncmp(arg, "--storage-root=", 15) == 0) { + if (!store_argument(app->storage_root, sizeof(app->storage_root), + arg + 15, "Storage", error)) return false; + } else if (strncmp(arg, "--nearby-root=", 14) == 0) { + if (!store_argument(app->nearby_root, sizeof(app->nearby_root), + arg + 14, "Nearby model", error)) return false; + } else if (strncmp(arg, "--secondary-pet=", 16) == 0) { + if (!store_argument(app->secondary_model_id, + sizeof(app->secondary_model_id), arg + 16, + "Secondary model", error)) return false; + app->secondary_pet = app->secondary_model_id[0] != '\0'; + } else if (strncmp(arg, "--secondary-position=", 21) == 0) { + char *separator = NULL, *end = NULL; + long x = strtol(arg + 21, &separator, 10); + long y = separator && *separator == ',' ? + strtol(separator + 1, &end, 10) : 0; + app->secondary_origin_known = separator != arg + 21 && end && + end != separator + 1 && !*end && x >= INT_MIN && x <= INT_MAX && + y >= INT_MIN && y <= INT_MAX; + if (app->secondary_origin_known) { + app->secondary_origin_x = (int)x; + app->secondary_origin_y = (int)y; + } + } else if (strncmp(arg, "--ci-import=", 12) == 0) { + if (!store_argument(app->smoke_import_path, sizeof(app->smoke_import_path), + arg + 12, "Import", error)) return false; + } else if (strncmp(arg, "--ci-model=", 11) == 0) { + if (!store_argument(app->smoke_model, sizeof(app->smoke_model), + arg + 11, "Model", error)) return false; + } else if (strncmp(arg, "--ci-viewer-trace=", 18) == 0) { + if (!store_argument(app->smoke_viewer_trace, + sizeof(app->smoke_viewer_trace), arg + 18, + "Viewer trace", error)) return false; + } else if (strncmp(arg, "--ci-live2d-scenario=", 21) == 0 && + !store_argument(app->smoke_live2d_scenario, + sizeof(app->smoke_live2d_scenario), arg + 21, + "Scenario", error)) return false; + } + if (app->smoke && !app->smoke_deadline_ns) + app->smoke_deadline_ns = 1500000000ull; + return true; +} diff --git a/src/runtime/model/model_catalog_refresh.c b/src/runtime/model/model_catalog_refresh.c index 135743a9..50553081 100644 --- a/src/runtime/model/model_catalog_refresh.c +++ b/src/runtime/model/model_catalog_refresh.c @@ -83,9 +83,8 @@ static bool start_refresh(BongoCatApp *app) { snprintf(job->asset_root, sizeof(job->asset_root), "%s", app->asset_root); snprintf(job->data_root, sizeof(job->data_root), "%s", app->data_root); snprintf(job->cache_root, sizeof(job->cache_root), "%s", app->cache_root); - const char *base = SDL_GetBasePath(); snprintf(job->nearby_root, sizeof(job->nearby_root), "%s", - base ? base : ""); + app->nearby_root); snprintf(job->active_model_id, sizeof(job->active_model_id), "%s", app->session.active_model_id); refresh->busy = true; diff --git a/src/runtime/model/model_catalog_runtime.c b/src/runtime/model/model_catalog_runtime.c index 90392ad6..7fc83434 100644 --- a/src/runtime/model/model_catalog_runtime.c +++ b/src/runtime/model/model_catalog_runtime.c @@ -165,7 +165,7 @@ void bongo_cat_model_catalog_scan(BongoCatApp *app, bool cleanup, void bongo_cat_app_rescan_models(BongoCatApp *app) { if (!app) return; bongo_cat_model_refresh_invalidate(app); - bongo_cat_model_catalog_scan(app, true, SDL_GetBasePath()); + bongo_cat_model_catalog_scan(app, true, app->nearby_root); bongo_cat_model_catalog_finish(app); } @@ -197,7 +197,7 @@ void bongo_cat_app_refresh_nearby_models(BongoCatApp *app) { snprintf(active_model_id, sizeof(active_model_id), "%s", app->session.active_model_id); scan_owned_models(app, false); - scan_nearby_root(app, SDL_GetBasePath()); + scan_nearby_root(app, app->nearby_root); snprintf(app->session.active_model_id, sizeof(app->session.active_model_id), "%s", active_model_id); bongo_cat_model_catalog_finish(app); diff --git a/src/runtime/model/multi_pet_process.c b/src/runtime/model/multi_pet_process.c index b3917f72..1c493c38 100644 --- a/src/runtime/model/multi_pet_process.c +++ b/src/runtime/model/multi_pet_process.c @@ -120,18 +120,25 @@ static bool spawn_pet(BongoCatApp *app, SecondaryPet *pet, size_t index, uint64_t now) { char model_arg[BONGO_CAT_ID_CAP + 20]; char storage_arg[BONGO_CAT_PATH_CAP + 20]; + char nearby_arg[BONGO_CAT_PATH_CAP + 20]; char position_arg[80]; int x = app->session.window.x, y = app->session.window.y; if (app->window) SDL_GetWindowPosition(app->window, &x, &y); snprintf(model_arg, sizeof(model_arg), "--secondary-pet=%s", pet->model_id); snprintf(position_arg, sizeof(position_arg), "--secondary-position=%d,%d", x + 36 * (int)(index + 1), y + 36 * (int)(index + 1)); - const char *args[5] = {app->executable_path[0] ? app->executable_path : - BONGO_CAT_EXECUTABLE, model_arg, position_arg, NULL, NULL}; + const char *args[6] = {app->executable_path[0] ? app->executable_path : + BONGO_CAT_EXECUTABLE, model_arg, position_arg, NULL, NULL, NULL}; + size_t argument = 3; if (app->storage_root[0]) { snprintf(storage_arg, sizeof(storage_arg), "--storage-root=%s", app->storage_root); - args[3] = storage_arg; + args[argument++] = storage_arg; + } + if (app->nearby_root[0]) { + snprintf(nearby_arg, sizeof(nearby_arg), "--nearby-root=%s", + app->nearby_root); + args[argument++] = nearby_arg; } bongo_cat_multi_pet_clear_remove_request(app, pet->model_id); bongo_cat_multi_pet_clear_pass_through_request(app, pet->model_id); diff --git a/src/runtime/runtime.h b/src/runtime/runtime.h index d4155e9f..1960417a 100644 --- a/src/runtime/runtime.h +++ b/src/runtime/runtime.h @@ -21,6 +21,8 @@ void bongo_cat_import_nearby_shutdown(void); BongoCatResult bongo_cat_app_locate_assets(BongoCatApp *app, BongoCatError *error); bool bongo_cat_startup_prepare(BongoCatApp *app, int argc, char **argv, BongoCatError *error); +bool bongo_cat_startup_arguments(BongoCatApp *app, int argc, char **argv, + BongoCatError *error); void bongo_cat_startup_stage(BongoCatApp *app, const char *stage); void bongo_cat_startup_ready(BongoCatApp *app); void bongo_cat_runtime_stage(BongoCatApp *app, const char *stage); diff --git a/tests/model_import/test_mver_nearby_identity.c b/tests/model_import/test_mver_nearby_identity.c index bd74754b..88401936 100644 --- a/tests/model_import/test_mver_nearby_identity.c +++ b/tests/model_import/test_mver_nearby_identity.c @@ -16,6 +16,13 @@ static int failures; failures++; \ } } while (0) +static bool model_displayed(const BongoCatApp *app, const char *name) { + for (size_t i = 0; app && i < app->models.count; ++i) + if (strcmp(app->models.entries[i].display_name, name) == 0) + return true; + return false; +} + int test_mver_nearby_identity(void) { failures = 0; char *temporary = SDL_GetCurrentDirectory(); @@ -95,21 +102,45 @@ int test_mver_nearby_identity(void) { int test_mver_nearby_refresh(void) { failures = 0; - const char *base = SDL_GetBasePath(); char *temporary = SDL_GetCurrentDirectory(); - CHECK(base != NULL && temporary != NULL); - if (!base || !temporary) { + CHECK(temporary != NULL); + if (!temporary) { SDL_free(temporary); return failures; } + BongoCatApp *parsed = calloc(1, sizeof(*parsed)); + CHECK(parsed != NULL); + if (parsed) { + char nearby_argument[BONGO_CAT_PATH_CAP + 20]; + snprintf(nearby_argument, sizeof(nearby_argument), + "--nearby-root=%s", temporary); + char *arguments[] = {"BongoCat", nearby_argument}; + BongoCatError argument_error = {0}; + CHECK(bongo_cat_startup_arguments(parsed, 2, arguments, + &argument_error)); + CHECK(strcmp(parsed->nearby_root, temporary) == 0); + free(parsed); + } + unsigned long long nonce = (unsigned long long)SDL_GetTicksNS(); - char source[BONGO_CAT_PATH_CAP], data[BONGO_CAT_PATH_CAP]; - snprintf(source, sizeof(source), "%s/bongo-cat-nearby-refresh-%llu", - base, nonce); + char root[BONGO_CAT_PATH_CAP], data[BONGO_CAT_PATH_CAP]; + char startup_source[BONGO_CAT_PATH_CAP], sync_source[BONGO_CAT_PATH_CAP]; + char background_source[BONGO_CAT_PATH_CAP]; + snprintf(root, sizeof(root), "%s/bongo-cat-nearby-root-%llu", + temporary, nonce); snprintf(data, sizeof(data), "%s/bongo-cat-nearby-refresh-data-%llu", temporary, nonce); + CHECK(SDL_CreateDirectory(root)); CHECK(SDL_CreateDirectory(data)); + char name[96]; + snprintf(name, sizeof(name), "startup-source-%llu", nonce); + CHECK(child(startup_source, sizeof(startup_source), root, name, false)); + snprintf(name, sizeof(name), "sync-source-%llu", nonce); + CHECK(child(sync_source, sizeof(sync_source), root, name, false)); + snprintf(name, sizeof(name), "background-source-%llu", nonce); + CHECK(child(background_source, sizeof(background_source), root, name, false)); + CHECK(mver_fixture(startup_source)); BongoCatApp *app = calloc(1, sizeof(*app)); CHECK(app != NULL); @@ -121,29 +152,31 @@ int test_mver_nearby_refresh(void) { "%s/resources/assets", BONGO_CAT_NATIVE_SOURCE_DIR); snprintf(app->data_root, sizeof(app->data_root), "%s", data); snprintf(app->cache_root, sizeof(app->cache_root), "%s", data); + snprintf(app->nearby_root, sizeof(app->nearby_root), "%s", root); + bongo_cat_app_rescan_models(app); + CHECK(model_displayed(app, bongo_cat_path_name(startup_source))); + CHECK(mver_fixture(sync_source)); bongo_cat_app_refresh_nearby_models(app); - size_t before = app->models.count; - CHECK(mver_fixture(source)); + CHECK(model_displayed(app, bongo_cat_path_name(startup_source)) || + model_displayed(app, bongo_cat_path_name(sync_source))); + CHECK(mver_fixture(background_source)); bongo_cat_app_request_nearby_model_refresh(app); /* A synchronous catalog mutation invalidates the in-flight snapshot. The refresh worker must discard it, rerun, and commit only the newer scan. */ bongo_cat_app_refresh_installed_models(app); uint64_t deadline = SDL_GetTicksNS() + 5000000000ull; - while (app->models.count == before && SDL_GetTicksNS() < deadline) { + while (!model_displayed(app, bongo_cat_path_name(background_source)) && + SDL_GetTicksNS() < deadline) { bongo_cat_model_refresh_update(app); SDL_Delay(2); } - CHECK(app->models.count == before + 1); - CHECK(app->models.entries[app->models.count - 1].managed); - CHECK(strcmp(app->models.entries[app->models.count - 1].display_name, - bongo_cat_path_name(source)) == 0); - CHECK(strcmp(app->session.active_model_id, "standard") == 0); + CHECK(model_displayed(app, bongo_cat_path_name(background_source))); bongo_cat_model_refresh_shutdown(app); free(app); cleanup: - CHECK(bongo_cat_model_remove_tree(source, NULL)); + CHECK(bongo_cat_model_remove_tree(root, NULL)); CHECK(bongo_cat_model_remove_tree(data, NULL)); SDL_free(temporary); return failures; From 495dcb4cad92b86c5ec9e3f8d28a7b4a69cc13e0 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 20 Aug 2026 18:14:44 +0800 Subject: [PATCH 055/334] fix(windows): close legacy app during installer update --- cmake/Packaging.cmake | 55 +++++++++++++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/cmake/Packaging.cmake b/cmake/Packaging.cmake index af2a79a7..fd5d2881 100644 --- a/cmake/Packaging.cmake +++ b/cmake/Packaging.cmake @@ -118,7 +118,8 @@ if(WIN32) "${BONGO_CAT_NSIS_TEMPLATE}") string(REPLACE [=[ !include "MUI.nsh"]=] [=[ !include "MUI.nsh" - !include "WordFunc.nsh"]=] BONGO_CAT_NSIS_TEMPLATE + !include "WordFunc.nsh" + !include "Win\RestartManager.nsh"]=] BONGO_CAT_NSIS_TEMPLATE "${BONGO_CAT_NSIS_TEMPLATE}") string(REPLACE [=[ Var IS_DEFAULT_INSTALLDIR]=] [=[ Var IS_DEFAULT_INSTALLDIR @@ -146,17 +147,47 @@ bongo_cat_shortcuts_done: string(REPLACE [=[@CPACK_NSIS_EXTRA_PREINSTALL_COMMANDS@]=] [=[ StrCmp "$BONGO_CAT_UPGRADE_DIR" "" bongo_cat_upgrade_ready ExecWait '"$BONGO_CAT_UPGRADE_DIR\BongoCat.exe" --shutdown-for-update' - FindWindow $4 "" "BongoCat" - StrCmp $4 0 bongo_cat_uninstall_old - SendMessage $4 0x0010 0 0 /TIMEOUT=5000 - StrCpy $5 0 -bongo_cat_wait_for_shutdown: - Sleep 100 - FindWindow $4 "" "BongoCat" - StrCmp $4 0 bongo_cat_uninstall_old - IntOp $5 $5 + 1 - IntCmp $5 150 bongo_cat_upgrade_failed \ - bongo_cat_wait_for_shutdown bongo_cat_upgrade_failed + ; Versions before update shutdown support interpret WM_CLOSE as "hide to + ; tray". Find a legacy SDL tray window owned by the exact installed + ; executable and trigger its Exit entry so state is flushed normally. + StrCpy $0 0 +bongo_cat_find_legacy_tray: + System::Call 'user32::FindWindowExW(p -3, p r0, w "Message", p 0) p .r0' + StrCmp "$0" "0" bongo_cat_restart_manager_shutdown + System::Call 'user32::GetWindowThreadProcessId(p r0, *i .r6)' + System::Call 'kernel32::OpenProcess(i 0x00101000, i 0, i r6) p .r8' + StrCmp "$8" "0" bongo_cat_find_legacy_tray + StrCpy $5 1024 + System::Call 'kernel32::QueryFullProcessImageNameW(p r8, i 0, \ + w .r7, *i r5) i .r9' + StrCmp "$9" "0" bongo_cat_close_unmatched_process + StrCmp "$7" "$BONGO_CAT_UPGRADE_DIR\BongoCat.exe" 0 \ + bongo_cat_close_unmatched_process + ; Each legacy tray creation consumes eight SDL command identifiers. Try + ; the Exit slot for several shell-restoration generations. + StrCpy $3 8 +bongo_cat_signal_legacy_tray_exit: + System::Call 'user32::PostMessageW(p r0, i 0x0111, p r3, p 0)' + IntOp $3 $3 + 8 + IntCmp $3 136 bongo_cat_wait_for_legacy_shutdown \ + bongo_cat_signal_legacy_tray_exit bongo_cat_wait_for_legacy_shutdown + +bongo_cat_wait_for_legacy_shutdown: + System::Call 'kernel32::WaitForSingleObject(p r8, i 10000) i .r9' + System::Call 'kernel32::CloseHandle(p r8)' + StrCmp "$9" "0" bongo_cat_uninstall_old \ + bongo_cat_restart_manager_shutdown + +bongo_cat_close_unmatched_process: + System::Call 'kernel32::CloseHandle(p r8)' + Goto bongo_cat_find_legacy_tray + +bongo_cat_restart_manager_shutdown: + ; Tray-disabled legacy versions have no graceful remote-exit API. Windows + ; Restart Manager shuts down only processes using this exact executable. + !insertmacro RestartManager_ShutdownFile \ + "$BONGO_CAT_UPGRADE_DIR\BongoCat.exe" $9 + StrCmp "$9" "0" bongo_cat_uninstall_old bongo_cat_upgrade_failed bongo_cat_uninstall_old: IfFileExists "$BONGO_CAT_UPGRADE_DIR\Uninstall.exe" 0 \ From f963d2560e0aa8edd5703f71d1ddb644acb97051 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 20 Aug 2026 18:49:41 +0800 Subject: [PATCH 056/334] fix(windows): avoid blocking legacy installer updates --- cmake/Packaging.cmake | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/cmake/Packaging.cmake b/cmake/Packaging.cmake index fd5d2881..ad0b2ef9 100644 --- a/cmake/Packaging.cmake +++ b/cmake/Packaging.cmake @@ -123,7 +123,21 @@ if(WIN32) "${BONGO_CAT_NSIS_TEMPLATE}") string(REPLACE [=[ Var IS_DEFAULT_INSTALLDIR]=] [=[ Var IS_DEFAULT_INSTALLDIR - Var BONGO_CAT_UPGRADE_DIR]=] BONGO_CAT_NSIS_TEMPLATE + Var BONGO_CAT_UPGRADE_DIR + Var BONGO_CAT_UPDATE_SHUTDOWN]=] BONGO_CAT_NSIS_TEMPLATE + "${BONGO_CAT_NSIS_TEMPLATE}") + string(REPLACE [=[ !insertmacro MUI_PAGE_DIRECTORY]=] + [=[ !define MUI_PAGE_CUSTOMFUNCTION_PRE bongo_cat_directory_page_pre + !insertmacro MUI_PAGE_DIRECTORY]=] BONGO_CAT_NSIS_TEMPLATE + "${BONGO_CAT_NSIS_TEMPLATE}") + string(REPLACE [=[Function InstallOptionsPage]=] + [=[Function bongo_cat_directory_page_pre + StrCmp "$BONGO_CAT_UPGRADE_DIR" "" bongo_cat_directory_page_show + Abort +bongo_cat_directory_page_show: +FunctionEnd + +Function InstallOptionsPage]=] BONGO_CAT_NSIS_TEMPLATE "${BONGO_CAT_NSIS_TEMPLATE}") string(REPLACE [=[@CPACK_NSIS_CREATE_ICONS_EXTRA@]=] [=[ SetOutPath "$SMPROGRAMS\$STARTMENU_FOLDER" @@ -146,12 +160,21 @@ bongo_cat_shortcuts_done: ]=] BONGO_CAT_NSIS_TEMPLATE "${BONGO_CAT_NSIS_TEMPLATE}") string(REPLACE [=[@CPACK_NSIS_EXTRA_PREINSTALL_COMMANDS@]=] [=[ StrCmp "$BONGO_CAT_UPGRADE_DIR" "" bongo_cat_upgrade_ready - ExecWait '"$BONGO_CAT_UPGRADE_DIR\BongoCat.exe" --shutdown-for-update' + StrCmp "$BONGO_CAT_UPDATE_SHUTDOWN" "1" \ + bongo_cat_signal_update_shutdown bongo_cat_find_legacy_tray + +bongo_cat_signal_update_shutdown: + ClearErrors + ExecWait '"$BONGO_CAT_UPGRADE_DIR\BongoCat.exe" --shutdown-for-update' $3 + IfErrors bongo_cat_restart_manager_shutdown + StrCmp "$3" "0" bongo_cat_uninstall_old \ + bongo_cat_restart_manager_shutdown + ; Versions before update shutdown support interpret WM_CLOSE as "hide to ; tray". Find a legacy SDL tray window owned by the exact installed ; executable and trigger its Exit entry so state is flushed normally. - StrCpy $0 0 bongo_cat_find_legacy_tray: + StrCpy $0 0 System::Call 'user32::FindWindowExW(p -3, p r0, w "Message", p 0) p .r0' StrCmp "$0" "0" bongo_cat_restart_manager_shutdown System::Call 'user32::GetWindowThreadProcessId(p r0, *i .r6)' @@ -209,6 +232,8 @@ bongo_cat_upgrade_ready: bongo_cat_replace_nsis_function(BONGO_CAT_NSIS_TEMPLATE ".onInit" [=[Function .onInit SetShellVarContext current + StrCpy $BONGO_CAT_UPGRADE_DIR "" + StrCpy $BONGO_CAT_UPDATE_SHUTDOWN "" ReadRegStr $0 SHCTX \ "Software\Microsoft\Windows\CurrentVersion\Uninstall\BongoCat" \ @@ -251,12 +276,19 @@ bongo_cat_launch_failed: Quit bongo_cat_upgrade: + ; 0.1.0 treats an unknown update argument as a normal launch. Waiting for + ; that process would block forever when the application was not running. + ${VersionCompare} "$0" "0.1.1" $2 + StrCmp "$2" "2" bongo_cat_upgrade_paths + StrCpy $BONGO_CAT_UPDATE_SHUTDOWN "1" +bongo_cat_upgrade_paths: StrCpy $BONGO_CAT_UPGRADE_DIR "$1" StrCpy $INSTDIR "$1" Goto bongo_cat_install bongo_cat_upgrade_legacy: StrCpy $BONGO_CAT_UPGRADE_DIR "$1" + StrCpy $INSTDIR "$1" Goto bongo_cat_install bongo_cat_install: From c059c373ac7698a79a4b1611046d50e23d8169a0 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 20 Aug 2026 19:43:39 +0800 Subject: [PATCH 057/334] chore(i18n): refresh support page copy --- resources/assets/locales/zh-CN.json | 4 ++-- resources/assets/locales/zh-Hant.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index f1d4e929..3cd93b7b 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -208,8 +208,8 @@ "community": "社区", "qqGroup": "QQ 群", "coffee": "请开发团队喝杯咖啡?", - "starOnGitHub": "GitHub 加星", - "by": "作者:" + "starOnGitHub": "喜欢就点颗星吧", + "by": "by " }, "shortcut": { "toggleCat": "显示/隐藏", diff --git a/resources/assets/locales/zh-Hant.json b/resources/assets/locales/zh-Hant.json index de6aeac7..eceb503f 100644 --- a/resources/assets/locales/zh-Hant.json +++ b/resources/assets/locales/zh-Hant.json @@ -208,8 +208,8 @@ "community": "社群", "qqGroup": "QQ 群", "coffee": "請開發團隊喝杯咖啡?", - "starOnGitHub": "GitHub 加星", - "by": "作者:" + "starOnGitHub": "喜歡就點顆星吧", + "by": "by " }, "shortcut": { "toggleCat": "顯示/隱藏", From 604beb8857ebd990dbfadcbe1a8317fc71c1a496 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 20 Aug 2026 22:41:53 +0800 Subject: [PATCH 058/334] fix(windows): use modern folder picker for model imports --- cmake/PlatformRuntime.cmake | 1 + src/platform/windows/windows_dialog.c | 205 ++++++++++++++++++++++++ src/platform/windows/windows_dialog.h | 21 +++ src/ui/preferences/preferences_import.c | 9 ++ 4 files changed, 236 insertions(+) create mode 100644 src/platform/windows/windows_dialog.c create mode 100644 src/platform/windows/windows_dialog.h diff --git a/cmake/PlatformRuntime.cmake b/cmake/PlatformRuntime.cmake index 92cba602..f744cfab 100644 --- a/cmake/PlatformRuntime.cmake +++ b/cmake/PlatformRuntime.cmake @@ -8,6 +8,7 @@ if(WIN32) src/platform/windows/windows_borderless.c src/platform/windows/windows_capture.c src/platform/windows/windows_pointer.c + src/platform/windows/windows_dialog.c src/platform/windows/windows_direct_input.c src/platform/windows/windows_input.c src/platform/windows/windows_popup.c diff --git a/src/platform/windows/windows_dialog.c b/src/platform/windows/windows_dialog.c new file mode 100644 index 00000000..c8b92ffe --- /dev/null +++ b/src/platform/windows/windows_dialog.c @@ -0,0 +1,205 @@ +#include "windows_dialog.h" + +#ifdef _WIN32 + +#include +#include + +#include +#include +#include +#include + +typedef struct BongoCatWindowsFolderDialogArgs { + SDL_DialogFileCallback callback; + void *userdata; + SDL_Window *window; + char *default_location; + bool allow_many; +} BongoCatWindowsFolderDialogArgs; + +static void notify_cancel(BongoCatWindowsFolderDialogArgs *args) { + const char *files[] = {NULL}; + args->callback(args->userdata, files, -1); +} + +static char *wide_to_utf8(const wchar_t *value) { + if (!value) return NULL; + int length = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, + value, -1, NULL, 0, NULL, NULL); + if (length <= 0) return NULL; + char *result = SDL_malloc((size_t)length); + if (!result || !WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, + value, -1, result, length, NULL, NULL)) { + SDL_free(result); + return NULL; + } + return result; +} + +static HWND owner_window(SDL_Window *window) { + if (!window) return NULL; + return (HWND)SDL_GetPointerProperty(SDL_GetWindowProperties(window), + SDL_PROP_WINDOW_WIN32_HWND_POINTER, NULL); +} + +static bool show_folder_dialog(BongoCatWindowsFolderDialogArgs *args) { + IFileOpenDialog *dialog = NULL; + IShellItem *default_folder = NULL; + IShellItem *item = NULL; + IShellItemArray *items = NULL; + wchar_t *default_location = NULL; + LPWSTR path = NULL; + char **files = NULL; + bool callback_called = false; + bool com_initialized = false; + HRESULT result; + + result = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); + if (FAILED(result)) goto done; + com_initialized = true; + + result = CoCreateInstance(&CLSID_FileOpenDialog, NULL, + CLSCTX_INPROC_SERVER, &IID_IFileOpenDialog, (void **)&dialog); + if (FAILED(result)) goto done; + + FILEOPENDIALOGOPTIONS options = 0; + result = dialog->lpVtbl->GetOptions(dialog, &options); + if (FAILED(result)) goto done; + options |= FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST | + FOS_NOCHANGEDIR; + if (args->allow_many) options |= FOS_ALLOWMULTISELECT; + result = dialog->lpVtbl->SetOptions(dialog, options); + if (FAILED(result)) goto done; + + if (args->default_location && args->default_location[0]) { + int length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, + args->default_location, -1, NULL, 0); + if (length > 0) { + default_location = SDL_malloc((size_t)length * sizeof(*default_location)); + if (!default_location || !MultiByteToWideChar(CP_UTF8, + MB_ERR_INVALID_CHARS, args->default_location, -1, + default_location, length)) goto done; + result = SHCreateItemFromParsingName(default_location, NULL, + &IID_IShellItem, (void **)&default_folder); + if (SUCCEEDED(result)) { + if (FAILED(dialog->lpVtbl->SetFolder(dialog, default_folder))) { + default_folder->lpVtbl->Release(default_folder); + default_folder = NULL; + } + } + /* An invalid remembered location should not prevent opening the + * picker; Windows will use its normal last-location fallback. */ + result = S_OK; + } + } + + result = dialog->lpVtbl->Show(dialog, owner_window(args->window)); + if (result == HRESULT_FROM_WIN32(ERROR_CANCELLED)) { + notify_cancel(args); + callback_called = true; + goto done; + } + if (FAILED(result)) goto done; + + if (args->allow_many) { + DWORD count = 0; + result = dialog->lpVtbl->GetResults(dialog, &items); + if (FAILED(result) || !items || + FAILED(items->lpVtbl->GetCount(items, &count)) || count == 0) + goto done; + + files = SDL_calloc((size_t)count + 1, sizeof(*files)); + if (!files) goto done; + for (DWORD index = 0; index < count; ++index) { + IShellItem *selected = NULL; + result = items->lpVtbl->GetItemAt(items, index, &selected); + if (FAILED(result) || !selected || + FAILED(selected->lpVtbl->GetDisplayName(selected, + SIGDN_FILESYSPATH, &path))) { + if (selected) selected->lpVtbl->Release(selected); + goto done; + } + files[index] = wide_to_utf8(path); + CoTaskMemFree(path); + path = NULL; + selected->lpVtbl->Release(selected); + if (!files[index]) goto done; + } + args->callback(args->userdata, (const char *const *)files, -1); + callback_called = true; + } else { + result = dialog->lpVtbl->GetResult(dialog, &item); + if (FAILED(result) || !item || + FAILED(item->lpVtbl->GetDisplayName(item, SIGDN_FILESYSPATH, + &path))) goto done; + char *file = wide_to_utf8(path); + if (!file) goto done; + const char *selected[] = {file, NULL}; + args->callback(args->userdata, selected, -1); + callback_called = true; + SDL_free(file); + CoTaskMemFree(path); + path = NULL; + } + +done: + if (!callback_called) notify_cancel(args); + if (path) CoTaskMemFree(path); + if (files) { + for (char **file = files; *file; ++file) SDL_free(*file); + SDL_free(files); + } + if (item) item->lpVtbl->Release(item); + if (items) items->lpVtbl->Release(items); + if (default_folder) default_folder->lpVtbl->Release(default_folder); + if (dialog) dialog->lpVtbl->Release(dialog); + SDL_free(default_location); + if (com_initialized) CoUninitialize(); + return callback_called; +} + +static int SDLCALL folder_dialog_thread(void *userdata) { + BongoCatWindowsFolderDialogArgs *args = userdata; + show_folder_dialog(args); + SDL_free(args->default_location); + SDL_free(args); + return 0; +} + +void bongo_cat_windows_show_open_folder_dialog( + SDL_DialogFileCallback callback, void *userdata, SDL_Window *window, + const char *default_location, bool allow_many) { + if (!callback) return; + BongoCatWindowsFolderDialogArgs *args = SDL_calloc(1, sizeof(*args)); + if (!args) { + const char *files[] = {NULL}; + callback(userdata, files, -1); + return; + } + args->callback = callback; + args->userdata = userdata; + args->window = window; + args->allow_many = allow_many; + if (default_location) { + args->default_location = SDL_strdup(default_location); + if (!args->default_location) { + SDL_free(args); + const char *files[] = {NULL}; + callback(userdata, files, -1); + return; + } + } + SDL_Thread *thread = SDL_CreateThread(folder_dialog_thread, + "BongoCat_WindowsFolderDialog", args); + if (!thread) { + SDL_free(args->default_location); + SDL_free(args); + const char *files[] = {NULL}; + callback(userdata, files, -1); + return; + } + SDL_DetachThread(thread); +} + +#endif /* _WIN32 */ diff --git a/src/platform/windows/windows_dialog.h b/src/platform/windows/windows_dialog.h new file mode 100644 index 00000000..3b8aa5db --- /dev/null +++ b/src/platform/windows/windows_dialog.h @@ -0,0 +1,21 @@ +#ifndef BONGO_CAT_WINDOWS_DIALOG_H +#define BONGO_CAT_WINDOWS_DIALOG_H + +#ifdef _WIN32 + +#include +#include + +/* + * SDL 3.2's Windows folder-dialog backend still uses + * SHBrowseForFolderW(), which is the small, tree-only "Browse for Folder" + * dialog. Keep the same asynchronous callback contract here, but use the + * modern IFileDialog implementation instead. + */ +void bongo_cat_windows_show_open_folder_dialog( + SDL_DialogFileCallback callback, void *userdata, SDL_Window *window, + const char *default_location, bool allow_many); + +#endif /* _WIN32 */ + +#endif /* BONGO_CAT_WINDOWS_DIALOG_H */ diff --git a/src/ui/preferences/preferences_import.c b/src/ui/preferences/preferences_import.c index ae652248..e564c397 100644 --- a/src/ui/preferences/preferences_import.c +++ b/src/ui/preferences/preferences_import.c @@ -2,6 +2,10 @@ #include "preferences_state.h" #include "model_import.h" +#ifdef _WIN32 +#include "windows_dialog.h" +#endif + #include #include #include @@ -205,7 +209,12 @@ bool bongo_cat_preferences_import_open(BongoCatImportDialog *dialog, dialog->window_id = SDL_GetWindowID(window); ++dialog->references; SDL_UnlockMutex(dialog->mutex); +#ifdef _WIN32 + bongo_cat_windows_show_open_folder_dialog(import_callback, dialog, window, + NULL, true); +#else SDL_ShowOpenFolderDialog(import_callback, dialog, window, NULL, true); +#endif return true; } From 509057196ad74047e0a6a4d77805aef8a01add28 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 21 Aug 2026 15:37:07 +0800 Subject: [PATCH 059/334] Fix Tauri model input and Live2D parity --- CMakeLists.txt | 4 +- docs/parity.md | 36 +++++++++--- include/bongo_cat/model.h | 3 + include/bongo_cat/mouse.h | 2 + src/core/mouse_tracking.c | 40 +++++++++---- src/live2d/cubism_bridge.cpp | 6 ++ src/live2d/cubism_model.hpp | 14 ++++- src/live2d/cubism_model_load.cpp | 32 +++++++++- src/live2d/cubism_model_update.cpp | 56 ++++++++++++------ src/live2d/cubism_motion_playback.cpp | 3 +- src/live2d/cubism_motion_state.cpp | 13 +++-- src/live2d/cubism_parameter_overrides.cpp | 71 +++++++++++++++++++++++ src/live2d/live2d_stub.c | 5 ++ src/runtime/diagnostics/live2d_audit.c | 45 ++++++++------ src/runtime/input/mouse_mapping.c | 29 +++++---- src/runtime/model/model_files.c | 17 ++++-- tests/core/test_input.c | 17 ++++-- 17 files changed, 306 insertions(+), 87 deletions(-) create mode 100644 src/live2d/cubism_parameter_overrides.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 472f42de..28574bf8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -45,7 +45,9 @@ if(EXISTS "${CUBISM_CORE_HEADER}") set(BONGO_CAT_LIVE2D_SOURCES src/live2d/cubism_bridge.cpp src/live2d/cubism_expression.cpp src/live2d/cubism_model_load.cpp - src/live2d/cubism_model_update.cpp src/live2d/cubism_motion_playback.cpp + src/live2d/cubism_model_update.cpp + src/live2d/cubism_parameter_overrides.cpp + src/live2d/cubism_motion_playback.cpp src/live2d/cubism_motion_state.cpp src/live2d/cubism_viewer_look.cpp src/live2d/cubism_model_bounds.cpp src/live2d/cubism_model_validate.cpp) diff --git a/docs/parity.md b/docs/parity.md index 9f44b893..5b938162 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -67,12 +67,21 @@ the `physics3` authored FPS. That variant uses a `7.2727275 / 30` maximum target speed, a 0.15 second acceleration time, a pixel-scale desktop dead zone, and the Viewer's additional 1.2 input gain. Parameter scaling selects the authored positive or negative extent from the parameter's value immediately before the -additive update; all five default mappings use weight 1.0. Tracking follows -pointer movement continuously and keeps the Viewer's TargetPoint response +additive update; all five default mappings use weight 1.0. Screen coordinates +are normalized once for every source format: right is positive X, up is +positive Y, and the optional tilt channel is `-X * Y`. Tracking follows pointer +movement continuously and keeps the Viewer's TargetPoint response curve without requiring a mouse button. Primary and secondary button state remains independent for model-authored hand parameters. Viewer-default automatic Idle playback is enabled and avoids immediately repeating the same Idle motion when alternatives exist. +Application-owned keyboard, mouse-button, gamepad, and direct pointer +parameters are persistent high-priority overrides. They are applied after eye +blink, expression, look, and breath but immediately before physics, so authored +physics can consume the current input values. They are applied again after +physics and pose, immediately before the Cubism model update, so those stages +cannot erase application-owned state. Motion and preview baselines are saved +without the overrides, so input state cannot be absorbed by a later frame. Desktop tracking is model-centered by default, so look direction changes only when the pointer crosses the pet's visible center. Users can disable the setting to retain the legacy screen/work-area-centered coordinate mapping. @@ -84,8 +93,8 @@ Legacy mode retains Mver relative input. The display is selected from the pet's visible center. Negative-coordinate, stacked, and mixed-DPI layouts retain desktop coordinates, and display mode, topology, or scale changes invalidate and immediately resample the mapping. -In model-centered mode, `ParamAngleZ` receives the Mver-compatible `-X * Y` -pointer tilt when present, scaled to its authored positive or negative extent. +In model-centered mode, `ParamAngleZ` receives the shared `-X * Y` pointer tilt +when present, scaled to its authored positive or negative extent. Legacy mode does not synthesize Z. Models that author `ParamMouseX` or `ParamMouseY` receive the normalized pointer domain in addition to Cubism dragging; missing parameters remain a no-op. @@ -93,7 +102,15 @@ dragging; missing parameters remain a no-op. Mver 0.1.6 remains the compatibility baseline for package discovery, imported files, configuration, shortcuts, window composition, and projection only. A Tauri or standalone Live2D model is imported through the same adapter and then -runs through the Viewer-equivalent Cubism animation path. +runs through the Viewer-equivalent Cubism animation path and the same pointer +axis convention as an Mver package. + +Tauri-style `left-keys` and `right-keys` files are full-canvas raster overlays. +Their standard model handshake only exposes the authored hand-visibility +parameters; the package contains no bone, deformer, attachment point, or +per-key joint mapping. Limb continuity must therefore be authored into the +overlay artwork or the Live2D model itself. The runtime does not guess +model-specific joint parameters from a PNG position. Mver imports retain the source package's `l2d_correct`, `window_size`, `l2d_offset`, and horizontal-follow values in `.bongo-cat-adapter.json`. The native @@ -107,9 +124,12 @@ ratio. Pointer-driven head, body, eye, and physics parameters use Mver's configured `workarea`, or its DPI-virtualized primary-screen coordinate domain when no custom work area is enabled. -The Mver adapter translates the horizontal target sign and work-area mapping -before the values reach the shared Viewer-equivalent look stage. It does not -alter Cubism motion curves, expression blending, physics, or look timing. +The Mver adapter retains its work-area and left-handed mapping before values +reach the shared Viewer-equivalent look stage. Source left-handed mapping and +the user's mouse-mirror preference are composed exactly once for head, eye, +body, direct `ParamMouseX`, and the exact pointer overlay. Source format no +longer selects an implicit horizontal sign. The adapter does not alter Cubism +motion curves, expression blending, physics, or look timing. ## Cubism Viewer blind test diff --git a/include/bongo_cat/model.h b/include/bongo_cat/model.h index 0f59bcc3..2c14bad0 100644 --- a/include/bongo_cat/model.h +++ b/include/bongo_cat/model.h @@ -128,6 +128,9 @@ BongoCatResult bongo_cat_live2d_load(BongoCatLive2D *live2d, const char *model_d BongoCatLive2DLoadProgress progress, void *userdata, BongoCatError *error); bool bongo_cat_live2d_ready(const BongoCatLive2D *live2d); +/* Returns the authored pixel canvas size of the loaded model. */ +bool bongo_cat_live2d_canvas_size(const BongoCatLive2D *live2d, + int *width, int *height); void bongo_cat_live2d_resize(BongoCatLive2D *live2d, int width, int height); void bongo_cat_live2d_reshape(BongoCatLive2D *live2d, int width, int height); bool bongo_cat_live2d_update(BongoCatLive2D *live2d, float delta_seconds); diff --git a/include/bongo_cat/mouse.h b/include/bongo_cat/mouse.h index aed9cd2f..f1708d5b 100644 --- a/include/bongo_cat/mouse.h +++ b/include/bongo_cat/mouse.h @@ -17,6 +17,8 @@ bool bongo_cat_mouse_step(BongoCatMouseTracking *tracking, float delta_seconds, double *x, double *y); float bongo_cat_mouse_centered_ratio(double position, double center, double minimum, double maximum); +void bongo_cat_mouse_drag_coordinates(float x_ratio, float y_ratio, + bool horizontal_mirror, float *x, float *y); float bongo_cat_mouse_parameter_value(float minimum, float maximum, float x_ratio, float y_ratio, char axis, bool mirror); diff --git a/src/core/mouse_tracking.c b/src/core/mouse_tracking.c index a1668df4..2b281181 100644 --- a/src/core/mouse_tracking.c +++ b/src/core/mouse_tracking.c @@ -57,16 +57,36 @@ float bongo_cat_mouse_centered_ratio(double position, double center, return 0.5f; } +static float clamp_pointer_ratio(float value) { + if (!isfinite(value)) return 0.5f; + if (value < 0.0f) return 0.0f; + if (value > 1.0f) return 1.0f; + return value; +} + +void bongo_cat_mouse_drag_coordinates(float x_ratio, float y_ratio, + bool horizontal_mirror, float *x, float *y) { + float horizontal = clamp_pointer_ratio(x_ratio); + float vertical = clamp_pointer_ratio(y_ratio); + if (horizontal_mirror) horizontal = 1.0f - horizontal; + if (x) *x = 2.0f * horizontal - 1.0f; + if (y) *y = 1.0f - 2.0f * vertical; +} + +static float parameter_from_normalized(float minimum, float maximum, + float normalized) { + if (!isfinite(minimum) || !isfinite(maximum) || maximum < minimum) + return 0.0f; + if (minimum <= 0.0f && maximum >= 0.0f) + return normalized >= 0.0f ? normalized * maximum : + -normalized * minimum; + return minimum + (normalized + 1.0f) * 0.5f * (maximum - minimum); +} + float bongo_cat_mouse_parameter_value(float minimum, float maximum, float x_ratio, float y_ratio, char axis, bool mirror) { - float value; - if (axis == 'Z') { - float x = 1.0f - 2.0f * x_ratio; - float y = 1.0f - 2.0f * y_ratio; - value = x * y * minimum; - } else { - float ratio = axis == 'Y' ? y_ratio : x_ratio; - value = maximum - ratio * (maximum - minimum); - } - return axis != 'Y' && mirror ? -value : value; + float x = 0.0f, y = 0.0f; + bongo_cat_mouse_drag_coordinates(x_ratio, y_ratio, mirror, &x, &y); + float normalized = axis == 'Y' ? y : axis == 'Z' ? -x * y : x; + return parameter_from_normalized(minimum, maximum, normalized); } diff --git a/src/live2d/cubism_bridge.cpp b/src/live2d/cubism_bridge.cpp index 20d2df7c..f9e395ff 100644 --- a/src/live2d/cubism_bridge.cpp +++ b/src/live2d/cubism_bridge.cpp @@ -190,6 +190,12 @@ extern "C" bool bongo_cat_live2d_ready(const BongoCatLive2D *runtime) { return runtime && runtime->model; } +extern "C" bool bongo_cat_live2d_canvas_size(const BongoCatLive2D *runtime, + int *width, int *height) { + return runtime && runtime->model && + runtime->model->canvas_size(width, height); +} + extern "C" void bongo_cat_live2d_resize(BongoCatLive2D *runtime, int width, int height) { if (!runtime) return; if (width > 0 && height > 0) { diff --git a/src/live2d/cubism_model.hpp b/src/live2d/cubism_model.hpp index 40067c06..8fda001d 100644 --- a/src/live2d/cubism_model.hpp +++ b/src/live2d/cubism_model.hpp @@ -20,6 +20,7 @@ namespace bongo_cat { bool validate_model_setting_json(const std::vector &json, const char *setting_file, BongoCatError *error); class ViewerLookUpdater; +class ParameterOverrideUpdater; class NativeModel final : public Csm::CubismUserModel { public: @@ -31,6 +32,7 @@ class NativeModel final : public Csm::CubismUserModel { bool load_textures(BongoCatError *error, BongoCatLive2DLoadProgress progress, void *userdata); void release_render_resources(); + bool canvas_size(int *width, int *height) const; void resize(int width, int height); void reshape(int width, int height); bool update(float delta_seconds); @@ -73,6 +75,7 @@ class NativeModel final : public Csm::CubismUserModel { bool self_contained = false; }; private: + friend class ParameterOverrideUpdater; struct MotionRun { Csm::CubismMotionQueueEntryHandle handle = Csm::InvalidMotionQueueEntryHandleValue; @@ -108,6 +111,10 @@ class NativeModel final : public Csm::CubismUserModel { void expire_motion_runs(); void clear_motion_runs(); void expire_expression_fade(); + void capture_parameter_baseline(); + void save_parameters(); + void add_parameter_override_updater(); + void apply_parameter_overrides(); bool apply_motion_curve(const MotionStateCurve &curve, float value); bool restore_motion_defaults(const std::string &key); void select_motion(const std::string &key, bool selected); @@ -131,8 +138,9 @@ class NativeModel final : public Csm::CubismUserModel { std::vector texture_alpha_; std::vector parameter_snapshot_; std::vector part_snapshot_; - std::vector pending_parameter_values_; - std::vector pending_parameters_; + std::vector parameter_override_values_; + std::vector parameter_baseline_values_; + std::vector parameter_overrides_; std::vector motion_preview_parameters_; std::vector motion_preview_parts_; Csm::csmVector eye_blink_ids_; @@ -152,7 +160,7 @@ class NativeModel final : public Csm::CubismUserModel { bool mirror_ = false; BongoCatLive2DRenderOptions render_options_{}; bool direct_textures_ = false; - bool external_parameters_dirty_ = false; + bool parameter_overrides_applied_ = false; std::vector idle_motion_keys_; std::vector motion_runs_; ViewerLookUpdater *viewer_look_ = nullptr; diff --git a/src/live2d/cubism_model_load.cpp b/src/live2d/cubism_model_load.cpp index 2b714f22..c09f0afc 100644 --- a/src/live2d/cubism_model_load.cpp +++ b/src/live2d/cubism_model_load.cpp @@ -3,9 +3,11 @@ #include "bongo_cat/file.h" #include "bongo_cat/image.h" +#include #include #include #include +#include #include #include #include @@ -104,7 +106,7 @@ bool NativeModel::load(const char *directory, const char *setting_file, Csm::csmMap layout; setting_->GetLayoutMap(layout); _modelMatrix->SetupFromLayout(layout); - _model->SaveParameters(); + save_parameters(); if (progress) progress(userdata, .49f); return true; } @@ -120,8 +122,13 @@ bool NativeModel::load_model(BongoCatError *error) { bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, "Cubism rejected moc3: %s", name); return false; } - pending_parameter_values_.resize((size_t)_model->GetParameterCount()); - pending_parameters_.resize((size_t)_model->GetParameterCount()); + const size_t parameter_count = (size_t)_model->GetParameterCount(); + parameter_override_values_.assign(parameter_count, 0.0f); + parameter_baseline_values_.resize(parameter_count); + parameter_overrides_.assign(parameter_count, 0); + for (size_t i = 0; i < parameter_count; ++i) + parameter_baseline_values_[i] = _model->GetParameterValue((int)i); + parameter_overrides_applied_ = false; return true; } void NativeModel::load_expressions() { @@ -158,6 +165,25 @@ void NativeModel::load_effects() { } viewer_look_ = CSM_NEW ViewerLookUpdater(*_model); _updateScheduler.AddUpdatableList(viewer_look_); + _breath = Csm::CubismBreath::Create(); + if (_breath) { + Csm::csmVector parameters; + Csm::CubismIdManager *ids = Csm::CubismFramework::GetIdManager(); + parameters.PushBack(Csm::CubismBreath::BreathParameterData( + ids->GetId("ParamAngleX"), 0.0f, 15.0f, 6.5345f, 0.5f)); + parameters.PushBack(Csm::CubismBreath::BreathParameterData( + ids->GetId("ParamAngleY"), 0.0f, 8.0f, 3.5345f, 0.5f)); + parameters.PushBack(Csm::CubismBreath::BreathParameterData( + ids->GetId("ParamAngleZ"), 0.0f, 10.0f, 5.5345f, 0.5f)); + parameters.PushBack(Csm::CubismBreath::BreathParameterData( + ids->GetId("ParamBodyAngleX"), 0.0f, 4.0f, 15.5345f, 0.5f)); + parameters.PushBack(Csm::CubismBreath::BreathParameterData( + ids->GetId("ParamBreath"), 0.5f, 0.5f, 3.2345f, 0.5f)); + _breath->SetParameters(parameters); + _updateScheduler.AddUpdatableList( + CSM_NEW Csm::CubismBreathUpdater(*_breath)); + } + add_parameter_override_updater(); for (int i = 0; i < setting_->GetEyeBlinkParameterCount(); ++i) eye_blink_ids_.PushBack(setting_->GetEyeBlinkParameterId(i)); for (int i = 0; i < setting_->GetLipSyncParameterCount(); ++i) diff --git a/src/live2d/cubism_model_update.cpp b/src/live2d/cubism_model_update.cpp index c5cb3a9c..f1f5c5c1 100644 --- a/src/live2d/cubism_model_update.cpp +++ b/src/live2d/cubism_model_update.cpp @@ -11,8 +11,26 @@ #include #include #include +#include namespace bongo_cat { +bool NativeModel::canvas_size(int *width, int *height) const { + if (!_model || !width || !height) return false; + const float canvas_width = _model->GetCanvasWidthPixel(); + const float canvas_height = _model->GetCanvasHeightPixel(); + const float maximum = (float)std::numeric_limits::max(); + if (!std::isfinite(canvas_width) || !std::isfinite(canvas_height) || + canvas_width <= 0.0f || canvas_height <= 0.0f || + canvas_width > maximum || canvas_height > maximum) + return false; + const int rounded_width = (int)std::lround(canvas_width); + const int rounded_height = (int)std::lround(canvas_height); + if (rounded_width <= 0 || rounded_height <= 0) return false; + *width = rounded_width; + *height = rounded_height; + return true; +} + void NativeModel::resize(int width, int height) { if (width <= 0 || height <= 0) return; width_ = width; @@ -49,22 +67,18 @@ static bool changed(std::vector &snapshot, int count, Getter value) { bool NativeModel::update(float delta_seconds) { if (!_model || delta_seconds <= 0.0f) return false; if (delta_seconds > 0.25f) delta_seconds = 0.25f; - external_parameters_dirty_ = false; motion_updated_ = suppress_eye_blink_; _model->LoadParameters(); - for (int i = 0; i < _model->GetParameterCount(); ++i) { - if (!pending_parameters_[(size_t)i]) continue; - _model->SetParameterValue(i, pending_parameter_values_[(size_t)i]); - pending_parameters_[(size_t)i] = 0; - } + parameter_overrides_applied_ = false; if (automatic_idle_ && _motionManager->IsFinished()) start_idle_motion(); else if (!_motionManager->IsFinished()) motion_updated_ = _motionManager->UpdateMotion(_model, delta_seconds); expire_motion_runs(); - _model->SaveParameters(); + save_parameters(); _updateScheduler.OnLateUpdate(_model, delta_seconds); expire_expression_fade(); + apply_parameter_overrides(); _opacity = _model->GetModelOpacity(); _model->Update(); bool result = changed(parameter_snapshot_, _model->GetParameterCount(), @@ -128,19 +142,22 @@ void NativeModel::set_dragging(float x, float y, bool angle_z) { x = std::max(-1.0f, std::min(1.0f, x)); y = std::max(-1.0f, std::min(1.0f, y)); viewer_look_->set_target(x, y, angle_z); - external_parameters_dirty_ = true; } void NativeModel::prepare_viewer_audit() { + if (!_model) return; automatic_idle_ = false; suppress_eye_blink_ = true; _motionManager->StopAllMotions(); clear_motion_runs(); + std::fill(parameter_overrides_.begin(), parameter_overrides_.end(), 0); + std::fill(parameter_override_values_.begin(), + parameter_override_values_.end(), 0.0f); + parameter_overrides_applied_ = false; for (int i = 0; i < _model->GetParameterCount(); ++i) { _model->SetParameterValue(i, _model->GetParameterDefaultValue(i)); - pending_parameters_[(size_t)i] = 0; } - _model->SaveParameters(); + save_parameters(); } bool NativeModel::set_parameter(const char *id, float value) { @@ -148,10 +165,11 @@ bool NativeModel::set_parameter(const char *id, float value) { Csm::CubismIdHandle handle = Csm::CubismFramework::GetIdManager()->GetId(id); int index = _model->GetParameterIndex(handle); if (index < 0 || index >= _model->GetParameterCount()) return false; - _model->SetParameterValue(index, value); - pending_parameter_values_[(size_t)index] = value; - pending_parameters_[(size_t)index] = 1; - external_parameters_dirty_ = true; + parameter_override_values_[(size_t)index] = value; + parameter_overrides_[(size_t)index] = 1; + if (parameter_overrides_applied_) + _model->SetParameterValue(index, value); + else apply_parameter_overrides(); return true; } @@ -182,15 +200,19 @@ void NativeModel::start_idle_motion() { void NativeModel::capture_motion_preview() { if (!_model || motion_preview_active_) return; const int parameter_count = _model->GetParameterCount(); + const bool overrides_were_applied = parameter_overrides_applied_; std::vector current_parameters((size_t)parameter_count); for (int i = 0; i < parameter_count; ++i) current_parameters[(size_t)i] = _model->GetParameterValue(i); _model->LoadParameters(); + parameter_overrides_applied_ = false; + capture_parameter_baseline(); motion_preview_parameters_.resize((size_t)parameter_count); for (int i = 0; i < parameter_count; ++i) { motion_preview_parameters_[(size_t)i] = _model->GetParameterValue(i); _model->SetParameterValue(i, current_parameters[(size_t)i]); } + if (overrides_were_applied) apply_parameter_overrides(); const int part_count = _model->GetPartCount(); motion_preview_parts_.resize((size_t)part_count); for (int i = 0; i < part_count; ++i) @@ -203,20 +225,20 @@ void NativeModel::restore_motion_preview_state() { if (!_model || !motion_preview_active_) return; _motionManager->StopAllMotions(); clear_motion_runs(); + parameter_overrides_applied_ = false; const int parameter_count = _model->GetParameterCount(); for (int i = 0; i < parameter_count && (size_t)i < motion_preview_parameters_.size(); ++i) { _model->SetParameterValue(i, motion_preview_parameters_[(size_t)i]); - pending_parameters_[(size_t)i] = 0; } - _model->SaveParameters(); + save_parameters(); const int part_count = _model->GetPartCount(); for (int i = 0; i < part_count && (size_t)i < motion_preview_parts_.size(); ++i) _model->SetPartOpacity(i, motion_preview_parts_[(size_t)i]); _model->SetModelOpacity(motion_preview_opacity_); _opacity = motion_preview_opacity_; - external_parameters_dirty_ = true; + apply_parameter_overrides(); } bool NativeModel::preview_motion(const char *group, int index) { diff --git a/src/live2d/cubism_motion_playback.cpp b/src/live2d/cubism_motion_playback.cpp index 70959f75..de77bc6d 100644 --- a/src/live2d/cubism_motion_playback.cpp +++ b/src/live2d/cubism_motion_playback.cpp @@ -99,8 +99,7 @@ void NativeModel::expire_motion_runs() { replacement_running)) select_motion(run.key, false); } if (restored_persistent_state) { - _model->SaveParameters(); - external_parameters_dirty_ = true; + save_parameters(); } size_t output = 0; for (size_t i = 0; i < motion_runs_.size(); ++i) diff --git a/src/live2d/cubism_motion_state.cpp b/src/live2d/cubism_motion_state.cpp index 0d888c76..b8f54a19 100644 --- a/src/live2d/cubism_motion_state.cpp +++ b/src/live2d/cubism_motion_state.cpp @@ -234,8 +234,7 @@ bool NativeModel::restore_motion_defaults(const std::string &key) { restored = apply_motion_curve(curve, curve.normal) || restored; } if (!restored) return had_run; - _model->SaveParameters(); - external_parameters_dirty_ = true; + save_parameters(); return true; } @@ -244,8 +243,11 @@ bool NativeModel::apply_motion_curve(const MotionStateCurve &curve, if (!_model) return false; if (curve.parameter >= 0 && curve.parameter < _model->GetParameterCount()) { - _model->SetParameterValue(curve.parameter, value); - pending_parameters_[(size_t)curve.parameter] = 0; + const size_t index = (size_t)curve.parameter; + parameter_baseline_values_[index] = value; + float displayed = parameter_overrides_applied_ && + parameter_overrides_[index] ? parameter_override_values_[index] : value; + _model->SetParameterValue(curve.parameter, displayed); return true; } if (curve.part >= 0 && curve.part < _model->GetPartCount()) { @@ -275,8 +277,7 @@ bool NativeModel::restore_motion_state(const char *group, int index) { restored = apply_motion_curve(curve, curve.end) || restored; if (!restored) return false; select_motion(canonical, true); - _model->SaveParameters(); - external_parameters_dirty_ = true; + save_parameters(); return true; } diff --git a/src/live2d/cubism_parameter_overrides.cpp b/src/live2d/cubism_parameter_overrides.cpp new file mode 100644 index 00000000..1d8964c9 --- /dev/null +++ b/src/live2d/cubism_parameter_overrides.cpp @@ -0,0 +1,71 @@ +#include "cubism_model.hpp" + +#include +#include + +namespace bongo_cat { + +/* External input has to be visible to CubismPhysics, but must remain higher + priority than physics and pose at frame end. Running once immediately + before Physics and once after the scheduler provides both guarantees. */ +class ParameterOverrideUpdater final : public Csm::ICubismUpdater { +public: + explicit ParameterOverrideUpdater(NativeModel &owner) + : Csm::ICubismUpdater(Csm::CubismUpdateOrder_Physics - 1), + owner_(owner) {} + + void OnLateUpdate(Csm::CubismModel *model, + Csm::csmFloat32 delta_seconds) override { + (void)model; + (void)delta_seconds; + owner_.apply_parameter_overrides(); + } + +private: + NativeModel &owner_; +}; + +void NativeModel::add_parameter_override_updater() { + _updateScheduler.AddUpdatableList( + CSM_NEW ParameterOverrideUpdater(*this)); +} + +void NativeModel::capture_parameter_baseline() { + if (!_model) return; + const int count = _model->GetParameterCount(); + parameter_baseline_values_.resize((size_t)count); + for (int i = 0; i < count; ++i) + parameter_baseline_values_[(size_t)i] = _model->GetParameterValue(i); +} + +void NativeModel::save_parameters() { + if (!_model) return; + if (!parameter_overrides_applied_) { + capture_parameter_baseline(); + _model->SaveParameters(); + return; + } + /* Cubism's saved state is the motion baseline. Never let frame-end input + overrides become the starting values for a later motion frame. */ + const int count = _model->GetParameterCount(); + std::vector current((size_t)count); + for (int i = 0; i < count; ++i) { + current[(size_t)i] = _model->GetParameterValue(i); + _model->SetParameterValue(i, parameter_baseline_values_[(size_t)i]); + } + _model->SaveParameters(); + for (int i = 0; i < count; ++i) + _model->SetParameterValue(i, current[(size_t)i]); +} + +void NativeModel::apply_parameter_overrides() { + if (!_model) return; + const int count = _model->GetParameterCount(); + for (int i = 0; i < count; ++i) { + if (!parameter_overrides_[(size_t)i]) continue; + _model->SetParameterValue(i, parameter_override_values_[(size_t)i]); + } + parameter_overrides_applied_ = true; +} + +} // namespace bongo_cat diff --git a/src/live2d/live2d_stub.c b/src/live2d/live2d_stub.c index 27fe71a3..8179c240 100644 --- a/src/live2d/live2d_stub.c +++ b/src/live2d/live2d_stub.c @@ -33,6 +33,11 @@ bool bongo_cat_live2d_ready(const BongoCatLive2D *live2d) { return live2d && live2d->loaded; } +bool bongo_cat_live2d_canvas_size(const BongoCatLive2D *live2d, + int *width, int *height) { + (void)live2d; (void)width; (void)height; return false; +} + void bongo_cat_live2d_resize(BongoCatLive2D *live2d, int width, int height) { if (!live2d) return; live2d->width = width; diff --git a/src/runtime/diagnostics/live2d_audit.c b/src/runtime/diagnostics/live2d_audit.c index 6b938419..d1267f2a 100644 --- a/src/runtime/diagnostics/live2d_audit.c +++ b/src/runtime/diagnostics/live2d_audit.c @@ -8,7 +8,6 @@ typedef struct PointerAudit { bool ran; - bool mver; bool has_mouse; float angle_x[4]; float angle_y[4]; @@ -77,8 +76,7 @@ static bool reverse_pointer(BongoCatApp *app) { SDL_Rect bounds; double center_x, center_y; if (!pointer_test_center(app, &bounds, ¢er_x, ¢er_y)) return false; - pointer_audit = (PointerAudit){.ran = true, - .mver = app->model_render_options.mver_projection}; + pointer_audit = (PointerAudit){.ran = true}; app->settings.model.mouse_mirror = false; app->left_mouse_down = false; const float ratios[4][2] = { @@ -184,6 +182,19 @@ static bool active(BongoCatApp *app, const char *id) { return value(app, id, ¤t) && current > 0.5f; } +static bool inactive(BongoCatApp *app, const char *id) { + float current = 0.0f; + return value(app, id, ¤t) && current <= 0.5f; +} + +static bool parameter_override_scenario(const char *scenario) { + return strncmp(scenario, "key-", 4) == 0 || + strncmp(scenario, "keys-", 5) == 0 || + strncmp(scenario, "gamepad-", 8) == 0 || + strcmp(scenario, "mouse-left") == 0 || + strcmp(scenario, "mouse-right") == 0; +} + static bool signed_value(BongoCatApp *app, const char *id, bool positive) { float current = 0.0f; return value(app, id, ¤t) && (positive ? current > 0.05f : current < -0.05f); @@ -196,9 +207,6 @@ static bool assertions(BongoCatApp *app, const char *scenario, bool operation) { if (strcmp(scenario, "idle") == 0 || strcmp(scenario, "mouse-screen") == 0 || strcmp(scenario, "mouse-hand-screen") == 0 || strcmp(scenario, "mirror") == 0 || strcmp(scenario, "visual-consistency") == 0 || strcmp(scenario, "viewer-sequence") == 0 || - strcmp(scenario, "key-left-release") == 0 || - strcmp(scenario, "keys-both-release") == 0 || - strcmp(scenario, "key-stress") == 0 || strncmp(scenario, "motion-", 7) == 0) return true; if (strncmp(scenario, "expression-", 11) == 0) return bongo_cat_live2d_expression(app->live2d) == atoi(scenario + 11); @@ -210,6 +218,12 @@ static bool assertions(BongoCatApp *app, const char *scenario, bool operation) { strcmp(scenario, "gamepad-buttons") == 0) return active(app, "CatParamLeftHandDown") && active(app, "CatParamRightHandDown"); + if (strcmp(scenario, "key-left-release") == 0) + return inactive(app, "CatParamLeftHandDown"); + if (strcmp(scenario, "keys-both-release") == 0 || + strcmp(scenario, "key-stress") == 0) + return inactive(app, "CatParamLeftHandDown") && + inactive(app, "CatParamRightHandDown"); if (strcmp(scenario, "mouse-left") == 0) return active(app, "ParamMouseLeftDown"); if (strcmp(scenario, "mouse-right") == 0) @@ -217,25 +231,19 @@ static bool assertions(BongoCatApp *app, const char *scenario, bool operation) { if (strcmp(scenario, "mouse-move") == 0) { float angle_z = 0.0f; bool z_valid = !value(app, "ParamAngleZ", &angle_z) || - (app->model_render_options.mver_projection ? angle_z < -0.05f : - angle_z > 0.05f); - return signed_value(app, "ParamAngleX", - app->model_render_options.mver_projection) && + angle_z < -0.05f; + return signed_value(app, "ParamAngleX", true) && signed_value(app, "ParamAngleY", true) && z_valid; } if (strcmp(scenario, "mouse-move-mirror") == 0) - return signed_value(app, "ParamAngleX", true) && + return signed_value(app, "ParamAngleX", false) && signed_value(app, "ParamAngleY", true); if (strcmp(scenario, "mouse-reverse") == 0) { - bool horizontal = pointer_audit.mver ? + bool horizontal = pointer_audit.angle_x[0] < -5.0f && pointer_audit.angle_x[1] > 5.0f && pointer_audit.angle_x[2] < -5.0f && - pointer_audit.angle_x[3] > 5.0f : - pointer_audit.angle_x[0] > 5.0f && - pointer_audit.angle_x[1] < -5.0f && - pointer_audit.angle_x[2] > 5.0f && - pointer_audit.angle_x[3] < -5.0f; + pointer_audit.angle_x[3] > 5.0f; bool direction = pointer_audit.ran && horizontal && pointer_audit.angle_y[0] > 5.0f && pointer_audit.angle_y[1] > 5.0f && @@ -258,6 +266,9 @@ void bongo_cat_live2d_audit_run(BongoCatApp *app) { if (!app || !app->smoke_live2d_scenario[0]) return; uint64_t started = SDL_GetTicksNS(); bool result = apply(app, app->smoke_live2d_scenario); + if (result && parameter_override_scenario(app->smoke_live2d_scenario)) + for (int frame = 0; frame < 4; ++frame) + bongo_cat_app_step_live2d(app, 1.0f / 60.0f); double duration_ms = (SDL_GetTicksNS() - started) / 1000000.0; char path[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(path, sizeof(path), app->state_root, diff --git a/src/runtime/input/mouse_mapping.c b/src/runtime/input/mouse_mapping.c index 33717c42..bc3ec3ab 100644 --- a/src/runtime/input/mouse_mapping.c +++ b/src/runtime/input/mouse_mapping.c @@ -10,13 +10,13 @@ #endif static void set_parameter(BongoCatApp *app, const char *id, - float x_ratio, float y_ratio) { + float x_ratio, float y_ratio, bool horizontal_mirror) { BongoCatParameterRange range; if (!bongo_cat_live2d_parameter(app->live2d, id, &range)) return; size_t length = strlen(id); char axis = length ? id[length - 1] : 'X'; float value = bongo_cat_mouse_parameter_value(range.minimum, range.maximum, - x_ratio, y_ratio, axis, app->settings.model.mouse_mirror); + x_ratio, y_ratio, axis, horizontal_mirror); bongo_cat_live2d_set_parameter(app->live2d, id, value); } @@ -147,19 +147,24 @@ void bongo_cat_app_apply_mouse_coordinates(BongoCatApp *app, double hand_x, bounds.y, bounds.y + bounds.h); } bool exact_pointer = bongo_cat_overlay_mver_pointer_enabled(app->overlay); - bool mver = app->model_render_options.mver_projection; + bool overlay_left_handed = exact_pointer && + bongo_cat_overlay_mver_pointer_left_handed(app->overlay); bool left_handed = app->model_render_options.pointer_left_handed || - (exact_pointer && bongo_cat_overlay_mver_pointer_left_handed(app->overlay)); - float mver_x = left_handed ? 1.0f - gaze_x_ratio : gaze_x_ratio; - float drag_x = mver ? 2.0f * mver_x - 1.0f : - 1.0f - 2.0f * gaze_x_ratio; - float drag_y = 1.0f - 2.0f * gaze_y_ratio; - if (!mver && app->settings.model.mouse_mirror) drag_x = -drag_x; - bongo_cat_overlay_set_mver_pointer(app->overlay, hand_x_ratio, hand_y_ratio, + overlay_left_handed; + bool horizontal_mirror = left_handed != app->settings.model.mouse_mirror; + float drag_x = 0.0f, drag_y = 0.0f; + bongo_cat_mouse_drag_coordinates(gaze_x_ratio, gaze_y_ratio, + horizontal_mirror, &drag_x, &drag_y); + bool overlay_mirror = horizontal_mirror != overlay_left_handed; + float overlay_x_ratio = overlay_mirror + ? 1.0f - hand_x_ratio : hand_x_ratio; + bongo_cat_overlay_set_mver_pointer(app->overlay, overlay_x_ratio, hand_y_ratio, app->left_mouse_down, app->right_mouse_down, app->side_mouse_down); if (!exact_pointer) { - set_parameter(app, "ParamMouseX", 1.0f - hand_x_ratio, hand_y_ratio); - set_parameter(app, "ParamMouseY", hand_x_ratio, hand_y_ratio); + set_parameter(app, "ParamMouseX", hand_x_ratio, hand_y_ratio, + horizontal_mirror); + set_parameter(app, "ParamMouseY", hand_x_ratio, hand_y_ratio, + horizontal_mirror); } /* Pointer and window messages are not atomic during a native window move. */ if (!app->settings.model.mouse_centered || !app->window_drag_active) { diff --git a/src/runtime/model/model_files.c b/src/runtime/model/model_files.c index 69283029..81d16968 100644 --- a/src/runtime/model/model_files.c +++ b/src/runtime/model/model_files.c @@ -118,10 +118,19 @@ static void model_load_progress(void *userdata, float progress) { static bool apply_model_aspect(BongoCatApp *app, const BongoCatLive2DRenderOptions *options) { if (!app || !app->window) return false; - int reference_width = options && options->mver_projection - ? options->reference_width : 612; - int reference_height = options && options->mver_projection - ? options->reference_height : 354; + int reference_width = 612; + int reference_height = 354; + if (options && options->mver_projection) { + reference_width = options->reference_width; + reference_height = options->reference_height; + } else { + int canvas_width = 0, canvas_height = 0; + if (bongo_cat_live2d_canvas_size(app->live2d, + &canvas_width, &canvas_height)) { + reference_width = canvas_width; + reference_height = canvas_height; + } + } int x, y, width, height; if (reference_width <= 0 || reference_height <= 0 || !SDL_GetWindowPosition(app->window, &x, &y) || diff --git a/tests/core/test_input.c b/tests/core/test_input.c index 6da9b0f6..d3bf924b 100644 --- a/tests/core/test_input.c +++ b/tests/core/test_input.c @@ -118,16 +118,25 @@ void test_input(void) { CHECK(bongo_cat_mouse_step(&tracking, 1.0f / 60.0f, &x, &y)); CHECK(tracking.settled && x == 100.0 && y == 40.0); CHECK(!bongo_cat_mouse_step(&tracking, 1.0f / 60.0f, &x, &y)); + float drag_x = 0.0f, drag_y = 0.0f; + bongo_cat_mouse_drag_coordinates(0.0f, 0.0f, false, &drag_x, &drag_y); + CHECK(drag_x == -1.0f && drag_y == 1.0f); + bongo_cat_mouse_drag_coordinates(1.0f, 1.0f, false, &drag_x, &drag_y); + CHECK(drag_x == 1.0f && drag_y == -1.0f); + bongo_cat_mouse_drag_coordinates(1.0f, 0.0f, true, &drag_x, &drag_y); + CHECK(drag_x == -1.0f && drag_y == 1.0f); CHECK(bongo_cat_mouse_parameter_value(-30.0f, 30.0f, - 0.0f, 0.5f, 'X', false) == 30.0f); + 0.0f, 0.5f, 'X', false) == -30.0f); CHECK(bongo_cat_mouse_parameter_value(-30.0f, 30.0f, - 1.0f, 0.5f, 'X', false) == -30.0f); + 1.0f, 0.5f, 'X', false) == 30.0f); CHECK(bongo_cat_mouse_parameter_value(-30.0f, 30.0f, - 0.0f, 0.5f, 'X', true) == -30.0f); + 0.0f, 0.5f, 'X', true) == 30.0f); CHECK(bongo_cat_mouse_parameter_value(-30.0f, 30.0f, 0.5f, 0.0f, 'Y', true) == 30.0f); CHECK(bongo_cat_mouse_parameter_value(-30.0f, 30.0f, - 0.0f, 0.0f, 'Z', false) == -30.0f); + 0.0f, 0.0f, 'Z', false) == 30.0f); + CHECK(bongo_cat_mouse_parameter_value(-10.0f, 30.0f, + 0.5f, 0.5f, 'X', false) == 0.0f); CHECK(bongo_cat_mouse_centered_ratio(80.0, 80.0, 0.0, 100.0) == 0.5f); CHECK(bongo_cat_mouse_centered_ratio(0.0, 80.0, 0.0, 100.0) == 0.0f); CHECK(bongo_cat_mouse_centered_ratio(100.0, 80.0, 0.0, 100.0) == 1.0f); From 6289733691c07c883dd931e60f956b80ca8f2dfc Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 21 Aug 2026 15:52:31 +0800 Subject: [PATCH 060/334] Fix Live2D source line limit --- src/live2d/cubism_model_bounds.cpp | 18 ++++++++++++++++++ src/live2d/cubism_model_update.cpp | 18 ------------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/live2d/cubism_model_bounds.cpp b/src/live2d/cubism_model_bounds.cpp index 79379f6e..1a965c22 100644 --- a/src/live2d/cubism_model_bounds.cpp +++ b/src/live2d/cubism_model_bounds.cpp @@ -3,9 +3,27 @@ #include #include #include +#include namespace bongo_cat { +bool NativeModel::canvas_size(int *width, int *height) const { + if (!_model || !width || !height) return false; + const float canvas_width = _model->GetCanvasWidthPixel(); + const float canvas_height = _model->GetCanvasHeightPixel(); + const float maximum = (float)std::numeric_limits::max(); + if (!std::isfinite(canvas_width) || !std::isfinite(canvas_height) || + canvas_width <= 0.0f || canvas_height <= 0.0f || + canvas_width > maximum || canvas_height > maximum) + return false; + const int rounded_width = (int)std::lround(canvas_width); + const int rounded_height = (int)std::lround(canvas_height); + if (rounded_width <= 0 || rounded_height <= 0) return false; + *width = rounded_width; + *height = rounded_height; + return true; +} + NativeModel::ModelBounds NativeModel::capture_visible_bounds() const { ModelBounds bounds; if (!_model) return bounds; diff --git a/src/live2d/cubism_model_update.cpp b/src/live2d/cubism_model_update.cpp index f1f5c5c1..e18da592 100644 --- a/src/live2d/cubism_model_update.cpp +++ b/src/live2d/cubism_model_update.cpp @@ -11,26 +11,8 @@ #include #include #include -#include namespace bongo_cat { -bool NativeModel::canvas_size(int *width, int *height) const { - if (!_model || !width || !height) return false; - const float canvas_width = _model->GetCanvasWidthPixel(); - const float canvas_height = _model->GetCanvasHeightPixel(); - const float maximum = (float)std::numeric_limits::max(); - if (!std::isfinite(canvas_width) || !std::isfinite(canvas_height) || - canvas_width <= 0.0f || canvas_height <= 0.0f || - canvas_width > maximum || canvas_height > maximum) - return false; - const int rounded_width = (int)std::lround(canvas_width); - const int rounded_height = (int)std::lround(canvas_height); - if (rounded_width <= 0 || rounded_height <= 0) return false; - *width = rounded_width; - *height = rounded_height; - return true; -} - void NativeModel::resize(int width, int height) { if (width <= 0 || height <= 0) return; width_ = width; From 78ee128fcdce3d51c011ed1bd6d86e95b75fa731 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 21 Aug 2026 17:36:00 +0800 Subject: [PATCH 061/334] Add more models link to preferences --- cmake/RuntimeSources.cmake | 1 + src/ui/preferences/preferences_model.c | 6 +- src/ui/preferences/preferences_model_card.h | 2 + .../preferences/preferences_model_section.c | 57 +++++++++++++++++++ 4 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 src/ui/preferences/preferences_model_section.c diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 94e3ddde..bf0b7512 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -157,6 +157,7 @@ set(BONGO_CAT_UI_PREFERENCES_SOURCES src/ui/preferences/preferences_model_card.c src/ui/preferences/preferences_model_card_paint.c src/ui/preferences/preferences_model_cover.c + src/ui/preferences/preferences_model_section.c src/ui/preferences/preferences_model_glyphs.c src/ui/preferences/preferences_model_rename.c src/ui/preferences/preferences_notice.c diff --git a/src/ui/preferences/preferences_model.c b/src/ui/preferences/preferences_model.c index 1acd150e..4dbac0ba 100644 --- a/src/ui/preferences/preferences_model.c +++ b/src/ui/preferences/preferences_model.c @@ -246,8 +246,10 @@ void bongo_cat_preferences_page_model(BongoCatPreferences *value, "native.multiplePets", "Display multiple"), "", &multiple)) bongo_cat_app_set_multiple_pets(app, multiple); - bongo_cat_pref_section(context, - tr(app, "pages.preference.model.title", "Installed models")); + if (bongo_cat_preferences_model_section(value, context) && + !SDL_OpenURL("https://bongocat.pet/models")) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Cannot open model library: %s", SDL_GetError()); float width = nk_window_get_content_region(context).w; int columns = width >= 780 ? 4 : width >= 620 ? 3 : width >= 400 ? 2 : 1; struct nk_vec2 old_spacing = context->style.window.spacing; diff --git a/src/ui/preferences/preferences_model_card.h b/src/ui/preferences/preferences_model_card.h index 53c559a8..50bb6e6b 100644 --- a/src/ui/preferences/preferences_model_card.h +++ b/src/ui/preferences/preferences_model_card.h @@ -8,6 +8,8 @@ struct nk_context; bool bongo_cat_preferences_model_import_card(BongoCatPreferences *value, struct nk_context *context); +bool bongo_cat_preferences_model_section(BongoCatPreferences *value, + struct nk_context *context); void bongo_cat_preferences_model_card(BongoCatPreferences *value, struct nk_context *context, const BongoCatModelEntry *entry); void bongo_cat_preferences_model_select(BongoCatPreferences *value, diff --git a/src/ui/preferences/preferences_model_section.c b/src/ui/preferences/preferences_model_section.c new file mode 100644 index 00000000..82168209 --- /dev/null +++ b/src/ui/preferences/preferences_model_section.c @@ -0,0 +1,57 @@ +#include "preferences_model_card.h" +#include "preferences_state.h" +#include "ui_backend.h" +#include "ui_catime.h" +#include "bongo_cat/i18n.h" + +static const char *tr(BongoCatPreferences *value, const char *key, + const char *fallback) { + return bongo_cat_i18n_get(value->app->i18n, key, fallback); +} + +bool bongo_cat_preferences_model_section(BongoCatPreferences *value, + struct nk_context *context) { + struct nk_rect bounds; + nk_layout_row_dynamic(context, 22, 1); + if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return false; + BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); + struct nk_command_buffer *canvas = nk_window_get_canvas(context); + const struct nk_user_font *title_font = bongo_cat_ui_label_font(context); + const struct nk_user_font *action_font = bongo_cat_ui_caption_font(context); + const char *title = tr(value, + "pages.preference.model.title", "Installed models"); + float title_x = bounds.x + 14; + nk_fill_rect(canvas, nk_rect(bounds.x, bounds.y + 2, 4, 18), 2, p.pink); + float title_width = title_font->width(title_font->userdata, + title_font->height, title, nk_strlen(title)); + nk_draw_text(canvas, nk_rect(title_x, + bounds.y + (bounds.h - title_font->height) * .5f, + title_width + 1, title_font->height), title, nk_strlen(title), + title_font, nk_rgba(0, 0, 0, 0), p.text); + + const char *label = tr(value, + "pages.preference.model.tooltips.moreModels", "More Models"); + float label_width = action_font->width(action_font->userdata, + action_font->height, label, nk_strlen(label)); + float action_width = label_width + 15; + float action_x = bounds.x + bounds.w - action_width; + if (action_x < title_x + title_width + 12) return false; + struct nk_rect action = nk_rect(action_x, + bounds.y, action_width, bounds.h); + bool action_hover = nk_input_is_mouse_hovering_rect(&context->input, action); + struct nk_color action_color = action_hover ? p.pink : p.accent; + nk_draw_text(canvas, nk_rect(action.x, action.y + + (action.h - action_font->height) * .5f, label_width + 1, + action_font->height), label, nk_strlen(label), action_font, + nk_rgba(0, 0, 0, 0), action_color); + float arrow_x = action.x + action.w - 5; + float arrow_y = action.y + action.h * .5f; + nk_stroke_line(canvas, arrow_x - 4, arrow_y - 4, + arrow_x, arrow_y, 1.5f, action_color); + nk_stroke_line(canvas, arrow_x, arrow_y, + arrow_x - 4, arrow_y + 4, 1.5f, action_color); + if (action_hover) bongo_cat_ui_cursor_hover_rect(context, action, + BONGO_CAT_UI_CURSOR_POINTER); + return action_hover && nk_input_is_mouse_click_in_rect(&context->input, + NK_BUTTON_LEFT, action); +} From 3598168f63d73b662ae1d9d9bce3819687e431a6 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 21 Aug 2026 17:48:22 +0800 Subject: [PATCH 062/334] Use double chevron for more models link --- src/ui/preferences/preferences_model_section.c | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/ui/preferences/preferences_model_section.c b/src/ui/preferences/preferences_model_section.c index 82168209..8f30740c 100644 --- a/src/ui/preferences/preferences_model_section.c +++ b/src/ui/preferences/preferences_model_section.c @@ -33,7 +33,7 @@ bool bongo_cat_preferences_model_section(BongoCatPreferences *value, "pages.preference.model.tooltips.moreModels", "More Models"); float label_width = action_font->width(action_font->userdata, action_font->height, label, nk_strlen(label)); - float action_width = label_width + 15; + float action_width = label_width + 22; float action_x = bounds.x + bounds.w - action_width; if (action_x < title_x + title_width + 12) return false; struct nk_rect action = nk_rect(action_x, @@ -44,12 +44,17 @@ bool bongo_cat_preferences_model_section(BongoCatPreferences *value, (action.h - action_font->height) * .5f, label_width + 1, action_font->height), label, nk_strlen(label), action_font, nk_rgba(0, 0, 0, 0), action_color); - float arrow_x = action.x + action.w - 5; float arrow_y = action.y + action.h * .5f; - nk_stroke_line(canvas, arrow_x - 4, arrow_y - 4, - arrow_x, arrow_y, 1.5f, action_color); - nk_stroke_line(canvas, arrow_x, arrow_y, - arrow_x - 4, arrow_y + 4, 1.5f, action_color); + float first_arrow_x = action.x + label_width + 11; + float second_arrow_x = action.x + label_width + 17; + nk_stroke_line(canvas, first_arrow_x - 4, arrow_y - 4, + first_arrow_x, arrow_y, 1.5f, action_color); + nk_stroke_line(canvas, first_arrow_x, arrow_y, + first_arrow_x - 4, arrow_y + 4, 1.5f, action_color); + nk_stroke_line(canvas, second_arrow_x - 4, arrow_y - 4, + second_arrow_x, arrow_y, 1.5f, action_color); + nk_stroke_line(canvas, second_arrow_x, arrow_y, + second_arrow_x - 4, arrow_y + 4, 1.5f, action_color); if (action_hover) bongo_cat_ui_cursor_hover_rect(context, action, BONGO_CAT_UI_CURSOR_POINTER); return action_hover && nk_input_is_mouse_click_in_rect(&context->input, From 5ffc10d29854b207afec5e62033444de499abf53 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 21 Aug 2026 20:12:18 +0800 Subject: [PATCH 063/334] Match more models hover animation --- .../preferences/preferences_model_section.c | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/ui/preferences/preferences_model_section.c b/src/ui/preferences/preferences_model_section.c index 8f30740c..0590cee9 100644 --- a/src/ui/preferences/preferences_model_section.c +++ b/src/ui/preferences/preferences_model_section.c @@ -1,7 +1,9 @@ #include "preferences_model_card.h" #include "preferences_state.h" +#include "ui_animation.h" #include "ui_backend.h" #include "ui_catime.h" +#include "ui_paint.h" #include "bongo_cat/i18n.h" static const char *tr(BongoCatPreferences *value, const char *key, @@ -39,18 +41,25 @@ bool bongo_cat_preferences_model_section(BongoCatPreferences *value, struct nk_rect action = nk_rect(action_x, bounds.y, action_width, bounds.h); bool action_hover = nk_input_is_mouse_hovering_rect(&context->input, action); - struct nk_color action_color = action_hover ? p.pink : p.accent; + float hover_amount = bongo_cat_ui_animate_eased(context, + "model-more-link-hover", action_hover ? 1.0f : 0.0f, 180.0f, + BONGO_CAT_UI_EASE_STANDARD); + struct nk_color action_color = bongo_cat_ui_color_mix( + p.accent, p.pink, hover_amount); nk_draw_text(canvas, nk_rect(action.x, action.y + (action.h - action_font->height) * .5f, label_width + 1, action_font->height), label, nk_strlen(label), action_font, nk_rgba(0, 0, 0, 0), action_color); float arrow_y = action.y + action.h * .5f; - float first_arrow_x = action.x + label_width + 11; - float second_arrow_x = action.x + label_width + 17; + float arrow_offset = 2.0f * hover_amount; + float first_arrow_x = action.x + label_width + 11 + arrow_offset; + float second_arrow_x = action.x + label_width + 17 + arrow_offset; + struct nk_color first_arrow_color = bongo_cat_ui_color_alpha( + action_color, 0.5f); nk_stroke_line(canvas, first_arrow_x - 4, arrow_y - 4, - first_arrow_x, arrow_y, 1.5f, action_color); + first_arrow_x, arrow_y, 1.5f, first_arrow_color); nk_stroke_line(canvas, first_arrow_x, arrow_y, - first_arrow_x - 4, arrow_y + 4, 1.5f, action_color); + first_arrow_x - 4, arrow_y + 4, 1.5f, first_arrow_color); nk_stroke_line(canvas, second_arrow_x - 4, arrow_y - 4, second_arrow_x, arrow_y, 1.5f, action_color); nk_stroke_line(canvas, second_arrow_x, arrow_y, From a8c6d856dddc9b826f118e210a5db6905e6ffb38 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 21 Aug 2026 22:02:18 +0800 Subject: [PATCH 064/334] Harden platform input safety boundaries --- CMakeLists.txt | 4 + cmake/CheckPlatformRuntimeSafety.cmake | 175 ++++++++++++++++++ cmake/Info.plist.in | 2 - ...tform-game-coexistence-audit-2026-08-21.md | 139 ++++++++++++++ src/platform/macos/macos_input.m | 2 +- src/platform/windows/windows_pointer.c | 11 +- 6 files changed, 322 insertions(+), 11 deletions(-) create mode 100644 cmake/CheckPlatformRuntimeSafety.cmake create mode 100644 docs/platform-game-coexistence-audit-2026-08-21.md diff --git a/CMakeLists.txt b/CMakeLists.txt index 28574bf8..7cb2fbe5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -129,5 +129,9 @@ add_custom_target(check-lines ALL COMMAND ${CMAKE_COMMAND} -DROOT=${CMAKE_CURRENT_SOURCE_DIR} -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/CheckLines.cmake VERBATIM) +add_custom_target(check-platform-runtime-safety ALL + COMMAND ${CMAKE_COMMAND} -DROOT=${CMAKE_CURRENT_SOURCE_DIR} + -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/CheckPlatformRuntimeSafety.cmake + VERBATIM) include(cmake/Tests.cmake) include(cmake/Packaging.cmake) diff --git a/cmake/CheckPlatformRuntimeSafety.cmake b/cmake/CheckPlatformRuntimeSafety.cmake new file mode 100644 index 00000000..84cb965e --- /dev/null +++ b/cmake/CheckPlatformRuntimeSafety.cmake @@ -0,0 +1,175 @@ +if(NOT DEFINED ROOT) + message(FATAL_ERROR "ROOT is required") +endif() + +file(GLOB_RECURSE PRODUCTION_FILES + "${ROOT}/src/*.c" "${ROOT}/src/*.cc" "${ROOT}/src/*.cpp" + "${ROOT}/src/*.cxx" "${ROOT}/src/*.m" "${ROOT}/src/*.mm" + "${ROOT}/src/*.h" "${ROOT}/src/*.hh" "${ROOT}/src/*.hpp" + "${ROOT}/src/*.hxx" "${ROOT}/include/*.h" "${ROOT}/include/*.hh" + "${ROOT}/include/*.hpp" "${ROOT}/include/*.hxx") +list(FILTER PRODUCTION_FILES EXCLUDE REGEX "[/\\\\]src[/\\\\]tools[/\\\\]") + +set(FORBIDDEN_APIS + OpenProcess ReadProcessMemory WriteProcessMemory VirtualAllocEx + VirtualProtectEx CreateRemoteThread CreateRemoteThreadEx NtOpenProcess + NtReadVirtualMemory NtWriteVirtualMemory NtCreateThreadEx + RtlCreateUserThread QueueUserAPC SetThreadContext DebugActiveProcess + DebugBreakProcess MiniDumpWriteDump CreateToolhelp32Snapshot Process32First + Process32Next EnumProcesses EnumProcessModules AdjustTokenPrivileges + LookupPrivilegeValue OpenSCManager CreateService StartService ControlService + DeviceIoControl NtLoadDriver AttachThreadInput SendInput keybd_event + mouse_event RegisterRawInputDevices GetRawInputData + CGEventPost CGEventPostToPid CGEventCreateKeyboardEvent + CGEventCreateMouseEvent CGWarpMouseCursorPosition + CGAssociateMouseAndMouseCursorPosition IOHIDManagerCreate + IOHIDEventSystemClientCreate IOServiceOpen task_for_pid mach_vm_read + mach_vm_read_overwrite mach_vm_write mach_vm_allocate mach_vm_protect + AuthorizationCreate AuthorizationExecuteWithPrivileges SMJobBless + XTestFakeKeyEvent XTestFakeButtonEvent XTestFakeMotionEvent XWarpPointer + XGrabKey XGrabButton process_vm_readv process_vm_writev ptrace ioctl + setuid seteuid setgid setegid capset) +set(FORBIDDEN_TOKENS + SE_DEBUG_NAME SeDebugPrivilege PROCESS_VM_READ PROCESS_VM_WRITE + PROCESS_VM_OPERATION PROCESS_ALL_ACCESS THREAD_SET_CONTEXT + kCGHIDEventTap kCGHeadInsertEventTap IOConnectCall + /dev/input /dev/uinput CAP_SYS_PTRACE CAP_SYS_ADMIN) +set(FAILURES "") + +foreach(FILE IN LISTS PRODUCTION_FILES) + file(READ "${FILE}" SOURCE) + file(RELATIVE_PATH RELATIVE_FILE "${ROOT}" "${FILE}") + string(REPLACE "\\" "/" RELATIVE_FILE "${RELATIVE_FILE}") + foreach(API IN LISTS FORBIDDEN_APIS) + string(REGEX MATCH + "(^|[^A-Za-z0-9_])${API}[ \t\r\n]*\\(" MATCHED "${SOURCE}") + if(MATCHED) + list(APPEND FAILURES "${RELATIVE_FILE}: forbidden production API ${API}") + endif() + endforeach() + foreach(TOKEN IN LISTS FORBIDDEN_TOKENS) + string(FIND "${SOURCE}" "${TOKEN}" POSITION) + if(NOT POSITION EQUAL -1) + list(APPEND FAILURES "${RELATIVE_FILE}: forbidden production token ${TOKEN}") + endif() + endforeach() +endforeach() + +# Intentional sensitive capabilities remain confined to reviewed modules. +set(SENSITIVE_RULES + "SetWindowsHookEx|src/platform/windows/windows_input.c" + "GetAsyncKeyState|src/platform/windows/windows_keys.c" + "DirectInput8Create|src/platform/windows/windows_direct_input.c" + "OpenProcessToken|src/platform/windows/windows_diagnostics.c" + "BitBlt|src/platform/windows/windows_capture_probe.c" + "PrintWindow|src/platform/windows/windows_capture_probe.c" + "UpdateLayeredWindow|src/platform/windows/windows_layered.c" + "CGEventTapCreate|src/platform/macos/macos_input.m" + "CGEventTapEnable|src/platform/macos/macos_input.m" + "CGPreflightListenEventAccess|src/platform/macos/macos_input.m" + "CGRequestListenEventAccess|src/platform/macos/macos_input.m" + "XISelectEvents|src/platform/linux/linux_x11.c" + "XFixesSetWindowShapeRegion|src/platform/linux/linux_x11.c" + "XSendEvent|src/platform/linux/linux.c|src/platform/linux/linux_x11.c" + "XGrabPointer|src/platform/linux/linux_menu.c" + "XGrabKeyboard|src/platform/linux/linux_menu.c" + "SDL_INIT_GAMEPAD|src/runtime/input/gamepad.c" + "SDL_OpenGamepad|src/runtime/input/gamepad.c" + "SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS|src/runtime/input/gamepad.c" + "SDL_CreateProcessWithProperties|src/runtime/model/multi_pet_process.c" + "SDL_KillProcess|src/runtime/model/multi_pet_process.c") +foreach(RULE IN LISTS SENSITIVE_RULES) + string(REPLACE "|" ";" PARTS "${RULE}") + list(POP_FRONT PARTS CAPABILITY) + foreach(FILE IN LISTS PRODUCTION_FILES) + file(READ "${FILE}" SOURCE) + string(FIND "${SOURCE}" "${CAPABILITY}" POSITION) + if(POSITION EQUAL -1) + continue() + endif() + file(RELATIVE_PATH RELATIVE_FILE "${ROOT}" "${FILE}") + string(REPLACE "\\" "/" RELATIVE_FILE "${RELATIVE_FILE}") + list(FIND PARTS "${RELATIVE_FILE}" OWNER_POSITION) + if(OWNER_POSITION EQUAL -1) + list(JOIN PARTS ", " OWNERS) + list(APPEND FAILURES + "${RELATIVE_FILE}: ${CAPABILITY} is owned by ${OWNERS}") + endif() + endforeach() +endforeach() + +file(READ "${ROOT}/cmake/windows.manifest.in" WINDOWS_MANIFEST) +string(FIND "${WINDOWS_MANIFEST}" + "" + MANIFEST_POSITION) +if(MANIFEST_POSITION EQUAL -1) + list(APPEND FAILURES + "cmake/windows.manifest.in: expected asInvoker with uiAccess=false") +endif() + +file(READ "${ROOT}/cmake/Packaging.cmake" WINDOWS_PACKAGING) +string(FIND "${WINDOWS_PACKAGING}" "RequestExecutionLevel user" + INSTALLER_LEVEL_POSITION) +string(FIND "${WINDOWS_PACKAGING}" + "set(CPACK_NSIS_INSTALL_ROOT \"$LOCALAPPDATA/Programs\")" + INSTALLER_ROOT_POSITION) +if(INSTALLER_LEVEL_POSITION EQUAL -1 OR INSTALLER_ROOT_POSITION EQUAL -1) + list(APPEND FAILURES + "cmake/Packaging.cmake: expected current-user NSIS installation") +endif() + +file(READ "${ROOT}/cmake/Info.plist.in" MACOS_INFO) +string(FIND "${MACOS_INFO}" "NSInputMonitoringUsageDescription" + INPUT_MONITORING_POSITION) +string(FIND "${MACOS_INFO}" "NSAccessibilityUsageDescription" + ACCESSIBILITY_POSITION) +if(INPUT_MONITORING_POSITION EQUAL -1 OR NOT ACCESSIBILITY_POSITION EQUAL -1) + list(APPEND FAILURES + "cmake/Info.plist.in: expected Input Monitoring without Accessibility") +endif() + +file(READ "${ROOT}/src/platform/macos/macos_input.m" MACOS_INPUT) +foreach(TOKEN kCGSessionEventTap kCGTailAppendEventTap + kCGEventTapOptionListenOnly) + string(FIND "${MACOS_INPUT}" "${TOKEN}" POSITION) + if(POSITION EQUAL -1) + list(APPEND FAILURES + "src/platform/macos/macos_input.m: missing safe event tap token ${TOKEN}") + endif() +endforeach() + +file(READ "${ROOT}/cmake/PlatformRuntime.cmake" PLATFORM_RUNTIME) +string(FIND "${PLATFORM_RUNTIME}" "X11::Xtst" XTEST_POSITION) +if(NOT XTEST_POSITION EQUAL -1) + list(APPEND FAILURES + "cmake/PlatformRuntime.cmake: XTest must not enter the Linux runtime") +endif() + +file(READ "${ROOT}/cmake/Dependencies.cmake" DEPENDENCIES) +foreach(TOKEN 402fc52af4e731184ad6a704068b5ccd27d8f1b8 + e413151af71c23d316b6076a96a999342142afa792394eaf8a542a03503fc491) + string(FIND "${DEPENDENCIES}" "${TOKEN}" POSITION) + if(POSITION EQUAL -1) + list(APPEND FAILURES + "cmake/Dependencies.cmake: SDL dependency changed without safety review") + endif() +endforeach() + +file(READ "${ROOT}/cmake/ValidationTools.cmake" VALIDATION_TOOLS) +foreach(TOOL cubism_viewer_desktop_capture cubism_viewer_drag_capture) + string(REGEX MATCH + "add_executable\\(${TOOL}[ \t\r\n]+EXCLUDE_FROM_ALL" TOOL_ISOLATED + "${VALIDATION_TOOLS}") + if(NOT TOOL_ISOLATED) + list(APPEND FAILURES + "cmake/ValidationTools.cmake: ${TOOL} must remain EXCLUDE_FROM_ALL") + endif() +endforeach() + +if(FAILURES) + list(REMOVE_DUPLICATES FAILURES) + list(JOIN FAILURES "\n" MESSAGE_TEXT) + message(FATAL_ERROR "Platform runtime safety policy failed:\n${MESSAGE_TEXT}") +endif() + +message(STATUS "Platform runtime safety policy passed") diff --git a/cmake/Info.plist.in b/cmake/Info.plist.in index 532258ca..54ab56b3 100644 --- a/cmake/Info.plist.in +++ b/cmake/Info.plist.in @@ -16,8 +16,6 @@ CFBundleVersion@PROJECT_VERSION@ LSMinimumSystemVersion11.0 LSUIElement - NSAccessibilityUsageDescription - BongoCat needs accessibility access to react to global keyboard and mouse input. NSInputMonitoringUsageDescription BongoCat needs input monitoring access to animate from global keyboard and mouse input. NSHighResolutionCapable diff --git a/docs/platform-game-coexistence-audit-2026-08-21.md b/docs/platform-game-coexistence-audit-2026-08-21.md new file mode 100644 index 00000000..ff844226 --- /dev/null +++ b/docs/platform-game-coexistence-audit-2026-08-21.md @@ -0,0 +1,139 @@ +# Platform game coexistence audit - 2026-08-21 + +## Scope and outcome + +This audit covers the Windows, macOS, and Linux production runtimes, platform +permissions, window integration, autostart paths, packaging, and validation +tools. Its goal is to minimize behavior that can resemble game manipulation +while retaining the global input and overlay features required by a desktop +pet. + +BongoCat does not inject code, access another process's memory, install a +driver or privileged service, request debug privileges, or simulate keyboard +or mouse input. Platform-specific global input remains read-only and platform +windows are never attached to a game window. + +No source audit can guarantee that every third-party anti-cheat product will +avoid a false positive. Signing, release reputation, dependency behavior, and +each anti-cheat vendor's policy remain external factors. macOS and Linux were +audited from source on a Windows host; real-machine verification remains a +release gate. + +## Windows + +The diagnostic run confirmed that an unelevated process does not receive +keyboard edges from an elevated Task Manager through `WH_KEYBOARD_LL`, +`GetAsyncKeyState`, or background Raw Input. This is a Windows integrity +boundary, not a model or input-queue defect. Input resumes when a +normal-integrity window becomes foreground. + +The rejected workarounds are elevation, `uiAccess=true`, direct HID access, a +keyboard driver, process or DLL injection, and cross-process memory access. +The temporary Raw Input experiment, full virtual-key polling, and diagnostic +logging were removed. Retained physical-state polling only releases keys that +the normal hook previously saw go down. + +The production executable remains `asInvoker` with `uiAccess=false`. Low-level +keyboard and mouse hooks only enqueue BongoCat events, DirectInput opens only +`GUID_SysMouse` as nonexclusive background input, and the layered proxy belongs +to BongoCat. Window activation no longer attaches BongoCat's GUI input queue to +the current foreground thread. Non-topmost desktop anchoring searches only the +Windows `Progman`/`WorkerW` shell windows. + +The `BitBlt`/`PrintWindow` presentation probe runs only under the explicit +`--ci-smoke` path and receives only BongoCat-owned window handles. Normal +runtime does not capture the desktop or a game window, although those imports +can remain visible in the executable. + +The NSIS update path uses only `SYNCHRONIZE | +PROCESS_QUERY_LIMITED_INFORMATION` after verifying the executable path. The +installer is current-user-only. Static SDL can retain ordinary Raw Input, +`DeviceIoControl`, and process imports for its gamepad, video, and process +backends. SDL's gamepad subsystem is initialized only while a gamepad model is +active; an import alone does not prove the application calls every API. + +## macOS + +Global input uses a `kCGSessionEventTap` at `kCGTailAppendEventTap` with +`kCGEventTapOptionListenOnly`. The callback observes key, button, and pointer +events, returns the original event unchanged, and cannot suppress or rewrite +input. BongoCat requests only Input Monitoring access. The unused Accessibility +usage declaration was removed. + +The BongoCat-owned global keyboard and mouse path does not create or post +synthetic `CGEvent` input, use a HID-level event tap, request another process's +task port, use Mach process memory APIs, call `ptrace`, or install a privileged +helper. Static SDL can use the standard IOKit HID gamepad backend, but only +while a gamepad model is active. If macOS Secure Input or another security +control blocks observation, BongoCat accepts reduced input behavior instead of +bypassing it. + +The floating, click-through, capture-sharing, tray, and activation operations +target BongoCat's own `NSWindow`. Single-instance wake-up uses an app-specific +distributed notification. Autostart writes a user-owned LaunchAgent under +`~/Library/LaunchAgents`; it does not use `SMJobBless` or a system daemon. + +Input Monitoring is inherently a sensitive permission because it exposes +global key events. Developer ID signing, hardened runtime, notarization, and a +stable bundle identifier are required release controls, but a vendor can still +choose to restrict any process with a global event tap. + +## Linux + +The X11 path selects `XI_RawKey*`, `XI_RawButton*`, and `XI_RawMotion` events +from XInput2. It does not grab keys or buttons for global monitoring and does +not inject input. Pointer and keyboard grabs exist only while BongoCat's own +context menu is open and are released when the menu closes. + +`XSendEvent` is confined to the Linux window-management modules and sends only +EWMH `ClientMessage` requests for activation, taskbar state, and moving the +BongoCat window. Project production source does not link XTest and contains no +`XTestFake*`, `XWarpPointer`, `/dev/uinput`, `ptrace`, process memory, setuid, or +Linux capability path. Static SDL can enumerate `/dev/input` and hidraw devices +and use `ioctl` for standard gamepad support, but that subsystem is initialized +only while a gamepad model is active. BongoCat never creates a virtual input +device or asks SDL to synthesize input. Click-through changes only the input +shape region of BongoCat's own X11 window. + +Autostart writes a user desktop entry under `$XDG_CONFIG_HOME/autostart` or +`~/.config/autostart`. Directory opening executes `xdg-open` directly without +a shell. Single-instance activation reads the BongoCat lock file and sends a +window-manager activation request to the stored BongoCat window ID; it does not +access process memory. + +Native Wayland intentionally has no global-input bypass. When no X11/XWayland +window or XInput2 is available, global input is reported unavailable rather +than falling back to portals, evdev, elevated access, or a helper daemon. + +## Tool isolation + +Input simulation and desktop-capture code exists only in +`src/tools/cubism_viewer_*_capture.c`. Those Windows validation executables are +`EXCLUDE_FROM_ALL`, are not linked into `BongoCat.exe`, and are not installed or +packaged with the application. + +`cmake/CheckPlatformRuntimeSafety.cmake` enforces prohibited APIs and tokens, +platform permission boundaries, sensitive-module ownership, the reviewed SDL +source pin, and validation tool isolation. It scans project production source, +including Objective-C, but not fetched third-party source. + +## Release requirements + +- Rebuild release artifacts from clean source after diagnostic experiments are + removed; never distribute a diagnostic executable. +- Authenticode-sign and timestamp the Windows executable and installer. +- Sign macOS with Developer ID, enable hardened runtime, notarize, and test the + exact signed bundle because Input Monitoring consent is identity-sensitive. +- Sign Linux packages where the distribution format supports it, publish + checksums, and keep artifacts traceable to a tagged source revision. +- Recheck final binary imports, linked libraries, signatures, and packaged file + lists on every platform. +- Test alongside each supported anti-cheat on a non-production account. Vendor + allow-listing or false-positive review is the only authoritative resolution + for product-specific detection. + +## Repeatable checks + +- `cmake "-DROOT=$PWD" -P cmake/CheckPlatformRuntimeSafety.cmake` +- `cmake "-DROOT=$PWD" -P cmake/CheckLines.cmake` +- `git diff --check` diff --git a/src/platform/macos/macos_input.m b/src/platform/macos/macos_input.m index 8eeee9ec..9ae7f792 100644 --- a/src/platform/macos/macos_input.m +++ b/src/platform/macos/macos_input.m @@ -97,7 +97,7 @@ static int SDLCALL input_thread(void *userdata) { CGEventMaskBit(kCGEventOtherMouseDown) | CGEventMaskBit(kCGEventOtherMouseUp) | CGEventMaskBit(kCGEventMouseMoved) | CGEventMaskBit(kCGEventLeftMouseDragged) | CGEventMaskBit(kCGEventRightMouseDragged) | CGEventMaskBit(kCGEventOtherMouseDragged); - state->tap = CGEventTapCreate(kCGSessionEventTap, kCGHeadInsertEventTap, + state->tap = CGEventTapCreate(kCGSessionEventTap, kCGTailAppendEventTap, kCGEventTapOptionListenOnly, mask, event_tap, state); if (state->tap) { state->source = CFMachPortCreateRunLoopSource(NULL, state->tap, 0); diff --git a/src/platform/windows/windows_pointer.c b/src/platform/windows/windows_pointer.c index b62e09fb..6011eb5c 100644 --- a/src/platform/windows/windows_pointer.c +++ b/src/platform/windows/windows_pointer.c @@ -39,17 +39,12 @@ void bongo_cat_platform_raise_window(SDL_Window *window) { SDL_ShowWindow(window); HWND handle = (HWND)SDL_GetPointerProperty(SDL_GetWindowProperties(window), SDL_PROP_WINDOW_WIN32_HWND_POINTER, NULL); - if (!handle) { SDL_RaiseWindow(window); return; } + if (!handle) return; HWND proxy = bongo_cat_windows_layered_proxy(handle); if (IsIconic(handle)) ShowWindow(handle, SW_RESTORE); - HWND foreground = GetForegroundWindow(); - DWORD foreground_thread = foreground ? GetWindowThreadProcessId(foreground, NULL) : 0; - DWORD current_thread = GetCurrentThreadId(); - bool attached = foreground_thread && foreground_thread != current_thread && - AttachThreadInput(current_thread, foreground_thread, TRUE); - BringWindowToTop(handle); SetForegroundWindow(handle); SetActiveWindow(handle); + BringWindowToTop(handle); + SetForegroundWindow(handle); if (proxy) BringWindowToTop(proxy); - if (attached) AttachThreadInput(current_thread, foreground_thread, FALSE); bongo_cat_windows_capture_configure(handle); } From beb8d95fdd58bdab18b2c0d001d6cf47b5401789 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 21 Aug 2026 23:49:11 +0800 Subject: [PATCH 065/334] Add Microsoft Store MSIX packaging --- .github/scripts/check-publish-guards.ps1 | 91 ++++++++ .github/scripts/restore-cubism-sdk.ps1 | 83 +++++++ .github/workflows/ci.yml | 86 +++++++ .gitignore | 1 + packaging/microsoft-store/AppxManifest.xml.in | 58 +++++ packaging/microsoft-store/README.md | 88 +++++++ .../microsoft-store/build-store-package.ps1 | 221 ++++++++++++++++++ .../validate-store-package.ps1 | 165 +++++++++++++ 8 files changed, 793 insertions(+) create mode 100644 .github/scripts/check-publish-guards.ps1 create mode 100644 .github/scripts/restore-cubism-sdk.ps1 create mode 100644 packaging/microsoft-store/AppxManifest.xml.in create mode 100644 packaging/microsoft-store/README.md create mode 100644 packaging/microsoft-store/build-store-package.ps1 create mode 100644 packaging/microsoft-store/validate-store-package.ps1 diff --git a/.github/scripts/check-publish-guards.ps1 b/.github/scripts/check-publish-guards.ps1 new file mode 100644 index 00000000..1934aeca --- /dev/null +++ b/.github/scripts/check-publish-guards.ps1 @@ -0,0 +1,91 @@ +[CmdletBinding()] +param([switch]$SelfTest) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$upstreamGuard = "github.repository == 'bongocat-pet/BongoCat'" +$workflowDirectory = Join-Path (Split-Path $PSScriptRoot -Parent) 'workflows' +$sensitiveMarkers = @( + 'build-store-package.ps1', + 'CUBISM_SDK_ARCHIVE_URL' +) + +function Get-PublishingGuardFailures { + param( + [Parameter(Mandatory = $true)][string]$Text, + [Parameter(Mandatory = $true)][string]$Label + ) + + $jobsStart = $Text.IndexOf("jobs:", [StringComparison]::Ordinal) + if ($jobsStart -lt 0) { return @() } + $jobsText = $Text.Substring($jobsStart) + $jobPattern = '(?ms)^ ([A-Za-z0-9_-]+):\s*\r?\n' + + '(.*?)(?=^ [A-Za-z0-9_-]+:\s*\r?\n|\z)' + $failures = @() + + foreach ($job in [regex]::Matches($jobsText, $jobPattern)) { + $jobName = $job.Groups[1].Value + $jobText = $job.Value + $matched = @($sensitiveMarkers | Where-Object { $jobText.Contains($_) }) + if ($matched.Count -eq 0) { continue } + if (-not $jobText.Contains($upstreamGuard)) { + $failures += "$Label`: job '$jobName' lacks upstream guard; " + + "matched $($matched -join ', ')" + } + } + return $failures +} + +$workflowPaths = @( + Get-ChildItem -LiteralPath $workflowDirectory -File | + Where-Object { $_.Extension -in @('.yml', '.yaml') } | + Sort-Object FullName +) +$failures = @() +$checkedJobs = 0 +$selfTestText = $null + +foreach ($path in $workflowPaths) { + $text = Get-Content -LiteralPath $path.FullName -Raw + $pathFailures = @(Get-PublishingGuardFailures -Text $text -Label $path.Name) + $failures += $pathFailures + $jobsStart = $text.IndexOf("jobs:", [StringComparison]::Ordinal) + if ($jobsStart -ge 0) { + $jobsText = $text.Substring($jobsStart) + $checkedJobs += @([regex]::Matches($jobsText, + '(?ms)^ ([A-Za-z0-9_-]+):\s*\r?\n(.*?)(?=^ [A-Za-z0-9_-]+:\s*\r?\n|\z)') | + Where-Object { + $jobText = $_.Value + @($sensitiveMarkers | Where-Object { + $jobText.Contains($_) + }).Count -gt 0 + }).Count + } + if (-not $selfTestText -and + ($sensitiveMarkers | Where-Object { $text.Contains($_) })) { + $selfTestText = $text + } +} + +if ($SelfTest) { + if (-not $selfTestText) { + $failures += 'Self-test could not find a sensitive workflow.' + } + else { + $mutated = $selfTestText.Replace($upstreamGuard, 'true') + $caught = @(Get-PublishingGuardFailures -Text $mutated -Label 'self-test') + if ($caught.Count -eq 0) { + $failures += 'Self-test did not reject a removed upstream guard.' + } + } +} + +if ($failures.Count -gt 0) { + Write-Error ("Publishing guard policy failed:`n- " + + ($failures -join "`n- ")) + exit 1 +} + +Write-Host "Publishing guard policy passed for $checkedJobs sensitive job(s)." +if ($SelfTest) { Write-Host 'Publishing guard negative self-test passed.' } diff --git a/.github/scripts/restore-cubism-sdk.ps1 b/.github/scripts/restore-cubism-sdk.ps1 new file mode 100644 index 00000000..3ea52d50 --- /dev/null +++ b/.github/scripts/restore-cubism-sdk.ps1 @@ -0,0 +1,83 @@ +[CmdletBinding()] +param( + [string]$ArchiveUrl = $env:CUBISM_SDK_ARCHIVE_URL, + [string]$ExpectedSha256 = $env:CUBISM_SDK_ARCHIVE_SHA256, + [string]$Destination +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..')) +if (-not $Destination) { + $Destination = Join-Path $repositoryRoot 'vendor\CubismSdkForNative' +} + +if ([string]::IsNullOrWhiteSpace($ArchiveUrl) -or + [string]::IsNullOrWhiteSpace($ExpectedSha256)) { + throw @" +The Store workflow needs the licensed Cubism SDK archive. Configure the +CUBISM_SDK_ARCHIVE_URL and CUBISM_SDK_ARCHIVE_SHA256 repository secrets with a +private URL and SHA-256 for CubismSdkForNative 5 r.5. The SDK is intentionally +not committed to this repository. +"@ +} + +$ExpectedSha256 = $ExpectedSha256.Trim().ToUpperInvariant() +if ($ExpectedSha256 -notmatch '^[0-9A-F]{64}$') { + throw "CUBISM_SDK_ARCHIVE_SHA256 must be a 64-character SHA-256 digest." +} + +$Destination = [System.IO.Path]::GetFullPath($Destination) +$repositoryPrefix = $repositoryRoot.TrimEnd('\', '/') + + [IO.Path]::DirectorySeparatorChar +if (-not $Destination.StartsWith($repositoryPrefix, + [StringComparison]::OrdinalIgnoreCase) -or + (Split-Path $Destination -Leaf) -ne 'CubismSdkForNative') { + throw "Refusing to replace an unexpected Cubism SDK destination: $Destination" +} +$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("BongoCatCubism_" + [guid]::NewGuid()) +$archivePath = Join-Path $temporaryRoot "cubism-sdk.zip" +$extractPath = Join-Path $temporaryRoot "extracted" + +try { + New-Item -ItemType Directory -Force -Path $temporaryRoot, $extractPath | Out-Null + Write-Host "Downloading the private Cubism SDK archive..." + Invoke-WebRequest -Uri $ArchiveUrl -OutFile $archivePath -UseBasicParsing + + $actualSha256 = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToUpperInvariant() + if ($actualSha256 -ne $ExpectedSha256) { + throw "Cubism SDK archive hash mismatch: expected $ExpectedSha256, got $actualSha256." + } + + Expand-Archive -LiteralPath $archivePath -DestinationPath $extractPath -Force + $rootCandidate = Get-Item -LiteralPath $extractPath + if (-not (Test-Path (Join-Path $rootCandidate.FullName "Core\include\Live2DCubismCore.h"))) { + $rootCandidate = Get-ChildItem -LiteralPath $extractPath -Directory -Recurse | + Where-Object { Test-Path (Join-Path $_.FullName "Core\include\Live2DCubismCore.h") } | + Select-Object -First 1 + } + if (-not $rootCandidate) { + throw "The archive does not contain a CubismSdkForNative Core/include tree." + } + + $required = @( + (Join-Path $rootCandidate.FullName "Core\include\Live2DCubismCore.h"), + (Join-Path $rootCandidate.FullName "Core\lib\windows\x86_64\143\Live2DCubismCore_MT.lib"), + (Join-Path $rootCandidate.FullName "Framework\CMakeLists.txt"), + (Join-Path $rootCandidate.FullName "Samples\OpenGL\thirdParty\glew\build\cmake\CMakeLists.txt") + ) + foreach ($path in $required) { + if (-not (Test-Path -LiteralPath $path)) { + throw "The Cubism SDK archive is incomplete; missing $path." + } + } + + Remove-Item -LiteralPath $Destination -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force -Path $Destination | Out-Null + Get-ChildItem -LiteralPath $rootCandidate.FullName -Force | Copy-Item -Destination $Destination -Recurse -Force + Write-Host "Cubism SDK restored to $Destination" +} +finally { + Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb57d03f..60712fac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,17 @@ concurrency: cancel-in-progress: true jobs: + publishing-policy: + name: security / publishing-policy + runs-on: ubuntu-latest + steps: + - name: Checkout source + uses: actions/checkout@v5 + + - name: Validate publishing guards + shell: pwsh + run: ./.github/scripts/check-publish-guards.ps1 -SelfTest + quality: name: quality / syntax-tests runs-on: ubuntu-latest @@ -243,3 +254,78 @@ jobs: if-no-files-found: error retention-days: 30 compression-level: 0 + + store-package: + name: package / microsoft-store + if: >- + github.repository == 'bongocat-pet/BongoCat' && + github.event_name == 'push' && + github.ref == 'refs/heads/main' + needs: [publishing-policy, quality] + runs-on: windows-latest + env: + CUBISM_SDK_ARCHIVE_URL: ${{ secrets.CUBISM_SDK_ARCHIVE_URL }} + CUBISM_SDK_ARCHIVE_SHA256: ${{ secrets.CUBISM_SDK_ARCHIVE_SHA256 }} + steps: + - name: Checkout source + uses: actions/checkout@v5 + + - name: Restore licensed Cubism SDK + shell: pwsh + run: ./.github/scripts/restore-cubism-sdk.ps1 + + - name: Configure Store release + shell: pwsh + run: | + cmake -S . -B build-store -A x64 ` + -DBONGO_CAT_REQUIRE_CUBISM=ON ` + -DBONGO_CAT_WARNINGS_AS_ERRORS=ON + + - name: Build Store release + shell: pwsh + run: >- + & ./.github/scripts/build-windows.ps1 + cmake --build build-store --config Release --parallel 2 + + - name: Run Store release tests + shell: pwsh + run: >- + & ./.github/scripts/test-windows.ps1 + ctest --test-dir build-store -C Release --output-on-failure + + - name: Read app version + id: version + shell: pwsh + run: | + $header = Get-Content build-store/generated/bongo_cat/version.h -Raw + if ($header -notmatch '#define\s+BONGO_CAT_VERSION\s+"([^"]+)"') { + throw 'Unable to read BongoCat version from generated header' + } + "version=$($Matches[1])" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: Build Microsoft Store MSIX + shell: pwsh + run: | + ./packaging/microsoft-store/build-store-package.ps1 ` + -ExecutablePath "./build-store/Release/BongoCat.exe" ` + -Version "${{ steps.version.outputs.version }}" ` + -OutputDirectory "./store-output" + $msix = Get-Item "store-output/bongocat_${{ steps.version.outputs.version }}_x64.msix" + Write-Host ("Microsoft Store MSIX size: {0} KB" -f [math]::Round($msix.Length / 1KB, 2)) + + - name: Validate Microsoft Store MSIX + shell: pwsh + run: | + ./packaging/microsoft-store/validate-store-package.ps1 ` + -PackagePath "./store-output/bongocat_${{ steps.version.outputs.version }}_x64.msix" ` + -ExpectedAppVersion "${{ steps.version.outputs.version }}" + + - name: Upload Microsoft Store MSIX + uses: actions/upload-artifact@v7 + with: + name: bongocat-store-msix-${{ steps.version.outputs.version }}-${{ github.run_number }}-${{ github.run_attempt }} + path: store-output/bongocat_${{ steps.version.outputs.version }}_x64.msix + if-no-files-found: error + retention-days: 90 + compression-level: 0 + archive: false diff --git a/.gitignore b/.gitignore index 60de1200..a190eff9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # CMake build trees and generated metadata /build*/ /out/ +/output/ /CMakeFiles/ /Testing/ /CMakeCache.txt diff --git a/packaging/microsoft-store/AppxManifest.xml.in b/packaging/microsoft-store/AppxManifest.xml.in new file mode 100644 index 00000000..a46da704 --- /dev/null +++ b/packaging/microsoft-store/AppxManifest.xml.in @@ -0,0 +1,58 @@ + + + + + + + BongoCat + vladelaina + A Live2D desktop pet that reacts to keyboard and mouse input. + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/microsoft-store/README.md b/packaging/microsoft-store/README.md new file mode 100644 index 00000000..8b147a9e --- /dev/null +++ b/packaging/microsoft-store/README.md @@ -0,0 +1,88 @@ +# Microsoft Store packaging + +The protected `store-package` job in `.github/workflows/ci.yml` creates a raw +`bongocat__x64.msix` artifact on pushes to the upstream `main` +branch. The artifact is intentionally unsigned: Partner Center signs accepted +MSIX packages with the Microsoft Store certificate during submission. + +## Store identity + +- Package name: `vladelaina.bongocat` +- Publisher: `CN=5503A135-7FA4-466B-815C-DBE627F4065F` +- Publisher display name: `vladelaina` +- Package family name: `vladelaina.bongocat_hnew8t3b8e0t6` +- Store ID: `9P41MLSX72XW` + +The package name and publisher are stored in `AppxManifest.xml.in`. The PFN +and package SID are derived by Windows and must not be used as signing +secrets. The package uses `x64`, matching the current Cubism Core library and +the Windows build job. + +MSIX versions must have a non-zero major component and a zero revision. The +script maps app version `0.1.1` to package version `1.1.1.0` by default; pass +`-PackageVersion` when advancing the Store version independently. The app +version remains in the filename so GitHub artifacts are easy to identify. + +## Actions SDK setup + +Live2D's licensed Cubism SDK is ignored by Git and cannot be checked into this +public repository. Before enabling the protected job, add these repository +secrets: + +- `CUBISM_SDK_ARCHIVE_URL`: private HTTPS URL for a CubismSdkForNative 5 r.5 + ZIP archive. The archive must contain `Core`, `Framework`, and the OpenGL + sample's GLEW CMake tree. +- `CUBISM_SDK_ARCHIVE_SHA256`: SHA-256 digest of that ZIP. + +The job verifies the digest and required files before configuring CMake with +`BONGO_CAT_REQUIRE_CUBISM=ON`; it never uploads the diagnostic backend used by +the public cross-platform build. + +## Local build and install check + +On Windows with the Windows 10/11 SDK and the local Cubism SDK installed: + +```powershell +cmake -S . -B build-cubism -G "Visual Studio 17 2022" -A x64 ` + -DBONGO_CAT_REQUIRE_CUBISM=ON -DBONGO_CAT_WARNINGS_AS_ERRORS=ON +cmake --build build-cubism --config Release --target bongo_cat --parallel 2 + +.\packaging\microsoft-store\build-store-package.ps1 ` + -ExecutablePath .\build-cubism\Release\BongoCat.exe ` + -Version 0.1.1 +``` + +The script writes the unsigned Store submission package to +`output\microsoft-store\bongocat_0.1.1_x64.msix`. Upload that file directly to +Partner Center. It cannot be installed locally until Microsoft Store signs it. +Validate the exact Identity, PFN, version, architecture, payload, and unsigned +submission state with: + +```powershell +.\packaging\microsoft-store\validate-store-package.ps1 ` + -PackagePath .\output\microsoft-store\bongocat_0.1.1_x64.msix ` + -ExpectedAppVersion 0.1.1 +``` + +The protected Actions job runs this validation before uploading its artifact. + +To test local deployment before submission, explicitly build a separate +self-signed package: + +```powershell +.\packaging\microsoft-store\build-store-package.ps1 ` + -ExecutablePath .\build-cubism\Release\BongoCat.exe ` + -Version 0.1.1 -SignForLocalTesting + +# Run these certificate/deployment commands from an elevated PowerShell. +Import-Certificate ` + .\output\microsoft-store\bongocat_0.1.1_x64-local-test.cer ` + -CertStoreLocation Cert:\LocalMachine\TrustedPeople | Out-Null +Add-AppxPackage ` + .\output\microsoft-store\bongocat_0.1.1_x64-local-test.msix +Get-AppxPackage -Name vladelaina.bongocat +``` + +The `-local-test` certificate and package are only for sideload testing. Do not +upload either one to Partner Center. The GitHub Actions job never passes +`-SignForLocalTesting` and always emits the unsigned Store submission package. diff --git a/packaging/microsoft-store/build-store-package.ps1 b/packaging/microsoft-store/build-store-package.ps1 new file mode 100644 index 00000000..a7033256 --- /dev/null +++ b/packaging/microsoft-store/build-store-package.ps1 @@ -0,0 +1,221 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$ExecutablePath, + + [Parameter(Mandatory = $true)] + [string]$Version, + + [string]$PackageVersion, + [string]$OutputDirectory, + [string]$CertificateOutputPath, + [switch]$SignForLocalTesting +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path +$repositoryRoot = (Resolve-Path (Join-Path $scriptDirectory "..\..")).Path + +if (-not $OutputDirectory) { + $OutputDirectory = Join-Path $repositoryRoot "output\microsoft-store" +} + +$ExecutablePath = (Resolve-Path $ExecutablePath).Path +$OutputDirectory = [System.IO.Path]::GetFullPath($OutputDirectory) + +if ($Version -notmatch '^([0-9]+)\.([0-9]+)\.([0-9]+)$') { + throw "Version must use Major.Minor.Patch format, for example 0.1.1." +} + +$versionParts = @( + [int]$Matches[1], + [int]$Matches[2], + [int]$Matches[3] +) +if ($versionParts | Where-Object { $_ -gt 65535 }) { + throw "MSIX version components must be between 0 and 65535." +} + +# Microsoft Store package versions cannot start at zero. Keep the app's +# semantic version in the filename and reserve the first package component +# for Store versioning, which makes pre-1.0 builds valid and monotonic. +if (-not $PackageVersion) { + $packageMajor = $versionParts[0] + 1 + $PackageVersion = "$packageMajor.$($versionParts[1]).$($versionParts[2]).0" +} +if ($PackageVersion -notmatch '^([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)$') { + throw "PackageVersion must use Major.Minor.Patch.Revision format." +} +$packageParts = @( + [int]$Matches[1], + [int]$Matches[2], + [int]$Matches[3], + [int]$Matches[4] +) +if ($packageParts[0] -eq 0 -or ($packageParts | Where-Object { $_ -gt 65535 })) { + throw "MSIX version components must be between 0 and 65535 and Major cannot be zero." +} +if ($packageParts[3] -ne 0) { + throw "PackageVersion revision must be zero for Microsoft Store submissions." +} + +$packageBaseName = if ($SignForLocalTesting) { + "bongocat_${Version}_x64-local-test" +} +else { + "bongocat_${Version}_x64" +} +$msixPath = Join-Path $OutputDirectory "$packageBaseName.msix" +$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("BongoCatStorePackage_" + [guid]::NewGuid()) +$stagingDirectory = Join-Path $temporaryRoot "package" +$assetsDirectory = Join-Path $stagingDirectory "Assets" +$certificatePath = Join-Path $temporaryRoot "BongoCatStoreTemporary.pfx" +$certificate = $null + +if ($CertificateOutputPath -and -not $SignForLocalTesting) { + throw "CertificateOutputPath requires SignForLocalTesting. Store submission packages are unsigned." +} +if ($SignForLocalTesting -and -not $CertificateOutputPath) { + $CertificateOutputPath = Join-Path $OutputDirectory "$packageBaseName.cer" +} + +function Get-WindowsSdkTool { + param([Parameter(Mandatory = $true)][string]$Name) + + $programFilesX86 = ${env:ProgramFiles(x86)} + $sdkRoot = Join-Path $programFilesX86 "Windows Kits\10\bin" + if (Test-Path $sdkRoot) { + $sdkVersions = Get-ChildItem $sdkRoot -Directory | + Where-Object { $_.Name -match '^\d+\.\d+' } | + Sort-Object { [version]$_.Name } -Descending + + foreach ($sdkVersion in $sdkVersions) { + $candidate = Join-Path $sdkVersion.FullName "x64\$Name" + if (Test-Path $candidate) { + return $candidate + } + } + } + + $appCertKitCandidate = Join-Path $programFilesX86 "Windows Kits\10\App Certification Kit\$Name" + if (Test-Path $appCertKitCandidate) { + return $appCertKitCandidate + } + + throw "$Name was not found. Install the Windows 10/11 SDK." +} + +function New-StoreLogo { + param( + [Parameter(Mandatory = $true)][object]$Source, + [Parameter(Mandatory = $true)][int]$Width, + [Parameter(Mandatory = $true)][int]$Height, + [Parameter(Mandatory = $true)][string]$Destination + ) + + $bitmap = [System.Drawing.Bitmap]::new( + $Width, + $Height, + [System.Drawing.Imaging.PixelFormat]::Format32bppArgb) + try { + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + try { + $graphics.Clear([System.Drawing.Color]::Transparent) + $graphics.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality + $graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality + $graphics.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality + + $scale = [Math]::Min($Width / $Source.Width, $Height / $Source.Height) + $drawWidth = [Math]::Max(1, [int][Math]::Round($Source.Width * $scale)) + $drawHeight = [Math]::Max(1, [int][Math]::Round($Source.Height * $scale)) + $drawX = [int](($Width - $drawWidth) / 2) + $drawY = [int](($Height - $drawHeight) / 2) + $graphics.DrawImage($Source, $drawX, $drawY, $drawWidth, $drawHeight) + } + finally { + $graphics.Dispose() + } + + $bitmap.Save($Destination, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + $bitmap.Dispose() + } +} + +try { + New-Item -ItemType Directory -Force -Path $OutputDirectory, $stagingDirectory, $assetsDirectory | Out-Null + Remove-Item $msixPath -Force -ErrorAction SilentlyContinue + + Copy-Item $ExecutablePath (Join-Path $stagingDirectory "BongoCat.exe") + + $manifestTemplate = Get-Content (Join-Path $scriptDirectory "AppxManifest.xml.in") -Raw + $manifest = $manifestTemplate.Replace("@PACKAGE_VERSION@", $PackageVersion) + [System.IO.File]::WriteAllText( + (Join-Path $stagingDirectory "AppxManifest.xml"), + $manifest, + [System.Text.UTF8Encoding]::new($false)) + + Add-Type -AssemblyName System.Drawing + $sourceLogo = [System.Drawing.Image]::FromFile((Join-Path $repositoryRoot "resources\icons\icon.png")) + try { + New-StoreLogo $sourceLogo 50 50 (Join-Path $assetsDirectory "StoreLogo.png") + New-StoreLogo $sourceLogo 44 44 (Join-Path $assetsDirectory "Square44x44Logo.png") + New-StoreLogo $sourceLogo 150 150 (Join-Path $assetsDirectory "Square150x150Logo.png") + } + finally { + $sourceLogo.Dispose() + } + + $makeAppx = Get-WindowsSdkTool "makeappx.exe" + & $makeAppx pack /o /h SHA256 /d $stagingDirectory /p $msixPath + if ($LASTEXITCODE -ne 0) { + throw "MakeAppx failed with exit code $LASTEXITCODE." + } + + if ($SignForLocalTesting) { + $signTool = Get-WindowsSdkTool "signtool.exe" + $publisher = "CN=5503A135-7FA4-466B-815C-DBE627F4065F" + $certificate = New-SelfSignedCertificate ` + -Type Custom ` + -KeyUsage DigitalSignature ` + -KeyExportPolicy Exportable ` + -CertStoreLocation "Cert:\CurrentUser\My" ` + -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3", "2.5.29.19={text}") ` + -Subject $publisher ` + -FriendlyName "BongoCat temporary Microsoft Store package signing" + + $CertificateOutputPath = [System.IO.Path]::GetFullPath($CertificateOutputPath) + New-Item -ItemType Directory -Force -Path (Split-Path $CertificateOutputPath -Parent) | Out-Null + Export-Certificate -Cert $certificate -FilePath $CertificateOutputPath | Out-Null + + $plainPassword = [guid]::NewGuid().ToString("N") + $securePassword = ConvertTo-SecureString $plainPassword -AsPlainText -Force + Export-PfxCertificate -Cert $certificate -FilePath $certificatePath -Password $securePassword | Out-Null + + & $signTool sign /fd SHA256 /f $certificatePath /p $plainPassword $msixPath + if ($LASTEXITCODE -ne 0) { + throw "SignTool failed with exit code $LASTEXITCODE." + } + } + + if ($SignForLocalTesting) { + Write-Host "Signed local test package created: $msixPath" + } + else { + Write-Host "Unsigned Microsoft Store submission package created: $msixPath" + } + Write-Host "Package identity version: $PackageVersion" + if ($SignForLocalTesting) { + Write-Host "Certificate exported for local installation: $CertificateOutputPath" + } +} +finally { + if ($certificate) { + Remove-Item ("Cert:\CurrentUser\My\" + $certificate.Thumbprint) -Force -ErrorAction SilentlyContinue + } + Remove-Item $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/packaging/microsoft-store/validate-store-package.ps1 b/packaging/microsoft-store/validate-store-package.ps1 new file mode 100644 index 00000000..4587ec87 --- /dev/null +++ b/packaging/microsoft-store/validate-store-package.ps1 @@ -0,0 +1,165 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$PackagePath, + + [Parameter(Mandatory = $true)] + [string]$ExpectedAppVersion, + + [string]$ExpectedPackageVersion +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$PackagePath = (Resolve-Path $PackagePath).Path +if ($ExpectedAppVersion -notmatch '^([0-9]+)\.([0-9]+)\.([0-9]+)$') { + throw 'ExpectedAppVersion must use Major.Minor.Patch format.' +} +if (-not $ExpectedPackageVersion) { + $ExpectedPackageVersion = "$([int]$Matches[1] + 1).$($Matches[2]).$($Matches[3]).0" +} + +function Get-WindowsSdkTool { + param([Parameter(Mandatory = $true)][string]$Name) + + $sdkRoot = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin' + $sdkVersions = Get-ChildItem $sdkRoot -Directory | + Where-Object { $_.Name -match '^\d+\.\d+' } | + Sort-Object { [version]$_.Name } -Descending + foreach ($sdkVersion in $sdkVersions) { + $candidate = Join-Path $sdkVersion.FullName "x64\$Name" + if (Test-Path $candidate) { return $candidate } + } + throw "$Name was not found. Install the Windows 10/11 SDK." +} + +function Get-PeMachine { + param([Parameter(Mandatory = $true)][string]$Path) + + $stream = [IO.File]::OpenRead($Path) + $reader = [IO.BinaryReader]::new($stream) + try { + if ($reader.ReadUInt16() -ne 0x5A4D) { + throw 'The packaged executable does not have an MZ header.' + } + $stream.Position = 0x3C + $peOffset = $reader.ReadInt32() + if ($peOffset -lt 0x40 -or $peOffset -gt $stream.Length - 6) { + throw 'The packaged executable has an invalid PE offset.' + } + $stream.Position = $peOffset + if ($reader.ReadUInt32() -ne 0x00004550) { + throw 'The packaged executable does not have a PE signature.' + } + return $reader.ReadUInt16() + } + finally { + $reader.Dispose() + $stream.Dispose() + } +} + +if (-not ('BongoCatPackageIdentityNative' -as [type])) { + Add-Type -TypeDefinition @' +using System.Runtime.InteropServices; +using System.Text; +public static class BongoCatPackageIdentityNative { + [DllImport("kernelbase.dll", CharSet = CharSet.Unicode)] + public static extern int PackagePublisherIdFromPublisher( + string publisher, ref uint length, StringBuilder publisherId); +} +'@ +} + +$signature = Get-AuthenticodeSignature -FilePath $PackagePath +if ($signature.Status -ne [Management.Automation.SignatureStatus]::NotSigned) { + throw "Store submission package must be unsigned; status is $($signature.Status)." +} + +$temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) ( + 'BongoCatStoreValidation_' + [guid]::NewGuid()) +try { + New-Item -ItemType Directory -Path $temporaryRoot | Out-Null + $makeAppx = Get-WindowsSdkTool 'makeappx.exe' + & $makeAppx unpack /o /p $PackagePath /d $temporaryRoot | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "MakeAppx unpack failed with exit code $LASTEXITCODE." + } + + $manifestPath = Join-Path $temporaryRoot 'AppxManifest.xml' + [xml]$manifest = Get-Content -LiteralPath $manifestPath -Raw + $identity = $manifest.Package.Identity + $expectedIdentity = @{ + Name = 'vladelaina.bongocat' + Publisher = 'CN=5503A135-7FA4-466B-815C-DBE627F4065F' + Version = $ExpectedPackageVersion + ProcessorArchitecture = 'x64' + } + foreach ($field in $expectedIdentity.Keys) { + if ($identity.$field -ne $expectedIdentity[$field]) { + throw "Manifest Identity $field is '$($identity.$field)'; expected '$($expectedIdentity[$field])'." + } + } + + $application = $manifest.Package.Applications.Application + if ($application.Id -ne 'BongoCat' -or + $application.Executable -ne 'BongoCat.exe' -or + $application.EntryPoint -ne 'Windows.FullTrustApplication') { + throw 'Manifest application entry does not describe the BongoCat full-trust executable.' + } + + $requiredFiles = @( + 'BongoCat.exe', + 'Assets\StoreLogo.png', + 'Assets\Square44x44Logo.png', + 'Assets\Square150x150Logo.png' + ) + foreach ($relativePath in $requiredFiles) { + if (-not (Test-Path -LiteralPath (Join-Path $temporaryRoot $relativePath))) { + throw "Required package payload is missing: $relativePath" + } + } + if (Test-Path -LiteralPath (Join-Path $temporaryRoot 'AppxSignature.p7x')) { + throw 'Unsigned Store submission package unexpectedly contains AppxSignature.p7x.' + } + + $machine = Get-PeMachine (Join-Path $temporaryRoot 'BongoCat.exe') + if ($machine -ne 0x8664) { + throw ('Packaged executable machine is 0x{0:X4}; expected x64 0x8664.' -f $machine) + } + + $publisherLength = 0 + $publisherResult = [BongoCatPackageIdentityNative]::PackagePublisherIdFromPublisher( + $identity.Publisher, [ref]$publisherLength, $null) + if ($publisherResult -ne 122) { + throw "Publisher ID sizing failed with Windows error $publisherResult." + } + $publisherId = [Text.StringBuilder]::new([int]$publisherLength) + $publisherResult = [BongoCatPackageIdentityNative]::PackagePublisherIdFromPublisher( + $identity.Publisher, [ref]$publisherLength, $publisherId) + if ($publisherResult -ne 0) { + throw "Publisher ID calculation failed with Windows error $publisherResult." + } + $packageFamilyName = "$($identity.Name)_$publisherId" + if ($packageFamilyName -ne 'vladelaina.bongocat_hnew8t3b8e0t6') { + throw "Derived package family name is unexpected: $packageFamilyName" + } + + Write-Host 'Microsoft Store package validation passed.' + Write-Host "Identity: $($identity.Name)" + Write-Host "Version: $($identity.Version)" + Write-Host "Package family name: $packageFamilyName" + Write-Host 'Signature: unsigned (Partner Center submission)' +} +finally { + $tempPrefix = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\' + $resolvedTemporaryRoot = [IO.Path]::GetFullPath($temporaryRoot) + if ($resolvedTemporaryRoot.StartsWith($tempPrefix, + [StringComparison]::OrdinalIgnoreCase) -and + (Split-Path $resolvedTemporaryRoot -Leaf) -like + 'BongoCatStoreValidation_*') { + Remove-Item -LiteralPath $resolvedTemporaryRoot -Recurse -Force ` + -ErrorAction SilentlyContinue + } +} From ff475c556f8db29de12818af49589690b960d044 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 22 Aug 2026 00:01:38 +0800 Subject: [PATCH 066/334] fix: unify preferences window title --- resources/assets/locales/de-DE.json | 4 ++-- resources/assets/locales/en-US.json | 2 +- resources/assets/locales/es-ES.json | 4 ++-- resources/assets/locales/fr-FR.json | 4 ++-- resources/assets/locales/ja-JP.json | 4 ++-- resources/assets/locales/ko-KR.json | 4 ++-- resources/assets/locales/pt-BR.json | 4 ++-- resources/assets/locales/ru-RU.json | 4 ++-- resources/assets/locales/zh-CN.json | 4 ++-- resources/assets/locales/zh-Hant.json | 4 ++-- src/ui/preferences/preferences_about.c | 2 +- 11 files changed, 20 insertions(+), 20 deletions(-) diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index 954dc30d..b59e4f2f 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -189,7 +189,7 @@ "removeDesktopPet": "Diesen Desktop-Begleiter schlie\u00dfen", "modelLoadFailed": "Dieses Modell kann nicht angezeigt werden", "noBehaviors": "Keine Einträge", - "preferencesWindowTitle": "BongoCat - Einstellungen", + "preferencesWindowTitle": "BongoCat - Preferences", "startup": { "failed": "BongoCat konnte nicht gestartet werden.", "detail": "Technische Details finden Sie im Diagnoseprotokoll.", @@ -208,7 +208,7 @@ "community": "Community", "qqGroup": "QQ-Gruppe", "coffee": "Dem Entwicklungsteam einen Kaffee spendieren?", - "starOnGitHub": "GitHub-Stern", + "starOnGitHub": "GitHub-Stern!", "by": "von " }, "shortcut": { diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index 965152d0..cc12cc06 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -208,7 +208,7 @@ "community": "Community", "qqGroup": "QQ Group", "coffee": "Buy the development team a coffee?", - "starOnGitHub": "Star on GitHub", + "starOnGitHub": "Star on GitHub!", "by": "by " }, "shortcut": { diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index 224f5eaa..aaf896e0 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -189,7 +189,7 @@ "removeDesktopPet": "Cerrar esta mascota de escritorio", "modelLoadFailed": "No se puede mostrar este modelo", "noBehaviors": "No hay elementos", - "preferencesWindowTitle": "BongoCat - Preferencias", + "preferencesWindowTitle": "BongoCat - Preferences", "startup": { "failed": "No se pudo iniciar BongoCat.", "detail": "Consulte el registro de diagnóstico para ver los detalles técnicos.", @@ -208,7 +208,7 @@ "community": "Comunidad", "qqGroup": "Grupo de QQ", "coffee": "¿Invitar a un café al equipo de desarrollo?", - "starOnGitHub": "Estrella GitHub", + "starOnGitHub": "Estrella GitHub!", "by": "por " }, "shortcut": { diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index 15002621..45c8b284 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -189,7 +189,7 @@ "removeDesktopPet": "Fermer cette mascotte de bureau", "modelLoadFailed": "Impossible d'afficher ce modèle", "noBehaviors": "Aucun élément", - "preferencesWindowTitle": "BongoCat - Préférences", + "preferencesWindowTitle": "BongoCat - Preferences", "startup": { "failed": "Impossible de démarrer BongoCat.", "detail": "Consultez le journal de diagnostic pour les détails techniques.", @@ -208,7 +208,7 @@ "community": "Communauté", "qqGroup": "Groupe QQ", "coffee": "Offrir un café à l'équipe de développement ?", - "starOnGitHub": "Étoile GitHub", + "starOnGitHub": "Étoile GitHub!", "by": "par " }, "shortcut": { diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index a23d55f5..c6e070c6 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -189,7 +189,7 @@ "removeDesktopPet": "\u3053\u306e\u30c7\u30b9\u30af\u30c8\u30c3\u30d7\u30da\u30c3\u30c8\u3092\u9589\u3058\u308b", "modelLoadFailed": "このモデルを表示できません", "noBehaviors": "項目がありません", - "preferencesWindowTitle": "BongoCat - 設定", + "preferencesWindowTitle": "BongoCat - Preferences", "startup": { "failed": "BongoCat を起動できませんでした。", "detail": "技術的な詳細は診断ログを確認してください。", @@ -208,7 +208,7 @@ "community": "コミュニティ", "qqGroup": "QQグループ", "coffee": "開発チームにコーヒーを贈りますか?", - "starOnGitHub": "GitHubでスター", + "starOnGitHub": "GitHubでスター!", "by": "開発:" }, "shortcut": { diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index 0b44e50b..22af8b25 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -189,7 +189,7 @@ "removeDesktopPet": "\uc774 \ub370\uc2a4\ud06c\ud1b1 \ud3ab \ub2eb\uae30", "modelLoadFailed": "이 모델을 표시할 수 없습니다", "noBehaviors": "항목 없음", - "preferencesWindowTitle": "BongoCat - 설정", + "preferencesWindowTitle": "BongoCat - Preferences", "startup": { "failed": "BongoCat을 시작할 수 없습니다.", "detail": "기술적인 세부 정보는 진단 로그를 확인하세요.", @@ -208,7 +208,7 @@ "community": "커뮤니티", "qqGroup": "QQ 그룹", "coffee": "개발팀에 커피 한 잔을 선물할까요?", - "starOnGitHub": "GitHub 스타", + "starOnGitHub": "GitHub 스타!", "by": "제작: " }, "shortcut": { diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index 68f4436a..c24ee057 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -189,7 +189,7 @@ "removeDesktopPet": "Fechar este mascote da \u00e1rea de trabalho", "modelLoadFailed": "N\u00e3o foi poss\u00edvel exibir este modelo", "noBehaviors": "Nenhum item", - "preferencesWindowTitle": "BongoCat - Preferências", + "preferencesWindowTitle": "BongoCat - Preferences", "startup": { "failed": "Não foi possível iniciar o BongoCat.", "detail": "Consulte o log de diagnóstico para obter detalhes técnicos.", @@ -208,7 +208,7 @@ "community": "Comunidade", "qqGroup": "Grupo QQ", "coffee": "Pagar um café para a equipe de desenvolvimento?", - "starOnGitHub": "Estrela GitHub", + "starOnGitHub": "Estrela GitHub!", "by": "por " }, "shortcut": { diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index 9373d0fa..cbbdd06f 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -189,7 +189,7 @@ "removeDesktopPet": "\u0417\u0430\u043a\u0440\u044b\u0442\u044c \u044d\u0442\u043e\u0433\u043e \u043f\u0438\u0442\u043e\u043c\u0446\u0430", "modelLoadFailed": "Не удалось отобразить эту модель", "noBehaviors": "Нет элементов", - "preferencesWindowTitle": "BongoCat - Настройки", + "preferencesWindowTitle": "BongoCat - Preferences", "startup": { "failed": "Не удалось запустить BongoCat.", "detail": "Технические сведения доступны в журнале диагностики.", @@ -208,7 +208,7 @@ "community": "Сообщество", "qqGroup": "Группа QQ", "coffee": "Угостить команду разработки кофе?", - "starOnGitHub": "Звезда GitHub", + "starOnGitHub": "Звезда GitHub!", "by": "от " }, "shortcut": { diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index 3cd93b7b..2c2bb3a1 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -189,7 +189,7 @@ "removeDesktopPet": "\u5173\u95ed\u6b64\u684c\u5ba0", "modelLoadFailed": "\u6a21\u578b\u65e0\u6cd5\u663e\u793a", "noBehaviors": "暂无可用项目", - "preferencesWindowTitle": "BongoCat - 设置", + "preferencesWindowTitle": "BongoCat - Preferences", "startup": { "failed": "BongoCat 无法启动。", "detail": "请查看诊断日志了解技术详情。", @@ -208,7 +208,7 @@ "community": "社区", "qqGroup": "QQ 群", "coffee": "请开发团队喝杯咖啡?", - "starOnGitHub": "喜欢就点颗星吧", + "starOnGitHub": "喜欢就点颗星吧!", "by": "by " }, "shortcut": { diff --git a/resources/assets/locales/zh-Hant.json b/resources/assets/locales/zh-Hant.json index eceb503f..b1592629 100644 --- a/resources/assets/locales/zh-Hant.json +++ b/resources/assets/locales/zh-Hant.json @@ -189,7 +189,7 @@ "removeDesktopPet": "\u95dc\u9589\u6b64\u684c\u5bf5", "modelLoadFailed": "\u6a21\u578b\u7121\u6cd5\u986f\u793a", "noBehaviors": "暫無可用項目", - "preferencesWindowTitle": "BongoCat - 設定", + "preferencesWindowTitle": "BongoCat - Preferences", "startup": { "failed": "BongoCat 無法啟動。", "detail": "請查看診斷日誌以瞭解技術詳情。", @@ -208,7 +208,7 @@ "community": "社群", "qqGroup": "QQ 群", "coffee": "請開發團隊喝杯咖啡?", - "starOnGitHub": "喜歡就點顆星吧", + "starOnGitHub": "喜歡就點顆星吧!", "by": "by " }, "shortcut": { diff --git a/src/ui/preferences/preferences_about.c b/src/ui/preferences/preferences_about.c index ad5eb73d..c324b9ed 100644 --- a/src/ui/preferences/preferences_about.c +++ b/src/ui/preferences/preferences_about.c @@ -120,7 +120,7 @@ static void star_button(BongoCatPreferences *value, text(canvas, nk_rect(bounds.x + 50, bounds.y + (bounds.h - value->ui.label_font->height) * .5f, bounds.w - 62, value->ui.label_font->height), tr(value, - "native.support.starOnGitHub", "Star on GitHub"), + "native.support.starOnGitHub", "Star on GitHub!"), value->ui.label_font, nk_rgb(255, 255, 255)); link_cursor(context, bounds); if (hit(context, bounds)) From 5c8b156493467309969cc5c1335447fd3a35faf6 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 22 Aug 2026 14:18:13 +0800 Subject: [PATCH 067/334] fix(i18n): detect system language on first launch --- cmake/RuntimeSources.cmake | 1 + cmake/Tests.cmake | 1 + include/bongo_cat/config.h | 2 + src/core/config_names.c | 67 +++++++++++++++++++++++++ src/runtime/lifecycle/config_store.c | 11 ++++ src/runtime/lifecycle/system_language.c | 20 ++++++++ src/runtime/runtime.h | 1 + tests/core/test_language.c | 31 ++++++++++++ tests/core/test_main.c | 1 + tests/support/test.h | 1 + 10 files changed, 136 insertions(+) create mode 100644 src/runtime/lifecycle/system_language.c create mode 100644 tests/core/test_language.c diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index bf0b7512..9fc6d56f 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -40,6 +40,7 @@ set(BONGO_CAT_RUNTIME_LIFECYCLE_SOURCES src/runtime/lifecycle/shutdown.c src/runtime/lifecycle/startup.c src/runtime/lifecycle/startup_arguments.c + src/runtime/lifecycle/system_language.c src/runtime/lifecycle/storage_paths.c) set(BONGO_CAT_RUNTIME_DIAGNOSTIC_SOURCES diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index b5135bce..2c89bcc8 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -2,6 +2,7 @@ if(BUILD_TESTING) add_executable(bongo_cat_core_tests tests/core/test_main.c tests/core/test_config.c + tests/core/test_language.c tests/core/test_input.c tests/core/test_models.c tests/core/test_mver_pointer.c diff --git a/include/bongo_cat/config.h b/include/bongo_cat/config.h index 4bc3edff..0b6c1eaf 100644 --- a/include/bongo_cat/config.h +++ b/include/bongo_cat/config.h @@ -153,6 +153,8 @@ BongoCatResult bongo_cat_session_save(const char *path, const char *bongo_cat_theme_name(BongoCatTheme value); const char *bongo_cat_language_name(BongoCatLanguage value); bool bongo_cat_language_parse(const char *name, BongoCatLanguage *value); +bool bongo_cat_language_from_locale(const char *language, + const char *country, BongoCatLanguage *value); const char *bongo_cat_mode_name(BongoCatModelMode value); const char *bongo_cat_obs_background_color_name( BongoCatObsBackgroundColor value); diff --git a/src/core/config_names.c b/src/core/config_names.c index 61dd1d46..26f56c98 100644 --- a/src/core/config_names.c +++ b/src/core/config_names.c @@ -28,6 +28,73 @@ bool bongo_cat_language_parse(const char *name, BongoCatLanguage *value) { return false; } +static bool locale_part_equal(const char *text, size_t length, + const char *expected) { + if (length != strlen(expected)) return false; + for (size_t i = 0; i < length; ++i) { + unsigned char left = (unsigned char)text[i]; + unsigned char right = (unsigned char)expected[i]; + if (left >= 'A' && left <= 'Z') left += 'a' - 'A'; + if (right >= 'A' && right <= 'Z') right += 'a' - 'A'; + if (left != right) return false; + } + return true; +} + +static bool locale_has_part(const char *text, const char *expected) { + if (!text) return false; + while (*text) { + while (*text == '-' || *text == '_') text++; + const char *end = text; + while (*end && *end != '-' && *end != '_') end++; + if (locale_part_equal(text, (size_t)(end - text), expected)) + return true; + text = end; + } + return false; +} + +static bool locale_language_is(const char *language, const char *expected) { + if (!language) return false; + const char *end = language; + while (*end && *end != '-' && *end != '_') end++; + return locale_part_equal(language, (size_t)(end - language), expected); +} + +bool bongo_cat_language_from_locale(const char *language, + const char *country, BongoCatLanguage *value) { + if (!language || !value) return false; + if (locale_language_is(language, "zh")) { + bool hant = locale_has_part(language, "Hant") || + locale_has_part(country, "Hant"); + bool hans = locale_has_part(language, "Hans") || + locale_has_part(country, "Hans"); + bool traditional_region = locale_has_part(language, "TW") || + locale_has_part(language, "HK") || + locale_has_part(language, "MO") || + locale_has_part(country, "TW") || + locale_has_part(country, "HK") || + locale_has_part(country, "MO"); + *value = hant || (!hans && traditional_region) ? + BONGO_CAT_LANG_ZH_HANT : BONGO_CAT_LANG_ZH_CN; + return true; + } + static const struct { + const char *code; + BongoCatLanguage value; + } supported[] = {{"en", BONGO_CAT_LANG_EN_US}, + {"fr", BONGO_CAT_LANG_FR_FR}, {"de", BONGO_CAT_LANG_DE_DE}, + {"ja", BONGO_CAT_LANG_JA_JP}, {"ko", BONGO_CAT_LANG_KO_KR}, + {"pt", BONGO_CAT_LANG_PT_BR}, {"ru", BONGO_CAT_LANG_RU_RU}, + {"es", BONGO_CAT_LANG_ES_ES}}; + for (size_t i = 0; i < sizeof(supported) / sizeof(supported[0]); ++i) + if (locale_language_is(language, supported[i].code)) { + *value = supported[i].value; + return true; + } + return false; +} + const char *bongo_cat_mode_name(BongoCatModelMode value) { const char *names[] = {"standard", "keyboard", "gamepad"}; return (unsigned)value <= BONGO_CAT_MODE_GAMEPAD ? names[value] : names[0]; diff --git a/src/runtime/lifecycle/config_store.c b/src/runtime/lifecycle/config_store.c index 2fb0d0f2..993859fc 100644 --- a/src/runtime/lifecycle/config_store.c +++ b/src/runtime/lifecycle/config_store.c @@ -108,6 +108,17 @@ void bongo_cat_config_store_load(BongoCatApp *app) { app->settings_path, &app->settings, &error); app->settings_store_valid = loaded == BONGO_CAT_OK && bongo_cat_path_is_file(app->settings_path); + bool language_detected = false; + if (!app->settings_store_valid) { + /* System locale is only a first-run/recovery fallback; a valid + settings file always wins so an explicit user choice persists. */ + BongoCatLanguage detected_language = app->settings.app.language; + language_detected = bongo_cat_system_language(&detected_language); + if (language_detected) app->settings.app.language = detected_language; + } + if (language_detected) + SDL_Log("[runtime] Initial language selected from system locale: %s", + bongo_cat_language_name(app->settings.app.language)); if (loaded != BONGO_CAT_OK) { SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Settings ignored: %s", error.message); diff --git a/src/runtime/lifecycle/system_language.c b/src/runtime/lifecycle/system_language.c new file mode 100644 index 00000000..dc5c7d12 --- /dev/null +++ b/src/runtime/lifecycle/system_language.c @@ -0,0 +1,20 @@ +#include "runtime.h" + +#include + +bool bongo_cat_system_language(BongoCatLanguage *language) { + if (!language) return false; + int count = 0; + SDL_Locale **locales = SDL_GetPreferredLocales(&count); + bool matched = false; + for (int i = 0; locales && i < count; ++i) { + const SDL_Locale *locale = locales[i]; + if (locale && bongo_cat_language_from_locale(locale->language, + locale->country, language)) { + matched = true; + break; + } + } + if (locales) SDL_free(locales); + return matched; +} diff --git a/src/runtime/runtime.h b/src/runtime/runtime.h index 1960417a..8a52b6d7 100644 --- a/src/runtime/runtime.h +++ b/src/runtime/runtime.h @@ -111,6 +111,7 @@ bool bongo_cat_app_capture_pending_frame(BongoCatApp *app); void bongo_cat_runtime_flow_update(BongoCatApp *app, uint64_t now); void bongo_cat_random_expression_update(BongoCatApp *app, uint64_t now); void bongo_cat_random_expression_reset(BongoCatApp *app); +bool bongo_cat_system_language(BongoCatLanguage *language); void bongo_cat_config_store_load(BongoCatApp *app); void bongo_cat_config_store_update(BongoCatApp *app, uint64_t now); void bongo_cat_config_store_flush(BongoCatApp *app); diff --git a/tests/core/test_language.c b/tests/core/test_language.c new file mode 100644 index 00000000..d2215b2e --- /dev/null +++ b/tests/core/test_language.c @@ -0,0 +1,31 @@ +#include "test.h" +#include "bongo_cat/config.h" + +static void check_locale(const char *language, const char *country, + BongoCatLanguage expected) { + BongoCatLanguage actual = BONGO_CAT_LANG_COUNT; + CHECK(bongo_cat_language_from_locale(language, country, &actual)); + CHECK(actual == expected); +} + +void test_language(void) { + check_locale("en", "GB", BONGO_CAT_LANG_EN_US); + check_locale("fr-CA", NULL, BONGO_CAT_LANG_FR_FR); + check_locale("de", "AT", BONGO_CAT_LANG_DE_DE); + check_locale("ja", "JP", BONGO_CAT_LANG_JA_JP); + check_locale("ko", "KR", BONGO_CAT_LANG_KO_KR); + check_locale("pt", "PT", BONGO_CAT_LANG_PT_BR); + check_locale("ru", "RU", BONGO_CAT_LANG_RU_RU); + check_locale("es", "MX", BONGO_CAT_LANG_ES_ES); + check_locale("zh", "CN", BONGO_CAT_LANG_ZH_CN); + check_locale("zh", "SG", BONGO_CAT_LANG_ZH_CN); + check_locale("zh", "TW", BONGO_CAT_LANG_ZH_HANT); + check_locale("zh", "HK", BONGO_CAT_LANG_ZH_HANT); + check_locale("ZH-hAnT", "CN", BONGO_CAT_LANG_ZH_HANT); + check_locale("zh-Hans", "TW", BONGO_CAT_LANG_ZH_CN); + check_locale("zh", "Hant", BONGO_CAT_LANG_ZH_HANT); + BongoCatLanguage unchanged = BONGO_CAT_LANG_DE_DE; + CHECK(!bongo_cat_language_from_locale("it", "IT", &unchanged)); + CHECK(unchanged == BONGO_CAT_LANG_DE_DE); + CHECK(!bongo_cat_language_from_locale(NULL, NULL, &unchanged)); +} diff --git a/tests/core/test_main.c b/tests/core/test_main.c index 14ffdee0..f4516f33 100644 --- a/tests/core/test_main.c +++ b/tests/core/test_main.c @@ -4,6 +4,7 @@ int bongo_cat_test_failures; int main(void) { test_config(); + test_language(); test_input(); test_models(); test_mver_pointer(); diff --git a/tests/support/test.h b/tests/support/test.h index 58bf3f19..6986d827 100644 --- a/tests/support/test.h +++ b/tests/support/test.h @@ -10,6 +10,7 @@ extern int bongo_cat_test_failures; } } while (0) void test_config(void); +void test_language(void); void test_input(void); void test_models(void); void test_mver_pointer(void); From cbf766234de0473608747d43b4a8040a07be1402 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 22 Aug 2026 16:11:37 +0800 Subject: [PATCH 068/334] chore: update version and support information --- CMakeLists.txt | 2 +- resources/assets/locales/de-DE.json | 2 +- resources/assets/locales/en-US.json | 2 +- resources/assets/locales/es-ES.json | 2 +- resources/assets/locales/fr-FR.json | 2 +- resources/assets/locales/ja-JP.json | 2 +- resources/assets/locales/ko-KR.json | 2 +- resources/assets/locales/pt-BR.json | 2 +- resources/assets/locales/ru-RU.json | 2 +- resources/assets/locales/zh-CN.json | 2 +- resources/assets/locales/zh-Hant.json | 2 +- src/ui/preferences/preferences_about.c | 2 +- .../preferences/preferences_about_community.c | 82 +++++++++++++++++-- 13 files changed, 85 insertions(+), 21 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 7cb2fbe5..6ba09911 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -project(BongoCat VERSION 0.1.1 DESCRIPTION "BongoCat" HOMEPAGE_URL "https://bongocat.pet" LANGUAGES C CXX) +project(BongoCat VERSION 2.0.1 DESCRIPTION "BongoCat" HOMEPAGE_URL "https://bongocat.pet" LANGUAGES C CXX) include(cmake/Version.cmake) include(FetchContent) include(CTest) diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index b59e4f2f..4fd56492 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -204,7 +204,7 @@ "latest": "Bereits aktuell", "works": "Weitere Anwendungen", "worksText": "Weitere Software von vladelaina", - "refactorDeveloper": "Entwickler des C-Rewrites und Maintainer von BongoCat", + "refactorDeveloper": "Entwickler, der BongoCat in C neu geschrieben hat und zugleich aktueller Maintainer ist", "community": "Community", "qqGroup": "QQ-Gruppe", "coffee": "Dem Entwicklungsteam einen Kaffee spendieren?", diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index cc12cc06..1bc3db7d 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -204,7 +204,7 @@ "latest": "Already up to date", "works": "More apps", "worksText": "More software from vladelaina", - "refactorDeveloper": "BongoCat C rewrite developer & current maintainer", + "refactorDeveloper": "Developer who rewrote BongoCat in C and is also its current maintainer", "community": "Community", "qqGroup": "QQ Group", "coffee": "Buy the development team a coffee?", diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index aaf896e0..01cf7429 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -204,7 +204,7 @@ "latest": "Ya está actualizado", "works": "Más aplicaciones", "worksText": "Más software de vladelaina", - "refactorDeveloper": "Desarrollador de la versión C y mantenedor de BongoCat", + "refactorDeveloper": "Desarrollador que reescribió BongoCat en C y que también es su mantenedor actual", "community": "Comunidad", "qqGroup": "Grupo de QQ", "coffee": "¿Invitar a un café al equipo de desarrollo?", diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index 45c8b284..b0375696 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -204,7 +204,7 @@ "latest": "Déjà à jour", "works": "Plus d'applications", "worksText": "Plus de logiciels de vladelaina", - "refactorDeveloper": "Développeur de la refonte C et mainteneur de BongoCat", + "refactorDeveloper": "Développeur qui a réécrit BongoCat en C et qui en est aussi le mainteneur actuel", "community": "Communauté", "qqGroup": "Groupe QQ", "coffee": "Offrir un café à l'équipe de développement ?", diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index c6e070c6..b76195c0 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -204,7 +204,7 @@ "latest": "最新バージョンです", "works": "その他のアプリ", "worksText": "vladelainaのその他のソフトウェア", - "refactorDeveloper": "BongoCat C版の再構築者・現メンテナー", + "refactorDeveloper": "BongoCatをC言語で書き直した開発者であり、現在のメンテナーでもある", "community": "コミュニティ", "qqGroup": "QQグループ", "coffee": "開発チームにコーヒーを贈りますか?", diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index 22af8b25..d73bb29e 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -204,7 +204,7 @@ "latest": "최신 버전입니다", "works": "더 많은 앱", "worksText": "vladelaina의 다른 소프트웨어", - "refactorDeveloper": "BongoCat C 리빌드 개발자 및 현 관리자", + "refactorDeveloper": "BongoCat을 C 언어로 재작성한 개발자이자 현재 유지보수 담당자", "community": "커뮤니티", "qqGroup": "QQ 그룹", "coffee": "개발팀에 커피 한 잔을 선물할까요?", diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index c24ee057..38c1c05f 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -204,7 +204,7 @@ "latest": "Já está atualizado", "works": "Mais aplicativos", "worksText": "Outros softwares de vladelaina", - "refactorDeveloper": "Desenvolvedor da versão C e atual mantenedor do BongoCat", + "refactorDeveloper": "Desenvolvedor que reescreveu o BongoCat em C e também é seu mantenedor atual", "community": "Comunidade", "qqGroup": "Grupo QQ", "coffee": "Pagar um café para a equipe de desenvolvimento?", diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index cbbdd06f..84981ff9 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -204,7 +204,7 @@ "latest": "Установлена последняя версия", "works": "Другие приложения", "worksText": "Другие программы от vladelaina", - "refactorDeveloper": "Автор C-версии и текущий сопровождающий BongoCat", + "refactorDeveloper": "Разработчик, переписавший BongoCat на C, и его нынешний мейнтейнер", "community": "Сообщество", "qqGroup": "Группа QQ", "coffee": "Угостить команду разработки кофе?", diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index 2c2bb3a1..97ca47a4 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -204,7 +204,7 @@ "latest": "已是最新版本", "works": "更多作品", "worksText": "来自 vladelaina 的其他软件", - "refactorDeveloper": "BongoCat C 语言重构者与现任维护者", + "refactorDeveloper": "将BongoCat用C语言重构的开发者,同时也是现任维护者", "community": "社区", "qqGroup": "QQ 群", "coffee": "请开发团队喝杯咖啡?", diff --git a/resources/assets/locales/zh-Hant.json b/resources/assets/locales/zh-Hant.json index b1592629..004ce316 100644 --- a/resources/assets/locales/zh-Hant.json +++ b/resources/assets/locales/zh-Hant.json @@ -204,7 +204,7 @@ "latest": "已是最新版本", "works": "更多作品", "worksText": "來自 vladelaina 的其他軟體", - "refactorDeveloper": "BongoCat C 語言重構者與現任維護者", + "refactorDeveloper": "將BongoCat用C語言重構的開發者,同時也是現任維護者", "community": "社群", "qqGroup": "QQ 群", "coffee": "請開發團隊喝杯咖啡?", diff --git a/src/ui/preferences/preferences_about.c b/src/ui/preferences/preferences_about.c index c324b9ed..3b5abd7b 100644 --- a/src/ui/preferences/preferences_about.c +++ b/src/ui/preferences/preferences_about.c @@ -124,7 +124,7 @@ static void star_button(BongoCatPreferences *value, value->ui.label_font, nk_rgb(255, 255, 255)); link_cursor(context, bounds); if (hit(context, bounds)) - open_url("https://github.com/bongocat-pet/BongoCat"); + open_url("https://github.com/BongoCatPet/BongoCat"); } static void hero_title(BongoCatPreferences *value, struct nk_context *context, struct nk_command_buffer *canvas, struct nk_rect bounds, diff --git a/src/ui/preferences/preferences_about_community.c b/src/ui/preferences/preferences_about_community.c index 2fe690b2..282b0674 100644 --- a/src/ui/preferences/preferences_about_community.c +++ b/src/ui/preferences/preferences_about_community.c @@ -21,13 +21,20 @@ static void text(struct nk_command_buffer *canvas, struct nk_rect bounds, nk_rgba(0, 0, 0, 0), color); } -static void centered(struct nk_command_buffer *canvas, struct nk_rect bounds, - const char *value, const struct nk_user_font *font, struct nk_color color) { +static void centered_span(struct nk_command_buffer *canvas, + struct nk_rect bounds, const char *value, int length, + const struct nk_user_font *font, struct nk_color color) { float width = font->width(font->userdata, font->height, - value, nk_strlen(value)); - text(canvas, nk_rect(bounds.x + (bounds.w - width) * .5f, + value, length); + nk_draw_text(canvas, nk_rect(bounds.x + (bounds.w - width) * .5f, bounds.y + (bounds.h - font->height) * .5f, - NK_MIN(width + 1, bounds.w), font->height), value, font, color); + NK_MIN(width + 1, bounds.w), font->height), value, length, font, + nk_rgba(0, 0, 0, 0), color); +} + +static void centered(struct nk_command_buffer *canvas, struct nk_rect bounds, + const char *value, const struct nk_user_font *font, struct nk_color color) { + centered_span(canvas, bounds, value, nk_strlen(value), font, color); } static float span_width(const struct nk_user_font *font, @@ -42,6 +49,63 @@ static void span(struct nk_command_buffer *canvas, struct nk_rect bounds, nk_rgba(0, 0, 0, 0), color); } +static int next_utf8_boundary(const char *value, int length, int offset) { + int next = offset + 1; + while (next < length && + (((unsigned char)value[next] & 0xc0u) == 0x80u)) ++next; + return next; +} + +static int balanced_split(const char *value, int length, + const struct nk_user_font *font, float maximum_width, + bool spaces_only, float *widest_line) { + int best = -1; + float best_width = 1.0e30f; + for (int split = next_utf8_boundary(value, length, 0); + split < length; split = next_utf8_boundary(value, length, split)) { + if (spaces_only && value[split] != ' ') continue; + int left = split, right = split; + while (left > 0 && value[left - 1] == ' ') --left; + while (right < length && value[right] == ' ') ++right; + if (!left || right >= length) continue; + float left_width = font->width(font->userdata, font->height, + value, left); + float right_width = font->width(font->userdata, font->height, + value + right, length - right); + float width = NK_MAX(left_width, right_width); + if (width < best_width) { best = split; best_width = width; } + } + if (widest_line) *widest_line = best_width; + return best_width <= maximum_width ? best : -1; +} + +static void centered_wrapped(struct nk_command_buffer *canvas, + struct nk_rect bounds, const char *value, + const struct nk_user_font *font, struct nk_color color) { + int length = nk_strlen(value); + if (font->width(font->userdata, font->height, value, length) <= bounds.w) { + centered_span(canvas, bounds, value, length, font, color); + return; + } + float widest = 0.0f; + int split = balanced_split(value, length, font, bounds.w, true, &widest); + if (split < 0) + split = balanced_split(value, length, font, bounds.w, false, &widest); + if (split < 0) { + centered_span(canvas, bounds, value, length, font, color); + return; + } + int left = split, right = split; + while (left > 0 && value[left - 1] == ' ') --left; + while (right < length && value[right] == ' ') ++right; + struct nk_rect line = nk_rect(bounds.x, + bounds.y + (bounds.h - font->height * 2.0f) * .5f, + bounds.w, font->height); + centered_span(canvas, line, value, left, font, color); + line.y += font->height; + centered_span(canvas, line, value + right, length - right, font, color); +} + static bool hit(struct nk_context *context, struct nk_rect bounds) { return nk_input_is_mouse_hovering_rect(&context->input, bounds) && nk_input_is_mouse_click_in_rect(&context->input, NK_BUTTON_LEFT, bounds); @@ -57,9 +121,9 @@ static void community_link(BongoCatPreferences *value, struct nk_rect bounds, int index, BongoCatUIPalette p) { const char *labels[] = {"Discord", tr(value, "native.support.qqGroup", "QQ Group")}; - const char *details[] = {"https://discord.gg/vf8jqnattk", "1016541762"}; + const char *details[] = {"https://discord.gg/vf8jqnattk", "473033518"}; const char *urls[] = {"https://discord.gg/vf8jqnattk", - "https://qm.qq.com/q/jmr39ESZIk"}; + "https://qm.qq.com/q/sccLv3USXg"}; struct nk_color brand = index ? p.accent : nk_rgb(88, 101, 242); bool hover = nk_input_is_mouse_hovering_rect(&context->input, bounds); char id[32]; snprintf(id, sizeof(id), "community-hover-%d", index); @@ -150,9 +214,9 @@ void bongo_cat_preferences_about_projects_heading( BONGO_CAT_UI_CURSOR_POINTER); if (developer && hit(context, link)) open_url("https://vlaina.com/r/bongocat"); - centered(canvas, nk_rect(bounds.x, bounds.y + 72, bounds.w, 22), + centered_wrapped(canvas, nk_rect(bounds.x, bounds.y + 66, bounds.w, 42), tr(value, "native.support.refactorDeveloper", - "BongoCat C rewrite developer & current maintainer"), + "Developer who rewrote BongoCat in C and is also its current maintainer"), value->ui.caption_font, p.muted); } From 5cd7fde27311efe6e127ca8886283530e757cc2a Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 22 Aug 2026 16:32:52 +0800 Subject: [PATCH 069/334] fix: update feedback repository link --- src/ui/preferences/preferences_about.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/preferences/preferences_about.c b/src/ui/preferences/preferences_about.c index 3b5abd7b..9c4167cf 100644 --- a/src/ui/preferences/preferences_about.c +++ b/src/ui/preferences/preferences_about.c @@ -209,7 +209,7 @@ static void footer(BongoCatPreferences *value, struct nk_context *context, bongo_cat_ui_color_mix(p.accent, p.pink, feedback_amount)); link_cursor(context, feedback_link); if (hit(context, feedback_link)) - open_url("https://github.com/bongocat-pet/BongoCat/issues"); + open_url("https://github.com/BongoCatPet/BongoCat/issues"); } static void hero(BongoCatPreferences *value, struct nk_context *context) { From e18ef465e91caac1dce613648dec22fc78289bc8 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 22 Aug 2026 19:31:38 +0800 Subject: [PATCH 070/334] chore: keep project docs local --- .gitignore | 2 + README.md | 12 +- docs/code-organization.md | 57 ------ docs/feature-audit.md | 103 ----------- docs/modal-frame-architecture.md | 40 ---- docs/model-package-v2.md | 153 --------------- docs/parity.md | 175 ------------------ docs/performance-audit-2026-07-29.md | 83 --------- ...tform-game-coexistence-audit-2026-08-21.md | 139 -------------- docs/startup-reliability-audit-2026-07-26.md | 147 --------------- docs/storage-layout.md | 151 --------------- docs/system-audit-2026-07-17.md | 94 ---------- 12 files changed, 7 insertions(+), 1149 deletions(-) delete mode 100644 docs/code-organization.md delete mode 100644 docs/feature-audit.md delete mode 100644 docs/modal-frame-architecture.md delete mode 100644 docs/model-package-v2.md delete mode 100644 docs/parity.md delete mode 100644 docs/performance-audit-2026-07-29.md delete mode 100644 docs/platform-game-coexistence-audit-2026-08-21.md delete mode 100644 docs/startup-reliability-audit-2026-07-26.md delete mode 100644 docs/storage-layout.md delete mode 100644 docs/system-audit-2026-07-17.md diff --git a/.gitignore b/.gitignore index a190eff9..083150da 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,8 @@ Desktop.ini # Local reference files /.reference/ +/docs/ + # Local Windows audit tools /cmake/*.ps1 !/cmake/RunCubismViewerBlindTest.ps1 diff --git a/README.md b/README.md index 71aff16f..99ec3af1 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,9 @@ SDK is licensed and is not checked into this repository. ## Current State The native application, bundled model packages, preferences, import path, and -platform integrations are in this tree. The evidence ledger in -[`docs/feature-audit.md`](docs/feature-audit.md) records what has been exercised -on a real Windows Cubism build and what still needs a manual check on a -particular operating system or device. +platform integrations are in this tree. Validation is performed against real +Windows Cubism builds, with platform- and device-specific checks called out +when they still need a manual pass. Without Cubism, CMake selects a diagnostic backend. The application still starts, opens its preferences, processes input, and runs the native tests, but @@ -89,7 +88,7 @@ possible. ```text include/bongo_cat/ public C interfaces and data structures src/core/ config, paths, input state, catalogs, hashes -src/runtime/ runtime orchestration (see docs/code-organization.md) +src/runtime/ runtime orchestration lifecycle/ startup, frame timing, storage, and app state input/ runtime input mapping and shortcuts model/ catalog, storage, and import pipelines @@ -109,7 +108,6 @@ src/ui/ UI implementation grouped by responsibility resources/assets/ bundled models, textures, locales, and tray assets tests/ native tests grouped by subsystem, plus fixtures cmake/ dependency, platform, target, and source-policy modules -docs/ audit evidence, parity notes, and organization rules ``` Internal headers remain private to `src`; only `include/bongo_cat` is a public @@ -220,7 +218,7 @@ cmake --install build --config Release --component Runtime --prefix out The install tree is portable. Windows embeds the resource archive; Unix builds place `assets/` beside the executable or application bundle. User-owned files -follow [`docs/storage-layout.md`](docs/storage-layout.md). The portable +follow the per-user storage layout managed by the application. The portable executable and installed application use the same per-user storage layout on each platform; no files are written next to the executable. This pre-release build does not migrate settings from an older directory. Settings and session diff --git a/docs/code-organization.md b/docs/code-organization.md deleted file mode 100644 index 195ae5f9..00000000 --- a/docs/code-organization.md +++ /dev/null @@ -1,57 +0,0 @@ -# Native code organization - -The native implementation is organized by ownership. Directory names describe -the subsystem that owns a file; they are not a second public API. Public C -interfaces stay in `include/bongo_cat`, while headers under `src` are private -implementation details. - -## Dependency direction - -```text -include/bongo_cat - ^ - | -src/core <--- src/runtime/model/import - ^ ^ - | | -src/platform src/runtime (lifecycle, input, shell) - ^ ^ - +-------- src/ui (backend, rendering, preferences) - ^ - src/main.c -``` - -The arrows describe allowed ownership, not a requirement that every module -depends on every module below it: - -- `core` contains platform-neutral data and persistence primitives. -- `runtime/model` owns the catalog and storage model; `runtime/model/import` - owns format adapters and nearby-package discovery. -- `runtime/lifecycle`, `runtime/input`, and `runtime/shell` coordinate the - running application but do not define public data structures. -- `ui` consumes runtime state and renders it. UI code must not reach into a - platform implementation through relative paths; private include directories - are declared by CMake instead. -- `platform/` owns native APIs. Cross-platform code calls public platform - interfaces from `include/bongo_cat`. - -## CMake ownership - -`cmake/RuntimeSources.cmake` is the source-of-truth for runtime/UI ownership -groups. Platform-specific source and link settings live in -`cmake/PlatformRuntime.cmake`; validation executables, application resources, -and tests have their own target modules. This keeps the root `CMakeLists.txt` -focused on project-wide options and library wiring. - -When adding a source file: - -1. Put it in the smallest owning directory. -2. Add it to that directory's source group in `RuntimeSources.cmake`, or to the - relevant platform/target module. -3. Add private include directories only to the target that needs them. -4. Run `cmake -DROOT=. -P cmake/CheckLines.cmake`, configure a clean build, and - run `ctest --test-dir --output-on-failure`. - -Tests follow the same ownership model under `tests/core`, `tests/ui`, -`tests/model_import`, `tests/platform`, and `tests/i18n`. Fixtures stay under -`tests/fixtures` because they are data rather than implementation. diff --git a/docs/feature-audit.md b/docs/feature-audit.md deleted file mode 100644 index 099c19ef..00000000 --- a/docs/feature-audit.md +++ /dev/null @@ -1,103 +0,0 @@ -# Native feature audit - -This is the completion ledger for the independent native application. `PASS` -requires direct automated or visual evidence. `PENDING` means the implementation -exists but the required hardware or operating system has not been exercised. -A diagnostic drawing never counts as a Live2D visual pass. - -## Current evidence - -- Windows Cubism Release build: `build-delivery-final/Release/BongoCat.exe` - PASS. -- Native tests: `ctest --test-dir build-delivery-final -C Release` - PASS, 5/5. -- Source policy: `check-lines` - PASS; the default is 300 lines and reviewed - file-specific limits are declared in `cmake/CheckLines.cmake`. -- Preferences matrix: `build-cubism/visual-audit-preferences/audit.csv` - PASS, 50/50. -- Real Live2D matrix: `build-cubism/visual-audit-cubism/audit.csv` - PASS, 14/14. -- Pointer/mouse-mirror matrix: `build-cubism/visual-audit-mouse/audit.csv` - PASS, 5/5. -- Visible Cubism memory baseline: PENDING remeasurement on the current Release - build; working set and private committed memory are reported separately. -- Standalone smoke: 22 embedded shaders, `renderer=cubism-native`, GL error 0. -- Release ZIP and SHA-256 sidecar generation - PASS. -- Cubism Native SDK under `vendor/CubismSdkForNative` - PASS, SDK 5 r.5. -- Tauri-parity pointer mapping and frame-rate-independent damping - PASS. - -## Models and rendering - -| Requirement | Status | Evidence or remaining proof | -|---|---|---| -| Three bundled model packages | PASS | Model tests validate MOC3, textures, settings, motions and expressions. | -| Background and key overlays | PASS | Standard, keyboard and gamepad idle/input screenshots match their intended composition. | -| Custom model import/remove | PASS | Valid import/remove smoke passes; malformed and unsafe manifests are rejected. | -| Model selection | PASS | Model catalog tests plus three real-model startup screenshots. | -| Real Cubism model rendering | PASS | All models report `renderer=cubism-native`; screenshots show the expected Tauri-parity composition. | -| Masks, blending and drawable order | PASS | Cubism r.5 renders alpha, masks, face, paws and overlays without OpenGL errors. | -| Motion playback and fades | PASS | Both bundled motions are accepted and differ from idle by 85.63% and 2.11%. | -| Expression playback | PASS | Expression 0 is accepted and differs from the same-run idle baseline. | -| Motion audio | PENDING | Audio loading is implemented; a manual audible check remains. | -| Mirror visual result | PASS | Real-model mirror differs from idle by 41.95%. | - -## Input and behavior - -| Requirement | Status | Evidence or remaining proof | -|---|---|---| -| Keyboard left/right hands | PASS | Individual and simultaneous real frames differ from idle. | -| Mouse left/right buttons | PASS | Runtime parameters pass and real frames differ from idle. | -| Gamepad buttons and four axes | PASS | Runtime parameter assertions and real-frame differences pass. | -| Global Windows keyboard input | PASS | Injected events reach the native hook and alter overlays. | -| Global Windows mouse input | PASS | Injected down/up events reach the native input queue. | -| Global Windows pointer movement | PASS | Low-level hook coordinates, smoothing and parameter mapping are covered. | -| Pointer-driven Live2D tracking | PASS | Frames differ by 8.74%/9.18%; seven parameters and mirrored X/Z directions pass. | -| Windows lost key-release recovery | PASS | Core input tests cover physical-state reconciliation. | -| Gamepad hot plug | PENDING | SDL implementation exists; physical-device testing remains. | -| Behavior shortcuts | PENDING | Parsing and dispatch exist; a configured global shortcut smoke remains. | -| macOS input permission | PENDING | Native implementation exists; macOS manual verification remains. | - -## Window and application - -| Requirement | Status | Evidence or remaining proof | -|---|---|---| -| Transparent borderless main window | PASS | Win32 caption, frame, menu and caption buttons are removed. | -| Preferences window | PASS | 50 screenshots contain no blank frames. | -| Click-through and always-on-top | PASS | Native style flags pass the window smoke. | -| Hide on hover | PASS | Enter, hide, pass-through, leave and restore are asserted. | -| Keep inside monitor | PASS | Off-screen placement is clamped in the geometry smoke. | -| Menu scale and opacity | PASS | Native size and opacity values are asserted. | -| Shift plus right-drag resize | PASS | Synthetic gesture verifies scale, size and release. | -| Capture window presence | PENDING | The pet is hidden from the taskbar/Dock while remaining eligible for OBS window capture; Windows is implemented and macOS/Linux need platform exercise. | -| System tray | PASS | Native SDL tray handle and state callbacks are asserted. | -| Right-click context menu | PASS | Production Win32 popup opens Preferences. | -| Single instance | PASS | A second process exits while the first remains alive. | -| Autostart | PENDING | Platform implementations exist; OS login testing remains. | -| Hidden rendering suspension | PASS | Main loop skips model updates while hidden. | - -## Preferences, localization and data - -| Requirement | Status | Evidence or remaining proof | -|---|---|---| -| Cat, general, model, shortcuts and about pages | PASS | Current 50-view matrix. | -| Model card grid and direct-drawn icons | PASS | Responsive cards are present in both themes. | -| Simplified/Traditional Chinese, English, French, German, Japanese, Korean, Portuguese, Russian and Spanish | PASS | All ten locales pass shape, glyph, persistence and settings-window smoke checks. | -| Light and dark themes | PASS | Both themes are covered for every page and language. | -| System theme | PASS | Auto mode resolves through the native SDL system theme. | -| Click and drag model import | PASS | Both paths use the validated shared importer. | -| Atomic configuration persistence | PASS | Tests include truncated-write recovery. | -| No Electron or Tauri dependency | PASS | Native source/config scan is clean. | - -## Performance and platform gates - -| Requirement | Status | Evidence or remaining proof | -|---|---|---| -| Working set below 100 MiB | PENDING | Repeat the current Release measurement with working set and private committed memory reported separately. | -| Diagnostic idle CPU | PASS | Visible average 0.0324%; hidden average 0.0182%. | -| Cubism visible resource use | PASS | At the parity 60 FPS default: 0.94% total CPU on the current 4-CPU environment. | -| Five-minute bounded-memory runs | PASS | Visible, preferences and hidden modes remain bounded. | -| 24-hour bounded-memory run | PENDING | The harness exists; an uninterrupted result remains. | -| Windows 10 and 11 | PENDING | Current Windows passes; a second supported version remains. | -| macOS x64 and arm64 | PENDING | Requires macOS runners and signed application builds. | -| Linux X11 | PENDING | Requires a Linux runner with XInput2 and Xfixes. | -| Linux Wayland reduced-mode reporting | PENDING | Requires a Wayland session. | - -The Windows implementation has no remaining Cubism or visual blocker. The -remaining `PENDING` rows require a current memory rebaseline, external -hardware, long-duration execution, or operating systems that are not present -on the current machine. diff --git a/docs/modal-frame-architecture.md b/docs/modal-frame-architecture.md deleted file mode 100644 index 2e0c9858..00000000 --- a/docs/modal-frame-architecture.md +++ /dev/null @@ -1,40 +0,0 @@ -# Modal menu frame ownership - -Native context menus run a nested platform event loop. The SDL application loop -does not advance while one is open, so animation must be pumped explicitly. - -The dependency direction is intentionally one way: - -```text -native modal timer -> BongoCatModalTick -> runtime/modal_frame -> input + Live2D + render -``` - -Ownership boundaries: - -- `runtime/modal_frame.*` owns elapsed-time continuity and the work performed by - a modal frame. It is the only modal-related module allowed to coordinate input, - Live2D, and rendering. -- `runtime/window_menu_preview.c` owns temporary context-menu previews. It - delegates animation to the modal frame pump. -- `runtime/tray.c` owns tray state and supplies a modal frame callback. It does - not know how a native menu loop is implemented. -- `platform/windows_borderless.c` owns the desktop menu timer and highlight - messages. Its callback state is attached to the individual window. -- `platform/windows_tray.c` owns SDL tray-window subclassing and the tray menu - timer. Its callback state is attached to the individual tray window. -- Native window dragging uses the same pump while the operating system owns the - nested move loop. -- `platform/macos.m` schedules the same callback in the menu run-loop mode. - SDL's Linux tray is asynchronous; the custom X11 desktop menu calls the - callback from its own loop. - -Invariants for future changes: - -1. Platform menu modules must not include Live2D or application runtime state. -2. Modal ticks must update `app->last_frame_ns`, preventing a large catch-up - step when the SDL loop resumes. -3. The pump must drain model input without dispatching shortcuts before advancing Live2D. -4. Every native timer and subclass must be removed before its callback userdata - is destroyed. -5. Desktop and tray menus must both pass a held-open test with continuing frame - timestamps and different image hashes after pointer movement. diff --git a/docs/model-package-v2.md b/docs/model-package-v2.md deleted file mode 100644 index 2d3384d7..00000000 --- a/docs/model-package-v2.md +++ /dev/null @@ -1,153 +0,0 @@ -# BongoCat Model Package v2 - -## Purpose - -BongoCat uses its own installed package format. Tauri Live2D folders, -Bongo-Cat-Mver packages, and Mver image patches are import formats, not runtime -storage formats. - -The import boundary is: - -```text -Tauri / Mver / Mver patch - -> format importer - -> one BongoCat package per mode - -> normalized runtime adapter - -> application runtime -``` - -This keeps the large Mver model ecosystem available without carrying Mver's -three-mode executable layout into BongoCat's long-term data model. - -Bundled presets are read-only application assets and already follow the same -one-directory-per-mode runtime contract. The v2 disk manifest applies to -installed custom models; preset catalog entries receive equivalent synthetic -identity and capability fields without repackaging shipped assets. - -## Directory Contract - -```text -/ - .bongo-cat-package.json - payload/ - adapter/ - .bongo-cat-adapter.json - .bongo-cat-import-report.json - resources/ -``` - -`payload/` preserves the source files required by this mode. It is never -modified by runtime code. `adapter/` contains normalized BongoCat metadata and -derived assets. A package is independently installable and removable; sibling -modes are related only by `familyId`. - -For Mver imports, the original `config.json` may remain in `payload/` as source -provenance, while model and input-asset trees are copied only for the package's -mode. Neither loading nor removal depends on the other modes being installed. - -## Manifest v2 - -```json -{ - "schemaVersion": 2, - "packageId": "model--standard", - "contentDigest": "", - "familyId": "family-", - "displayName": "Example - Standard", - "mode": "standard", - "source": { - "format": "bongo-cat-mver", - "name": "Example", - "layout": "full-package", - "preserved": true - }, - "model": { - "directory": "payload/img/standard/cat_model", - "setting": "cat.model3.json" - }, - "runtime": { - "adapter": "adapter", - "metadata": "adapter/.bongo-cat-adapter.json", - "adapterSchemaVersion": 1, - "generatorVersion": 1 - }, - "capabilities": [ - "live2d", - "preview", - "runtime-adapter", - "input-images", - "behaviors", - "mver-projection", - "pointer-overlay" - ], - "extensions": { - "mver": { - "configuration": "payload/config.json" - } - } -} -``` - -`packageId` is the permanent application-facing ID. For unmanifested source -folders it is derived from the full candidate content digest and mode. An exact -re-import therefore resolves to the existing package instead of creating a -timestamp duplicate. `contentDigest` is a lowercase SHA-256 over the selected -model, per-file content fingerprints, source adapter inputs, mode, and import -format. `familyId` is present when one discovery operation yields related modes. - -The fields remain separate so a future signed or publisher-authored package can -keep a publisher ID across revisions while changing `contentDigest`. Raw folder -imports are intentionally content-addressed until such an identity exists. - -Readers ignore unknown capabilities, source fields, and extension namespaces. -Relative paths must stay inside the package. Only schema v2 is accepted. -`adapterSchemaVersion` describes the runtime contract. `generatorVersion` -tracks the importer implementation so derived adapters can be rebuilt in place -after conversion behavior changes without modifying preserved payloads. -The catalog accepts a v2 package only when its adapter metadata has the declared -schema, runtime kind, source format, render profile, and bindings structure. - -## Runtime Adapter - -`.bongo-cat-adapter.json` is source-format neutral: - -```json -{ - "schemaVersion": 1, - "kind": "bongo-cat-runtime-adapter", - "sourceFormat": "bongo-cat-mver", - "render": { "profile": "mver-0.1.6" }, - "bindings": [], - "standardPointer": {} -} -``` - -Tauri imports receive a minimal native adapter. Mver importers translate -render calibration, input bindings, pointer assets, motions, expressions, -effects, and audio defaults into this file. Runtime code does not read Mver -`config.json`. Generated and installed adapters use this filename and schema; -there is no alternate adapter reader. - -## Configuration Ownership - -The package owns model-authored state: Live2D references, render calibration, -input asset mappings, pointer geometry, motions, expressions, effects, and -audio behavior. - -Application settings own long-lived user choices: click-through and -always-on-top behavior, FPS, mirror and pointer preferences, hover behavior, -custom labels, shortcut overrides, language, and theme. Session state owns the -selected package ID plus window geometry, scale, opacity, and visibility. - -Importers may read source application settings to reproduce model-authored -calibration, but they must not make source window or desktop settings part of -the user's BongoCat preferences. - -## Evolution Rules - -- Add optional fields or capability strings without changing `schemaVersion`. -- Increment the manifest schema only for incompatible structural changes. -- Keep format-specific data under `extensions.`. -- Never require sibling modes for loading, updating, or removing a package. -- Never make runtime behavior depend directly on an import format's config. -- Reject unsupported schemas instead of guessing or silently migrating them. diff --git a/docs/parity.md b/docs/parity.md deleted file mode 100644 index 5b938162..00000000 --- a/docs/parity.md +++ /dev/null @@ -1,175 +0,0 @@ -# Native completion contract - -The standalone native application is complete only when each automated check -and the matching manual scenario pass without depending on another frontend or -desktop runtime. - -## Model and rendering - -- Load all three bundled Cubism `model3` models. -- Import and remove custom model directories without changing their layout. -- Render drawable order, masks, blend modes, opacity, multiply and screen color. -- Run `motion3` motions, `exp3` expressions, fade timing, and motion audio. -- Preserve mouse, keyboard, and gamepad parameter mappings. -- Draw the model background and at most one key overlay per hand. -- Preserve mirror, mouse mirror, model scale, opacity, radius, and FPS controls. - -## Input - -- Observe global keyboard press and release events. -- Observe global mouse buttons and coalesced pointer movement. -- Recover Windows key state when a release event is lost. -- Support SDL game controllers, sticks, triggers, and hot plug. -- Register behavior shortcuts and application shortcuts. -- Request the required macOS input-monitoring permission. - -## Window and application - -- Transparent, borderless, draggable main window with per-pixel alpha. -- Click-through, always-on-top, hide-on-hover, and keep-in-monitor behavior. -- Shift plus secondary drag resizing from 10 through 500 percent. -- Tray and context menus, capture-friendly taskbar/Dock suppression, and single instance handling. -- Restore window position and size across monitor and DPI changes. -- Autostart, project information, and application exit. - -## Preferences and data - -- General, cat, model, behavior, and shortcut preference views. -- Simplified Chinese, Traditional Chinese, English, French, German, Japanese, - Korean, Portuguese, Russian, and Spanish. -- Light, dark, and system themes. -- Persist native settings and custom models without external project paths. -- Build, test, run, and package with only files contained in this repository. -- Persist settings atomically and recover from a truncated write. - -## Release gates - -- Golden images for idle, key, mouse, controller, motion, and expression states. -- Recorded input traces produce equivalent parameter values in old and new apps. -- Windows 10/11, macOS x64/arm64, and Linux X11 manual matrix passes. -- Wayland reports reduced global-input support instead of silently failing. -- Idle CPU is below 0.3 percent on the reference machine. -- Working set is below 100 MiB with one bundled model active. -- Hidden rendering is suspended and a 24-hour run has no unbounded growth. -- Every hand-written file passes the configured source-size policy, whose - default is 300 physical lines with reviewed file-specific limits. - -## Cubism Viewer animation equivalence - -Live2D Cubism Viewer 5.3.03 is the animation and interaction baseline for every -model. Motion curves, expression blending, physics, eye blink, pose, idle -motion, and pointer-driven look updates use the official Cubism Framework -order and behavior. Source format never selects a second animation runtime. -Viewer mouse tracking feeds its configured `ParamAngleX`, `ParamAngleY`, -`ParamEyeBallX`, `ParamEyeBallY`, and, when present, `ParamBodyAngleX` channels -through the Viewer's own `TargetPoint` variant before physics consumes them at -the `physics3` authored FPS. That variant uses a `7.2727275 / 30` maximum target -speed, a 0.15 second acceleration time, a pixel-scale desktop dead zone, and -the Viewer's additional 1.2 input gain. Parameter scaling selects the authored -positive or negative extent from the parameter's value immediately before the -additive update; all five default mappings use weight 1.0. Screen coordinates -are normalized once for every source format: right is positive X, up is -positive Y, and the optional tilt channel is `-X * Y`. Tracking follows pointer -movement continuously and keeps the Viewer's TargetPoint response -curve without requiring a mouse button. Primary and secondary button state -remains independent for model-authored hand parameters. Viewer-default -automatic Idle playback is enabled and avoids immediately repeating the same -Idle motion when alternatives exist. -Application-owned keyboard, mouse-button, gamepad, and direct pointer -parameters are persistent high-priority overrides. They are applied after eye -blink, expression, look, and breath but immediately before physics, so authored -physics can consume the current input values. They are applied again after -physics and pose, immediately before the Cubism model update, so those stages -cannot erase application-owned state. Motion and preview baselines are saved -without the overrides, so input state cannot be absorbed by a later frame. -Desktop tracking is model-centered by default, so look direction changes only -when the pointer crosses the pet's visible center. Users can disable the -setting to retain the legacy screen/work-area-centered coordinate mapping. -In model-centered mode, the authored hand and device range maps absolutely to -the full display containing the pet, while gaze maps each side of the pet -center independently to the corresponding display edge. This keeps the pet -center neutral and preserves the full authored look range near display edges. -Legacy mode retains Mver relative input. -The display is selected from the pet's visible center. Negative-coordinate, -stacked, and mixed-DPI layouts retain desktop coordinates, and display mode, -topology, or scale changes invalidate and immediately resample the mapping. -In model-centered mode, `ParamAngleZ` receives the shared `-X * Y` pointer tilt -when present, scaled to its authored positive or negative extent. -Legacy mode does not synthesize Z. Models that author `ParamMouseX` or -`ParamMouseY` receive the normalized pointer domain in addition to Cubism -dragging; missing parameters remain a no-op. - -Mver 0.1.6 remains the compatibility baseline for package discovery, imported -files, configuration, shortcuts, window composition, and projection only. A -Tauri or standalone Live2D model is imported through the same adapter and then -runs through the Viewer-equivalent Cubism animation path and the same pointer -axis convention as an Mver package. - -Tauri-style `left-keys` and `right-keys` files are full-canvas raster overlays. -Their standard model handshake only exposes the authored hand-visibility -parameters; the package contains no bone, deformer, attachment point, or -per-key joint mapping. Limb continuity must therefore be authored into the -overlay artwork or the Live2D model itself. The runtime does not guess -model-specific joint parameters from a PNG position. - -Mver imports retain the source package's `l2d_correct`, `window_size`, -`l2d_offset`, and horizontal-follow values in `.bongo-cat-adapter.json`. The native -renderer uses the Mver 0.1.6 projection order for those models. Expressions keep -the authored canvas projection so the Live2D model remains aligned with the -background and input layers. The compatibility matrix also converts the -modern Cubism Core canvas units to Mver 0.1.6 units while retaining authored -Layout translation and `l2d_offset`. Its horizontal option controls following, -not model mirroring. Window resizing follows the imported reference aspect -ratio. Pointer-driven head, body, eye, and physics parameters use Mver's -configured `workarea`, or its DPI-virtualized primary-screen coordinate domain -when no custom work area is enabled. - -The Mver adapter retains its work-area and left-handed mapping before values -reach the shared Viewer-equivalent look stage. Source left-handed mapping and -the user's mouse-mirror preference are composed exactly once for head, eye, -body, direct `ParamMouseX`, and the exact pointer overlay. Source format no -longer selects an implicit horizontal sign. The adapter does not alter Cubism -motion curves, expression blending, physics, or look timing. - -## Cubism Viewer blind test - -The `viewer-sequence` CI scenario captures model-only Native frames and key -parameters at track and release samples `1, 2, 4, 8, 15, 30`. It does not use -desktop screenshots, so window decoration and desktop overlays cannot affect -the Native side. Reference Viewer screenshots are compared with: - -```powershell -cmake\RunCubismViewerBlindTest.ps1 ` - -ViewerDirectory build-delivery-final\viewer-studio-audit\viewer ` - -NativeDirectory build-delivery-final\viewer-studio-audit\native-run\cubism-viewer-audit\native ` - -OutputDirectory build-delivery-final\viewer-studio-audit\blind-test-final -``` - -The tool removes transparent, white, and black backgrounds, rejects detached -desktop overlays, and normalizes each source with its baseline model bounds. -It writes anonymous randomized A/B ballots, a separate answer key, normalized -frames, and metrics for the full model, face, hair, and hands. Dynamic metrics -also report each side's change from `track-000`, preventing a high static image -score from hiding a different interaction response curve. - -The blind-test entry point is a PowerShell wrapper around the native C target -`cubism_viewer_blind_test`; build it with: - -```powershell -cmake --build build-delivery-final --config Release --target bongo_cat_validation_tools -``` - -The standalone C metric tools are `mver_blind_metrics` and -`mver_phase_metrics`. The former accepts two image paths and optional mask and -threshold arguments; the latter accepts two frame directories and searches -their phase lag. The Viewer capture helpers (`cubism_viewer_desktop_capture` -and `cubism_viewer_drag_capture`) are also C targets and keep their existing -command-line arguments and trace formats. - -## Live2D texture quality - -Live2D atlases retain their authored pixel dimensions unless the Windows -working-size cap or the active GPU's `GL_MAX_TEXTURE_SIZE` requires a smaller -decode. Model textures use alpha-aware CPU-generated mipmaps with trilinear -sampling, while bounded anisotropic filtering remains enabled when supported. -This keeps minified edges stable without transparent-pixel color bleeding. diff --git a/docs/performance-audit-2026-07-29.md b/docs/performance-audit-2026-07-29.md deleted file mode 100644 index 52f00124..00000000 --- a/docs/performance-audit-2026-07-29.md +++ /dev/null @@ -1,83 +0,0 @@ -# Performance and package audit — 2026-07-29 - -## Scope - -This pass reduces CPU, memory, GPU texture storage, and package size without -changing pixels, animation timing, frame rate, source resource quality, or -supported Windows/macOS/Linux behavior. - -## Implemented changes - -- Shadow masks use `GL_R8` with RGB=`GL_ONE`, A=`GL_RED` swizzling instead of - storing redundant white RGB channels in `GL_RGBA8`. -- Same-color radial masks use the same single-channel path and retain the - original color through draw tinting. -- Paint texture ownership/upload was separated into `ui_paint_cache` so paint - algorithms and GPU cache policy remain independently maintainable. -- Preferences CPU buffers changed from 512/128 KiB to 256/96 KiB after 232 - captured frames showed historical peaks of 100,300/36,324 bytes. Full - interaction peaked at 47,920/16,458 bytes. -- Four unreferenced legacy assets are excluded from Windows, macOS, and Linux - runtime packages while remaining available in the source tree. -- Visual audit capture now moves the physical pointer outside the window to - remove non-deterministic hover-state noise. - -## Measured results - -| Metric | Before | After | Result | -|---|---:|---:|---:| -| Preferences paint cache | 9,430,040 B | 3,367,808 B | -6,062,232 B (-64.3%) | -| Runtime-flow peak working set | 156.051 MiB | 143.973 MiB | -12.078 MiB | -| Runtime-flow peak private memory | 126.188 MiB | 114.707 MiB | -11.481 MiB | -| Preferences soak working set | 136.207 MiB | 131.574 MiB | -4.633 MiB | -| Preferences soak private memory | 112.555 MiB | 107.773 MiB | -4.782 MiB | -| Hidden soak working set | 107.715 MiB | 102.922 MiB | -4.793 MiB | -| Hidden soak private memory | 94.586 MiB | 92.383 MiB | -2.203 MiB | -| Active settings CPU, 3-run mean | 182.292 ms | 156.250 ms | -14.3% | -| Idle settings CPU, 3-run mean | 0.344% | 0% | no idle work | -| Embedded PAK | 2,245,899 B | 2,228,107 B | -17,792 B | -| Windows executable | 5,456,384 B | 5,438,464 B | -17,920 B | - -## Visual and behavioral parity - -- Fixed-input old/new comparison: 10/10 preferences captures were exactly - identical across 6,120,000 compared pixels; MAE=0 and RMSE=0. -- Visual matrix: 53/53 views passed. -- Overlay matrix: 11/11 views passed; all 8 input scenarios passed. -- Preferences scenarios: 12/12 passed. -- Navigation: all 5 pages passed; P90 frame time 8.67 ms, max 10.29 ms. -- Focus audit passed, including dragging on the first press after focus loss. -- Cursor audit passed for all 11 cursor targets. -- Context menu audit passed with Settings first and all nested labels valid. -- Runtime flow passed all 17 stages with no handle growth or warm recovery - memory growth. - -## Cross-platform and risk decisions - -- Shared rendering remains portable C/OpenGL 3.3; macOS keeps its OpenGL 4.1 - core context and the same R8/swizzle semantics. -- MSVC and MinGW Release builds enforce the 300-line policy. -- Runtime/package exclusions are mirrored in Windows PAK creation, macOS bundle - staging, and Linux installation. -- UPX and whole-pack runtime compression remain intentionally rejected because - they increase antivirus, signing, startup, and peak-memory risk. -- PNG and FLAC data were not recompressed: the current assets are already - compressed, and no verified pixel/PCM-identical optimizer is part of the - toolchain. Exact duplicate payloads remain deduplicated by the asset packer. - -## Evidence - -- `build-final/optimization-paint-r8-final` -- `build-final/visual-parity-paint-r8-after-stable` -- `build-final/optimization-visual-final` -- `build-final/optimization-overlay-final` -- `build-final/optimization-preference-scenarios-final` -- `build-final/optimization-navigation-final` -- `build-final/optimization-focus-final` -- `build-final/optimization-cursor-final` -- `build-final/optimization-context-menu-final` -- `build-final/optimization-soak-visible` -- `build-final/optimization-soak-hidden-final` -- `build-final/optimization-soak-preferences` -- `build-final/optimization-runtime-flow` -- `build-final/optimization-startup-performance` diff --git a/docs/platform-game-coexistence-audit-2026-08-21.md b/docs/platform-game-coexistence-audit-2026-08-21.md deleted file mode 100644 index ff844226..00000000 --- a/docs/platform-game-coexistence-audit-2026-08-21.md +++ /dev/null @@ -1,139 +0,0 @@ -# Platform game coexistence audit - 2026-08-21 - -## Scope and outcome - -This audit covers the Windows, macOS, and Linux production runtimes, platform -permissions, window integration, autostart paths, packaging, and validation -tools. Its goal is to minimize behavior that can resemble game manipulation -while retaining the global input and overlay features required by a desktop -pet. - -BongoCat does not inject code, access another process's memory, install a -driver or privileged service, request debug privileges, or simulate keyboard -or mouse input. Platform-specific global input remains read-only and platform -windows are never attached to a game window. - -No source audit can guarantee that every third-party anti-cheat product will -avoid a false positive. Signing, release reputation, dependency behavior, and -each anti-cheat vendor's policy remain external factors. macOS and Linux were -audited from source on a Windows host; real-machine verification remains a -release gate. - -## Windows - -The diagnostic run confirmed that an unelevated process does not receive -keyboard edges from an elevated Task Manager through `WH_KEYBOARD_LL`, -`GetAsyncKeyState`, or background Raw Input. This is a Windows integrity -boundary, not a model or input-queue defect. Input resumes when a -normal-integrity window becomes foreground. - -The rejected workarounds are elevation, `uiAccess=true`, direct HID access, a -keyboard driver, process or DLL injection, and cross-process memory access. -The temporary Raw Input experiment, full virtual-key polling, and diagnostic -logging were removed. Retained physical-state polling only releases keys that -the normal hook previously saw go down. - -The production executable remains `asInvoker` with `uiAccess=false`. Low-level -keyboard and mouse hooks only enqueue BongoCat events, DirectInput opens only -`GUID_SysMouse` as nonexclusive background input, and the layered proxy belongs -to BongoCat. Window activation no longer attaches BongoCat's GUI input queue to -the current foreground thread. Non-topmost desktop anchoring searches only the -Windows `Progman`/`WorkerW` shell windows. - -The `BitBlt`/`PrintWindow` presentation probe runs only under the explicit -`--ci-smoke` path and receives only BongoCat-owned window handles. Normal -runtime does not capture the desktop or a game window, although those imports -can remain visible in the executable. - -The NSIS update path uses only `SYNCHRONIZE | -PROCESS_QUERY_LIMITED_INFORMATION` after verifying the executable path. The -installer is current-user-only. Static SDL can retain ordinary Raw Input, -`DeviceIoControl`, and process imports for its gamepad, video, and process -backends. SDL's gamepad subsystem is initialized only while a gamepad model is -active; an import alone does not prove the application calls every API. - -## macOS - -Global input uses a `kCGSessionEventTap` at `kCGTailAppendEventTap` with -`kCGEventTapOptionListenOnly`. The callback observes key, button, and pointer -events, returns the original event unchanged, and cannot suppress or rewrite -input. BongoCat requests only Input Monitoring access. The unused Accessibility -usage declaration was removed. - -The BongoCat-owned global keyboard and mouse path does not create or post -synthetic `CGEvent` input, use a HID-level event tap, request another process's -task port, use Mach process memory APIs, call `ptrace`, or install a privileged -helper. Static SDL can use the standard IOKit HID gamepad backend, but only -while a gamepad model is active. If macOS Secure Input or another security -control blocks observation, BongoCat accepts reduced input behavior instead of -bypassing it. - -The floating, click-through, capture-sharing, tray, and activation operations -target BongoCat's own `NSWindow`. Single-instance wake-up uses an app-specific -distributed notification. Autostart writes a user-owned LaunchAgent under -`~/Library/LaunchAgents`; it does not use `SMJobBless` or a system daemon. - -Input Monitoring is inherently a sensitive permission because it exposes -global key events. Developer ID signing, hardened runtime, notarization, and a -stable bundle identifier are required release controls, but a vendor can still -choose to restrict any process with a global event tap. - -## Linux - -The X11 path selects `XI_RawKey*`, `XI_RawButton*`, and `XI_RawMotion` events -from XInput2. It does not grab keys or buttons for global monitoring and does -not inject input. Pointer and keyboard grabs exist only while BongoCat's own -context menu is open and are released when the menu closes. - -`XSendEvent` is confined to the Linux window-management modules and sends only -EWMH `ClientMessage` requests for activation, taskbar state, and moving the -BongoCat window. Project production source does not link XTest and contains no -`XTestFake*`, `XWarpPointer`, `/dev/uinput`, `ptrace`, process memory, setuid, or -Linux capability path. Static SDL can enumerate `/dev/input` and hidraw devices -and use `ioctl` for standard gamepad support, but that subsystem is initialized -only while a gamepad model is active. BongoCat never creates a virtual input -device or asks SDL to synthesize input. Click-through changes only the input -shape region of BongoCat's own X11 window. - -Autostart writes a user desktop entry under `$XDG_CONFIG_HOME/autostart` or -`~/.config/autostart`. Directory opening executes `xdg-open` directly without -a shell. Single-instance activation reads the BongoCat lock file and sends a -window-manager activation request to the stored BongoCat window ID; it does not -access process memory. - -Native Wayland intentionally has no global-input bypass. When no X11/XWayland -window or XInput2 is available, global input is reported unavailable rather -than falling back to portals, evdev, elevated access, or a helper daemon. - -## Tool isolation - -Input simulation and desktop-capture code exists only in -`src/tools/cubism_viewer_*_capture.c`. Those Windows validation executables are -`EXCLUDE_FROM_ALL`, are not linked into `BongoCat.exe`, and are not installed or -packaged with the application. - -`cmake/CheckPlatformRuntimeSafety.cmake` enforces prohibited APIs and tokens, -platform permission boundaries, sensitive-module ownership, the reviewed SDL -source pin, and validation tool isolation. It scans project production source, -including Objective-C, but not fetched third-party source. - -## Release requirements - -- Rebuild release artifacts from clean source after diagnostic experiments are - removed; never distribute a diagnostic executable. -- Authenticode-sign and timestamp the Windows executable and installer. -- Sign macOS with Developer ID, enable hardened runtime, notarize, and test the - exact signed bundle because Input Monitoring consent is identity-sensitive. -- Sign Linux packages where the distribution format supports it, publish - checksums, and keep artifacts traceable to a tagged source revision. -- Recheck final binary imports, linked libraries, signatures, and packaged file - lists on every platform. -- Test alongside each supported anti-cheat on a non-production account. Vendor - allow-listing or false-positive review is the only authoritative resolution - for product-specific detection. - -## Repeatable checks - -- `cmake "-DROOT=$PWD" -P cmake/CheckPlatformRuntimeSafety.cmake` -- `cmake "-DROOT=$PWD" -P cmake/CheckLines.cmake` -- `git diff --check` diff --git a/docs/startup-reliability-audit-2026-07-26.md b/docs/startup-reliability-audit-2026-07-26.md deleted file mode 100644 index 2c36132f..00000000 --- a/docs/startup-reliability-audit-2026-07-26.md +++ /dev/null @@ -1,147 +0,0 @@ -# Startup and preferences reliability audit - updated 2026-07-29 - -## Scope and outcome - -This audit covers the standalone native release on the available Windows host, -with emphasis on double-click startup, damaged-data recovery, first-frame -validity, preferences behavior, transparent rendering, resource stability and -portable paths. The audited binary is: - -- `build-final/Release/BongoCat.exe` -- Source baseline before this final audit set: `378fd43` -- SHA-256: `F924800D504356AD30034E975A2B9610DE489A109C2A39739E99756BC0B79037` -- Size: 5,456,384 bytes - -The MSVC and MinGW Release configurations each pass 10/10 CTest cases. No -finite suite can guarantee startup on every possible machine, so signing, -other Windows versions and real macOS/Linux machines remain explicit gates. - -## Startup protections - -| Failure class | Implemented protection | -|---|---| -| A second launch appeared to do nothing | Notify the primary process, restore it on-screen and raise the window. | -| Hidden or off-screen saved state | Recover a visible on-screen main window for interactive launch. | -| Invalid or partially written settings | Strict versioned JSON parsing, defaults on corruption and atomic writes. | -| Damaged extracted assets | Verify embedded assets by size and SHA-256 and repair the cache. | -| Damaged custom Live2D model | Validate packages before install/load and fall back to a bundled model. | -| Unavailable global hooks | Continue startup and persist an actionable diagnostic. | -| Unsupported multisampling | Retry without MSAA and prove the resulting sample count is zero. | -| Fatal graphics initialization | Persist startup stage and error logs instead of failing silently. | -| Deep, spaced or Unicode paths | Use bounded UTF-safe path handling and long-path-aware Windows I/O. | -| Large portable model trees | Apply depth, directory and time budgets without recursive stack growth. | -| Interrupted model import | Isolate temporary imports and clean stale residue on startup. | -| Stale first-frame evidence | Wait for a newly completed audit file and retry transient sharing failures. | - -## Automated startup evidence - -The test set covers core, i18n, UI, app state, model import units and formats, -optional real samples, portable startup soak, the startup reliability matrix -and preferences navigation. - -The startup matrix independently verifies: - -- fresh extraction and a visible nonblank first frame with `gl_error=0`; -- actual normal MSAA values of `sample_buffers=1`, `sample_count=4`; -- forced fallback values of `sample_buffers=0`, `sample_count=0`; -- same-size embedded-asset corruption and cache repair; -- malformed settings and malformed model recovery; -- hidden and far off-screen window recovery; -- unavailable global hooks; -- bounded portable-directory scanning; -- spaces, Unicode and paths longer than the legacy Windows `MAX_PATH` limit; -- long-path cache reuse, cleanup and model import; -- persisted fatal OpenGL diagnostics; and -- second-instance wake-up from hidden and tray-closed states without hanging. - -All 30 PowerShell audit scripts parse successfully, `git diff --check` is clean -and the 300-line handwritten-source policy passes. - -## Preferences verification - -| Area | Result | Evidence | -|---|---|---| -| Visual matrix | PASS 53/53 | 50 preferences combinations plus 3 models | -| HTML reference parity | PASS | 98.4996% average pixel similarity; 97.9526% minimum page | -| Main interactions | PASS x6 | Toggle, scroll, navigation, combo, locale and shortcut editing | -| Sidebar navigation | PASS x10 | 50/50 clicks; all five pages selected every loop | -| Persistence | PASS | Locale, toggle, stepper and shortcut survive save/restart paths | -| Dialog/state scenarios | PASS 12/12 | Behavior, expression, Escape, outside-close, shortcut, locale, controls and toast | -| Pointer feedback | PASS 11/11 | Tabs, close, toggles, combo, shortcut, import/model cards and cancel | -| Focus recovery | PASS | First inactive-window press drags 56 x 32 px | -| Tray ordering | PASS | Preferences is the first tray entry | -| Localization fonts | PASS | Five locales, both themes, 2x oversampling, `1024x8192` atlas | - -Configuration is intentionally split by ownership rather than retaining Tauri -compatibility: user preferences and runtime session state are separately -versioned and atomically persisted. The interaction audit validates both file -formats and debounce behavior. - -## Rendering integrity - -The model overlay now emits premultiplied RGBA and uses independent color/alpha -blend factors. Preferences retain straight-alpha color blending with an -independent alpha channel. The software compositor uses the complete -source-over equation, and model textures use linear no-mipmap filtering aligned -with the original renderer. - -Across 11 model/input captures, each audited frame contains 2,149 to 2,361 -semi-transparent antialiased pixels and zero RGB-greater-than-alpha violations. -Standard, keyboard and gamepad models, left/right/both hands, releases, stress -and gamepad-button states all pass. - -## Performance and stability - -- Cold first valid frame: 1,192 ms. -- Seven warm launches: 511 ms average, 550 ms P95. -- Navigation settles in 235.1 ms, with 9.50 ms average, 15.20 ms P90 and - 16.76 ms maximum audited frame time. -- Active preferences animation uses about 19.5% CPU on this host; idle is 0%. -- Main 30-second soak trends about +0.38 MiB with one additional handle. -- Preferences 30-second soak trends about +0.17 MiB with no handle growth. -- Full runtime close/reopen/recovery flow stays below 170 MiB working set and - 140 MiB private bytes and shows no continuing growth. - -The larger font atlas is a deliberate quality/performance tradeoff. Baking -memory is temporary, runtime memory remains within measured budgets, and idle -rendering does not continue consuming CPU. - -## Windows release properties - -- x64 GUI executable with the static MSVC runtime; imported dependencies are - Windows system components. -- ASLR, high-entropy VA, DEP/NX and Control Flow Guard are enabled. -- Large application state and startup catalogs live on the heap. -- The executable is not Authenticode-signed. Signing remains required before - broad public distribution to establish provenance and reduce SmartScreen - warnings. - -## Cross-platform boundary - -Portable configuration, rendering and bounded/atomic I/O are shared. Native -window, input, tray and startup behavior remain isolated by platform. CI defines -warnings-as-errors build/test/package jobs for Windows x64, Ubuntu Linux x64 and -macOS. This Windows host cannot honestly certify other systems' compositor, -permissions, signing or GUI behavior; their CI and real-machine smokes must pass -for every release candidate. - -## Retained evidence - -- `build-final/visual-audit-final-current/audit.csv` -- `build-final/parity-antialias-current/page0.json` through `page4.json` -- `build-final/overlay-audit-final-current-3/overlay-audit.csv` -- `build-final/preference-scenarios-final-current/audit.csv` -- `build-final/preferences-navigation-stable/result.json` -- `build-final/preferences-interaction-robust-6/result.json` -- `build-final/preferences-performance-current-2/result.json` -- `build-final/focus-current/result.json` -- `build-final/cursor-current/result.json` -- `build-final/context-menu-current/result.json` -- `build-final/runtime-flow-current/runtime-flow-summary.csv` - -## Repeatable commands - -- `cmake --build build-final --config Release --parallel 2` -- `ctest --test-dir build-final -C Release --output-on-failure` -- `ctest --test-dir build-mingw-final -C Release --output-on-failure` -- `cmake -DROOT=. -P cmake/CheckLines.cmake` diff --git a/docs/storage-layout.md b/docs/storage-layout.md deleted file mode 100644 index 081bda5f..00000000 --- a/docs/storage-layout.md +++ /dev/null @@ -1,151 +0,0 @@ -# Storage Layout - -BongoCat owns its application data independently of both supported source -formats. Tauri Live2D and Bongo-Cat-Mver are model import formats only. - -## Ownership - -```text -config/settings.json long-lived user preferences -config/settings.json.rejected* preserved invalid settings -data/models/ installed model package v2 directories -cache/embedded-assets-/ extracted packaged application assets -cache/model-adapters/ generated adapters for nearby Tauri/Mver sources -state/session.json disposable window and active-model state -state/session.json.rejected* preserved invalid session state -state/ diagnostic and smoke-run artifacts -logs/ startup and failure logs -``` - -`settings.json` uses format `bongocat/settings`, schema 1. It contains rendering -and pointer preferences, window behavior, application preferences, shortcuts, -behavior overrides, model display-name overrides, and an `extensions` object. -Unknown namespaces inside `extensions` are preserved across load and save; the -serialized object is limited to 4095 bytes so settings memory remains bounded. -The complete settings or session document is limited to 1 MiB. - -`session.json` uses format `bongocat/session`, schema 1. It contains only the -active model ID and window visibility, scale, opacity, optional known position, -and size. -Deleting it must not change the user's preferences or installed models. - -Every user-selected Tauri or Mver source is converted into an application-owned -package v2 directory under `data/models/`. - -For portable Tauri and Mver distributions, the application also performs a -bounded scan at its launch location. Windows shortcuts created by the installer -pass their own directory explicitly; other Windows launches use the process -working directory, while other platforms retain the executable directory as the -default. `--nearby-root=` overrides the location. These nearby -sources remain externally owned and are loaded in place; only their generated runtime adapters are stored under -`cache/model-adapters/`. The scan is limited to three directory levels, 256 -directories, and 500 ms of directory-discovery work. Unchanged sources reuse a -metadata signature and cached content identity, so rescans do not rehash model -files or regenerate input images. Set `BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN` for -isolated development runs that must ignore adjacent sources. - -```json -{ - "format": "bongocat/settings", - "schemaVersion": 1, - "rendering": { - "modelMirrored": false, - "pointerMirrored": false, - "centerPointerTracking": true, - "ignorePointerInput": false, - "maximumFps": 60 - }, - "window": { - "clickThrough": false, - "alwaysOnTop": true, - "hideOnPointerOver": false, - "keepOnScreen": false, - "captureBackground": false, - "captureBackgroundColor": "#00ff00", - "hideDelaySeconds": 0.0 - }, - "application": { - "launchAtLogin": false, - "showTrayIcon": true, - "theme": "auto", - "language": "en-US" - }, - "shortcuts": { - "toggleVisibility": "", - "openSettings": "", - "toggleModelMirror": "", - "toggleClickThrough": "", - "toggleAlwaysOnTop": "" - }, - "behaviorOverrides": [], - "modelOverrides": [], - "extensions": {} -} -``` - -```json -{ - "format": "bongocat/session", - "schemaVersion": 1, - "window": { - "visible": true, - "scalePercent": 100.0, - "opacityPercent": 100.0, - "position": { "x": 0, "y": 0 }, - "size": { "width": 612, "height": 354 } - }, - "activeModelId": "standard" -} -``` - -`position` is omitted until the application has observed a real window -position. Once present, `(0, 0)` is treated as an intentional location. - -Both documents are written atomically through a temporary file, a durable -flush, and replacement. There are no readers or migrations for pre-release -configuration formats. - -Schema 1 treats known fields strictly: an omitted field keeps its default, but -a present field with the wrong JSON type, an unsupported enum string, an -out-of-range machine integer, or an invalid nested structure rejects the whole -document. Duplicate known JSON keys are also rejected. Unknown fields outside -`extensions` are ignored and not preserved; -extension owners must store their data under a unique key in `extensions`. - -Before saving, settings and session values are canonicalized. Numeric runtime -values are clamped to supported limits, non-finite values return to defaults, -invalid empty override rows are removed, and override IDs are unique. If an -override ID occurs more than once in memory or in a loaded document, later -non-empty values replace the corresponding earlier values. - -Invalid or unsupported files are moved aside before defaults are saved. The -first rejected file uses the sibling name `settings.json.rejected` or -`session.json.rejected`; further files use `.rejected.1`, `.rejected.2`, and so -on. I/O and allocation failures leave the original in place and disable saving -that document for the current run. This prevents a transient failure or a file -that could not be preserved from being overwritten. - -## Platform Roots - -| Platform | Config and data | State, cache, and logs | -| --- | --- | --- | -| Windows | `%LOCALAPPDATA%\BongoCat\config`, `data` | `%LOCALAPPDATA%\BongoCat\state`, `cache`, `logs` | -| macOS | `~/Library/Application Support/BongoCat/config`, `data` | Application Support `state`; `~/Library/Caches/BongoCat`; `~/Library/Logs/BongoCat` | -| Linux | `$XDG_CONFIG_HOME/bongocat`, `$XDG_DATA_HOME/bongocat` | `$XDG_STATE_HOME/bongocat`, `$XDG_CACHE_HOME/bongocat`; logs under state | - -The settings file is therefore `%LOCALAPPDATA%\BongoCat\config\settings.json` -on Windows, `~/Library/Application Support/BongoCat/config/settings.json` on -macOS, and `$XDG_CONFIG_HOME/bongocat/settings.json` on Linux. - -Linux falls back to the corresponding `~/.config`, `~/.local/share`, -`~/.local/state`, and `~/.cache` roots when an XDG variable is unset. -Windows keeps all application data in the current user's local profile so the -portable executable and the per-user installer share one layout. Application -data is file-backed on every platform. Windows does not use the registry for -settings or autostart; enabling autostart creates a shortcut in the current -user's Startup folder. The project is pre-release, so no settings are migrated -from an older directory; a fresh launch uses the paths listed above directly. - -`--storage-root=` is the only path override. It creates `config`, `data`, -`cache`, `state`, and `logs` beneath the supplied root for isolated development -or CI runs. diff --git a/docs/system-audit-2026-07-17.md b/docs/system-audit-2026-07-17.md deleted file mode 100644 index 1bc93716..00000000 --- a/docs/system-audit-2026-07-17.md +++ /dev/null @@ -1,94 +0,0 @@ -# Systematic release audit - updated 2026-07-29 - -## Scope and result - -This audit covers the native cross-platform application on the available -Windows host: startup and recovery, Cubism rendering, transparent-window -composition, preferences parity and interaction, tray/context menus, focus and -dragging, configuration persistence, resource use, source policy and standalone -delivery. The audited release executable is: - -- `build-final/Release/BongoCat.exe` -- Size: 5,456,384 bytes -- SHA-256: `F924800D504356AD30034E975A2B9610DE489A109C2A39739E99756BC0B79037` - -MSVC Release and MinGW Release both build successfully and pass 10/10 CTest -cases. No failed automated check is being waived in this report. - -## Defects corrected - -| Area | Correction | Verification | -|---|---|---| -| Overlay edges could develop dark/bright halos | Emit premultiplied RGBA and use independent RGB/alpha blending | 11/11 overlay captures, zero premultiply violations | -| Preferences alpha accumulated incorrectly | Keep straight-alpha color blending while composing destination alpha independently | Visual, interaction and scenario matrices | -| Software layer composition ignored destination alpha | Implement the complete source-over equation | Overlay release/input captures | -| Model textures rendered black or with inconsistent filtering | Match Cubism/Pixi linear no-mipmap filtering and retain bounded anisotropy | Three bundled model captures, `gl_error=0` | -| Font edges were visibly coarse | Bake horizontal and vertical 2x oversampling | Multilingual `1024x8192` atlas and 53/53 captures | -| Requested MSAA was assumed rather than measured | Record actual SDL sample buffers/count and assert both normal and fallback paths | Normal `1/4`, fallback `0/0` | -| Sidebar and shortcut audits depended on fragile desktop injection | Drive SDL-compatible window/input paths and add an internal shortcut smoke event | Ten navigation loops and six full interaction loops | -| Heavy-load capture could sample a stale/black frame | Retry completed-frame reads and validate capture color/black ratios | Stable visual and interaction runs | -| Tray preferences entry was below window toggles | Put Preferences first and assert the first SDL tray entry | Tray self-test plus context-menu capture | -| Inactive-window dragging needed an activation click | Accept the first press after focus loss | First press moved the window 56 x 32 px | -| Resource budgets no longer reflected the high-quality font atlas | Re-measure and set runtime-flow ceilings to 170/140 MiB | Runtime reopen/recovery flow | - -## Automated evidence - -| Area | Result | -|---|---| -| MSVC Release tests | PASS - 10/10 | -| MinGW Release tests | PASS - 10/10 | -| Source policy | PASS - checked handwritten files are at most 300 lines | -| PowerShell audit syntax | PASS - 30/30 scripts parse | -| Preferences visual matrix | PASS - 50/50: 5 pages x 5 locales x 2 themes | -| Main model visual matrix | PASS - standard, keyboard and gamepad | -| Current HTML pixel comparison | PASS - 98.4996% average similarity; minimum 97.9526% | -| Preferences scenarios | PASS - 12/12 | -| Preferences interaction | PASS - toggle, scroll, navigation, combo, locale, shortcut, debounce, formats and persistence | -| Sidebar stress | PASS - 10 loops, 50/50 selections | -| Cursor hit regions | PASS - 11/11 | -| Overlay/input states | PASS - 11/11 | -| Premultiplied alpha | PASS - 2,149 to 2,361 antialiased pixels per audited frame, zero violations | -| Tray/context menu | PASS - Preferences is the first item | -| Focus recovery drag | PASS - movement occurs on the first press | -| Startup recovery matrix | PASS - corruption, paths, fallback, diagnostics and second instance | - -The pixel comparison uses the current native screenshots and the retained HTML -reference at 900 x 680. It is a measured visual-parity check, not a claim that -different operating-system text rasterizers will produce byte-identical pixels. - -## Performance and stability - -- Cold first valid frame: 1,192 ms. -- Warm first valid frame: 511 ms average, 550 ms P95. -- Preferences transition: 235.1 ms settled; 9.50 ms average frame, 15.20 ms P90. -- Preferences animation CPU: about 19.5%; settled idle CPU: 0%. -- Main 30-second soak: about +0.38 MiB trend, handles +1. -- Preferences 30-second soak: about +0.17 MiB trend, handles unchanged. -- Runtime flow, including settings close/reopen and model recovery, shows no - continuing growth; measured peak was 156.188 MiB working set and 126.125 MiB - private bytes, below the 170/140 MiB audit budgets. - -## Cross-platform boundary - -Shared behavior stays in portable SDL/OpenGL/C code. Windows-only focus, -startup and shell integration remain behind platform source boundaries; macOS -and Linux have their own platform units. CI defines warnings-as-errors builds, -tests and packages for Windows x64, Ubuntu Linux x64 and macOS. - -The available host proves the MSVC and MinGW Windows paths. Real macOS/Linux GUI -behavior, compositor differences, permissions, signing and packaging remain -release gates and must not be represented as locally executed tests. - -## Repeatable commands - -- `cmake --build build-final --config Release --parallel 2` -- `ctest --test-dir build-final -C Release --output-on-failure` -- `ctest --test-dir build-mingw-final -C Release --output-on-failure` -- `cmake -DROOT=. -P cmake/CheckLines.cmake` - -## External release gates - -- Authenticode/notarization and platform-specific signing. -- Real Windows version coverage beyond this host. -- Real macOS and Linux GUI/compositor smoke tests. -- Physical gamepad hot-plug, audible output and an uninterrupted long soak. From 8341f288ed1a8d9732aafc4930f4e379a1aff4e9 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 22 Aug 2026 23:09:37 +0800 Subject: [PATCH 071/334] fix: prevent Live2D expressions from being clipped --- include/bongo_cat/config.h | 4 + include/bongo_cat/model.h | 11 ++ src/core/config_session_defaults.c | 10 ++ src/core/config_session_io.c | 18 ++- src/live2d/cubism_bridge.cpp | 11 ++ src/live2d/cubism_model.hpp | 12 ++ src/live2d/cubism_model_bounds.cpp | 161 +++++++++++++++++++++++++++ src/live2d/cubism_model_load.cpp | 1 + src/live2d/cubism_model_update.cpp | 31 ++++-- src/live2d/live2d_stub.c | 17 +++ src/runtime/lifecycle/app.c | 11 ++ src/runtime/lifecycle/config_store.c | 2 + src/runtime/model/model_files.c | 66 +++++++++-- src/runtime/runtime.h | 5 + src/runtime/shell/window.c | 3 + src/runtime/shell/window_geometry.c | 49 ++++++++ tests/core/test_config.c | 22 +++- 17 files changed, 412 insertions(+), 22 deletions(-) diff --git a/include/bongo_cat/config.h b/include/bongo_cat/config.h index 0b6c1eaf..c6548889 100644 --- a/include/bongo_cat/config.h +++ b/include/bongo_cat/config.h @@ -65,6 +65,10 @@ typedef struct BongoCatWindowState { int y; int width; int height; + /* The authored composition size inside width/height. The outer window + may be larger to hold expression geometry outside the base canvas. */ + int content_width; + int content_height; } BongoCatWindowState; typedef struct BongoCatApplicationPreferences { diff --git a/include/bongo_cat/model.h b/include/bongo_cat/model.h index 2c14bad0..2b5d1f52 100644 --- a/include/bongo_cat/model.h +++ b/include/bongo_cat/model.h @@ -102,6 +102,13 @@ typedef struct BongoCatLive2DVisualState { bool fitted, visible, mver_projection; } BongoCatLive2DVisualState; +/* Extra transparent space around the authored model canvas, expressed as a + fraction of that canvas dimension. For example, top=0.5 reserves half a + canvas height above the normal composition. */ +typedef struct BongoCatLive2DFrame { + float left, top, right, bottom; +} BongoCatLive2DFrame; + typedef struct BongoCatLive2DRenderOptions { bool mver_projection; bool source_mirror; @@ -131,6 +138,10 @@ bool bongo_cat_live2d_ready(const BongoCatLive2D *live2d); /* Returns the authored pixel canvas size of the loaded model. */ bool bongo_cat_live2d_canvas_size(const BongoCatLive2D *live2d, int *width, int *height); +bool bongo_cat_live2d_frame(const BongoCatLive2D *live2d, + BongoCatLive2DFrame *frame); +bool bongo_cat_live2d_viewport(const BongoCatLive2D *live2d, + int *x, int *y, int *width, int *height); void bongo_cat_live2d_resize(BongoCatLive2D *live2d, int width, int height); void bongo_cat_live2d_reshape(BongoCatLive2D *live2d, int width, int height); bool bongo_cat_live2d_update(BongoCatLive2D *live2d, float delta_seconds); diff --git a/src/core/config_session_defaults.c b/src/core/config_session_defaults.c index 0547641e..b6dc3e14 100644 --- a/src/core/config_session_defaults.c +++ b/src/core/config_session_defaults.c @@ -30,6 +30,8 @@ void bongo_cat_session_defaults(BongoCatSessionState *session) { BONGO_CAT_DEFAULT_WINDOW_OPACITY_PERCENT; session->window.width = 612; session->window.height = 354; + session->window.content_width = 612; + session->window.content_height = 354; memcpy(session->active_model_id, "standard", sizeof("standard")); } @@ -101,6 +103,14 @@ void bongo_cat_session_validate(BongoCatSessionState *session) { if (session->window.height < 64) session->window.height = 64; if (session->window.width > 8192) session->window.width = 8192; if (session->window.height > 8192) session->window.height = 8192; + if (session->window.content_width < 64) + session->window.content_width = session->window.width; + if (session->window.content_height < 64) + session->window.content_height = session->window.height; + if (session->window.content_width > 8192) + session->window.content_width = 8192; + if (session->window.content_height > 8192) + session->window.content_height = 8192; if (!normalize_text(session->active_model_id, sizeof(session->active_model_id)) || !session->active_model_id[0]) diff --git a/src/core/config_session_io.c b/src/core/config_session_io.c index 8988f19b..a46ce7f3 100644 --- a/src/core/config_session_io.c +++ b/src/core/config_session_io.c @@ -130,10 +130,20 @@ BongoCatResult bongo_cat_session_load(const char *path, read_int(position, "y", &loaded.window.y, true, error); if (valid) loaded.window.position_known = true; } - if (valid && size) + if (valid && size) { + int content_width = 0, content_height = 0; valid = read_int(size, "width", &loaded.window.width, true, error) && - read_int(size, "height", &loaded.window.height, true, error); + read_int(size, "height", &loaded.window.height, true, error) && + read_int(size, "contentWidth", &content_width, false, error) && + read_int(size, "contentHeight", &content_height, false, error); + if (valid) { + loaded.window.content_width = content_width > 0 + ? content_width : loaded.window.width; + loaded.window.content_height = content_height > 0 + ? content_height : loaded.window.height; + } + } } if (valid) valid = read_active_model(root, &loaded, error) && read_additional_models(additional_models, &loaded, error) && @@ -218,6 +228,10 @@ BongoCatResult bongo_cat_session_save(const char *path, built = built && size && yyjson_mut_obj_add_int(doc, size, "width", canonical.window.width) && yyjson_mut_obj_add_int(doc, size, "height", canonical.window.height) && + yyjson_mut_obj_add_int(doc, size, "contentWidth", + canonical.window.content_width) && + yyjson_mut_obj_add_int(doc, size, "contentHeight", + canonical.window.content_height) && yyjson_mut_obj_add_strcpy(doc, root, "activeModelId", canonical.active_model_id) && write_additional_models(doc, root, &canonical) && diff --git a/src/live2d/cubism_bridge.cpp b/src/live2d/cubism_bridge.cpp index f9e395ff..62b6b558 100644 --- a/src/live2d/cubism_bridge.cpp +++ b/src/live2d/cubism_bridge.cpp @@ -196,6 +196,17 @@ extern "C" bool bongo_cat_live2d_canvas_size(const BongoCatLive2D *runtime, runtime->model->canvas_size(width, height); } +extern "C" bool bongo_cat_live2d_frame(const BongoCatLive2D *runtime, + BongoCatLive2DFrame *frame) { + return runtime && runtime->model && runtime->model->frame(frame); +} + +extern "C" bool bongo_cat_live2d_viewport(const BongoCatLive2D *runtime, + int *x, int *y, int *width, int *height) { + return runtime && runtime->model && + runtime->model->viewport(x, y, width, height); +} + extern "C" void bongo_cat_live2d_resize(BongoCatLive2D *runtime, int width, int height) { if (!runtime) return; if (width > 0 && height > 0) { diff --git a/src/live2d/cubism_model.hpp b/src/live2d/cubism_model.hpp index 8fda001d..85f1f763 100644 --- a/src/live2d/cubism_model.hpp +++ b/src/live2d/cubism_model.hpp @@ -33,6 +33,8 @@ class NativeModel final : public Csm::CubismUserModel { BongoCatLive2DLoadProgress progress, void *userdata); void release_render_resources(); bool canvas_size(int *width, int *height) const; + bool frame(BongoCatLive2DFrame *frame) const; + bool viewport(int *x, int *y, int *width, int *height) const; void resize(int width, int height); void reshape(int width, int height); bool update(float delta_seconds); @@ -97,6 +99,11 @@ class NativeModel final : public Csm::CubismUserModel { void load_motions(BongoCatLive2DLoadProgress progress, void *userdata); void start_idle_motion(); ModelBounds capture_visible_bounds() const; + void prepare_expression_frame(); + void build_projection(Csm::CubismMatrix44 &projection, + int width, int height); + void apply_viewport_projection(Csm::CubismMatrix44 &projection) const; + void update_viewport(); void record_visible_state(Csm::CubismMatrix44 &projection); void capture_motion_preview(); void restore_motion_preview_state(); @@ -150,8 +157,13 @@ class NativeModel final : public Csm::CubismUserModel { int height_ = 354; int renderer_width_ = 0; int renderer_height_ = 0; + int viewport_x_ = 0; + int viewport_y_ = 0; + int viewport_width_ = 612; + int viewport_height_ = 354; int expression_index_ = -1; bool expression_clearing_ = false; + BongoCatLive2DFrame frame_{}; BongoCatLive2DVisualState visual_state_{}; bool visual_state_ready_ = false; bool motion_updated_ = false; diff --git a/src/live2d/cubism_model_bounds.cpp b/src/live2d/cubism_model_bounds.cpp index 1a965c22..95fa9740 100644 --- a/src/live2d/cubism_model_bounds.cpp +++ b/src/live2d/cubism_model_bounds.cpp @@ -1,5 +1,6 @@ #include "cubism_model.hpp" +#include #include #include #include @@ -98,6 +99,166 @@ NativeModel::ModelBounds NativeModel::capture_visible_bounds() const { return bounds; } +static float frame_margin(float overflow, float padding) { + /* Tiny mathematical overhangs are common at transparent mesh edges and + do not produce visible clipping. */ + return overflow > 0.03f ? (overflow + padding) * 0.5f : 0.0f; +} + +void NativeModel::prepare_expression_frame() { + frame_ = BongoCatLive2DFrame{}; + /* Mver models have authored background/input layers in the same canvas; + changing their viewport would break that compatibility contract. */ + if (!_model || render_options_.mver_projection) { + update_viewport(); + return; + } + const int parameter_count = _model->GetParameterCount(); + std::vector base((size_t)parameter_count); + for (int i = 0; i < parameter_count; ++i) + base[(size_t)i] = _model->GetParameterValue(i); + + _model->Update(); + ModelBounds envelope = capture_visible_bounds(); + auto include = [&envelope](const ModelBounds &source) { + if (!source.valid) return; + if (!envelope.valid) { + envelope = source; + return; + } + envelope.min_x = std::min(envelope.min_x, source.min_x); + envelope.min_y = std::min(envelope.min_y, source.min_y); + envelope.max_x = std::max(envelope.max_x, source.max_x); + envelope.max_y = std::max(envelope.max_y, source.max_y); + }; + for (size_t i = 0; i < expression_names_.size(); ++i) { + auto found = expressions_.find(expression_names_[i]); + if (found == expressions_.end()) continue; + for (int parameter = 0; parameter < parameter_count; ++parameter) + _model->SetParameterValue(parameter, base[(size_t)parameter]); + auto *expression = static_cast( + found->second); + const auto parameters = expression->GetExpressionParameters(); + for (Csm::csmUint32 parameter = 0; + parameter < parameters.GetSize(); ++parameter) { + const auto &value = parameters[parameter]; + int index = _model->GetParameterIndex(value.ParameterId); + if (index < 0 || index >= parameter_count) continue; + switch (value.BlendType) { + case Csm::CubismExpressionMotion::Additive: + _model->AddParameterValue(index, value.Value); + break; + case Csm::CubismExpressionMotion::Multiply: + _model->MultiplyParameterValue(index, value.Value); + break; + case Csm::CubismExpressionMotion::Overwrite: + _model->SetParameterValue(index, value.Value); + break; + } + } + _model->Update(); + include(capture_visible_bounds()); + } + for (int parameter = 0; parameter < parameter_count; ++parameter) + _model->SetParameterValue(parameter, base[(size_t)parameter]); + _model->Update(); + + int reference_width = 0, reference_height = 0; + if (render_options_.mver_projection) { + reference_width = render_options_.reference_width; + reference_height = render_options_.reference_height; + } + if ((reference_width <= 0 || reference_height <= 0) && + !canvas_size(&reference_width, &reference_height)) { + update_viewport(); + return; + } + if (!envelope.valid) { + update_viewport(); + return; + } + Csm::CubismMatrix44 projection; + build_projection(projection, reference_width, reference_height); + float x0 = projection.TransformX(envelope.min_x); + float x1 = projection.TransformX(envelope.max_x); + float y0 = projection.TransformY(envelope.min_y); + float y1 = projection.TransformY(envelope.max_y); + float min_x = std::min(x0, x1), max_x = std::max(x0, x1); + float min_y = std::min(y0, y1), max_y = std::max(y0, y1); + if (!std::isfinite(min_x) || !std::isfinite(max_x) || + !std::isfinite(min_y) || !std::isfinite(max_y)) { + update_viewport(); + return; + } + float span = std::max(max_x - min_x, max_y - min_y); + float padding = std::max(0.04f, std::min(0.16f, span * 0.05f)); + float horizontal = std::max( + frame_margin(-1.0f - min_x, padding), + frame_margin(max_x - 1.0f, padding)); + /* Mirroring can move either horizontal overhang to the opposite side. */ + frame_.left = horizontal; + frame_.right = horizontal; + frame_.bottom = frame_margin(-1.0f - min_y, padding); + frame_.top = frame_margin(max_y - 1.0f, padding); + update_viewport(); +} + +void NativeModel::update_viewport() { + float horizontal = 1.0f + frame_.left + frame_.right; + float vertical = 1.0f + frame_.top + frame_.bottom; + if (!std::isfinite(horizontal) || !std::isfinite(vertical) || + horizontal <= 0.0f || vertical <= 0.0f || width_ <= 0 || height_ <= 0) { + viewport_x_ = viewport_y_ = 0; + viewport_width_ = std::max(1, width_); + viewport_height_ = std::max(1, height_); + return; + } + float content_width = width_ / horizontal; + float content_height = height_ / vertical; + int left = (int)std::lround(frame_.left * content_width); + int right = (int)std::lround(frame_.right * content_width); + int bottom = (int)std::lround(frame_.bottom * content_height); + int top = (int)std::lround(frame_.top * content_height); + viewport_x_ = std::max(0, std::min(width_ - 1, left)); + viewport_y_ = std::max(0, std::min(height_ - 1, bottom)); + viewport_width_ = std::max(1, width_ - viewport_x_ - std::max(0, right)); + viewport_height_ = std::max(1, height_ - viewport_y_ - std::max(0, top)); +} + +bool NativeModel::frame(BongoCatLive2DFrame *frame) const { + if (!_model || !frame) return false; + *frame = frame_; + return true; +} + +bool NativeModel::viewport(int *x, int *y, int *width, int *height) const { + if (!_model || !x || !y || !width || !height) return false; + *x = viewport_x_; + *y = viewport_y_; + *width = viewport_width_; + *height = viewport_height_; + return true; +} + +void NativeModel::apply_viewport_projection( + Csm::CubismMatrix44 &projection) const { + if (width_ <= 0 || height_ <= 0 || viewport_width_ <= 0 || + viewport_height_ <= 0) return; + float scale_x = (float)viewport_width_ / (float)width_; + float scale_y = (float)viewport_height_ / (float)height_; + float translate_x = (2.0f * viewport_x_ + viewport_width_) / + (float)width_ - 1.0f; + float translate_y = (2.0f * viewport_y_ + viewport_height_) / + (float)height_ - 1.0f; + float *matrix = projection.GetArray(); + matrix[0] *= scale_x; + matrix[4] *= scale_x; + matrix[12] = matrix[12] * scale_x + translate_x; + matrix[1] *= scale_y; + matrix[5] *= scale_y; + matrix[13] = matrix[13] * scale_y + translate_y; +} + void NativeModel::record_visible_state(Csm::CubismMatrix44 &projection) { visual_state_.drawable_count = _model->GetDrawableCount(); for (int i = 0; i < _model->GetDrawableCount(); ++i) { diff --git a/src/live2d/cubism_model_load.cpp b/src/live2d/cubism_model_load.cpp index c09f0afc..29edf8f8 100644 --- a/src/live2d/cubism_model_load.cpp +++ b/src/live2d/cubism_model_load.cpp @@ -253,6 +253,7 @@ bool NativeModel::load_textures(BongoCatError *error, if (progress) progress(userdata, .50f + .45f * (float)(i + 1) / (float)(count > 0 ? count : 1)); } + prepare_expression_frame(); release_renderer(); CreateRenderer((Csm::csmUint32)width_, (Csm::csmUint32)height_); renderer_width_ = width_; diff --git a/src/live2d/cubism_model_update.cpp b/src/live2d/cubism_model_update.cpp index e18da592..70a7a815 100644 --- a/src/live2d/cubism_model_update.cpp +++ b/src/live2d/cubism_model_update.cpp @@ -17,6 +17,7 @@ void NativeModel::resize(int width, int height) { if (width <= 0 || height <= 0) return; width_ = width; height_ = height; + update_viewport(); if (!_model || (width == renderer_width_ && height == renderer_height_)) return; if (!_model->IsBlendModeEnabled()) { renderer_width_ = width_; @@ -31,7 +32,11 @@ void NativeModel::resize(int width, int height) { } void NativeModel::reshape(int width, int height) { - if (width > 0 && height > 0) { width_ = width; height_ = height; } + if (width > 0 && height > 0) { + width_ = width; + height_ = height; + update_viewport(); + } } template @@ -72,11 +77,9 @@ bool NativeModel::update(float delta_seconds) { return result; } -void NativeModel::draw() { - if (!_model || width_ <= 0 || height_ <= 0) return; - auto *manager = Csm::Rendering::CubismOffscreenManager_OpenGLES2::GetInstance(); - manager->BeginFrameProcess(); - Csm::CubismMatrix44 projection; +void NativeModel::build_projection(Csm::CubismMatrix44 &projection, + int width, int height) { + if (!_model || width <= 0 || height <= 0) return; if (render_options_.mver_projection) { float aspect = (float)render_options_.reference_width / (float)render_options_.reference_height; @@ -84,12 +87,13 @@ void NativeModel::draw() { render_options_.projection_scale, render_options_.projection_scale * aspect); projection.Translate(render_options_.offset_x, render_options_.offset_y); - } else if (_model->GetCanvasWidth() > 1.0f && width_ < height_) { + } else if (_model->GetCanvasWidth() > 1.0f && width < height) { _modelMatrix->SetWidth(2.0f); projection.Scale(mirror_ ? -1.0f : 1.0f, - (float)width_ / (float)height_); + (float)width / (float)height); } else { - projection.Scale((mirror_ ? -1.0f : 1.0f) * (float)height_ / (float)width_, 1.0f); + projection.Scale((mirror_ ? -1.0f : 1.0f) * + (float)height / (float)width, 1.0f); } projection.MultiplyByMatrix(_modelMatrix); if (render_options_.mver_projection) { @@ -102,6 +106,15 @@ void NativeModel::draw() { matrix[4] *= mver_core_canvas_scale; matrix[5] *= mver_core_canvas_scale; } +} + +void NativeModel::draw() { + if (!_model || width_ <= 0 || height_ <= 0) return; + auto *manager = Csm::Rendering::CubismOffscreenManager_OpenGLES2::GetInstance(); + manager->BeginFrameProcess(); + Csm::CubismMatrix44 projection; + build_projection(projection, viewport_width_, viewport_height_); + apply_viewport_projection(projection); visual_state_ = BongoCatLive2DVisualState{}; visual_state_.fit_scale = 1.0f; visual_state_.mver_projection = render_options_.mver_projection; diff --git a/src/live2d/live2d_stub.c b/src/live2d/live2d_stub.c index 8179c240..e18a0701 100644 --- a/src/live2d/live2d_stub.c +++ b/src/live2d/live2d_stub.c @@ -38,6 +38,23 @@ bool bongo_cat_live2d_canvas_size(const BongoCatLive2D *live2d, (void)live2d; (void)width; (void)height; return false; } +bool bongo_cat_live2d_frame(const BongoCatLive2D *live2d, + BongoCatLive2DFrame *frame) { + if (!live2d || !frame) return false; + *frame = (BongoCatLive2DFrame){0}; + return true; +} + +bool bongo_cat_live2d_viewport(const BongoCatLive2D *live2d, + int *x, int *y, int *width, int *height) { + if (!live2d || !x || !y || !width || !height) return false; + *x = 0; + *y = 0; + *width = live2d->width; + *height = live2d->height; + return true; +} + void bongo_cat_live2d_resize(BongoCatLive2D *live2d, int width, int height) { if (!live2d) return; live2d->width = width; diff --git a/src/runtime/lifecycle/app.c b/src/runtime/lifecycle/app.c index 3460fe09..9887daca 100644 --- a/src/runtime/lifecycle/app.c +++ b/src/runtime/lifecycle/app.c @@ -211,14 +211,25 @@ static bool render(BongoCatApp *app, bool present) { glDisable(GL_SCISSOR_TEST); glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); bongo_cat_window_clear_background(app); + int content_x = 0, content_y = 0, content_width = width, + content_height = height; + bool content_viewport = bongo_cat_live2d_viewport(app->live2d, + &content_x, &content_y, &content_width, &content_height) && + content_width > 0 && content_height > 0; + if (content_viewport) + glViewport(content_x, content_y, content_width, content_height); bongo_cat_overlay_draw_background(app->overlay, app->settings.model.mirror); + glViewport(0, 0, width, height); bongo_cat_live2d_set_mirror(app->live2d, app->settings.model.mirror); bongo_cat_live2d_draw(app->live2d); + if (content_viewport) + glViewport(content_x, content_y, content_width, content_height); bongo_cat_overlay_draw_pointer_before_keys(app->overlay); if (app->settings.model.mouse_centered && app->pointer_known && !app->model_pointer_anchor_ready) bongo_cat_app_apply_mouse_position(app, app->pointer_x, app->pointer_y, 0.0f); bongo_cat_overlay_draw_keys(app->overlay, app->settings.model.mirror); bongo_cat_overlay_draw_effect(app->overlay, app->settings.model.mirror); bongo_cat_overlay_draw_pointer_after_keys(app->overlay); + glViewport(0, 0, width, height); bongo_cat_frame_capture_pending(app, width, height); if (!present) { app->dirty = true; diff --git a/src/runtime/lifecycle/config_store.c b/src/runtime/lifecycle/config_store.c index 993859fc..516079f8 100644 --- a/src/runtime/lifecycle/config_store.c +++ b/src/runtime/lifecycle/config_store.c @@ -50,6 +50,8 @@ static uint64_t session_hash(const BongoCatSessionState *session) { HASH_FIELD(session->window.y); HASH_FIELD(session->window.width); HASH_FIELD(session->window.height); + HASH_FIELD(session->window.content_width); + HASH_FIELD(session->window.content_height); HASH_FIELD(session->active_model_id); HASH_FIELD(session->additional_model_count); size_t model_count = session->additional_model_count; diff --git a/src/runtime/model/model_files.c b/src/runtime/model/model_files.c index 81d16968..e13ae059 100644 --- a/src/runtime/model/model_files.c +++ b/src/runtime/model/model_files.c @@ -115,8 +115,32 @@ static void model_load_progress(void *userdata, float progress) { bongo_cat_app_render_now(app); } +typedef struct ModelContentAnchor { + int x, y; + bool valid; +} ModelContentAnchor; + +static ModelContentAnchor capture_model_content_anchor(BongoCatApp *app) { + ModelContentAnchor anchor = {0}; + int window_x = 0, window_y = 0; + if (!app || !app->window || + !SDL_GetWindowPosition(app->window, &window_x, &window_y)) return anchor; + int left = 0, top = 0, ignored_width = 0, ignored_height = 0; + int content_width = app->session.window.content_width > 0 + ? app->session.window.content_width : app->session.window.width; + int content_height = app->session.window.content_height > 0 + ? app->session.window.content_height : app->session.window.height; + if (!bongo_cat_window_frame_size(app, content_width, content_height, + &ignored_width, &ignored_height, &left, &top)) return anchor; + anchor.x = window_x + left + content_width / 2; + anchor.y = window_y + top + content_height / 2; + anchor.valid = true; + return anchor; +} + static bool apply_model_aspect(BongoCatApp *app, - const BongoCatLive2DRenderOptions *options) { + const BongoCatLive2DRenderOptions *options, + const ModelContentAnchor *anchor, bool replacing_model) { if (!app || !app->window) return false; int reference_width = 612; int reference_height = 354; @@ -134,15 +158,37 @@ static bool apply_model_aspect(BongoCatApp *app, int x, y, width, height; if (reference_width <= 0 || reference_height <= 0 || !SDL_GetWindowPosition(app->window, &x, &y) || - !SDL_GetWindowSize(app->window, &width, &height) || height <= 0) + !SDL_GetWindowSize(app->window, &width, &height)) return false; - int next_width = (int)((double)height * reference_width / + int content_height = app->session.window.content_height > 0 + ? app->session.window.content_height : height; + int content_width = (int)((double)content_height * reference_width / reference_height + 0.5); - if (next_width < 64) next_width = 64; - if (next_width > 8192) next_width = 8192; - if (next_width == width) return false; - return bongo_cat_window_apply_geometry(app, x, y, - app->session.window.scale_percent, next_width, height); + if (content_width < 64) content_width = 64; + if (content_width > 8192) content_width = 8192; + if (content_height < 64) content_height = 64; + if (content_height > 8192) content_height = 8192; + int next_width = 0, next_height = 0, left = 0, top = 0; + if (!bongo_cat_window_frame_size(app, content_width, content_height, + &next_width, &next_height, &left, &top)) return false; + bool restored_frame = !replacing_model && + SDL_abs(width - next_width) <= 1 && SDL_abs(height - next_height) <= 1; + int next_x = x, next_y = y; + if (anchor && anchor->valid && !restored_frame) { + next_x = anchor->x - left - content_width / 2; + next_y = anchor->y - top - content_height / 2; + } + app->session.window.content_width = content_width; + app->session.window.content_height = content_height; + if (next_x == x && next_y == y && next_width == width && + next_height == height) return false; + bool changed = bongo_cat_window_apply_geometry(app, next_x, next_y, + app->session.window.scale_percent, next_width, next_height); + if (changed) { + app->session.window.content_width = content_width; + app->session.window.content_height = content_height; + } + return changed; } static void commit_model(BongoCatApp *app, @@ -200,6 +246,7 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, "force_mouse=%d left_handed=%d pointer_bounds=%d", render_options.projection_scale, render_options.mouse_force_move, render_options.pointer_left_handed, render_options.custom_pointer_bounds); + ModelContentAnchor content_anchor = capture_model_content_anchor(app); int pixel_width = app->session.window.width, pixel_height = app->session.window.height; if (app->window) SDL_GetWindowSizeInPixels(app->window, &pixel_width, &pixel_height); SDL_Window *previous_window = SDL_GL_GetCurrentWindow(); @@ -269,7 +316,8 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, bongo_cat_app_restore_behavior_state(app, entry->id); bongo_cat_random_expression_reset(app); app->pointer_known = false; - bool geometry_changed = apply_model_aspect(app, &render_options); + bool geometry_changed = apply_model_aspect(app, &render_options, + &content_anchor, replacing_model); if (app->window) { if (geometry_changed) SDL_SyncWindow(app->window); SDL_GetWindowSizeInPixels(app->window, &pixel_width, &pixel_height); diff --git a/src/runtime/runtime.h b/src/runtime/runtime.h index 8a52b6d7..fb46e9a4 100644 --- a/src/runtime/runtime.h +++ b/src/runtime/runtime.h @@ -56,6 +56,11 @@ void bongo_cat_window_cancel_wheel_animation(BongoCatApp *app); bool bongo_cat_window_wheel_self_test(BongoCatApp *app); bool bongo_cat_window_scaled_size(int base_width, int base_height, float base_scale, float requested_scale, float *actual_scale, int *width, int *height); +bool bongo_cat_window_frame_size(BongoCatApp *app, + int content_width, int content_height, int *width, int *height, + int *left, int *top); +bool bongo_cat_window_content_size(BongoCatApp *app, + int width, int height, int *content_width, int *content_height); bool bongo_cat_window_apply_geometry(BongoCatApp *app, int x, int y, float scale, int width, int height); bool bongo_cat_window_set_scale(BongoCatApp *app, float scale); diff --git a/src/runtime/shell/window.c b/src/runtime/shell/window.c index b35910f8..7a2fbe6b 100644 --- a/src/runtime/shell/window.c +++ b/src/runtime/shell/window.c @@ -144,6 +144,9 @@ bool bongo_cat_window_event(BongoCatApp *app, const SDL_Event *event) { if (event->type == SDL_EVENT_WINDOW_RESIZED) { app->session.window.width = event->window.data1; app->session.window.height = event->window.data2; + bongo_cat_window_content_size(app, event->window.data1, + event->window.data2, &app->session.window.content_width, + &app->session.window.content_height); bongo_cat_window_clamp_to_display(app); app->dirty = true; } diff --git a/src/runtime/shell/window_geometry.c b/src/runtime/shell/window_geometry.c index 5da76d5f..30681381 100644 --- a/src/runtime/shell/window_geometry.c +++ b/src/runtime/shell/window_geometry.c @@ -1,5 +1,6 @@ #include "runtime.h" +#include #include #define WINDOW_MIN_DIMENSION 64 @@ -11,6 +12,51 @@ static int round_dimension(double value) { return (int)(value + 0.5); } +static BongoCatLive2DFrame model_frame(BongoCatApp *app) { + BongoCatLive2DFrame frame = {0}; + if (app && app->live2d) bongo_cat_live2d_frame(app->live2d, &frame); + if (!isfinite(frame.left) || frame.left < 0.0f) frame.left = 0.0f; + if (!isfinite(frame.top) || frame.top < 0.0f) frame.top = 0.0f; + if (!isfinite(frame.right) || frame.right < 0.0f) frame.right = 0.0f; + if (!isfinite(frame.bottom) || frame.bottom < 0.0f) frame.bottom = 0.0f; + return frame; +} + +bool bongo_cat_window_frame_size(BongoCatApp *app, + int content_width, int content_height, int *width, int *height, + int *left, int *top) { + if (content_width <= 0 || content_height <= 0 || !width || !height) + return false; + BongoCatLive2DFrame frame = model_frame(app); + double frame_width = content_width * + (1.0 + frame.left + frame.right); + double frame_height = content_height * + (1.0 + frame.top + frame.bottom); + if (!isfinite(frame_width) || !isfinite(frame_height) || + frame_width < 1.0 || frame_height < 1.0 || + frame_width > WINDOW_MAX_DIMENSION || + frame_height > WINDOW_MAX_DIMENSION) return false; + *width = round_dimension(frame_width); + *height = round_dimension(frame_height); + if (left) *left = round_dimension(content_width * frame.left); + if (top) *top = round_dimension(content_height * frame.top); + return true; +} + +bool bongo_cat_window_content_size(BongoCatApp *app, + int width, int height, int *content_width, int *content_height) { + if (width <= 0 || height <= 0 || !content_width || !content_height) + return false; + BongoCatLive2DFrame frame = model_frame(app); + double horizontal = 1.0 + frame.left + frame.right; + double vertical = 1.0 + frame.top + frame.bottom; + if (!isfinite(horizontal) || !isfinite(vertical) || + horizontal <= 0.0 || vertical <= 0.0) return false; + *content_width = SDL_max(1, round_dimension(width / horizontal)); + *content_height = SDL_max(1, round_dimension(height / vertical)); + return true; +} + bool bongo_cat_window_scaled_size(int base_width, int base_height, float base_scale, float requested_scale, float *actual_scale, int *width, int *height) { if (base_width <= 0 || base_height <= 0 || base_scale <= 0.0f || @@ -44,6 +90,9 @@ bool bongo_cat_window_apply_geometry(BongoCatApp *app, int x, int y, app->session.window.position_known = true; app->session.window.width = width; app->session.window.height = height; + bongo_cat_window_content_size(app, width, height, + &app->session.window.content_width, + &app->session.window.content_height); if (SDL_GetWindowSizeInPixels(app->window, &app->resize_pixel_width, &app->resize_pixel_height)) app->resize_pending = true; diff --git a/tests/core/test_config.c b/tests/core/test_config.c index 29ecd9be..6eda2f42 100644 --- a/tests/core/test_config.c +++ b/tests/core/test_config.c @@ -39,7 +39,8 @@ static void check_defaults_and_validation(void) { CHECK(settings.window.obs_background_color == BONGO_CAT_OBS_BACKGROUND_GREEN); CHECK(session.window.visible && session.window.width == 612 && - session.window.height == 354); + session.window.height == 354 && session.window.content_width == 612 && + session.window.content_height == 354); CHECK(strcmp(session.active_model_id, "standard") == 0); CHECK(session.additional_model_count == 0); CHECK(bongo_cat_session_add_model(&session, "keyboard")); @@ -180,6 +181,10 @@ void test_config(void) { session.window.x = -321; session.window.position_known = true; session.window.opacity_percent = 75.0f; + session.window.width = 700; + session.window.height = 500; + session.window.content_width = 612; + session.window.content_height = 354; memcpy(session.active_model_id, "model", sizeof("model")); CHECK(bongo_cat_session_add_model(&session, "keyboard")); CHECK(bongo_cat_session_add_model(&session, "gamepad")); @@ -209,6 +214,8 @@ void test_config(void) { CHECK(contains_text(settings_path, "\"example\"")); CHECK(!contains_text(settings_path, "activeModelId")); CHECK(contains_text(session_path, "\"format\": \"bongocat/session\"")); + CHECK(contains_text(session_path, "\"contentWidth\": 612") && + contains_text(session_path, "\"contentHeight\": 354")); CHECK(contains_text(session_path, "\"activeModelId\": \"model\"")); CHECK(contains_text(session_path, "\"additionalModelIds\"")); CHECK(contains_text(session_path, "\"activeBehaviors\"")); @@ -237,7 +244,11 @@ void test_config(void) { &(BongoCatModelEntry){.id = "model", .display_name = "Imported"}), "Display") == 0); CHECK(loaded_session.window.x == -321 && - loaded_session.window.opacity_percent == 75.0f); + loaded_session.window.opacity_percent == 75.0f && + loaded_session.window.width == 700 && + loaded_session.window.height == 500 && + loaded_session.window.content_width == 612 && + loaded_session.window.content_height == 354); CHECK(strcmp(loaded_session.active_model_id, "model") == 0); CHECK(loaded_session.additional_model_count == 2 && bongo_cat_session_model_active(&loaded_session, "keyboard") && @@ -283,6 +294,13 @@ void test_config(void) { "\"window\":{\"visible\":123}}"); CHECK(bongo_cat_session_load(unsupported, &loaded_session, &error) == BONGO_CAT_ERROR_FORMAT); + write_text(unsupported, + "{\"format\":\"bongocat/session\",\"schemaVersion\":1," + "\"window\":{\"size\":{\"width\":700,\"height\":500}}}"); + bongo_cat_session_defaults(&loaded_session); + CHECK(bongo_cat_session_load(unsupported, &loaded_session, &error) == + BONGO_CAT_OK && loaded_session.window.content_width == 700 && + loaded_session.window.content_height == 500); static BongoCatSettings canonical_settings; static BongoCatSessionState canonical_session; From df9296289f2b0bba8439f07fbdfdb3808e8d4454 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 23 Aug 2026 05:28:21 +0800 Subject: [PATCH 072/334] feat: update license and project links --- CMakeLists.txt | 2 +- LICENSE | 37 ++++++++++--------- README.md | 5 +++ cmake/Packaging.cmake | 2 + resources/assets/locales/en-US.json | 2 +- resources/assets/locales/zh-CN.json | 2 +- resources/assets/locales/zh-Hant.json | 2 +- resources/assets/models/LICENSE | 28 ++++++++++++++ src/ui/preferences/preferences_about.c | 8 ++-- .../preferences/preferences_about_community.c | 4 +- src/ui/preferences/preferences_render.c | 2 +- 11 files changed, 66 insertions(+), 28 deletions(-) create mode 100644 resources/assets/models/LICENSE diff --git a/CMakeLists.txt b/CMakeLists.txt index 6ba09911..ef3ff3fa 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -project(BongoCat VERSION 2.0.1 DESCRIPTION "BongoCat" HOMEPAGE_URL "https://bongocat.pet" LANGUAGES C CXX) +project(BongoCat VERSION 0.1.0 DESCRIPTION "BongoCat" HOMEPAGE_URL "https://github.com/vladelaina/BongoCat" LANGUAGES C CXX) include(cmake/Version.cmake) include(FetchContent) include(CTest) diff --git a/LICENSE b/LICENSE index 18cd5ed3..9b5e0453 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,24 @@ -MIT License + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 -Copyright (c) 2026 vladelaina +SPDX-License-Identifier: GPL-3.0-only -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. + BongoCat is free software: you can redistribute it and/or modify it under + the terms of the GNU General Public License as published by the Free + Software Foundation, either version 3 of the License, or (at your option) + any later version. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + BongoCat is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + details. + + You should have received a copy of the GNU General Public License along + with BongoCat. If not, see . + + The complete license terms are incorporated by reference from the official + GNU GPL v3 text at https://www.gnu.org/licenses/gpl-3.0.txt. diff --git a/README.md b/README.md index 99ec3af1..8f8cae2e 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,11 @@ Artifacts are named `portable-*` and, on Windows, `installer-*`. ## Third-Party and Distribution Notes +The BongoCat application source code is licensed under the GNU General Public +License v3.0. The three bundled model packages under +`resources/assets/models/standard`, `keyboard`, and `gamepad` are separately +licensed under the MIT License; see `resources/assets/models/LICENSE`. + Third-party components retain their upstream licenses. Cubism SDK files must be obtained from Live2D and are not redistributed here. Before distributing a binary, review the licenses for SDL3, yyjson, stb, miniaudio, Nuklear, OpenGL, diff --git a/cmake/Packaging.cmake b/cmake/Packaging.cmake index ad0b2ef9..56698350 100644 --- a/cmake/Packaging.cmake +++ b/cmake/Packaging.cmake @@ -1,6 +1,8 @@ install(TARGETS bongo_cat RUNTIME DESTINATION . COMPONENT Runtime BUNDLE DESTINATION . COMPONENT Runtime) +install(FILES LICENSE DESTINATION . COMPONENT Runtime) +install(FILES resources/assets/models/LICENSE DESTINATION assets/models COMPONENT Runtime) if(UNIX AND NOT APPLE) install(DIRECTORY resources/assets DESTINATION . COMPONENT Runtime PATTERN "logo-mac.png" EXCLUDE PATTERN "ui-icons.png" EXCLUDE diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index 1bc3db7d..c3e9ef6e 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -196,7 +196,7 @@ "diagnosticLog": "Diagnostic log:" }, "support": { - "title": "Support the Dev Team", + "title": "Support the Developer", "heroText": "Thank you for your support. Every use and share helps BongoCat continue to grow.", "version": "App version", "checkUpdate": "Check for updates", diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index 97ca47a4..b05f4450 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -196,7 +196,7 @@ "diagnosticLog": "诊断日志:" }, "support": { - "title": "支持开发团队", + "title": "支持开发者", "heroText": "感谢你的支持。每一次使用与分享,都让 BongoCat 继续成长。", "version": "应用版本", "checkUpdate": "检查更新", diff --git a/resources/assets/locales/zh-Hant.json b/resources/assets/locales/zh-Hant.json index 004ce316..f19bb2c8 100644 --- a/resources/assets/locales/zh-Hant.json +++ b/resources/assets/locales/zh-Hant.json @@ -196,7 +196,7 @@ "diagnosticLog": "診斷日誌:" }, "support": { - "title": "支援開發團隊", + "title": "支援開發者", "heroText": "感謝你的支持。每一次使用與分享,都讓 BongoCat 持續成長。", "version": "應用程式版本", "checkUpdate": "檢查更新", diff --git a/resources/assets/models/LICENSE b/resources/assets/models/LICENSE new file mode 100644 index 00000000..fad250a3 --- /dev/null +++ b/resources/assets/models/LICENSE @@ -0,0 +1,28 @@ +MIT License + +SPDX-License-Identifier: MIT + +Copyright (c) 2026 vladelaina + +The bundled model assets in the `standard`, `keyboard`, and `gamepad` +directories are provided under the MIT License. This applies to those model +assets and their accompanying textures, motions, expressions, and input +artwork only; it does not change the GPL-3.0 license of the BongoCat source. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of these model assets and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/ui/preferences/preferences_about.c b/src/ui/preferences/preferences_about.c index 9c4167cf..60ae59f0 100644 --- a/src/ui/preferences/preferences_about.c +++ b/src/ui/preferences/preferences_about.c @@ -124,14 +124,14 @@ static void star_button(BongoCatPreferences *value, value->ui.label_font, nk_rgb(255, 255, 255)); link_cursor(context, bounds); if (hit(context, bounds)) - open_url("https://github.com/BongoCatPet/BongoCat"); + open_url("https://github.com/vladelaina/BongoCat"); } static void hero_title(BongoCatPreferences *value, struct nk_context *context, struct nk_command_buffer *canvas, struct nk_rect bounds, BongoCatUIPalette p) { const char *title = "BongoCat"; const char *by = tr(value, "native.support.by", "by "); - const char *developer = "BongoCat-pet"; + const char *developer = "vladelaina"; float gap = 8, title_width = width(value->ui.hero_font, title); float by_width = width(value->ui.caption_font, by), developer_width = width(value->ui.caption_font, developer); @@ -149,7 +149,7 @@ static void hero_title(BongoCatPreferences *value, struct nk_context *context, text(canvas, link, developer, value->ui.caption_font, bongo_cat_ui_color_mix(p.accent, p.pink, amount)); link_cursor(context, link); if (hit(context, link)) - open_url("https://github.com/bongocat-pet"); + open_url("https://github.com/vladelaina/BongoCat"); } static void footer(BongoCatPreferences *value, struct nk_context *context, struct nk_command_buffer *canvas, struct nk_rect bounds, @@ -209,7 +209,7 @@ static void footer(BongoCatPreferences *value, struct nk_context *context, bongo_cat_ui_color_mix(p.accent, p.pink, feedback_amount)); link_cursor(context, feedback_link); if (hit(context, feedback_link)) - open_url("https://github.com/BongoCatPet/BongoCat/issues"); + open_url("https://github.com/vladelaina/BongoCat/issues"); } static void hero(BongoCatPreferences *value, struct nk_context *context) { diff --git a/src/ui/preferences/preferences_about_community.c b/src/ui/preferences/preferences_about_community.c index 282b0674..30503dd4 100644 --- a/src/ui/preferences/preferences_about_community.c +++ b/src/ui/preferences/preferences_about_community.c @@ -121,9 +121,9 @@ static void community_link(BongoCatPreferences *value, struct nk_rect bounds, int index, BongoCatUIPalette p) { const char *labels[] = {"Discord", tr(value, "native.support.qqGroup", "QQ Group")}; - const char *details[] = {"https://discord.gg/vf8jqnattk", "473033518"}; + const char *details[] = {"https://discord.gg/vf8jqnattk", "422616922"}; const char *urls[] = {"https://discord.gg/vf8jqnattk", - "https://qm.qq.com/q/sccLv3USXg"}; + "https://qm.qq.com/q/6ksRMCZIuA"}; struct nk_color brand = index ? p.accent : nk_rgb(88, 101, 242); bool hover = nk_input_is_mouse_hovering_rect(&context->input, bounds); char id[32]; snprintf(id, sizeof(id), "community-hover-%d", index); diff --git a/src/ui/preferences/preferences_render.c b/src/ui/preferences/preferences_render.c index 9cfa42ec..85f32ef5 100644 --- a/src/ui/preferences/preferences_render.c +++ b/src/ui/preferences/preferences_render.c @@ -66,7 +66,7 @@ static bool draw_shell(BongoCatPreferences *value, struct nk_context *context, tr(value, "pages.preference.general.title", "General"), tr(value, "pages.preference.model.title", "Model"), tr(value, "pages.preference.shortcut.title", "Shortcuts"), - tr(value, "native.support.title", "Support the Dev Team")}; + tr(value, "native.support.title", "Support the Developer")}; bool modal = bongo_cat_preferences_remove_dialog_active(value->app) || bongo_cat_preferences_behavior_dialog_active(value); BongoCatUIPalette p = bongo_cat_ui_palette(dark); From 589ada2217358d747f3801a48e453825da653ecc Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 23 Aug 2026 12:26:26 +0800 Subject: [PATCH 073/334] Update README.md --- README.md | 511 +++++++++++++++++++++++++++--------------------------- 1 file changed, 259 insertions(+), 252 deletions(-) diff --git a/README.md b/README.md index 8f8cae2e..14b48077 100644 --- a/README.md +++ b/README.md @@ -1,288 +1,295 @@ -# BongoCat - -`BongoCat` is a small native desktop overlay for Live2D models. It keeps a -borderless, transparent window on the desktop and maps keyboard, mouse, and -gamepad input to the model. - -This README is about the source tree and its build. It is not a user manual. -The Windows build with the Live2D Cubism SDK is the reference build; the public -CI jobs build and test the same program with a diagnostic renderer because the -SDK is licensed and is not checked into this repository. - -## Current State - -The native application, bundled model packages, preferences, import path, and -platform integrations are in this tree. Validation is performed against real -Windows Cubism builds, with platform- and device-specific checks called out -when they still need a manual pass. - -Without Cubism, CMake selects a diagnostic backend. The application still -starts, opens its preferences, processes input, and runs the native tests, but -it cannot draw or animate a real Live2D model. Configure with -`-DBONGO_CAT_REQUIRE_CUBISM=ON` when a missing SDK should be a configuration error -instead of a diagnostic build. - -## Features - -- Standard, keyboard, and gamepad model modes, with three bundled model - packages. -- Cubism `.model3.json` loading, textures, motions, expressions, physics, pose, - eye blink, and breath updates. -- Import and removal of custom model packages. A package is checked for a valid - manifest and safe file references before it is installed under the user data - directory. -- Bounded automatic discovery of Bongo-Cat-Mver packages beside the launch - location. On Windows, shortcuts use their own folder as that location. - Nearby sources and installed packages share one model list; packages may use - `config.json` + `img/` directly or an intact nested directory. -- Mver 0.1.6 is the animation and input behavior baseline; standalone - Tauri/Live2D model folders are loaded through a canvas and input adapter. - Model containers with nested full packages and image-only variants (for - example `A-*` plus `Z-*` directories) are also discovered. The source stays - read-only and BongoCat caches only generated adapter files. -- Keyboard and mouse input, pointer tracking, gamepad buttons and axes, - mirroring, lost-release recovery, and configurable shortcuts. -- Optional keep-on-screen positioning, disabled by default. A fully unreachable - window is recovered after a display is disconnected, without polling or - interacting with fullscreen applications. -- Transparent borderless window, click-through, always-on-top, hover hiding, - monitor clamping, scaling, opacity, tray integration, and Shift + right-drag - resizing. -- Preferences, light/dark themes, and ten shipped locales: Simplified Chinese, - Traditional Chinese, English, French, German, Japanese, Korean, Brazilian - Portuguese, Russian, and Spanish. - -## How the Runtime Is Split - -The program is intentionally split at the points where the implementation has -different ownership: -```text -SDL events and native hooks - | - v - input state (C11 atomics) ---> main-thread application - | - model parameters and UI state - | - Cubism update -> OpenGL draw -``` +tip -The platform hooks do not touch a model. They publish timestamped events to a -bounded queue; pointer coordinates are coalesced separately so mouse movement -does not consume the queue needed for key and button transitions. The main loop -drains the queue, applies releases and parameters, updates the model, and swaps -the SDL OpenGL window. -Frames are submitted when something changed. An idle Cubism model is allowed to -accumulate a short time slice before it updates, which keeps an idle transparent -window quiet without making motions depend on a fixed frame rate. -The C runtime calls a small interface in `src/live2d`. The Cubism implementation -is C++17 because that is the SDK's API; the rest of the application remains C11. -This keeps SDK types out of the public headers and makes the diagnostic backend -possible. +## Bongo Cat History -## Source Tree +The original Bongo Cat GIF originated on May 7, 2018, when an animated cat GIF made by Twitter user @StrayRogue was edited by @DitzyFlama, with the edit including bongos which were hit by the cat to the tune of "Athletic" from the Super Mario World soundtrack. The original artist clarified that Bongo Cat was a cat-like blob rather than an actual cat, showing a drawing of the character's full body in another tweet. -```text -include/bongo_cat/ public C interfaces and data structures -src/core/ config, paths, input state, catalogs, hashes -src/runtime/ runtime orchestration - lifecycle/ startup, frame timing, storage, and app state - input/ runtime input mapping and shortcuts - model/ catalog, storage, and import pipelines - shell/ window, tray, and modal-frame behavior - diagnostics/ frame and Live2D runtime audits -src/render/ OpenGL helpers and overlays -src/live2d/ Cubism bridge and diagnostic backend -src/platform/ platform adapters grouped by OS - windows/ Win32 window, input, capture, tray, and UTF-8 glue - macos/ Cocoa and Application Services glue - linux/ X11 and native menu glue -src/ui/ UI implementation grouped by responsibility - backend/ Nuklear/SDL backend, input, scale, and cursors - rendering/ fonts, paint primitives, and native theme - theme/ Catime theme components and icons - preferences/ preference pages, dialogs, model cards, and widgets -resources/assets/ bundled models, textures, locales, and tray assets -tests/ native tests grouped by subsystem, plus fixtures -cmake/ dependency, platform, target, and source-policy modules -``` +It has later been edited to many other songs and many different instruments in fan-made videos, appearing on social media such as YouTube and Twitter. The meme has covered many songs from video game soundtracks, such as music from Persona 5 and Super Mario, as well as mainstream songs such as Toto's "Africa" and Darude's "Sandstorm". After an increase in popularity, Stray Rogue began making and selling Bongo Cat merchandise. Bongo Cat also has been made into an interactive website. -Internal headers remain private to `src`; only `include/bongo_cat` is a public -include surface. New implementation files should live beside the subsystem -that owns them, and CMake source lists should be updated in the matching -`RuntimeSources.cmake` or target module. +The meme was also developed into key-mapping software in 2018. The program originated from open-source code written by Hamish Duncanson on GitHub, and was later modified by contributors such as MMmmmoko, ayangweb, and kuroni. -## Dependencies +In 2023, Bongo Cat's cover of "What Was I Made For?" by Billie Eilish was shared online. The cover went viral on TikTok and fans sang this version during Eilish's concerts in 2024. By 2025, Filipino congressman Francisco "Kiko" Barzaga, representative of Dasmariñas City, made the Bongo Cat cover a part of his public persona, branding himself "Congressmeow" and singing the lyrics "meow meow meow meow" on social media. -The build uses SDL3 for the window and event loop, desktop OpenGL for drawing, -yyjson for JSON, stb for image loading, miniaudio for motion audio, and -Nuklear for the preferences UI. With `BONGO_CAT_FETCH_DEPS=ON` (the default), CMake -fetches pinned revisions of those open-source dependencies and verifies their -SHA-256 values. Set it to `OFF` to use installed packages and headers instead. -The Live2D Cubism SDK is a separate download. Put Cubism SDK for Native 5 r.5 -at `vendor/CubismSdkForNative` after accepting Live2D's license. The expected -tree contains `Core`, `Framework`, and the SDK's OpenGL sample dependencies. -On Windows, run the SDK helper once: -```powershell -cd vendor\CubismSdkForNative\Samples\OpenGL\thirdParty\scripts -.\setup_glew_glfw.bat -``` +## Project Status -The SDK is ignored by Git and is never fetched by this project. -## Building -### Windows with Cubism +## Technical Architecture -Use Visual Studio 2022 or the matching Build Tools (v143, Windows 10/11 SDK): +> The current native version is built on C/C++, SDL3, and OpenGL. The diagram below may look a little complicated, but it’s actually not difficult to understand. -```bat -build.bat Release -``` +### Runtime ownership and frame scheduling -The repository build script displays a live `#F77DAA` progress bar, keeps -configuration and build logs under `build-cubism/`, and uses two parallel jobs -by default. Set `BONGOCAT_BUILD_JOBS` to change the job count or set -`BONGOCAT_CLEAN_BUILD=1` to request a clean build. The Cubism SDK is used -automatically when it is present; set `BONGOCAT_REQUIRE_CUBISM=1` to fail -instead of using the diagnostic backend when it is missing. The equivalent -manual commands are: - -```powershell -cmake -S . -B build-cubism -G "Visual Studio 17 2022" -A x64 ` - -DBONGO_CAT_REQUIRE_CUBISM=ON ` - -DBONGO_CAT_WARNINGS_AS_ERRORS=ON -cmake --build build-cubism --config Release --parallel 2 -ctest --test-dir build-cubism -C Release --output-on-failure -``` - -The executable is `build-cubism/Release/BongoCat.exe`. +The native runtime is intentionally split at ownership boundaries: -Build the versioned portable release archive and SHA-256 file with: - -```powershell -cmake --build build-cubism --config Release --target package +```text +Platform input listeners +(keyboard / pointer) + | + v + C11 input state + (atomic edge queue + coalesced pointer path) + | + v + main-thread application <----- SDL3 events + | + v + model parameters, overlay, and UI state + | + v + Cubism update -> OpenGL composition -> platform presentation ``` -On Windows, build the graphical installer and its SHA-256 file with NSIS 3.03 -or newer available on `PATH`: - -```powershell -cmake --build build-cubism --config Release --target package-installer +Platform input listeners never manipulate a model directly. Windows low-level +hooks, the macOS Quartz event tap, and Linux XInput2 publish timestamped +keyboard and mouse-button transitions to application-owned input state. On +Windows, DirectInput is sampled through the platform pointer interface when an +imported model requests relative movement; SDL3 gamepad events are normalized +on the main thread. Pointer coordinates use a separate atomic coalescing path, +so high-frequency motion does not displace the bounded queue used for key and +button edges. + +Each loop iteration waits for SDL or native wakeups, the next scheduler +deadline, or pending UI/window work. It then drains queued events and release +recovery, applies input-derived parameters and overlay state, and advances the +model with elapsed time. In the normal pet-window path, a frame is presented +when the application is marked dirty; explicit preview, resize, and capture +operations may request additional renders. Presentation is platform-specific: +`SDL_GL_SwapWindow` is used on macOS and Linux, and on Windows when layered +presentation is inactive; active Windows layered presentation uses +`UpdateLayeredWindow`. + +The scheduler does not require a fixed simulation step. With Cubism enabled, +the next model update is derived from the configured maximum FPS (defaulting to +60 FPS), and the runtime receives the elapsed time since the previous update. +Elapsed gaps are capped at 250 ms and subdivided into at most eight substeps +for stability. If an update produces no visual change, the normal pet window +is not presented again unless another state transition requests a render. + +The C runtime calls the C ABI declared in `include/bongo_cat/model.h`, whose +bridge and model implementation live in `src/live2d`. When the Cubism SDK is +enabled, that implementation is C++17 because the SDK exposes a C++ API; the +rest of the native C sources use C11. Cubism SDK types stay behind opaque C +handles and C-compatible structures, while `src/live2d/live2d_stub.c` provides +the diagnostic backend when the SDK is unavailable. + + +```mermaid +%%{init: {"flowchart": {"curve": "catmullRom", "htmlLabels": true, "nodeSpacing": 24, "rankSpacing": 38}}}%% +flowchart TB + User(["User Input
Keyboard / Mouse / Gamepad"]) + ModelSources(["External Model Sources
Standalone .model3.json / Mver package / Mver image patch"]) + Desktop(["Desktop Output
Transparent pet window / Preferences window"]) + + subgraph Platform["Platform Backends"] + direction LR + Win["Windows
Win32 hooks / DirectInput / layered-window support"] + Mac["macOS
Cocoa / ApplicationServices / Quartz event tap"] + Linux["Linux
X11 / XInput2 / XFixes"] + end + + subgraph Native["BongoCat Native Runtime"] + direction TB + + subgraph Orchestration["Runtime Orchestration / C11"] + direction LR + Entry["Process Entry
src/main.c calls bongo_cat_app_run"] + Lifecycle["Application Lifecycle
startup / storage paths / configuration / shutdown"] + EventLoop["SDL3 Event and Frame Loop
wait / dispatch / drain input / update / render"] + AppState[("BongoCatApp State
settings / session / catalogs / platform and runtime state")] + + Entry --> Lifecycle --> EventLoop + Lifecycle <--> AppState + EventLoop <--> AppState + end + + subgraph InputShell["Input and Desktop Shell"] + direction LR + GlobalInput["Global Keyboard and Pointer Capture
Win32 hooks / Quartz event tap / XInput2"] + InputQueue[("Atomic Input State
ordered edge queue / release-edge recovery / coalesced pointer position")] + Gamepad["SDL3 Gamepad Events
active-device selection / buttons / axes"] + InputMap["Input Dispatch
shortcuts / model parameters / pointer mapping"] + Shell["Desktop Shell
window / tray / context menu / drag and resize / click-through / multi-pet coordination"] + + GlobalInput --> InputQueue --> InputMap + Gamepad --> InputMap + EventLoop --> Shell + EventLoop --> InputMap + InputMap --> AppState + Shell --> AppState + end + + subgraph ModelPipeline["Model Discovery, Adaptation, and Cataloging"] + direction LR + BuiltIn["Built-in Models
resources/assets/models"] + Discover["Source Discovery
standalone model3 / Mver package / image patch"] + Validate["Validation and Identity
manifest references / bounded paths / SHA-256 digest / metadata"] + Adapt["Runtime Adapter Generation
preview assets / input overlays / projection and binding metadata"] + Installed[("Installed Packages
data_root/models / preserved source payload / package descriptor")] + Nearby[("Nearby Models
source tree stays authoritative / cache_root/model-adapters")] + Catalog["Model and Behavior Catalogs
models / motions / expressions / sounds / effects"] + + Discover --> Validate --> Adapt + Adapt --> Installed --> Catalog + Adapt --> Nearby --> Catalog + BuiltIn --> Catalog + Catalog --> AppState + end + + subgraph Presentation["Animation, Composition, and UI"] + direction LR + Live2DBridge["C ABI / C++17 Bridge"] + Cubism["Live2D Cubism Runtime
model / motion / expression / physics / pose / parameter overrides"] + Images["Image Pipeline
stb decode and resize / alpha masks and mipmaps / OpenGL texture upload"] + Overlay["Input Overlay Runtime
background / pointer / key images / effects"] + Audio["Audio Playback
miniaudio engine / asynchronous file decoding"] + Composite["OpenGL Frame Composition
clear and background / Live2D model / pointer, keys, and effects"] + Preferences["Nuklear Preferences UI
Catime theme / localization / settings / model management"] + Present["Platform Presentation
Windows: UpdateLayeredWindow or GL swap
macOS and Linux: SDL_GL_SwapWindow"] + + Live2DBridge --> Cubism + Cubism --> Composite --> Present + Images --> Cubism + Images --> Overlay --> Composite + AppState <--> Preferences + end + + EventLoop --> Live2DBridge + EventLoop --> Composite + EventLoop --> Preferences + AppState --> Live2DBridge + AppState --> Composite + InputMap --> Live2DBridge + InputMap --> Overlay + InputMap --> Audio + Catalog --> Live2DBridge + Catalog --> Overlay + Catalog --> Audio + Shell --> Preferences + end + + subgraph Dependencies["Native Dependencies"] + direction LR + SDL["SDL3"] + OpenGL["OpenGL"] + GLEW["GLEW
Cubism OpenGL function loading"] + CubismSDK["Live2D Cubism SDK"] + YYJSON["yyjson"] + STB["stb_image / stb_image_resize2 / stb_image_write"] + Miniaudio["miniaudio"] + Nuklear["Nuklear"] + end + + subgraph Toolchain["Build, Verification, and Distribution"] + direction LR + CMake["CMake
pinned dependencies / platform sources / asset staging and embedding"] + CTest["CTest
core / i18n / UI / app state / model import / motion state / Windows capture"] + Audits["Static and Runtime Checks
line policy / platform API allowlist / smoke and visual audits"] + Packaging["Distribution
Windows ZIP and NSIS / macOS ZIP / Linux TGZ / Microsoft Store MSIX"] + + CMake --> CTest + CMake --> Audits + CMake --> Packaging + end + + User --> GlobalInput + User --> Gamepad + User --> Shell + ModelSources --> Discover + + Win --> GlobalInput + Mac --> GlobalInput + Linux --> GlobalInput + Win --> Shell + Mac --> Shell + Linux --> Shell + Win --> Present + Mac --> Present + Linux --> Present + + Present --> Desktop + Preferences --> Desktop + + SDL -.-> EventLoop + SDL -.-> Gamepad + SDL -.-> Shell + SDL -.-> Present + OpenGL -.-> Cubism + OpenGL -.-> Composite + OpenGL -.-> Preferences + GLEW -.-> Cubism + CubismSDK -.-> Cubism + YYJSON -.-> Lifecycle + YYJSON -.-> Validate + STB -.-> Images + Miniaudio -.-> Audio + Nuklear -.-> Preferences + + CMake -.-> Entry + CTest -.-> AppState + CTest -.-> Preferences + CTest -.-> Live2DBridge + CTest -.-> Present + Audits -.-> EventLoop + Audits -.-> GlobalInput + Audits -.-> Composite + + classDef external fill:#fff4cc,stroke:#b7791f,color:#3d2b00,stroke-width:1.5px + classDef platform fill:#e8f1ff,stroke:#4a78b8,color:#14263d,stroke-width:1.5px + classDef runtime fill:#ffe8f0,stroke:#d95f8d,color:#3d1725,stroke-width:1.5px + classDef state fill:#f3eaff,stroke:#8a63c7,color:#2c1b46,stroke-width:1.5px + classDef input fill:#e7f8ff,stroke:#3f8eaa,color:#12323d,stroke-width:1.5px + classDef model fill:#eaf8e8,stroke:#57945a,color:#18361a,stroke-width:1.5px + classDef render fill:#f3ecff,stroke:#8064b3,color:#281b40,stroke-width:1.5px + classDef dependency fill:#f4f4f5,stroke:#71717a,color:#27272a,stroke-width:1.2px + classDef tooling fill:#fff0dd,stroke:#c47b2c,color:#42270d,stroke-width:1.5px + + class User,ModelSources,Desktop external + class Win,Mac,Linux platform + class Entry,Lifecycle,EventLoop runtime + class AppState,InputQueue,Installed,Nearby state + class GlobalInput,Gamepad,InputMap,Shell input + class BuiltIn,Discover,Validate,Adapt,Catalog model + class Live2DBridge,Cubism,Images,Overlay,Audio,Composite,Preferences,Present render + class SDL,OpenGL,GLEW,CubismSDK,YYJSON,STB,Miniaudio,Nuklear dependency + class CMake,CTest,Audits,Packaging tooling + + style Native fill:#fffafd,stroke:#d95f8d,stroke-width:2px + style Platform fill:#f8fbff,stroke:#7da1cf,stroke-width:1.5px + style Orchestration fill:#fff7fa,stroke:#ed9eb8,stroke-width:1px + style InputShell fill:#f5fcff,stroke:#86bfd3,stroke-width:1px + style ModelPipeline fill:#f7fcf5,stroke:#91bd8f,stroke-width:1px + style Presentation fill:#faf7ff,stroke:#ad98cf,stroke-width:1px + style Dependencies fill:#fafafa,stroke:#a1a1aa,stroke-width:1px + style Toolchain fill:#fffaf3,stroke:#dda866,stroke-width:1.5px ``` -The installer is current-user-only: it requests no administrator elevation and -defaults to `%LOCALAPPDATA%\Programs\BongoCat`. Its shortcuts and uninstall -entry are also created for the current Windows user. - -Release files are written to `build-cubism/dist`. Portable archives use the -conventional product-version-platform-architecture format, and installers add -the `-setup` suffix. The installer registers BongoCat in Windows Apps & -features, adds Start Menu entries, offers a desktop shortcut, and includes an -uninstaller. The installed executable keeps the stable `BongoCat.exe` name so -upgrades do not break shortcuts. -### Diagnostic or Unix build -```powershell -cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -cmake --build build -ctest --test-dir build --output-on-failure -``` -The native Linux branch requires the usual OpenGL and X11/XInput2/Xfixes -development packages. macOS uses its Cocoa and ApplicationServices frameworks. -To use system dependencies rather than CMake's pinned downloads: -```powershell -cmake -S . -B build -G Ninja -DBONGO_CAT_FETCH_DEPS=OFF -``` +## Special Thanks❤️ -When fetching is disabled, CMake needs SDL3-static and yyjson package -configurations plus `stb_image.h`, `stb_image_write.h`, `miniaudio.h`, and -`nuklear.h`. Their include roots can be set with -`BONGO_CAT_STB_INCLUDE_DIR`, `BONGO_CAT_MINIAUDIO_INCLUDE_DIR`, and -`BONGO_CAT_NUKLEAR_INCLUDE_DIR`. + + Contribution Leaderboard + -### Install tree -```powershell -cmake --install build --config Release --component Runtime --prefix out -``` -The install tree is portable. Windows embeds the resource archive; Unix builds -place `assets/` beside the executable or application bundle. User-owned files -follow the per-user storage layout managed by the application. The portable -executable and installed application use the same per-user storage layout on -each platform; no files are written next to the executable. This pre-release -build does not migrate settings from an older directory. Settings and session -state use separate schema-1 documents. Unsupported formats are rejected. - -## Runtime Data and Test Switches - -Storage is separated into `config`, `data`, `cache`, `state`, and `logs`. -Installed packages live under `data/models`; generated nearby-model adapters -live under `cache/model-adapters`. A Tauri or Bongo-Cat-Mver package can be used -without installing it by placing its model directory beside `BongoCat` or its -Windows shortcut. Startup discovery scans at most three directory levels and -256 directories, with a 500 ms directory-work budget. Unchanged -sources reuse their cached identity and generated adapter. -A collection such as `露西亚-誓焰版` may be selected as one folder; its full -package and nested image patches are discovered together. -Tests and isolated launches may override the complete storage layout: -```text -BongoCat --storage-root=C:\path\to\isolated-storage -BongoCat --nearby-root=C:\path\to\model-collection -``` - -Arguments beginning with `--ci-` are test instrumentation. They select a model, -preference page, language, theme, frame audit, or input audit; they are not a -stable end-user command-line interface. +--- -## Tests +
-The CTest suite covers strict settings/session validation, model discovery, -input ordering and recovery, shortcuts, localization, UI helpers, application -state, and SHA-256 resource validation. Only current schemas are accepted. +Copyright © 2026 - **BongoCat**\ +By vladelaina\ +Made with ❤️ & ⌨️ -```powershell -ctest --test-dir build --output-on-failure -cmake --build build --target check-lines -``` +
-`check-lines` enforces a default maximum of 300 physical lines for native C, -C++, Objective-C, header, CMake module, and native test files. A small set of -mechanically dense files has reviewed, explicit limits in the policy. The rule -is a review aid, not a claim that shorter files are automatically better. - -## Continuous Integration - -GitHub Actions runs Cppcheck, the source-size check, a warning-clean Linux build, -CTest, and a Windows/Linux/macOS build matrix on pushes, pull requests, tags, -and manual runs. Each matrix job uploads its diagnostic portable release and -SHA-256 file as a separate artifact; Windows uploads the current-user-only NSIS -installer and its checksum as a second artifact. This keeps the portable package -and installer independently downloadable from the workflow run. The packages -are ZIP on Windows and macOS, and TGZ on Linux. A licensed Cubism SDK is not -provisioned on the runners, so these CI packages use the diagnostic backend. -Artifacts are named `portable-*` and, on Windows, `installer-*`. - -## Third-Party and Distribution Notes - -The BongoCat application source code is licensed under the GNU General Public -License v3.0. The three bundled model packages under -`resources/assets/models/standard`, `keyboard`, and `gamepad` are separately -licensed under the MIT License; see `resources/assets/models/LICENSE`. - -Third-party components retain their upstream licenses. Cubism SDK files must be -obtained from Live2D and are not redistributed here. Before distributing a -binary, review the licenses for SDL3, yyjson, stb, miniaudio, Nuklear, OpenGL, -and the selected platform SDK together with the project's own distribution -terms. From 50880342c85eb5f51a9b43f7acf4aa134024e705 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 23 Aug 2026 12:46:13 +0800 Subject: [PATCH 074/334] Update README.md --- README.md | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/README.md b/README.md index 14b48077..9ef7b7b7 100644 --- a/README.md +++ b/README.md @@ -3,16 +3,6 @@ -## Bongo Cat History - -The original Bongo Cat GIF originated on May 7, 2018, when an animated cat GIF made by Twitter user @StrayRogue was edited by @DitzyFlama, with the edit including bongos which were hit by the cat to the tune of "Athletic" from the Super Mario World soundtrack. The original artist clarified that Bongo Cat was a cat-like blob rather than an actual cat, showing a drawing of the character's full body in another tweet. - -It has later been edited to many other songs and many different instruments in fan-made videos, appearing on social media such as YouTube and Twitter. The meme has covered many songs from video game soundtracks, such as music from Persona 5 and Super Mario, as well as mainstream songs such as Toto's "Africa" and Darude's "Sandstorm". After an increase in popularity, Stray Rogue began making and selling Bongo Cat merchandise. Bongo Cat also has been made into an interactive website. - -The meme was also developed into key-mapping software in 2018. The program originated from open-source code written by Hamish Duncanson on GitHub, and was later modified by contributors such as MMmmmoko, ayangweb, and kuroni. - -In 2023, Bongo Cat's cover of "What Was I Made For?" by Billie Eilish was shared online. The cover went viral on TikTok and fans sang this version during Eilish's concerts in 2024. By 2025, Filipino congressman Francisco "Kiko" Barzaga, representative of Dasmariñas City, made the Bongo Cat cover a part of his public persona, branding himself "Congressmeow" and singing the lyrics "meow meow meow meow" on social media. - ## Project Status From f3688f59a411f1161682fec354b47868bb8fc821 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 23 Aug 2026 12:52:53 +0800 Subject: [PATCH 075/334] fix: update developer support link --- src/ui/preferences/preferences_about.c | 2 +- src/ui/preferences/preferences_about_community.c | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/preferences/preferences_about.c b/src/ui/preferences/preferences_about.c index 60ae59f0..35c1aa93 100644 --- a/src/ui/preferences/preferences_about.c +++ b/src/ui/preferences/preferences_about.c @@ -288,7 +288,7 @@ static void projects(BongoCatPreferences *value, struct nk_context *context) { value->catime_height, "Catime", "https://cati.me/", false, p); project(value, context, canvas, nk_rect(center + 42, bounds.y + 112, card_width, 200), value->vlaina_texture, value->vlaina_width, - value->vlaina_height, "vlaina", "https://vlaina.com/r/bongocat", + value->vlaina_height, "vladelaina", "https://vladelaina.com", true, p); } diff --git a/src/ui/preferences/preferences_about_community.c b/src/ui/preferences/preferences_about_community.c index 30503dd4..0ac947cf 100644 --- a/src/ui/preferences/preferences_about_community.c +++ b/src/ui/preferences/preferences_about_community.c @@ -213,7 +213,7 @@ void bongo_cat_preferences_about_projects_heading( if (hover) bongo_cat_ui_cursor_hover_rect(context, link, BONGO_CAT_UI_CURSOR_POINTER); if (developer && hit(context, link)) - open_url("https://vlaina.com/r/bongocat"); + open_url("https://vladelaina.com"); centered_wrapped(canvas, nk_rect(bounds.x, bounds.y + 66, bounds.w, 42), tr(value, "native.support.refactorDeveloper", "Developer who rewrote BongoCat in C and is also its current maintainer"), From e7c8f352d3222f823bdbe52f9a0a601d17c207a7 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 23 Aug 2026 18:42:31 +0800 Subject: [PATCH 076/334] fix: restore vlaina project link --- src/ui/preferences/preferences_about.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/preferences/preferences_about.c b/src/ui/preferences/preferences_about.c index 35c1aa93..85f591af 100644 --- a/src/ui/preferences/preferences_about.c +++ b/src/ui/preferences/preferences_about.c @@ -288,7 +288,7 @@ static void projects(BongoCatPreferences *value, struct nk_context *context) { value->catime_height, "Catime", "https://cati.me/", false, p); project(value, context, canvas, nk_rect(center + 42, bounds.y + 112, card_width, 200), value->vlaina_texture, value->vlaina_width, - value->vlaina_height, "vladelaina", "https://vladelaina.com", + value->vlaina_height, "vlaina", "https://vlaina.com", true, p); } From 0df3e769db1872544293b3cb046d6acb2f21b455 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 23 Aug 2026 19:00:09 +0800 Subject: [PATCH 077/334] fix: remove developer description from support page --- .../preferences/preferences_about_community.c | 61 ------------------- 1 file changed, 61 deletions(-) diff --git a/src/ui/preferences/preferences_about_community.c b/src/ui/preferences/preferences_about_community.c index 0ac947cf..0a312451 100644 --- a/src/ui/preferences/preferences_about_community.c +++ b/src/ui/preferences/preferences_about_community.c @@ -49,63 +49,6 @@ static void span(struct nk_command_buffer *canvas, struct nk_rect bounds, nk_rgba(0, 0, 0, 0), color); } -static int next_utf8_boundary(const char *value, int length, int offset) { - int next = offset + 1; - while (next < length && - (((unsigned char)value[next] & 0xc0u) == 0x80u)) ++next; - return next; -} - -static int balanced_split(const char *value, int length, - const struct nk_user_font *font, float maximum_width, - bool spaces_only, float *widest_line) { - int best = -1; - float best_width = 1.0e30f; - for (int split = next_utf8_boundary(value, length, 0); - split < length; split = next_utf8_boundary(value, length, split)) { - if (spaces_only && value[split] != ' ') continue; - int left = split, right = split; - while (left > 0 && value[left - 1] == ' ') --left; - while (right < length && value[right] == ' ') ++right; - if (!left || right >= length) continue; - float left_width = font->width(font->userdata, font->height, - value, left); - float right_width = font->width(font->userdata, font->height, - value + right, length - right); - float width = NK_MAX(left_width, right_width); - if (width < best_width) { best = split; best_width = width; } - } - if (widest_line) *widest_line = best_width; - return best_width <= maximum_width ? best : -1; -} - -static void centered_wrapped(struct nk_command_buffer *canvas, - struct nk_rect bounds, const char *value, - const struct nk_user_font *font, struct nk_color color) { - int length = nk_strlen(value); - if (font->width(font->userdata, font->height, value, length) <= bounds.w) { - centered_span(canvas, bounds, value, length, font, color); - return; - } - float widest = 0.0f; - int split = balanced_split(value, length, font, bounds.w, true, &widest); - if (split < 0) - split = balanced_split(value, length, font, bounds.w, false, &widest); - if (split < 0) { - centered_span(canvas, bounds, value, length, font, color); - return; - } - int left = split, right = split; - while (left > 0 && value[left - 1] == ' ') --left; - while (right < length && value[right] == ' ') ++right; - struct nk_rect line = nk_rect(bounds.x, - bounds.y + (bounds.h - font->height * 2.0f) * .5f, - bounds.w, font->height); - centered_span(canvas, line, value, left, font, color); - line.y += font->height; - centered_span(canvas, line, value + right, length - right, font, color); -} - static bool hit(struct nk_context *context, struct nk_rect bounds) { return nk_input_is_mouse_hovering_rect(&context->input, bounds) && nk_input_is_mouse_click_in_rect(&context->input, NK_BUTTON_LEFT, bounds); @@ -214,10 +157,6 @@ void bongo_cat_preferences_about_projects_heading( BONGO_CAT_UI_CURSOR_POINTER); if (developer && hit(context, link)) open_url("https://vladelaina.com"); - centered_wrapped(canvas, nk_rect(bounds.x, bounds.y + 66, bounds.w, 42), - tr(value, "native.support.refactorDeveloper", - "Developer who rewrote BongoCat in C and is also its current maintainer"), - value->ui.caption_font, p.muted); } void bongo_cat_preferences_about_community( From 953263be262ed3d21603edbce8fb2674bcb583da Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 23 Aug 2026 19:08:53 +0800 Subject: [PATCH 078/334] Update README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9ef7b7b7..82d96faa 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ tip - +> [!TIP] +> The model featured in this demonstration is from [宇痕冫](https://www.bilibili.com/video/BV1ZVK56HECF). From 38c0af42aa50bbb6b0d3fda62bd4b02f5aeffb09 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 23 Aug 2026 19:37:26 +0800 Subject: [PATCH 079/334] feat: adapt preferences chrome for macOS --- cmake/PlatformRuntime.cmake | 1 + include/bongo_cat/platform.h | 2 ++ src/platform/linux/linux.c | 4 ++++ src/platform/macos/macos_preferences.m | 29 ++++++++++++++++++++++++ src/platform/windows/windows.c | 4 ++++ src/ui/preferences/preferences_render.c | 6 ++--- src/ui/preferences/preferences_scale.c | 8 +++++++ src/ui/theme/ui_catime.c | 30 ++++++++++++++++++++----- src/ui/theme/ui_catime.h | 6 +++-- 9 files changed, 79 insertions(+), 11 deletions(-) create mode 100644 src/platform/macos/macos_preferences.m diff --git a/cmake/PlatformRuntime.cmake b/cmake/PlatformRuntime.cmake index f744cfab..418537cb 100644 --- a/cmake/PlatformRuntime.cmake +++ b/cmake/PlatformRuntime.cmake @@ -25,6 +25,7 @@ elseif(APPLE) enable_language(OBJC) target_sources(bongo_cat_runtime PRIVATE src/platform/macos/macos.m + src/platform/macos/macos_preferences.m src/platform/macos/macos_input.m src/platform/macos/macos_keys.m src/platform/macos/macos_menu.m) diff --git a/include/bongo_cat/platform.h b/include/bongo_cat/platform.h index e5fe4fce..4a656d60 100644 --- a/include/bongo_cat/platform.h +++ b/include/bongo_cat/platform.h @@ -101,6 +101,8 @@ bool bongo_cat_platform_relative_pointer(BongoCatPlatform *platform, void bongo_cat_platform_relative_pointer_reset(BongoCatPlatform *platform); void bongo_cat_platform_set_always_on_top(BongoCatPlatform *platform, bool enabled); void bongo_cat_platform_raise_window(SDL_Window *window); +/* Configure platform-native chrome for the preferences window when available. */ +void bongo_cat_platform_configure_preferences_window(SDL_Window *window); bool bongo_cat_platform_open_directory(const char *path); bool bongo_cat_platform_set_geometry(BongoCatPlatform *platform, int x, int y, int width, int height); diff --git a/src/platform/linux/linux.c b/src/platform/linux/linux.c index 9c214ecd..26aa93a9 100644 --- a/src/platform/linux/linux.c +++ b/src/platform/linux/linux.c @@ -24,6 +24,10 @@ static int instance_lock = -1; static BongoCatPlatform *active_platform; +void bongo_cat_platform_configure_preferences_window(SDL_Window *window) { + (void)window; +} + static void publish_instance_window(SDL_Window *window) { if (instance_lock < 0 || !window) return; Window id = (Window)SDL_GetNumberProperty(SDL_GetWindowProperties(window), diff --git a/src/platform/macos/macos_preferences.m b/src/platform/macos/macos_preferences.m new file mode 100644 index 00000000..aaaea5e9 --- /dev/null +++ b/src/platform/macos/macos_preferences.m @@ -0,0 +1,29 @@ +#include "bongo_cat/platform.h" + +#ifdef __APPLE__ +#import +#include +#include + +void bongo_cat_platform_configure_preferences_window(SDL_Window *sdl_window) { + if (!sdl_window) return; + NSWindow *window = (__bridge NSWindow *)SDL_GetPointerProperty( + SDL_GetWindowProperties(sdl_window), + SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, NULL); + if (!window) return; + NSWindowStyleMask style = [window styleMask]; + style |= NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | + NSWindowStyleMaskMiniaturizable | NSWindowStyleMaskResizable | + NSWindowStyleMaskFullSizeContentView; + [window setStyleMask:style]; + [window setTitle:@""]; + [window setTitleVisibility:NSWindowTitleHidden]; + [window setTitlebarAppearsTransparent:YES]; + [window setToolbar:nil]; + [window setTabbingMode:NSWindowTabbingModeDisallowed]; + [window setMovableByWindowBackground:NO]; + [[window standardWindowButton:NSWindowCloseButton] setHidden:NO]; + [[window standardWindowButton:NSWindowMiniaturizeButton] setHidden:NO]; + [[window standardWindowButton:NSWindowZoomButton] setHidden:NO]; +} +#endif diff --git a/src/platform/windows/windows.c b/src/platform/windows/windows.c index efc8baed..813b0925 100644 --- a/src/platform/windows/windows.c +++ b/src/platform/windows/windows.c @@ -16,6 +16,10 @@ static HWND native_window(BongoCatPlatform *platform) { SDL_PROP_WINDOW_WIN32_HWND_POINTER, NULL); } +void bongo_cat_platform_configure_preferences_window(SDL_Window *window) { + (void)window; +} + BongoCatResult bongo_cat_platform_init(BongoCatPlatform *platform, SDL_Window *window, BongoCatInputState *input, BongoCatError *error) { if (!platform || !window || !input) return BONGO_CAT_ERROR_ARGUMENT; diff --git a/src/ui/preferences/preferences_render.c b/src/ui/preferences/preferences_render.c index 85f32ef5..c3d60259 100644 --- a/src/ui/preferences/preferences_render.c +++ b/src/ui/preferences/preferences_render.c @@ -69,7 +69,7 @@ static bool draw_shell(BongoCatPreferences *value, struct nk_context *context, tr(value, "native.support.title", "Support the Developer")}; bool modal = bongo_cat_preferences_remove_dialog_active(value->app) || bongo_cat_preferences_behavior_dialog_active(value); - BongoCatUIPalette p = bongo_cat_ui_palette(dark); + const bool native_chrome = bongo_cat_ui_native_chrome(); BongoCatUIPalette p = bongo_cat_ui_palette(dark); bongo_cat_ui_shell_draw(context, width, height, dark, !value->transparent_window); float sidebar = bongo_cat_ui_sidebar_width(width); @@ -83,7 +83,7 @@ static bool draw_shell(BongoCatPreferences *value, struct nk_context *context, bool title_clicked = false; if (nk_group_begin(context, "preferences-sidebar", NK_WINDOW_NO_SCROLLBAR)) { bongo_cat_ui_header(context, "BongoCat", - value->ui.caption_font, value->logo_texture, &title_clicked, !modal, dark); + value->ui.caption_font, value->logo_texture, &title_clicked, !modal, dark, native_chrome); if (title_clicked && !SDL_OpenURL("https://bongocat.pet")) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Cannot open website: %s", SDL_GetError()); bongo_cat_ui_set_icons(draw_icon, value); @@ -110,7 +110,7 @@ static bool draw_shell(BongoCatPreferences *value, struct nk_context *context, bool close_requested = false; if (nk_group_begin(context, "preferences-content", NK_WINDOW_NO_SCROLLBAR)) { close_requested = bongo_cat_ui_content_header(context, - menus[value->page], value->page, !modal, dark); + menus[value->page], value->page, !modal, dark, native_chrome); float body_height = interior_height - BONGO_CAT_UI_HEADER_HEIGHT; nk_layout_row_dynamic(context, NK_MAX(120.0f, body_height), 1); struct nk_rect body_bounds = nk_widget_bounds(context); diff --git a/src/ui/preferences/preferences_scale.c b/src/ui/preferences/preferences_scale.c index 7b646eb6..f6a96745 100644 --- a/src/ui/preferences/preferences_scale.c +++ b/src/ui/preferences/preferences_scale.c @@ -61,6 +61,13 @@ static SDL_HitTestResult SDLCALL preference_hit_test(SDL_Window *window, int width = 0, height = 0; SDL_GetWindowSize(window, &width, &height); int edge = scaled(7.0f, scale); +#ifdef __APPLE__ + /* AppKit owns the traffic-light controls in the native titlebar. Do not + classify their hit area as a resize corner from SDL's borderless hit + test callback. */ + if (point->x < scaled(100.0f, scale) && point->y < scaled(40.0f, scale)) + return SDL_HITTEST_NORMAL; +#endif bool left = point->x < edge, right = point->x >= width - edge; bool top = point->y < edge, bottom = point->y >= height - edge; if (top && left) return SDL_HITTEST_RESIZE_TOPLEFT; @@ -127,6 +134,7 @@ bool bongo_cat_preferences_open_window(BongoCatPreferences *value) { "Transparent preferences window unavailable: %s", SDL_GetError()); if (!create_window(value, width, height, flags, false)) return false; } + bongo_cat_platform_configure_preferences_window(value->window); SDL_SetWindowPosition(value->window, SDL_WINDOWPOS_CENTERED_DISPLAY(display), SDL_WINDOWPOS_CENTERED_DISPLAY(display)); SDL_SyncWindow(value->window); diff --git a/src/ui/theme/ui_catime.c b/src/ui/theme/ui_catime.c index dee7f51f..22a4a4d2 100644 --- a/src/ui/theme/ui_catime.c +++ b/src/ui/theme/ui_catime.c @@ -43,6 +43,14 @@ float bongo_cat_ui_sidebar_width(float window_width) { BONGO_CAT_UI_SIDEBAR_WIDTH; } +bool bongo_cat_ui_native_chrome(void) { +#ifdef __APPLE__ + return true; +#else + return false; +#endif +} + void bongo_cat_ui_shell_draw(struct nk_context *context, float width, float height, bool dark, bool native_frame) { BongoCatUIPalette p = bongo_cat_ui_palette(dark); @@ -101,9 +109,11 @@ static void signature(struct nk_command_buffer *canvas, struct nk_rect bounds, bool bongo_cat_ui_header(struct nk_context *context, const char *title, const struct nk_user_font *font, unsigned int logo_texture, - bool *title_clicked, bool interactive, bool dark) { + bool *title_clicked, bool interactive, bool dark, bool native_chrome) { struct nk_rect bounds; - float height = nk_window_get_content_region(context).w < 100 ? 118.0f : 148.0f; + bool narrow = nk_window_get_content_region(context).w < 100; + float height = narrow ? (native_chrome ? 148.0f : 118.0f) : + (native_chrome ? 176.0f : 148.0f); nk_layout_row_dynamic(context, height, 1); if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return false; BongoCatUIPalette p = bongo_cat_ui_palette(dark); @@ -115,8 +125,9 @@ bool bongo_cat_ui_header(struct nk_context *context, const char *title, "brand-logo-hover", hover ? 1.0f : 0.0f, 250.0f, BONGO_CAT_UI_EASE_SPRING); size *= 1.0f + .08f * hover_amount; + float top = native_chrome ? 41.0f : (narrow ? 10.0f : 13.0f); struct nk_rect frame = nk_rect(bounds.x + (bounds.w - size) * .5f, - bounds.y + (bounds.w < 100 ? 10.0f : 13.0f) - + bounds.y + top - (size - (bounds.w < 100 ? 54.0f : 72.0f)) * .5f, size, size); float rounding = bounds.w < 100 ? 14.0f : 18.0f; if (p.effects) bongo_cat_ui_paint_shadow(context, frame, rounding, @@ -137,9 +148,14 @@ bool bongo_cat_ui_header(struct nk_context *context, const char *title, } if (bounds.w >= 100) { if (!font) font = bongo_cat_ui_caption_font(context); - centered(canvas, nk_rect(bounds.x, bounds.y + 88, bounds.w, 28), title, + float title_y = native_chrome ? 116.0f : 88.0f; + centered(canvas, nk_rect(bounds.x, bounds.y + title_y, bounds.w, 28), title, font, p.pink); - signature(canvas, bounds, p); + if (native_chrome) { + struct nk_rect signature_bounds = nk_rect(bounds.x, bounds.y + 28, + bounds.w, bounds.h - 28); + signature(canvas, signature_bounds, p); + } else signature(canvas, bounds, p); } if (hover) bongo_cat_ui_cursor_hover_rect(context, bounds, BONGO_CAT_UI_CURSOR_POINTER); @@ -148,7 +164,8 @@ bool bongo_cat_ui_header(struct nk_context *context, const char *title, return false; } bool bongo_cat_ui_content_header(struct nk_context *context, - const char *title, int icon, bool interactive, bool dark) { + const char *title, int icon, bool interactive, bool dark, + bool native_chrome) { struct nk_rect bounds; nk_layout_row_dynamic(context, BONGO_CAT_UI_HEADER_HEIGHT, 1); if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return false; @@ -165,6 +182,7 @@ bool bongo_cat_ui_content_header(struct nk_context *context, bounds.y + (bounds.h - font->height) * .5f, bounds.w - 110, font->height); nk_draw_text(canvas, text, title, nk_strlen(title), font, nk_rgba(0, 0, 0, 0), p.accent); + if (native_chrome) return false; struct nk_rect close = nk_rect(bounds.x + bounds.w - 48, bounds.y + 11, 34, 34); return bongo_cat_ui_close_button(context, canvas, close, p.muted, p.accent, interactive); diff --git a/src/ui/theme/ui_catime.h b/src/ui/theme/ui_catime.h index 3c4da09a..3a05a883 100644 --- a/src/ui/theme/ui_catime.h +++ b/src/ui/theme/ui_catime.h @@ -41,9 +41,10 @@ typedef void (*BongoCatUIIconDraw)(void *userdata, struct nk_color color); bool bongo_cat_ui_header(struct nk_context *context, const char *title, const struct nk_user_font *font, unsigned int logo_texture, - bool *title_clicked, bool interactive, bool dark); + bool *title_clicked, bool interactive, bool dark, bool native_chrome); bool bongo_cat_ui_content_header(struct nk_context *context, - const char *title, int icon, bool interactive, bool dark); + const char *title, int icon, bool interactive, bool dark, + bool native_chrome); bool bongo_cat_ui_close_button(struct nk_context *context, struct nk_command_buffer *canvas, struct nk_rect bounds, struct nk_color normal, struct nk_color hover, bool interactive); @@ -58,6 +59,7 @@ bool bongo_cat_ui_draw_icon(struct nk_command_buffer *canvas, int icon, void bongo_cat_ui_fallback_icon(struct nk_command_buffer *canvas, int icon, struct nk_rect bounds, struct nk_color color); float bongo_cat_ui_sidebar_width(float window_width); +bool bongo_cat_ui_native_chrome(void); bool bongo_cat_ui_close_hit(float x, float y, float width); bool bongo_cat_ui_title_link_hit(float x, float y, float width); bool bongo_cat_ui_title_drag_hit(float x, float y, float width); From 379f099aee0fd4852158adc34882e322a9beced8 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 23 Aug 2026 20:56:48 +0800 Subject: [PATCH 080/334] feat: clarify licenses and harden locale encoding --- .editorconfig | 9 +++ LICENSE | 14 ++--- LICENSE-MIT | 30 ++++++++++ README.md | 11 ++++ resources/assets/locales/de-DE.json | 2 +- resources/assets/locales/en-US.json | 2 +- resources/assets/locales/es-ES.json | 2 +- resources/assets/locales/fr-FR.json | 2 +- resources/assets/locales/ja-JP.json | 2 +- resources/assets/locales/ko-KR.json | 2 +- resources/assets/locales/pt-BR.json | 2 +- resources/assets/locales/ru-RU.json | 2 +- resources/assets/locales/zh-CN.json | 2 +- resources/assets/locales/zh-Hant.json | 2 +- resources/assets/models/LICENSE | 4 +- src/ui/preferences/preferences_about.c | 8 +-- .../preferences/preferences_about_community.c | 20 +++++++ .../preferences/preferences_about_community.h | 4 ++ src/ui/preferences/preferences_language.c | 2 +- src/ui/preferences/preferences_scale.c | 2 +- tests/i18n/test_i18n.c | 58 ++++++++++++++++++- 21 files changed, 157 insertions(+), 25 deletions(-) create mode 100644 .editorconfig create mode 100644 LICENSE-MIT diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..6c8fe4bb --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true + +[*.{c,h,m,mm,cpp,hpp,json,cmake,md,ps1,js}] +trim_trailing_whitespace = true diff --git a/LICENSE b/LICENSE index 9b5e0453..339cf43f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,24 +1,24 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 -SPDX-License-Identifier: GPL-3.0-only +SPDX-License-Identifier: AGPL-3.0-only Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. BongoCat is free software: you can redistribute it and/or modify it under - the terms of the GNU General Public License as published by the Free + the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. BongoCat is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - You should have received a copy of the GNU General Public License along + You should have received a copy of the GNU Affero General Public License along with BongoCat. If not, see . The complete license terms are incorporated by reference from the official - GNU GPL v3 text at https://www.gnu.org/licenses/gpl-3.0.txt. + GNU AGPL v3 text at https://www.gnu.org/licenses/agpl-3.0.txt. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 00000000..8b7db9cd --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,30 @@ +MIT License + +SPDX-License-Identifier: MIT + +Copyright (c) 2026 vladelaina + +The default built-in model mode (`standard`) and the bundled model assets in +`resources/assets/models/standard`, `resources/assets/models/keyboard`, and +`resources/assets/models/gamepad` are provided under the MIT License. This +license applies to those model assets and their accompanying textures, +motions, expressions, and input artwork only. It does not relicense the +BongoCat source code or native runtime, which are covered by AGPL-3.0-only. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of these model assets and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md index 82d96faa..86a33aa4 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,17 @@ ## Project Status +## License + +The BongoCat source code and native runtime are licensed under +[AGPL-3.0-only](LICENSE). + +The default built-in model mode (`standard`) remains MIT-licensed. The +bundled model assets in `resources/assets/models/standard`, `keyboard`, and +`gamepad` are covered by the separate [MIT license notice](LICENSE-MIT). +That MIT license applies to the model assets and their accompanying artwork +only; it does not relicense the BongoCat source code or native runtime. + ## Technical Architecture diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index 4fd56492..f5f21c85 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -189,7 +189,7 @@ "removeDesktopPet": "Diesen Desktop-Begleiter schlie\u00dfen", "modelLoadFailed": "Dieses Modell kann nicht angezeigt werden", "noBehaviors": "Keine Einträge", - "preferencesWindowTitle": "BongoCat - Preferences", + "preferencesWindowTitle": "BongoCat - Settings", "startup": { "failed": "BongoCat konnte nicht gestartet werden.", "detail": "Technische Details finden Sie im Diagnoseprotokoll.", diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index c3e9ef6e..d2680cba 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -189,7 +189,7 @@ "removeDesktopPet": "Close this desktop pet", "modelLoadFailed": "Unable to display this model", "noBehaviors": "No items", - "preferencesWindowTitle": "BongoCat - Preferences", + "preferencesWindowTitle": "BongoCat - Settings", "startup": { "failed": "BongoCat could not start.", "detail": "See the diagnostic log for technical details.", diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index 01cf7429..a7da674d 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -189,7 +189,7 @@ "removeDesktopPet": "Cerrar esta mascota de escritorio", "modelLoadFailed": "No se puede mostrar este modelo", "noBehaviors": "No hay elementos", - "preferencesWindowTitle": "BongoCat - Preferences", + "preferencesWindowTitle": "BongoCat - Settings", "startup": { "failed": "No se pudo iniciar BongoCat.", "detail": "Consulte el registro de diagnóstico para ver los detalles técnicos.", diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index b0375696..57bfb46d 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -189,7 +189,7 @@ "removeDesktopPet": "Fermer cette mascotte de bureau", "modelLoadFailed": "Impossible d'afficher ce modèle", "noBehaviors": "Aucun élément", - "preferencesWindowTitle": "BongoCat - Preferences", + "preferencesWindowTitle": "BongoCat - Settings", "startup": { "failed": "Impossible de démarrer BongoCat.", "detail": "Consultez le journal de diagnostic pour les détails techniques.", diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index b76195c0..55bc8f30 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -189,7 +189,7 @@ "removeDesktopPet": "\u3053\u306e\u30c7\u30b9\u30af\u30c8\u30c3\u30d7\u30da\u30c3\u30c8\u3092\u9589\u3058\u308b", "modelLoadFailed": "このモデルを表示できません", "noBehaviors": "項目がありません", - "preferencesWindowTitle": "BongoCat - Preferences", + "preferencesWindowTitle": "BongoCat - Settings", "startup": { "failed": "BongoCat を起動できませんでした。", "detail": "技術的な詳細は診断ログを確認してください。", diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index d73bb29e..9b5436b8 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -189,7 +189,7 @@ "removeDesktopPet": "\uc774 \ub370\uc2a4\ud06c\ud1b1 \ud3ab \ub2eb\uae30", "modelLoadFailed": "이 모델을 표시할 수 없습니다", "noBehaviors": "항목 없음", - "preferencesWindowTitle": "BongoCat - Preferences", + "preferencesWindowTitle": "BongoCat - Settings", "startup": { "failed": "BongoCat을 시작할 수 없습니다.", "detail": "기술적인 세부 정보는 진단 로그를 확인하세요.", diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index 38c1c05f..21fe61f9 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -189,7 +189,7 @@ "removeDesktopPet": "Fechar este mascote da \u00e1rea de trabalho", "modelLoadFailed": "N\u00e3o foi poss\u00edvel exibir este modelo", "noBehaviors": "Nenhum item", - "preferencesWindowTitle": "BongoCat - Preferences", + "preferencesWindowTitle": "BongoCat - Settings", "startup": { "failed": "Não foi possível iniciar o BongoCat.", "detail": "Consulte o log de diagnóstico para obter detalhes técnicos.", diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index 84981ff9..db650000 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -189,7 +189,7 @@ "removeDesktopPet": "\u0417\u0430\u043a\u0440\u044b\u0442\u044c \u044d\u0442\u043e\u0433\u043e \u043f\u0438\u0442\u043e\u043c\u0446\u0430", "modelLoadFailed": "Не удалось отобразить эту модель", "noBehaviors": "Нет элементов", - "preferencesWindowTitle": "BongoCat - Preferences", + "preferencesWindowTitle": "BongoCat - Settings", "startup": { "failed": "Не удалось запустить BongoCat.", "detail": "Технические сведения доступны в журнале диагностики.", diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index b05f4450..68ba1e1a 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -189,7 +189,7 @@ "removeDesktopPet": "\u5173\u95ed\u6b64\u684c\u5ba0", "modelLoadFailed": "\u6a21\u578b\u65e0\u6cd5\u663e\u793a", "noBehaviors": "暂无可用项目", - "preferencesWindowTitle": "BongoCat - Preferences", + "preferencesWindowTitle": "BongoCat - Settings", "startup": { "failed": "BongoCat 无法启动。", "detail": "请查看诊断日志了解技术详情。", diff --git a/resources/assets/locales/zh-Hant.json b/resources/assets/locales/zh-Hant.json index f19bb2c8..ba0c09ca 100644 --- a/resources/assets/locales/zh-Hant.json +++ b/resources/assets/locales/zh-Hant.json @@ -189,7 +189,7 @@ "removeDesktopPet": "\u95dc\u9589\u6b64\u684c\u5bf5", "modelLoadFailed": "\u6a21\u578b\u7121\u6cd5\u986f\u793a", "noBehaviors": "暫無可用項目", - "preferencesWindowTitle": "BongoCat - Preferences", + "preferencesWindowTitle": "BongoCat - Settings", "startup": { "failed": "BongoCat 無法啟動。", "detail": "請查看診斷日誌以瞭解技術詳情。", diff --git a/resources/assets/models/LICENSE b/resources/assets/models/LICENSE index fad250a3..585a2078 100644 --- a/resources/assets/models/LICENSE +++ b/resources/assets/models/LICENSE @@ -7,7 +7,9 @@ Copyright (c) 2026 vladelaina The bundled model assets in the `standard`, `keyboard`, and `gamepad` directories are provided under the MIT License. This applies to those model assets and their accompanying textures, motions, expressions, and input -artwork only; it does not change the GPL-3.0 license of the BongoCat source. +artwork only; it does not change the AGPL-3.0-only license of the BongoCat +source. The canonical copy of this notice is also available at +`../../LICENSE-MIT`. Permission is hereby granted, free of charge, to any person obtaining a copy of these model assets and associated documentation files (the "Software"), to diff --git a/src/ui/preferences/preferences_about.c b/src/ui/preferences/preferences_about.c index 85f591af..fd6faafd 100644 --- a/src/ui/preferences/preferences_about.c +++ b/src/ui/preferences/preferences_about.c @@ -233,15 +233,16 @@ static void hero(BongoCatPreferences *value, struct nk_context *context) { bounds.y + 18, 144, 144), p); hero_title(value, context, canvas, nk_rect(bounds.x + 3, bounds.y + 178, bounds.w, 36), p); - centered_wrapped(canvas, nk_rect(bounds.x + 36, bounds.y + 215, + bongo_cat_preferences_about_website(value, context, canvas, + nk_rect(bounds.x, bounds.y + 214, bounds.w, 24), p); + centered_wrapped(canvas, nk_rect(bounds.x + 36, bounds.y + 242, bounds.w - 72, 40), tr(value, "native.support.heroText", "Thank you for your support. Every use and share helps BongoCat grow."), value->ui.caption_font, p.muted); footer(value, context, canvas, - nk_rect(bounds.x, bounds.y + 260, bounds.w, 40), p); + nk_rect(bounds.x, bounds.y + 286, bounds.w, 34), p); } - static void project(BongoCatPreferences *value, struct nk_context *context, struct nk_command_buffer *canvas, struct nk_rect bounds, unsigned int texture, int image_width, int image_height, const char *name, const char *url, @@ -291,7 +292,6 @@ static void projects(BongoCatPreferences *value, struct nk_context *context) { value->vlaina_height, "vlaina", "https://vlaina.com", true, p); } - void bongo_cat_preferences_page_about(BongoCatPreferences *value, struct nk_context *context) { bongo_cat_preferences_support_assets_load(value); diff --git a/src/ui/preferences/preferences_about_community.c b/src/ui/preferences/preferences_about_community.c index 0a312451..c7a2cd8b 100644 --- a/src/ui/preferences/preferences_about_community.c +++ b/src/ui/preferences/preferences_about_community.c @@ -59,6 +59,26 @@ static void open_url(const char *url) { "Cannot open URL: %s", SDL_GetError()); } +void bongo_cat_preferences_about_website( + BongoCatPreferences *value, struct nk_context *context, + struct nk_command_buffer *canvas, struct nk_rect bounds, + BongoCatUIPalette palette) { + static const char website[] = "\xE5\xAE\x98\xE7\xBD\x91: bongocat.pet"; + const struct nk_user_font *font = value->ui.caption_font; + float text_width = font->width(font->userdata, font->height, + website, nk_strlen(website)); + struct nk_rect link = nk_rect(bounds.x + (bounds.w - text_width) * .5f, + bounds.y, text_width + 2, bounds.h); + bool hover = nk_input_is_mouse_hovering_rect(&context->input, link); + float amount = bongo_cat_ui_animate_eased(context, "support-website-hover", + hover ? 1.0f : 0.0f, 200, BONGO_CAT_UI_EASE_STANDARD); + centered(canvas, link, website, font, + bongo_cat_ui_color_mix(palette.accent, palette.pink, amount)); + if (hover) bongo_cat_ui_cursor_hover_rect(context, link, + BONGO_CAT_UI_CURSOR_POINTER); + if (hit(context, link)) open_url("https://bongocat.pet"); +} + static void community_link(BongoCatPreferences *value, struct nk_context *context, struct nk_command_buffer *canvas, struct nk_rect bounds, int index, BongoCatUIPalette p) { diff --git a/src/ui/preferences/preferences_about_community.h b/src/ui/preferences/preferences_about_community.h index cf525418..f9704637 100644 --- a/src/ui/preferences/preferences_about_community.h +++ b/src/ui/preferences/preferences_about_community.h @@ -8,5 +8,9 @@ void bongo_cat_preferences_about_community( void bongo_cat_preferences_about_projects_heading( BongoCatPreferences *value, struct nk_context *context, struct nk_rect bounds); +void bongo_cat_preferences_about_website( + BongoCatPreferences *value, struct nk_context *context, + struct nk_command_buffer *canvas, struct nk_rect bounds, + BongoCatUIPalette palette); #endif diff --git a/src/ui/preferences/preferences_language.c b/src/ui/preferences/preferences_language.c index d912bbd0..d2bcb610 100644 --- a/src/ui/preferences/preferences_language.c +++ b/src/ui/preferences/preferences_language.c @@ -29,7 +29,7 @@ bool bongo_cat_preferences_reload_language(BongoCatPreferences *value) { bongo_cat_ui_paint_destroy(&value->ui); if (value->window) SDL_SetWindowTitle(value->window, bongo_cat_i18n_get(value->app->i18n, - "native.preferencesWindowTitle", "BongoCat - Preferences")); + "native.preferencesWindowTitle", "BongoCat - Settings")); if (value->app->tray) bongo_cat_tray_sync(value->app->tray); value->render_dirty = true; return true; diff --git a/src/ui/preferences/preferences_scale.c b/src/ui/preferences/preferences_scale.c index f6a96745..343ad6b8 100644 --- a/src/ui/preferences/preferences_scale.c +++ b/src/ui/preferences/preferences_scale.c @@ -109,7 +109,7 @@ static void discard_window(BongoCatPreferences *value) { static bool create_window(BongoCatPreferences *value, int width, int height, SDL_WindowFlags flags, bool transparent) { const char *title = bongo_cat_i18n_get(value->app->i18n, - "native.preferencesWindowTitle", "BongoCat - Preferences"); + "native.preferencesWindowTitle", "BongoCat - Settings"); value->window = SDL_CreateWindow(title, width, height, flags | (transparent ? SDL_WINDOW_TRANSPARENT : 0)); diff --git a/tests/i18n/test_i18n.c b/tests/i18n/test_i18n.c index 74172cd9..4fb05dd7 100644 --- a/tests/i18n/test_i18n.c +++ b/tests/i18n/test_i18n.c @@ -1,9 +1,41 @@ #include "bongo_cat/i18n.h" +#include "bongo_cat/utf8.h" +#include #include #include #include +static bool locale_encoding_valid(const char *root, const char *name) { + char path[BONGO_CAT_PATH_CAP]; + snprintf(path, sizeof(path), "%s/%s.json", root, name); + FILE *file = fopen(path, "rb"); + if (!file || fseek(file, 0, SEEK_END) != 0) { + if (file) fclose(file); + return false; + } + long end = ftell(file); + if (end < 0 || fseek(file, 0, SEEK_SET) != 0) { + fclose(file); return false; + } + size_t length = (size_t)end; + char *contents = malloc(length + 1); + bool valid = contents && fread(contents, 1, length, file) == length; + fclose(file); + if (!valid) { free(contents); return false; } + contents[length] = '\0'; + if (length >= 3 && (unsigned char)contents[0] == 0xef && + (unsigned char)contents[1] == 0xbb && + (unsigned char)contents[2] == 0xbf) valid = false; + for (size_t i = 0; valid && i + 2 < length; ++i) + if ((unsigned char)contents[i] == 0xef && + (unsigned char)contents[i + 1] == 0xbf && + (unsigned char)contents[i + 2] == 0xbd) valid = false; + valid = valid && bongo_cat_utf8_valid(contents); + free(contents); + return valid; +} + static yyjson_doc *load(const char *root, const char *name) { char path[BONGO_CAT_PATH_CAP]; snprintf(path, sizeof(path), "%s/%s.json", root, name); @@ -75,6 +107,22 @@ static bool covers_value(const uint32_t *ranges, yyjson_val *value) { return true; } +static bool contains_replacement(yyjson_val *value) { + if (yyjson_is_str(value)) { + const unsigned char *text = (const unsigned char *)yyjson_get_str(value); + while (*text) if (next_utf8(&text) == 0xfffd) return true; + } else if (yyjson_is_arr(value)) { + size_t index, count; yyjson_val *item; + yyjson_arr_foreach(value, index, count, item) + if (contains_replacement(item)) return true; + } else if (yyjson_is_obj(value)) { + size_t index, count; yyjson_val *key, *item; + yyjson_obj_foreach(value, index, count, key, item) + if (contains_replacement(item)) return true; + } + return false; +} + int main(void) { char root[BONGO_CAT_PATH_CAP]; snprintf(root, sizeof(root), "%s/resources/assets/locales", BONGO_CAT_NATIVE_SOURCE_DIR); @@ -94,9 +142,17 @@ int main(void) { 0x65e5, 0xd55c, 0x00ea, 0x0420, 0x00f1}; for (int language = 0; language < BONGO_CAT_LANG_COUNT; ++language) { const char *name = bongo_cat_language_name((BongoCatLanguage)language); + if (!locale_encoding_valid(root, name)) { + fprintf(stderr, "Invalid UTF-8 locale encoding: %s\n", name); + return 8; + } yyjson_doc *document = load(root, name); if (!document || !same_shape(yyjson_doc_get_root(reference), - yyjson_doc_get_root(document), "")) return 2; + yyjson_doc_get_root(document), "") || + contains_replacement(yyjson_doc_get_root(document))) { + fprintf(stderr, "Invalid replacement character in locale: %s\n", name); + return 2; + } BongoCatError error = {0}; BongoCatI18n *i18n = bongo_cat_i18n_create(root, (BongoCatLanguage)language, &error); uint32_t ranges[2048]; From b39c0b7c587523b95fd1778ed792bf8761735e2d Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 23 Aug 2026 21:53:30 +0800 Subject: [PATCH 081/334] fix: make localized links and keys reusable --- CMakeLists.txt | 4 ++ cmake/CheckLocalization.cmake | 45 +++++++++++++++++++ resources/assets/locales/de-DE.json | 1 + resources/assets/locales/en-US.json | 1 + resources/assets/locales/es-ES.json | 1 + resources/assets/locales/fr-FR.json | 1 + resources/assets/locales/ja-JP.json | 1 + resources/assets/locales/ko-KR.json | 1 + resources/assets/locales/pt-BR.json | 1 + resources/assets/locales/ru-RU.json | 1 + resources/assets/locales/zh-CN.json | 1 + resources/assets/locales/zh-Hant.json | 1 + src/ui/preferences/preferences_about.c | 6 ++- .../preferences/preferences_about_community.c | 19 ++++---- .../preferences/preferences_about_community.h | 5 ++- tests/i18n/test_i18n.c | 1 + 16 files changed, 77 insertions(+), 13 deletions(-) create mode 100644 cmake/CheckLocalization.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index ef3ff3fa..d80e855b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -129,6 +129,10 @@ add_custom_target(check-lines ALL COMMAND ${CMAKE_COMMAND} -DROOT=${CMAKE_CURRENT_SOURCE_DIR} -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/CheckLines.cmake VERBATIM) +add_custom_target(check-localization ALL + COMMAND ${CMAKE_COMMAND} -DROOT=${CMAKE_CURRENT_SOURCE_DIR} + -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/CheckLocalization.cmake + VERBATIM) add_custom_target(check-platform-runtime-safety ALL COMMAND ${CMAKE_COMMAND} -DROOT=${CMAKE_CURRENT_SOURCE_DIR} -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/CheckPlatformRuntimeSafety.cmake diff --git a/cmake/CheckLocalization.cmake b/cmake/CheckLocalization.cmake new file mode 100644 index 00000000..4e75bee2 --- /dev/null +++ b/cmake/CheckLocalization.cmake @@ -0,0 +1,45 @@ +if(NOT DEFINED ROOT) + message(FATAL_ERROR "ROOT is required") +endif() + +file(GLOB_RECURSE SOURCES + "${ROOT}/src/*.c" "${ROOT}/src/*.cc" "${ROOT}/src/*.cpp" + "${ROOT}/src/*.cxx" "${ROOT}/src/*.m" "${ROOT}/src/*.mm" + "${ROOT}/src/*.h" "${ROOT}/src/*.hh" "${ROOT}/src/*.hpp" + "${ROOT}/src/*.hxx" "${ROOT}/include/*.h" "${ROOT}/include/*.hh" + "${ROOT}/include/*.hpp" "${ROOT}/include/*.hxx") +set(KEYS "") +foreach(SOURCE IN LISTS SOURCES) + file(READ "${SOURCE}" CONTENT) + string(REGEX MATCHALL + "\"(native|pages|components|composables)\\.[A-Za-z0-9_.]+\"" + FOUND_KEYS "${CONTENT}") + foreach(FOUND IN LISTS FOUND_KEYS) + string(LENGTH "${FOUND}" LENGTH) + math(EXPR LAST "${LENGTH} - 2") + string(SUBSTRING "${FOUND}" 1 ${LAST} KEY) + list(APPEND KEYS "${KEY}") + endforeach() +endforeach() +list(REMOVE_DUPLICATES KEYS) + +file(GLOB LOCALES "${ROOT}/resources/assets/locales/*.json") +if(NOT LOCALES) + message(FATAL_ERROR "No locale files found") +endif() +foreach(LOCALE IN LISTS LOCALES) + file(READ "${LOCALE}" JSON) + foreach(KEY IN LISTS KEYS) + string(REPLACE "." ";" PARTS "${KEY}") + set(VALUE "${JSON}") + foreach(PART IN LISTS PARTS) + string(JSON VALUE ERROR_VARIABLE ERROR GET "${VALUE}" "${PART}") + if(ERROR) + message(FATAL_ERROR + "Missing localization key '${KEY}' in ${LOCALE}") + endif() + endforeach() + endforeach() +endforeach() +list(LENGTH KEYS KEY_COUNT) +message(STATUS "Localization key policy passed (${KEY_COUNT} keys)") diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index f5f21c85..460e2b58 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -197,6 +197,7 @@ }, "support": { "title": "Entwicklungsteam unterstützen", + "website": "Website: bongocat.pet", "heroText": "Vielen Dank für Ihre Unterstützung. Jede Nutzung und Weitergabe hilft BongoCat, weiter zu wachsen.", "version": "Anwendungsversion", "checkUpdate": "Nach Updates suchen", diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index d2680cba..42dcb96f 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -197,6 +197,7 @@ }, "support": { "title": "Support the Developer", + "website": "Website: bongocat.pet", "heroText": "Thank you for your support. Every use and share helps BongoCat continue to grow.", "version": "App version", "checkUpdate": "Check for updates", diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index a7da674d..bbddf971 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -197,6 +197,7 @@ }, "support": { "title": "Apoyar al equipo de desarrollo", + "website": "Sitio web: bongocat.pet", "heroText": "Gracias por su apoyo. Cada uso y cada difusión ayudan a BongoCat a seguir creciendo.", "version": "Versión de la aplicación", "checkUpdate": "Buscar actualizaciones", diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index 57bfb46d..ca22167d 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -197,6 +197,7 @@ }, "support": { "title": "Soutenir l'équipe de développement", + "website": "Site web: bongocat.pet", "heroText": "Merci pour votre soutien. Chaque utilisation et chaque partage aide BongoCat à continuer de grandir.", "version": "Version de l'application", "checkUpdate": "Rechercher des mises à jour", diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index 55bc8f30..daf1e63d 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -197,6 +197,7 @@ }, "support": { "title": "開発チームを支援", + "website": "公式サイト: bongocat.pet", "heroText": "ご支援ありがとうございます。利用や共有の一つひとつがBongoCatの成長につながります。", "version": "アプリのバージョン", "checkUpdate": "更新を確認", diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index 9b5436b8..a2a5b662 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -197,6 +197,7 @@ }, "support": { "title": "개발팀 지원", + "website": "공식 웹사이트: bongocat.pet", "heroText": "지원해 주셔서 감사합니다. 사용할 때마다, 공유할 때마다 BongoCat이 계속 성장하는 데 도움이 됩니다.", "version": "앱 버전", "checkUpdate": "업데이트 확인", diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index 21fe61f9..e16ed82d 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -197,6 +197,7 @@ }, "support": { "title": "Apoiar a equipe de desenvolvimento", + "website": "Site: bongocat.pet", "heroText": "Obrigado pelo apoio. Cada uso e compartilhamento ajuda o BongoCat a continuar crescendo.", "version": "Versão do aplicativo", "checkUpdate": "Verificar atualizações", diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index db650000..061f4d56 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -197,6 +197,7 @@ }, "support": { "title": "Поддержать команду разработки", + "website": "Сайт: bongocat.pet", "heroText": "Спасибо за поддержку. Каждое использование и распространение помогает BongoCat развиваться дальше.", "version": "Версия приложения", "checkUpdate": "Проверить обновления", diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index 68ba1e1a..aceaa5b8 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -197,6 +197,7 @@ }, "support": { "title": "支持开发者", + "website": "官网: bongocat.pet", "heroText": "感谢你的支持。每一次使用与分享,都让 BongoCat 继续成长。", "version": "应用版本", "checkUpdate": "检查更新", diff --git a/resources/assets/locales/zh-Hant.json b/resources/assets/locales/zh-Hant.json index ba0c09ca..764f4947 100644 --- a/resources/assets/locales/zh-Hant.json +++ b/resources/assets/locales/zh-Hant.json @@ -197,6 +197,7 @@ }, "support": { "title": "支援開發者", + "website": "官網: bongocat.pet", "heroText": "感謝你的支持。每一次使用與分享,都讓 BongoCat 持續成長。", "version": "應用程式版本", "checkUpdate": "檢查更新", diff --git a/src/ui/preferences/preferences_about.c b/src/ui/preferences/preferences_about.c index fd6faafd..a342d69b 100644 --- a/src/ui/preferences/preferences_about.c +++ b/src/ui/preferences/preferences_about.c @@ -233,8 +233,10 @@ static void hero(BongoCatPreferences *value, struct nk_context *context) { bounds.y + 18, 144, 144), p); hero_title(value, context, canvas, nk_rect(bounds.x + 3, bounds.y + 178, bounds.w, 36), p); - bongo_cat_preferences_about_website(value, context, canvas, - nk_rect(bounds.x, bounds.y + 214, bounds.w, 24), p); + bongo_cat_preferences_about_localized_link(value, context, canvas, + nk_rect(bounds.x, bounds.y + 214, bounds.w, 24), + "native.support.website", "Website: bongocat.pet", + "https://bongocat.pet", "support-website-hover", p); centered_wrapped(canvas, nk_rect(bounds.x + 36, bounds.y + 242, bounds.w - 72, 40), tr(value, "native.support.heroText", diff --git a/src/ui/preferences/preferences_about_community.c b/src/ui/preferences/preferences_about_community.c index c7a2cd8b..eed2c51b 100644 --- a/src/ui/preferences/preferences_about_community.c +++ b/src/ui/preferences/preferences_about_community.c @@ -59,24 +59,25 @@ static void open_url(const char *url) { "Cannot open URL: %s", SDL_GetError()); } -void bongo_cat_preferences_about_website( - BongoCatPreferences *value, struct nk_context *context, - struct nk_command_buffer *canvas, struct nk_rect bounds, - BongoCatUIPalette palette) { - static const char website[] = "\xE5\xAE\x98\xE7\xBD\x91: bongocat.pet"; +/* Localized labels feed the font atlas and must not be hardcoded here. */ +void bongo_cat_preferences_about_localized_link(BongoCatPreferences *value, + struct nk_context *context, struct nk_command_buffer *canvas, + struct nk_rect bounds, const char *key, const char *fallback, + const char *url, const char *animation_id, BongoCatUIPalette palette) { + const char *label = tr(value, key, fallback); const struct nk_user_font *font = value->ui.caption_font; float text_width = font->width(font->userdata, font->height, - website, nk_strlen(website)); + label, nk_strlen(label)); struct nk_rect link = nk_rect(bounds.x + (bounds.w - text_width) * .5f, bounds.y, text_width + 2, bounds.h); bool hover = nk_input_is_mouse_hovering_rect(&context->input, link); - float amount = bongo_cat_ui_animate_eased(context, "support-website-hover", + float amount = bongo_cat_ui_animate_eased(context, animation_id, hover ? 1.0f : 0.0f, 200, BONGO_CAT_UI_EASE_STANDARD); - centered(canvas, link, website, font, + centered(canvas, link, label, font, bongo_cat_ui_color_mix(palette.accent, palette.pink, amount)); if (hover) bongo_cat_ui_cursor_hover_rect(context, link, BONGO_CAT_UI_CURSOR_POINTER); - if (hit(context, link)) open_url("https://bongocat.pet"); + if (hit(context, link)) open_url(url); } static void community_link(BongoCatPreferences *value, diff --git a/src/ui/preferences/preferences_about_community.h b/src/ui/preferences/preferences_about_community.h index f9704637..a4e87cca 100644 --- a/src/ui/preferences/preferences_about_community.h +++ b/src/ui/preferences/preferences_about_community.h @@ -8,9 +8,10 @@ void bongo_cat_preferences_about_community( void bongo_cat_preferences_about_projects_heading( BongoCatPreferences *value, struct nk_context *context, struct nk_rect bounds); -void bongo_cat_preferences_about_website( +void bongo_cat_preferences_about_localized_link( BongoCatPreferences *value, struct nk_context *context, struct nk_command_buffer *canvas, struct nk_rect bounds, - BongoCatUIPalette palette); + const char *key, const char *fallback, const char *url, + const char *animation_id, BongoCatUIPalette palette); #endif diff --git a/tests/i18n/test_i18n.c b/tests/i18n/test_i18n.c index 4fb05dd7..0fbc0a48 100644 --- a/tests/i18n/test_i18n.c +++ b/tests/i18n/test_i18n.c @@ -136,6 +136,7 @@ int main(void) { "native.startup.failed", "native.startup.detail", "native.startup.diagnosticLog", + "native.support.website", "native.support.starOnGitHub", "native.support.by"}; const uint32_t expected[] = {'A', 0x4e2d, 0x8a2d, 0x00e7, 0x00fc, From 0ac84465cda038b77d658d239b40b34117a0bdf1 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 24 Aug 2026 08:40:15 +0800 Subject: [PATCH 082/334] Update LICENSE --- LICENSE | 667 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 652 insertions(+), 15 deletions(-) diff --git a/LICENSE b/LICENSE index 339cf43f..19cc6f03 100644 --- a/LICENSE +++ b/LICENSE @@ -1,24 +1,661 @@ - GNU AFFERO GENERAL PUBLIC LICENSE + GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 -SPDX-License-Identifier: AGPL-3.0-only - Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - BongoCat is free software: you can redistribute it and/or modify it under - the terms of the GNU Affero General Public License as published by the Free - Software Foundation, either version 3 of the License, or (at your option) - any later version. + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . - BongoCat is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more - details. +Also add information on how to contact you by electronic and paper mail. - You should have received a copy of the GNU Affero General Public License along - with BongoCat. If not, see . + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. - The complete license terms are incorporated by reference from the official - GNU AGPL v3 text at https://www.gnu.org/licenses/agpl-3.0.txt. + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. From eb99c187990e53a8b9ff6202be4005771101bccd Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 24 Aug 2026 08:49:42 +0800 Subject: [PATCH 083/334] Create CODE_OF_CONDUCT.md --- CODE_OF_CONDUCT.md | 79 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 CODE_OF_CONDUCT.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..0b098b30 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,79 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at vladelaina@gmail.com. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at . + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at . Translations are available at . From 5fe083204b41d3cad7640c4fa6aa15ff409407c7 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 24 Aug 2026 08:51:36 +0800 Subject: [PATCH 084/334] Create PULL_REQUEST_TEMPLATE.md --- .github/PULL_REQUEST_TEMPLATE.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..2fece481 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,24 @@ +## Ciallo~(∠・ω<)⌒★ + +### 🎉 You are awesome! + +Thank you for taking the time to make this project better! We truly value your contribution.💕 + +--- + +### 📝 What's changed? +*(Briefly describe what you did or what problem you fixed)* + +> + +--- + +### 🤝 Don't worry about perfection + +**We value your ideas and participation more than perfect code!** + +If there are any style, testing, or detail issues, **we will help fix and polish them**. Just submit it, and we'll handle the rest! + +--- + +*Thanks again! Happy to build this with you! 🙌* From e47ecd20548be1230938629f56b2b708c01535b8 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 24 Aug 2026 08:53:48 +0800 Subject: [PATCH 085/334] Create SECURITY.md --- SECURITY.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..e973d808 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,8 @@ +# Security Policy + +## Reporting Security Issues + +We always aim to ship secure software and take security flaws seriously. Thank you for responsibly disclosing your findings to the team to keep the open source community a safe space. + +To report a security issue, reach us at vladelaina@gmail.com. We will be in touch should we need any additional information or guidance to fix the bug. + From c3b6493eda4ff101803873d8f8f6ed12fbe16107 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 24 Aug 2026 08:55:14 +0800 Subject: [PATCH 086/334] Update README.md --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 86a33aa4..7631ec39 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ > [!TIP] > The model featured in this demonstration is from [宇痕冫](https://www.bilibili.com/video/BV1ZVK56HECF). - +

+ +

## Project Status From f1433a4b640814aee0b915c58166b930f9d9d4e4 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 24 Aug 2026 09:22:01 +0800 Subject: [PATCH 087/334] Add files via upload --- .github/ISSUE_TEMPLATE/1_feature_request.yml | 38 +++++++++++++ .github/ISSUE_TEMPLATE/2_bug_report.yml | 56 ++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 5 ++ 3 files changed, 99 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/1_feature_request.yml create mode 100644 .github/ISSUE_TEMPLATE/2_bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/1_feature_request.yml b/.github/ISSUE_TEMPLATE/1_feature_request.yml new file mode 100644 index 00000000..3f51e5d0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1_feature_request.yml @@ -0,0 +1,38 @@ +name: ✨ Feature Request ✨ +description: "💡 Got a great idea? Share your creativity with us!" +title: "[✨Feature Request]: " +labels: ['enhancement'] +body: + - type: input + id: problem + attributes: + label: 🌈 Feature You'd Like + description: "Briefly describe the feature you'd like to add or improve. Let's make the software even better together!" + validations: + required: false + - type: textarea + id: way-to-solve + attributes: + label: 🚀 Ideal Solution + description: "What does your perfect solution look like? Feel free to use your imagination - don't worry about technical details!" + validations: + required: true + - type: textarea + id: instead + attributes: + label: 🤔 Alternative Solutions (Optional) + description: "If you have other ideas, please share them here. The more, the merrier!" + validations: + required: false + - type: checkboxes + id: "latest-version" + attributes: + label: "✅ Final Step" + description: "Before submitting your brilliant idea, please confirm the following:" + options: + - label: "🌟 I've checked the latest version and confirmed my requested feature isn't already implemented." + required: true + - label: "📋 I've reviewed existing feature requests to make sure this isn't a duplicate." + required: true + - label: "🌍 I've written this request in English so it can be understood by the wider community." + required: true diff --git a/.github/ISSUE_TEMPLATE/2_bug_report.yml b/.github/ISSUE_TEMPLATE/2_bug_report.yml new file mode 100644 index 00000000..b6552938 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2_bug_report.yml @@ -0,0 +1,56 @@ +name: 🐛 Bug Report 🐛 +description: "🛠️ Found a problem? Let us know here!" +title: "[🐛Bug]: " +labels: ["bug"] +body: + - type: input + id: "expectation" + attributes: + label: "✨ What You Expected" + description: "How should the software work? Describe what you expected to happen." + validations: + required: false + - type: textarea + id: "describe-the-bug" + attributes: + label: "❓ What Actually Happened" + description: "Oops! Something went wrong? Please tell us what you saw - the more details, the better!" + validations: + required: true + - type: textarea + id: "reproduction-method" + attributes: + label: "📝 Steps to Reproduce the Issue" + description: "Can you tell us how you encountered this bug? List the steps so we can find the problem faster." + placeholder: "For example:\n1. Used what feature\n2. Then this happened...\n3. Oops! Problem occurred!" + validations: + required: false + - type: dropdown + id: "platform" + attributes: + label: "📌 Platform" + description: "Which platform are you using? For Linux, please select your desktop environment." + options: + - "Windows" + - "macOS" + - "Linux - GNOME" + - "Linux - KDE Plasma" + - "Linux - Xfce" + - "Linux - Cinnamon" + - "Linux - MATE" + - "Linux - LXQt" + - "Linux - Other desktop environment" + validations: + required: true + - type: checkboxes + id: "latest-version" + attributes: + label: "✅ Final Checklist" + description: "Before submitting your bug report, please confirm the following:" + options: + - label: "⚡ I've confirmed I'm using the latest version, not an older one." + required: true + - label: "📋 I've checked the FAQ and known issues list to confirm this is a new bug." + required: true + - label: "🌍 This report is written in English so that more members of the community can read and understand it." + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..577d3745 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: 💬 Questions & Discussions 💬 + url: https://github.com/vladelaina/BongoCat/discussions + about: "🤝 Have a question or want to chat? Join our friendly community discussions!" From cc17afde23dcad58537dda4da7fd0f1883b5e421 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 24 Aug 2026 10:30:16 +0800 Subject: [PATCH 088/334] Update 2_bug_report.yml --- .github/ISSUE_TEMPLATE/2_bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/2_bug_report.yml b/.github/ISSUE_TEMPLATE/2_bug_report.yml index b6552938..62b72307 100644 --- a/.github/ISSUE_TEMPLATE/2_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/2_bug_report.yml @@ -39,6 +39,7 @@ body: - "Linux - Cinnamon" - "Linux - MATE" - "Linux - LXQt" + - "Linux - Niri" - "Linux - Other desktop environment" validations: required: true From d8e34c40b380d43f6a80cd0df2bb810765f996b0 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 24 Aug 2026 10:37:07 +0800 Subject: [PATCH 089/334] Improve model import and cover capture --- .github/scripts/check-publish-guards.ps1 | 18 ++- .github/workflows/release.yml | 125 ++++++++++++++++++ CMakeLists.txt | 1 + cmake/Tests.cmake | 1 + include/bongo_cat/app.h | 2 + include/bongo_cat/model.h | 4 + src/live2d/cubism_bridge.cpp | 20 ++- src/live2d/cubism_cover_capture.cpp | 27 ++++ src/live2d/cubism_model.hpp | 1 + src/live2d/live2d_stub.c | 25 +++- src/runtime/model/import/model_import.c | 3 + src/runtime/model/import/model_import.h | 3 + .../model/import/model_import_format.c | 16 ++- .../model/import/model_import_source.c | 4 +- .../import/mver/model_import_mver_patch.c | 4 +- src/runtime/model/model_behavior_state.c | 6 + src/runtime/model/model_catalog_runtime.c | 1 + src/ui/preferences/preferences.c | 25 +++- src/ui/preferences/preferences_model_card.c | 7 +- src/ui/preferences/preferences_model_cover.c | 46 ++++++- src/ui/preferences/preferences_state.h | 1 + tests/model_import/test_mver_import.c | 8 ++ tests/model_import/test_slim_package.c | 58 ++++++++ 23 files changed, 383 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 src/live2d/cubism_cover_capture.cpp create mode 100644 tests/model_import/test_slim_package.c diff --git a/.github/scripts/check-publish-guards.ps1 b/.github/scripts/check-publish-guards.ps1 index 1934aeca..657e516c 100644 --- a/.github/scripts/check-publish-guards.ps1 +++ b/.github/scripts/check-publish-guards.ps1 @@ -4,11 +4,15 @@ param([switch]$SelfTest) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest -$upstreamGuard = "github.repository == 'bongocat-pet/BongoCat'" +$upstreamGuards = @( + "github.repository == 'bongocat-pet/BongoCat'", + "github.repository == 'vladelaina/BongoCat'" +) $workflowDirectory = Join-Path (Split-Path $PSScriptRoot -Parent) 'workflows' $sensitiveMarkers = @( 'build-store-package.ps1', - 'CUBISM_SDK_ARCHIVE_URL' + 'CUBISM_SDK_ARCHIVE_URL', + 'softprops/action-gh-release' ) function Get-PublishingGuardFailures { @@ -29,7 +33,10 @@ function Get-PublishingGuardFailures { $jobText = $job.Value $matched = @($sensitiveMarkers | Where-Object { $jobText.Contains($_) }) if ($matched.Count -eq 0) { continue } - if (-not $jobText.Contains($upstreamGuard)) { + $guarded = @($upstreamGuards | Where-Object { + $jobText.Contains($_) + }).Count -gt 0 + if (-not $guarded) { $failures += "$Label`: job '$jobName' lacks upstream guard; " + "matched $($matched -join ', ')" } @@ -73,7 +80,10 @@ if ($SelfTest) { $failures += 'Self-test could not find a sensitive workflow.' } else { - $mutated = $selfTestText.Replace($upstreamGuard, 'true') + $mutated = $selfTestText + foreach ($guard in $upstreamGuards) { + $mutated = $mutated.Replace($guard, 'true') + } $caught = @(Get-PublishingGuardFailures -Text $mutated -Label 'self-test') if ($caught.Count -eq 0) { $failures += 'Self-test did not reject a removed upstream guard.' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..619c6c66 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,125 @@ +name: Release BongoCat + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +concurrency: + group: bongo-cat-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + name: release / windows-x64 + if: github.repository == 'vladelaina/BongoCat' + runs-on: windows-latest + env: + CUBISM_SDK_ARCHIVE_URL: ${{ secrets.CUBISM_SDK_ARCHIVE_URL }} + CUBISM_SDK_ARCHIVE_SHA256: ${{ secrets.CUBISM_SDK_ARCHIVE_SHA256 }} + + steps: + - name: Checkout source + uses: actions/checkout@v5 + + - name: Validate tag version + id: version + shell: pwsh + run: | + $cmake = Get-Content CMakeLists.txt -Raw + if ($cmake -notmatch '(?m)^project\(BongoCat VERSION ([0-9]+\.[0-9]+\.[0-9]+)\b') { + throw 'Unable to read the BongoCat version from CMakeLists.txt' + } + $version = $Matches[1] + $expectedTag = "v$version" + if ($env:GITHUB_REF_NAME -ne $expectedTag) { + throw "Tag $env:GITHUB_REF_NAME does not match project version $version" + } + "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT ` + -Append -Encoding utf8 + + - name: Restore licensed Cubism SDK + shell: pwsh + run: ./.github/scripts/restore-cubism-sdk.ps1 + + - name: Configure release + shell: pwsh + run: | + cmake -S . -B build-release -A x64 ` + -DBONGO_CAT_REQUIRE_CUBISM=ON ` + -DBONGO_CAT_WARNINGS_AS_ERRORS=ON + + - name: Build release + shell: pwsh + run: >- + & ./.github/scripts/build-windows.ps1 + cmake --build build-release --config Release --parallel 2 + + - name: Test release + shell: pwsh + run: >- + & ./.github/scripts/test-windows.ps1 + ctest --test-dir build-release -C Release --output-on-failure + + - name: Install NSIS + shell: pwsh + run: | + if (-not (Get-Command makensis.exe -ErrorAction SilentlyContinue)) { + choco install nsis --no-progress --yes + } + $nsisCommand = Get-Command makensis.exe -ErrorAction SilentlyContinue + $nsisRoots = @( + (Join-Path ${env:ProgramFiles(x86)} 'NSIS'), + (Join-Path $env:ProgramFiles 'NSIS') + ) + if ($nsisCommand) { $nsisRoots += Split-Path $nsisCommand.Source } + $nsisRoot = $nsisRoots | Where-Object { + Test-Path -LiteralPath (Join-Path $_ 'makensis.exe') + } | Select-Object -First 1 + if (-not $nsisRoot) { + throw 'NSIS was not installed (makensis.exe was not found)' + } + $nsisRoot | Out-File -FilePath $env:GITHUB_PATH ` + -Append -Encoding utf8 + + - name: Package release + id: package + shell: pwsh + run: | + $name = (Get-Content build-release/bongo-cat-package-name.txt -Raw).Trim() + $expected = "BongoCat-${{ steps.version.outputs.version }}-windows-x64" + if ($name -ne $expected) { + throw "Unexpected release package name: $name" + } + + cmake --build build-release --config Release --target package + cmake --build build-release --config Release --target package-installer + + $files = @( + "build-release/dist/$name.zip", + "build-release/dist/$name.zip.sha256", + "build-release/dist/$name-setup.exe", + "build-release/dist/$name-setup.exe.sha256" + ) + foreach ($file in $files) { + if (-not (Test-Path -LiteralPath $file)) { + throw "Release file is missing: $file" + } + } + "name=$name" | Out-File -FilePath $env:GITHUB_OUTPUT ` + -Append -Encoding utf8 + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v2 + with: + name: BongoCat v${{ steps.version.outputs.version }} + generate_release_notes: true + fail_on_unmatched_files: true + files: | + build-release/dist/${{ steps.package.outputs.name }}.zip + build-release/dist/${{ steps.package.outputs.name }}.zip.sha256 + build-release/dist/${{ steps.package.outputs.name }}-setup.exe + build-release/dist/${{ steps.package.outputs.name }}-setup.exe.sha256 diff --git a/CMakeLists.txt b/CMakeLists.txt index d80e855b..773ed6f0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -47,6 +47,7 @@ if(EXISTS "${CUBISM_CORE_HEADER}") src/live2d/cubism_model_load.cpp src/live2d/cubism_model_update.cpp src/live2d/cubism_parameter_overrides.cpp + src/live2d/cubism_cover_capture.cpp src/live2d/cubism_motion_playback.cpp src/live2d/cubism_motion_state.cpp src/live2d/cubism_viewer_look.cpp src/live2d/cubism_model_bounds.cpp diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index 2c89bcc8..1021914f 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -50,6 +50,7 @@ if(BUILD_TESTING) tests/ui/test_preferences_text.c tests/model_import/test_mver_nearby_identity.c tests/model_import/test_model_import_identity.c + tests/model_import/test_slim_package.c tests/model_import/test_mver_support.c) add_executable(bongo_cat_mver_import_tests ${BONGO_CAT_MVER_IMPORT_TEST_SOURCES}) diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index 9db62eda..19f4a94b 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -185,6 +185,8 @@ void bongo_cat_app_set_multiple_pets(BongoCatApp *app, bool enabled); bool bongo_cat_app_run_behavior(BongoCatApp *app, const BongoCatBehaviorEntry *behavior); void bongo_cat_app_capture_behavior_state(BongoCatApp *app); +void bongo_cat_app_forget_behavior_state(BongoCatApp *app, + const char *model_id); void bongo_cat_app_restore_behavior_state(BongoCatApp *app, const char *model_id); BongoCatResult bongo_cat_app_import_model(BongoCatApp *app, const char *source, BongoCatError *error); diff --git a/include/bongo_cat/model.h b/include/bongo_cat/model.h index 2b5d1f52..18b0f933 100644 --- a/include/bongo_cat/model.h +++ b/include/bongo_cat/model.h @@ -128,6 +128,8 @@ typedef struct BongoCatLive2DRenderOptions { } BongoCatLive2DRenderOptions; BongoCatLive2D *bongo_cat_live2d_create(const char *asset_root, BongoCatError *error); +BongoCatLive2D *bongo_cat_live2d_create_cover_runtime( + const char *asset_root, BongoCatError *error); void bongo_cat_live2d_destroy(BongoCatLive2D *live2d); BongoCatResult bongo_cat_live2d_load(BongoCatLive2D *live2d, const char *model_dir, const char *setting_file, bool preset, @@ -175,6 +177,8 @@ bool bongo_cat_live2d_motion_same_toggle(const BongoCatLive2D *live2d, const char *right_group, int right_index); bool bongo_cat_live2d_set_expression(BongoCatLive2D *live2d, int index); int bongo_cat_live2d_expression(const BongoCatLive2D *live2d); +/* Accepts only a runtime created by bongo_cat_live2d_create_cover_runtime. */ +bool bongo_cat_live2d_prepare_cover(BongoCatLive2D *live2d); bool bongo_cat_live2d_visual_state(const BongoCatLive2D *live2d, BongoCatLive2DVisualState *state); diff --git a/src/live2d/cubism_bridge.cpp b/src/live2d/cubism_bridge.cpp index 62b6b558..4966ecb8 100644 --- a/src/live2d/cubism_bridge.cpp +++ b/src/live2d/cubism_bridge.cpp @@ -120,20 +120,30 @@ struct BongoCatLive2D { bongo_cat::NativeModel *model; int width = 612; int height = 354; + bool cover_runtime = false; }; -extern "C" BongoCatLive2D *bongo_cat_live2d_create(const char *asset_root, - BongoCatError *error) { +static BongoCatLive2D *create_runtime(const char *asset_root, + bool cover_runtime, BongoCatError *error) { resource_root = asset_root ? asset_root : ""; if (!start_framework(error)) return nullptr; BongoCatLive2D *runtime = new(std::nothrow) BongoCatLive2D{}; if (!runtime) { stop_framework(); bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, "Cannot allocate Cubism runtime"); - } + } else runtime->cover_runtime = cover_runtime; return runtime; } +extern "C" BongoCatLive2D *bongo_cat_live2d_create(const char *asset_root, + BongoCatError *error) { + return create_runtime(asset_root, false, error); +} + +extern "C" BongoCatLive2D *bongo_cat_live2d_create_cover_runtime( + const char *asset_root, BongoCatError *error) { + return create_runtime(asset_root, true, error); } + extern "C" void bongo_cat_live2d_destroy(BongoCatLive2D *runtime) { if (!runtime) return; delete runtime->model; @@ -281,6 +291,10 @@ extern "C" bool bongo_cat_live2d_set_expression(BongoCatLive2D *runtime, int ind return runtime && runtime->model && runtime->model->set_expression(index); } extern "C" int bongo_cat_live2d_expression(const BongoCatLive2D *runtime) { return runtime && runtime->model ? runtime->model->expression() : -1; } +extern "C" bool bongo_cat_live2d_prepare_cover(BongoCatLive2D *runtime) { + try { return runtime && runtime->cover_runtime && runtime->model && + runtime->model->prepare_cover(); } catch (...) { return false; } +} extern "C" bool bongo_cat_live2d_visual_state(const BongoCatLive2D *runtime, BongoCatLive2DVisualState *state) { return runtime && runtime->model && runtime->model->visual_state(state); } diff --git a/src/live2d/cubism_cover_capture.cpp b/src/live2d/cubism_cover_capture.cpp new file mode 100644 index 00000000..f755e882 --- /dev/null +++ b/src/live2d/cubism_cover_capture.cpp @@ -0,0 +1,27 @@ +#include "cubism_model.hpp" + +namespace bongo_cat { + +bool NativeModel::prepare_cover() { + if (!_model) return false; + int parameter_count = _model->GetParameterCount(); + int part_count = _model->GetPartCount(); + for (const auto &item : motion_states_) { + auto owner = motion_toggle_owners_.find(item.first); + if ((owner != motion_toggle_owners_.end() && + owner->second != item.first) || + !motion_is_persistent(item.first)) continue; + for (const MotionStateCurve &curve : item.second.curves) { + if (curve.parameter >= 0 && curve.parameter < parameter_count) + _model->SetParameterValue(curve.parameter, curve.end); + else if (curve.part >= 0 && curve.part < part_count) + _model->SetPartOpacity(curve.part, curve.end); + else if (curve.model_opacity) + _model->SetModelOpacity(curve.end); + } + } + _model->Update(); + return true; +} + +} // namespace bongo_cat diff --git a/src/live2d/cubism_model.hpp b/src/live2d/cubism_model.hpp index 85f1f763..b20fac6f 100644 --- a/src/live2d/cubism_model.hpp +++ b/src/live2d/cubism_model.hpp @@ -57,6 +57,7 @@ class NativeModel final : public Csm::CubismUserModel { const char *right_group, int right_index) const; bool set_expression(int index); int expression() const { return expression_index_; } + bool prepare_cover(); bool visual_state(BongoCatLive2DVisualState *state) const; public: diff --git a/src/live2d/live2d_stub.c b/src/live2d/live2d_stub.c index e18a0701..9376c3f7 100644 --- a/src/live2d/live2d_stub.c +++ b/src/live2d/live2d_stub.c @@ -3,15 +3,32 @@ #include -struct BongoCatLive2D { int width; int height; bool loaded; }; +struct BongoCatLive2D { + int width; + int height; + bool loaded; + bool cover_runtime; +}; -BongoCatLive2D *bongo_cat_live2d_create(const char *asset_root, BongoCatError *error) { +static BongoCatLive2D *create_runtime(const char *asset_root, + bool cover_runtime, BongoCatError *error) { (void)asset_root; BongoCatLive2D *value = calloc(1, sizeof(*value)); if (!value) bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, "Cannot allocate Live2D runtime"); + else value->cover_runtime = cover_runtime; return value; } +BongoCatLive2D *bongo_cat_live2d_create(const char *asset_root, + BongoCatError *error) { + return create_runtime(asset_root, false, error); +} + +BongoCatLive2D *bongo_cat_live2d_create_cover_runtime( + const char *asset_root, BongoCatError *error) { + return create_runtime(asset_root, true, error); +} + void bongo_cat_live2d_destroy(BongoCatLive2D *live2d) { free(live2d); } BongoCatResult bongo_cat_live2d_load(BongoCatLive2D *live2d, const char *model_dir, @@ -129,6 +146,10 @@ bool bongo_cat_live2d_set_expression(BongoCatLive2D *value, int index) { int bongo_cat_live2d_expression(const BongoCatLive2D *value) { (void)value; return -1; } +bool bongo_cat_live2d_prepare_cover(BongoCatLive2D *value) { + if (!value || !value->cover_runtime) return false; + return false; +} bool bongo_cat_live2d_visual_state(const BongoCatLive2D *value, BongoCatLive2DVisualState *state) { (void)value; (void)state; return false; diff --git a/src/runtime/model/import/model_import.c b/src/runtime/model/import/model_import.c index 38024034..a0a50c29 100644 --- a/src/runtime/model/import/model_import.c +++ b/src/runtime/model/import/model_import.c @@ -235,6 +235,9 @@ BongoCatResult bongo_cat_app_import_model(BongoCatApp *app, const char *source, if (receipt.installed[i]) { preferred = i; break; } const char *imported_id = receipt.count ? receipt.ids[preferred] : NULL; bongo_cat_app_refresh_installed_models(app); + for (size_t i = 0; i < receipt.count; ++i) + if (receipt.installed[i]) + bongo_cat_app_forget_behavior_state(app, receipt.ids[i]); if (imported_id && bongo_cat_app_select_model(app, imported_id)) return BONGO_CAT_OK; #ifndef BONGO_CAT_HAS_CUBISM diff --git a/src/runtime/model/import/model_import.h b/src/runtime/model/import/model_import.h index 74865f3d..74ae6eee 100644 --- a/src/runtime/model/import/model_import.h +++ b/src/runtime/model/import/model_import.h @@ -5,6 +5,7 @@ #include "bongo_cat/model.h" #define BONGO_CAT_IMPORT_CANDIDATE_CAP 16 +#define BONGO_CAT_SKIN_CONFIG_FILE "bongocat.skin.json" typedef enum BongoCatImportFormat { BONGO_CAT_IMPORT_TAURI, @@ -58,6 +59,8 @@ typedef struct BongoCatApp BongoCatApp; bool bongo_cat_import_discover(const char *source, BongoCatImportDiscovery *discovery, BongoCatError *error); +bool bongo_cat_import_mver_config_path(const char *root, + char *path, size_t capacity); int bongo_cat_import_mver_discover(const char *source, BongoCatImportDiscovery *discovery, BongoCatError *error); int bongo_cat_import_mver_discover_exact(const char *source, diff --git a/src/runtime/model/import/model_import_format.c b/src/runtime/model/import/model_import_format.c index 44070111..2822d673 100644 --- a/src/runtime/model/import/model_import_format.c +++ b/src/runtime/model/import/model_import_format.c @@ -41,12 +41,20 @@ static bool parent_path(const char *path, char *parent, size_t capacity) { return true; } +bool bongo_cat_import_mver_config_path(const char *root, + char *path, size_t capacity) { + static const char *names[] = {BONGO_CAT_SKIN_CONFIG_FILE, "config.json"}; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) + if (bongo_cat_path_join(path, capacity, root, names[i]) && + bongo_cat_path_is_file(path)) return true; + return false; +} + static bool package_at(const char *source, char *config, char *image_root) { - return bongo_cat_path_join(config, BONGO_CAT_PATH_CAP, source, - "config.json") && + return bongo_cat_import_mver_config_path(source, config, + BONGO_CAT_PATH_CAP) && bongo_cat_path_join(image_root, BONGO_CAT_PATH_CAP, source, "img") && - bongo_cat_path_is_file(config) && bongo_cat_path_is_dir(image_root) && - mver_shape(image_root); + bongo_cat_path_is_dir(image_root) && mver_shape(image_root); } static bool find_package(const char *source, char *package, size_t capacity, diff --git a/src/runtime/model/import/model_import_source.c b/src/runtime/model/import/model_import_source.c index 9850eaba..d60716ee 100644 --- a/src/runtime/model/import/model_import_source.c +++ b/src/runtime/model/import/model_import_source.c @@ -63,9 +63,11 @@ BongoCatResult bongo_cat_import_source_directory(const char *source, if (name && suffix_matches(name, ".png") && image_package_root(source, directory, capacity)) return BONGO_CAT_OK; if (!name || (SDL_strcasecmp(name, "config.json") != 0 && + SDL_strcasecmp(name, BONGO_CAT_SKIN_CONFIG_FILE) != 0 && !suffix_matches(name, ".model3.json"))) { bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Select an Mver config.json, image-patch PNG, or Live2D .model3.json file"); + "Select a BongoCat skin file, Mver config.json, image-patch PNG, " + "or Live2D .model3.json file"); return BONGO_CAT_ERROR_FORMAT; } if (parent_path(source, directory, capacity)) return BONGO_CAT_OK; diff --git a/src/runtime/model/import/mver/model_import_mver_patch.c b/src/runtime/model/import/mver/model_import_mver_patch.c index de11eb2f..66e68266 100644 --- a/src/runtime/model/import/mver/model_import_mver_patch.c +++ b/src/runtime/model/import/mver/model_import_mver_patch.c @@ -60,9 +60,9 @@ static bool parent_path(const char *path, char *parent, size_t capacity) { static bool full_package(const char *path) { char config[BONGO_CAT_PATH_CAP], image[BONGO_CAT_PATH_CAP]; - return bongo_cat_path_join(config, sizeof(config), path, "config.json") && + return bongo_cat_import_mver_config_path(path, config, sizeof(config)) && bongo_cat_path_join(image, sizeof(image), path, "img") && - bongo_cat_path_is_file(config) && bongo_cat_path_is_dir(image) && + bongo_cat_path_is_dir(image) && patch_shape(image); } diff --git a/src/runtime/model/model_behavior_state.c b/src/runtime/model/model_behavior_state.c index 4c0c122e..032ef181 100644 --- a/src/runtime/model/model_behavior_state.c +++ b/src/runtime/model/model_behavior_state.c @@ -31,6 +31,12 @@ static void remove_model_state(BongoCatSessionState *session, session->active_behavior_count = output; } +void bongo_cat_app_forget_behavior_state(BongoCatApp *app, + const char *model_id) { + if (!app || !model_id || !model_id[0]) return; + remove_model_state(&app->session, model_id); +} + static void make_room(BongoCatSessionState *session, size_t required) { size_t count = session->active_behavior_count; if (required > BONGO_CAT_BEHAVIOR_BINDING_CAP) diff --git a/src/runtime/model/model_catalog_runtime.c b/src/runtime/model/model_catalog_runtime.c index 7fc83434..241b06c4 100644 --- a/src/runtime/model/model_catalog_runtime.c +++ b/src/runtime/model/model_catalog_runtime.c @@ -272,6 +272,7 @@ BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, restore_model_selection(app, &previous_selection); return BONGO_CAT_ERROR_IO; } + bongo_cat_app_forget_behavior_state(app, id); bongo_cat_settings_set_model_label(&app->settings, id, ""); bongo_cat_app_rescan_models(app); return BONGO_CAT_OK; diff --git a/src/ui/preferences/preferences.c b/src/ui/preferences/preferences.c index 1c18f951..d9b2583c 100644 --- a/src/ui/preferences/preferences.c +++ b/src/ui/preferences/preferences.c @@ -96,11 +96,19 @@ static Uint32 event_window(const SDL_Event *event) { case SDL_EVENT_MOUSE_BUTTON_DOWN: case SDL_EVENT_MOUSE_BUTTON_UP: return event->button.windowID; case SDL_EVENT_MOUSE_WHEEL: return event->wheel.windowID; - case SDL_EVENT_DROP_FILE: return event->drop.windowID; + case SDL_EVENT_DROP_BEGIN: case SDL_EVENT_DROP_FILE: + case SDL_EVENT_DROP_COMPLETE: case SDL_EVENT_DROP_POSITION: + return event->drop.windowID; default: return 0; } } +static void enter_model_drop(BongoCatPreferences *value) { + value->page = 2; + value->import_drop_active = true; + value->render_dirty = true; +} + bool bongo_cat_preferences_chrome_drag_allowed( const BongoCatPreferences *value) { return value && value->app && !value->native_drag && @@ -195,10 +203,24 @@ bool bongo_cat_preferences_event(BongoCatPreferences *value, const SDL_Event *ev if (event_window(event) != SDL_GetWindowID(value->window)) return false; if (value->shown_ns && event->common.timestamp && event->common.timestamp < value->shown_ns) return true; + if (event->type == SDL_EVENT_DROP_BEGIN) { + enter_model_drop(value); + return true; + } + if (event->type == SDL_EVENT_DROP_POSITION) { + enter_model_drop(value); + return true; + } + if (event->type == SDL_EVENT_DROP_COMPLETE) { + value->import_drop_active = false; + value->render_dirty = true; + return true; + } if (bongo_cat_preferences_scale_event(value, event)) return true; if (event->type == SDL_EVENT_WINDOW_FOCUS_LOST) { if (value->chrome_dragging) SDL_CaptureMouse(false); value->chrome_dragging = false; + value->import_drop_active = false; bongo_cat_pref_controls_reset(&value->ui.context); } if (value->app->smoke_input_audit && (event->type == SDL_EVENT_MOUSE_BUTTON_DOWN || @@ -212,6 +234,7 @@ bool bongo_cat_preferences_event(BongoCatPreferences *value, const SDL_Event *ev return true; } if (event->type == SDL_EVENT_DROP_FILE) { + value->import_drop_active = false; bongo_cat_preferences_import_path(value->app, value->window, event->drop.data); return true; } diff --git a/src/ui/preferences/preferences_model_card.c b/src/ui/preferences/preferences_model_card.c index 587a2871..82f55c1e 100644 --- a/src/ui/preferences/preferences_model_card.c +++ b/src/ui/preferences/preferences_model_card.c @@ -36,7 +36,8 @@ bool bongo_cat_preferences_model_import_card(BongoCatPreferences *value, if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return false; bounds.y += 5.0f; bounds.h += 1.0f; BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); - bool hover = nk_input_is_mouse_hovering_rect(&context->input, bounds); + bool pointer_hover = nk_input_is_mouse_hovering_rect(&context->input, bounds); + bool hover = pointer_hover || value->import_drop_active; float lift = bongo_cat_ui_animate_eased(context, "model-import-hover", hover ? 1.0f : 0.0f, 250.0f, BONGO_CAT_UI_EASE_STANDARD); bounds.y -= 2.0f * lift; @@ -69,9 +70,9 @@ bool bongo_cat_preferences_model_import_card(BongoCatPreferences *value, text(context, canvas, nk_rect(cx - width * .5f, cy + 30, NK_MIN(width + 1, bounds.w - 20), 24), label, p.accent, value->ui.caption_font); - if (hover) bongo_cat_ui_cursor_hover_rect(context, bounds, + if (pointer_hover) bongo_cat_ui_cursor_hover_rect(context, bounds, BONGO_CAT_UI_CURSOR_POINTER); - return hover && nk_input_is_mouse_click_in_rect(&context->input, + return pointer_hover && nk_input_is_mouse_click_in_rect(&context->input, NK_BUTTON_LEFT, bounds); } diff --git a/src/ui/preferences/preferences_model_cover.c b/src/ui/preferences/preferences_model_cover.c index 15bb0bae..c470926f 100644 --- a/src/ui/preferences/preferences_model_cover.c +++ b/src/ui/preferences/preferences_model_cover.c @@ -129,10 +129,50 @@ static bool capture_model_cover(BongoCatApp *app, if (!bongo_cat_path_join(temporary, sizeof(temporary), resources, ".bongo-cat-runtime-cover.png")) return false; bongo_cat_file_remove(temporary); - snprintf(app->pending_model_cover_path, - sizeof(app->pending_model_cover_path), "%s", temporary); - bongo_cat_app_capture_pending_frame(app); + int width = 0, height = 0; + SDL_GetWindowSizeInPixels(app->window, &width, &height); + BongoCatLive2D *cover = NULL; + BongoCatError ignored = {0}; + if (width > 1 && height > 1 && + SDL_GL_MakeCurrent(app->window, app->gl_context)) { + /* Generated-cover state is destructive, so it requires its own model. */ + cover = bongo_cat_live2d_create_cover_runtime( + app->asset_root, &ignored); + if (cover) { + bongo_cat_live2d_reshape(cover, width, height); + if (bongo_cat_live2d_load(cover, entry->directory, + entry->setting_file, entry->preset, + &app->model_render_options, NULL, NULL, + &ignored) != BONGO_CAT_OK) { + bongo_cat_live2d_destroy(cover); + cover = NULL; + } + } + } + if (cover && cover != app->live2d && + bongo_cat_live2d_prepare_cover(cover)) { + bongo_cat_live2d_resize(cover, width, height); + glViewport(0, 0, width, height); + glEnable(GL_MULTISAMPLE); + glDisable(GL_SCISSOR_TEST); + glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + glClearColor(0.0f, 0.0f, 0.0f, 0.0f); + glClear(GL_COLOR_BUFFER_BIT); + int x = 0, y = 0, content_width = width, content_height = height; + if (bongo_cat_live2d_viewport(cover, &x, &y, + &content_width, &content_height) && + content_width > 0 && content_height > 0) + glViewport(x, y, content_width, content_height); + bongo_cat_live2d_set_mirror(cover, app->settings.model.mirror); + bongo_cat_live2d_draw(cover); + glViewport(0, 0, width, height); + snprintf(app->pending_model_cover_path, + sizeof(app->pending_model_cover_path), "%s", temporary); + bongo_cat_frame_capture_pending(app, width, height); + app->dirty = true; + } app->pending_model_cover_path[0] = '\0'; + if (cover) bongo_cat_live2d_destroy(cover); if (previous_window && previous_context && (previous_window != SDL_GL_GetCurrentWindow() || previous_context != SDL_GL_GetCurrentContext())) diff --git a/src/ui/preferences/preferences_state.h b/src/ui/preferences/preferences_state.h index 76f2fe26..2cb5cd51 100644 --- a/src/ui/preferences/preferences_state.h +++ b/src/ui/preferences/preferences_state.h @@ -47,6 +47,7 @@ struct BongoCatPreferences { nk_rune glyph_ranges[2048]; bool input_active; bool import_requested; + bool import_drop_active; BongoCatImportDialog *import_dialog; bool frame_checked; bool render_dirty; diff --git a/tests/model_import/test_mver_import.c b/tests/model_import/test_mver_import.c index 52e3328f..2f216b1f 100644 --- a/tests/model_import/test_mver_import.c +++ b/tests/model_import/test_mver_import.c @@ -20,6 +20,7 @@ int test_preferences_text(void); int test_mver_nearby_identity(void); int test_mver_nearby_refresh(void); int test_mver_missing_motion_groups(void); int test_model_import_identity(void); +int test_slim_package(void); static bool chord(const char *json, bool gamepad, const char *expected) { yyjson_doc *document = yyjson_read(json, strlen(json), 0); BongoCatImportCandidate candidate = {0}; @@ -207,6 +208,12 @@ static void container_discovery(void) { CHECK(child(bundle, sizeof(bundle), root, "bundle", true)); CHECK(child(nested_package, sizeof(nested_package), bundle, "model", true)); CHECK(mver_fixture(nested_package)); + char nested_config[BONGO_CAT_PATH_CAP], slim_config[BONGO_CAT_PATH_CAP]; + CHECK(child(nested_config, sizeof(nested_config), nested_package, + "config.json", false)); + CHECK(child(slim_config, sizeof(slim_config), nested_package, + BONGO_CAT_SKIN_CONFIG_FILE, false)); + CHECK(SDL_RenamePath(nested_config, slim_config)); CHECK(child(variant, sizeof(variant), bundle, "variant", true)); CHECK(child(variant_model, sizeof(variant_model), variant, "model", true)); CHECK(child(variant_img, sizeof(variant_img), variant_model, "img", true)); @@ -296,5 +303,6 @@ int main(void) { failures += test_model_import_identity(); container_discovery(); tauri_exact_discovery(); + failures += test_slim_package(); return failures ? 1 : 0; } diff --git a/tests/model_import/test_slim_package.c b/tests/model_import/test_slim_package.c new file mode 100644 index 00000000..efe480c8 --- /dev/null +++ b/tests/model_import/test_slim_package.c @@ -0,0 +1,58 @@ +#include "model_import.h" +#include "model_storage.h" +#include "test_mver_support.h" +#include "bongo_cat/path.h" + +#include +#include + +const char *test_mver_pointer_config(bool live2d); + +int test_slim_package(void) { + int failures = 0; +#define CHECK_SLIM(value) do { if (!(value)) { \ + fprintf(stderr, "%s:%d: check failed: %s\n", \ + __FILE__, __LINE__, #value); failures++; \ +} } while (0) + char *temporary = SDL_GetCurrentDirectory(); + CHECK_SLIM(temporary != NULL); + if (!temporary) return failures; + char source[BONGO_CAT_PATH_CAP], data[BONGO_CAT_PATH_CAP]; + char config[BONGO_CAT_PATH_CAP], slim[BONGO_CAT_PATH_CAP]; + unsigned long long nonce = (unsigned long long)SDL_GetTicksNS(); + snprintf(source, sizeof(source), "%s/bongo-cat-slim-%llu", + temporary, nonce); + snprintf(data, sizeof(data), "%s/bongo-cat-slim-data-%llu", + temporary, nonce); + CHECK_SLIM(mver_fixture(source)); + CHECK_SLIM(SDL_CreateDirectory(data)); + CHECK_SLIM(child(config, sizeof(config), source, "config.json", false)); + CHECK_SLIM(child(slim, sizeof(slim), source, + BONGO_CAT_SKIN_CONFIG_FILE, false)); + CHECK_SLIM(SDL_RenamePath(config, slim)); + char compact[1400]; + snprintf(compact, sizeof(compact), + "{\"schemaVersion\":1,\"kind\":\"bongocat-skin\"," + "\"sourceFormat\":\"bongo-cat-mver\",%s", + test_mver_pointer_config(true) + 1); + CHECK_SLIM(write_text(slim, compact)); + + BongoCatImportReceipt receipt = {0}; + BongoCatError error = {0}; + CHECK_SLIM(bongo_cat_import_install(source, data, &receipt, &error) == + BONGO_CAT_OK); + CHECK_SLIM(receipt.count == 1 && receipt.installed_count == 1); + char installed[BONGO_CAT_PATH_CAP], models[BONGO_CAT_PATH_CAP]; + char package[BONGO_CAT_PATH_CAP]; + CHECK_SLIM(bongo_cat_path_join(models, sizeof(models), data, "models")); + CHECK_SLIM(bongo_cat_path_join(package, sizeof(package), models, + receipt.ids[0])); + CHECK_SLIM(child(installed, sizeof(installed), package, + "payload/" BONGO_CAT_SKIN_CONFIG_FILE, false)); + CHECK_SLIM(bongo_cat_path_is_file(installed)); + CHECK_SLIM(bongo_cat_model_remove_tree(source, NULL)); + CHECK_SLIM(bongo_cat_model_remove_tree(data, NULL)); + SDL_free(temporary); +#undef CHECK_SLIM + return failures; +} From 5bb440923b86687167964b349944a4801745b44c Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 24 Aug 2026 11:50:52 +0800 Subject: [PATCH 090/334] refactor: split oversized source files --- CMakeLists.txt | 5 +- cmake/CheckLines.cmake | 28 +-- cmake/CheckPlatformRuntimeSafety.cmake | 2 + cmake/Packaging.cmake | 54 +----- cmake/PackagingPlatform.cmake | 53 ++++++ cmake/PlatformRuntime.cmake | 1 + cmake/RuntimeSources.cmake | 9 + cmake/Tests.cmake | 2 + cmake/ValidationTools.cmake | 9 +- src/live2d/cubism_bridge.cpp | 12 +- src/live2d/cubism_bridge_visual.cpp | 6 + src/live2d/cubism_model_idle.cpp | 22 +++ src/live2d/cubism_model_update.cpp | 14 -- src/live2d/cubism_motion_selection.cpp | 22 +++ src/live2d/cubism_motion_state.cpp | 16 -- src/live2d/cubism_runtime.hpp | 13 ++ src/platform/macos/macos.m | 26 --- src/platform/macos/macos_preferences.m | 32 ++++ src/platform/windows/windows_layered.c | 3 - src/platform/windows/windows_opacity.c | 7 + src/runtime/diagnostics/live2d_audit.c | 30 +-- .../diagnostics/live2d_audit_scenario.c | 32 ++++ .../diagnostics/live2d_audit_scenario.h | 11 ++ src/runtime/diagnostics/live2d_viewer_audit.c | 38 +--- .../diagnostics/live2d_viewer_timing.c | 46 +++++ .../diagnostics/live2d_viewer_timing.h | 9 + src/runtime/lifecycle/app.c | 27 --- src/runtime/lifecycle/app_input.c | 40 ++++ .../model/import/model_import_digest.c | 11 -- .../model/import/model_import_digest_api.c | 12 ++ src/runtime/model/model_files.c | 82 +------- src/runtime/model/model_geometry.c | 73 ++++++++ src/runtime/model/model_geometry.h | 16 ++ src/runtime/model/multi_pet.c | 12 -- src/runtime/model/multi_pet_selection.c | 15 ++ src/tools/cubism_viewer_blind_frames.c | 114 ++++++++++++ src/tools/cubism_viewer_blind_frames.h | 30 +++ src/tools/cubism_viewer_blind_test.c | 145 ++------------- src/tools/cubism_viewer_desktop_capture.c | 175 +----------------- src/tools/cubism_viewer_desktop_capture.h | 31 ++++ .../cubism_viewer_desktop_capture_dxgi.c | 148 +++++++++++++++ src/tools/mver_phase_frames.c | 105 +++++++++++ src/tools/mver_phase_frames.h | 25 +++ src/tools/mver_phase_metrics.c | 122 +----------- src/ui/preferences/preferences_about.c | 13 +- src/ui/preferences/preferences_about_page.c | 15 ++ .../preferences/preferences_page_dispatch.c | 12 ++ .../preferences/preferences_page_dispatch.h | 9 + src/ui/preferences/preferences_render.c | 12 +- .../preferences/preferences_widget_shortcut.c | 102 ++++++++++ src/ui/preferences/preferences_widgets.c | 132 +++---------- .../preferences_widgets_internal.h | 6 + tests/core/test_config.c | 120 +----------- tests/core/test_config_validation.c | 122 ++++++++++++ tests/core/test_config_validation.h | 6 + tests/model_import/test_mver_container.c | 71 +++++++ tests/model_import/test_mver_import.c | 69 +------ .../model_import/test_mver_import_internal.h | 16 ++ 58 files changed, 1324 insertions(+), 1066 deletions(-) create mode 100644 cmake/PackagingPlatform.cmake create mode 100644 src/live2d/cubism_bridge_visual.cpp create mode 100644 src/live2d/cubism_model_idle.cpp create mode 100644 src/live2d/cubism_motion_selection.cpp create mode 100644 src/live2d/cubism_runtime.hpp create mode 100644 src/platform/windows/windows_opacity.c create mode 100644 src/runtime/diagnostics/live2d_audit_scenario.c create mode 100644 src/runtime/diagnostics/live2d_audit_scenario.h create mode 100644 src/runtime/diagnostics/live2d_viewer_timing.c create mode 100644 src/runtime/diagnostics/live2d_viewer_timing.h create mode 100644 src/runtime/lifecycle/app_input.c create mode 100644 src/runtime/model/import/model_import_digest_api.c create mode 100644 src/runtime/model/model_geometry.c create mode 100644 src/runtime/model/model_geometry.h create mode 100644 src/runtime/model/multi_pet_selection.c create mode 100644 src/tools/cubism_viewer_blind_frames.c create mode 100644 src/tools/cubism_viewer_blind_frames.h create mode 100644 src/tools/cubism_viewer_desktop_capture.h create mode 100644 src/tools/cubism_viewer_desktop_capture_dxgi.c create mode 100644 src/tools/mver_phase_frames.c create mode 100644 src/tools/mver_phase_frames.h create mode 100644 src/ui/preferences/preferences_about_page.c create mode 100644 src/ui/preferences/preferences_page_dispatch.c create mode 100644 src/ui/preferences/preferences_page_dispatch.h create mode 100644 src/ui/preferences/preferences_widget_shortcut.c create mode 100644 tests/core/test_config_validation.c create mode 100644 tests/core/test_config_validation.h create mode 100644 tests/model_import/test_mver_container.c create mode 100644 tests/model_import/test_mver_import_internal.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 773ed6f0..b2da39c9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -43,12 +43,15 @@ if(EXISTS "${CUBISM_CORE_HEADER}") else() include(cmake/Cubism.cmake) set(BONGO_CAT_LIVE2D_SOURCES - src/live2d/cubism_bridge.cpp src/live2d/cubism_expression.cpp + src/live2d/cubism_bridge.cpp src/live2d/cubism_bridge_visual.cpp + src/live2d/cubism_expression.cpp src/live2d/cubism_model_load.cpp + src/live2d/cubism_model_idle.cpp src/live2d/cubism_model_update.cpp src/live2d/cubism_parameter_overrides.cpp src/live2d/cubism_cover_capture.cpp src/live2d/cubism_motion_playback.cpp + src/live2d/cubism_motion_selection.cpp src/live2d/cubism_motion_state.cpp src/live2d/cubism_viewer_look.cpp src/live2d/cubism_model_bounds.cpp src/live2d/cubism_model_validate.cpp) diff --git a/cmake/CheckLines.cmake b/cmake/CheckLines.cmake index a33d8d57..21a4fa34 100644 --- a/cmake/CheckLines.cmake +++ b/cmake/CheckLines.cmake @@ -22,33 +22,7 @@ foreach(FILE IN LISTS FILES) file(RELATIVE_PATH RELATIVE_FILE "${ROOT}" "${FILE}") string(REPLACE "\\" "/" RELATIVE_FILE "${RELATIVE_FILE}") - set(LIMIT 300) - if(RELATIVE_FILE STREQUAL "src/media/image.c") - set(LIMIT 332) - elseif(RELATIVE_FILE STREQUAL "src/tools/cubism_viewer_blind_test.c") - set(LIMIT 376) - elseif(RELATIVE_FILE STREQUAL "src/tools/cubism_viewer_desktop_capture.c") - set(LIMIT 396) - elseif(RELATIVE_FILE STREQUAL "src/tools/mver_phase_metrics.c") - set(LIMIT 321) - elseif(RELATIVE_FILE STREQUAL - "src/runtime/diagnostics/live2d_audit.c") - set(LIMIT 320) - elseif(RELATIVE_FILE STREQUAL - "src/ui/preferences/preferences_import.c") - set(LIMIT 320) - elseif(RELATIVE_FILE STREQUAL - "src/ui/preferences/preferences_widgets.c") - set(LIMIT 350) - elseif(RELATIVE_FILE STREQUAL - "src/runtime/lifecycle/startup.c") - set(LIMIT 305) - elseif(RELATIVE_FILE STREQUAL - "src/ui/preferences/preferences_about.c") - set(LIMIT 301) - elseif(RELATIVE_FILE STREQUAL "tests/core/test_config.c") - set(LIMIT 309) - endif() + set(LIMIT 299) if(COUNT GREATER LIMIT) list(APPEND FAILED "${RELATIVE_FILE}: ${COUNT} (limit ${LIMIT})") diff --git a/cmake/CheckPlatformRuntimeSafety.cmake b/cmake/CheckPlatformRuntimeSafety.cmake index 84cb965e..cc7ba80e 100644 --- a/cmake/CheckPlatformRuntimeSafety.cmake +++ b/cmake/CheckPlatformRuntimeSafety.cmake @@ -108,6 +108,8 @@ if(MANIFEST_POSITION EQUAL -1) endif() file(READ "${ROOT}/cmake/Packaging.cmake" WINDOWS_PACKAGING) +file(READ "${ROOT}/cmake/PackagingPlatform.cmake" WINDOWS_PACKAGE_PLATFORM) +string(APPEND WINDOWS_PACKAGING "\n${WINDOWS_PACKAGE_PLATFORM}") string(FIND "${WINDOWS_PACKAGING}" "RequestExecutionLevel user" INSTALLER_LEVEL_POSITION) string(FIND "${WINDOWS_PACKAGING}" diff --git a/cmake/Packaging.cmake b/cmake/Packaging.cmake index 56698350..fa4b2171 100644 --- a/cmake/Packaging.cmake +++ b/cmake/Packaging.cmake @@ -9,59 +9,7 @@ if(UNIX AND NOT APPLE) PATTERN "ui-symbols-1x.png" EXCLUDE PATTERN "ui-symbols-4x.png" EXCLUDE) endif() -if(CMAKE_SIZEOF_VOID_P EQUAL 8) - set(BONGO_CAT_PACKAGE_ARCH "x64") -else() - set(BONGO_CAT_PACKAGE_ARCH "x86") -endif() - -if(APPLE) - if(CMAKE_OSX_ARCHITECTURES) - list(LENGTH CMAKE_OSX_ARCHITECTURES BONGO_CAT_OSX_ARCH_COUNT) - if(BONGO_CAT_OSX_ARCH_COUNT GREATER 1) - set(BONGO_CAT_PACKAGE_ARCH "universal") - else() - list(GET CMAKE_OSX_ARCHITECTURES 0 BONGO_CAT_OSX_ARCH) - if(BONGO_CAT_OSX_ARCH MATCHES "^(arm64|aarch64)$") - set(BONGO_CAT_PACKAGE_ARCH "arm64") - endif() - endif() - elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "^(arm64|aarch64|ARM64)$") - set(BONGO_CAT_PACKAGE_ARCH "arm64") - endif() - set(BONGO_CAT_PACKAGE_PLATFORM "macos-${BONGO_CAT_PACKAGE_ARCH}") - set(CPACK_GENERATOR "ZIP") -elseif(WIN32) - set(BONGO_CAT_PACKAGE_PLATFORM "windows-${BONGO_CAT_PACKAGE_ARCH}") - set(CPACK_GENERATOR "ZIP") - set(CPACK_PACKAGE_INSTALL_DIRECTORY "BongoCat") - set(CPACK_PACKAGE_INSTALL_REGISTRY_KEY "BongoCat") - set(CPACK_MONOLITHIC_INSTALL ON) - set(CPACK_COMPONENT_INSTALL OFF) - set(CPACK_PACKAGE_EXECUTABLES "BongoCat" "BongoCat") - set(CPACK_CREATE_DESKTOP_LINKS "BongoCat") - set(CPACK_NSIS_EXECUTABLES_DIRECTORY ".") - set(CPACK_NSIS_INSTALL_ROOT "$LOCALAPPDATA/Programs") - set(CPACK_NSIS_DISPLAY_NAME "BongoCat") - set(CPACK_NSIS_PACKAGE_NAME "BongoCat") - set(CPACK_NSIS_INSTALLED_ICON_NAME "BongoCat.exe") - set(CPACK_NSIS_MUI_ICON - "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/icon.ico") - set(CPACK_NSIS_MUI_UNIICON - "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/icon.ico") - set(CPACK_NSIS_ENABLE_UNINSTALL_BEFORE_INSTALL OFF) - set(CPACK_NSIS_MODIFY_PATH OFF) - set(CPACK_NSIS_MUI_FINISHPAGE_RUN "BongoCat.exe") - set(CPACK_NSIS_HELP_LINK "${PROJECT_HOMEPAGE_URL}") - set(CPACK_NSIS_URL_INFO_ABOUT "${PROJECT_HOMEPAGE_URL}") - set(CPACK_NSIS_MANIFEST_DPI_AWARE ON) -else() - if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(arm64|aarch64|ARM64)$") - set(BONGO_CAT_PACKAGE_ARCH "arm64") - endif() - set(BONGO_CAT_PACKAGE_PLATFORM "linux-${BONGO_CAT_PACKAGE_ARCH}") - set(CPACK_GENERATOR "TGZ") -endif() +include(cmake/PackagingPlatform.cmake) set(BONGO_CAT_PACKAGE_NAME "BongoCat-${PROJECT_VERSION}-${BONGO_CAT_PACKAGE_PLATFORM}") diff --git a/cmake/PackagingPlatform.cmake b/cmake/PackagingPlatform.cmake new file mode 100644 index 00000000..304b67ad --- /dev/null +++ b/cmake/PackagingPlatform.cmake @@ -0,0 +1,53 @@ +if(CMAKE_SIZEOF_VOID_P EQUAL 8) + set(BONGO_CAT_PACKAGE_ARCH "x64") +else() + set(BONGO_CAT_PACKAGE_ARCH "x86") +endif() + +if(APPLE) + if(CMAKE_OSX_ARCHITECTURES) + list(LENGTH CMAKE_OSX_ARCHITECTURES BONGO_CAT_OSX_ARCH_COUNT) + if(BONGO_CAT_OSX_ARCH_COUNT GREATER 1) + set(BONGO_CAT_PACKAGE_ARCH "universal") + else() + list(GET CMAKE_OSX_ARCHITECTURES 0 BONGO_CAT_OSX_ARCH) + if(BONGO_CAT_OSX_ARCH MATCHES "^(arm64|aarch64)$") + set(BONGO_CAT_PACKAGE_ARCH "arm64") + endif() + endif() + elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "^(arm64|aarch64|ARM64)$") + set(BONGO_CAT_PACKAGE_ARCH "arm64") + endif() + set(BONGO_CAT_PACKAGE_PLATFORM "macos-${BONGO_CAT_PACKAGE_ARCH}") + set(CPACK_GENERATOR "ZIP") +elseif(WIN32) + set(BONGO_CAT_PACKAGE_PLATFORM "windows-${BONGO_CAT_PACKAGE_ARCH}") + set(CPACK_GENERATOR "ZIP") + set(CPACK_PACKAGE_INSTALL_DIRECTORY "BongoCat") + set(CPACK_PACKAGE_INSTALL_REGISTRY_KEY "BongoCat") + set(CPACK_MONOLITHIC_INSTALL ON) + set(CPACK_COMPONENT_INSTALL OFF) + set(CPACK_PACKAGE_EXECUTABLES "BongoCat" "BongoCat") + set(CPACK_CREATE_DESKTOP_LINKS "BongoCat") + set(CPACK_NSIS_EXECUTABLES_DIRECTORY ".") + set(CPACK_NSIS_INSTALL_ROOT "$LOCALAPPDATA/Programs") + set(CPACK_NSIS_DISPLAY_NAME "BongoCat") + set(CPACK_NSIS_PACKAGE_NAME "BongoCat") + set(CPACK_NSIS_INSTALLED_ICON_NAME "BongoCat.exe") + set(CPACK_NSIS_MUI_ICON + "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/icon.ico") + set(CPACK_NSIS_MUI_UNIICON + "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/icon.ico") + set(CPACK_NSIS_ENABLE_UNINSTALL_BEFORE_INSTALL OFF) + set(CPACK_NSIS_MODIFY_PATH OFF) + set(CPACK_NSIS_MUI_FINISHPAGE_RUN "BongoCat.exe") + set(CPACK_NSIS_HELP_LINK "${PROJECT_HOMEPAGE_URL}") + set(CPACK_NSIS_URL_INFO_ABOUT "${PROJECT_HOMEPAGE_URL}") + set(CPACK_NSIS_MANIFEST_DPI_AWARE ON) +else() + if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(arm64|aarch64|ARM64)$") + set(BONGO_CAT_PACKAGE_ARCH "arm64") + endif() + set(BONGO_CAT_PACKAGE_PLATFORM "linux-${BONGO_CAT_PACKAGE_ARCH}") + set(CPACK_GENERATOR "TGZ") +endif() diff --git a/cmake/PlatformRuntime.cmake b/cmake/PlatformRuntime.cmake index 418537cb..10e84d42 100644 --- a/cmake/PlatformRuntime.cmake +++ b/cmake/PlatformRuntime.cmake @@ -13,6 +13,7 @@ if(WIN32) src/platform/windows/windows_input.c src/platform/windows/windows_popup.c src/platform/windows/windows_layered.c + src/platform/windows/windows_opacity.c src/platform/windows/windows_tray.c src/platform/windows/windows_assets.c src/platform/windows/windows_diagnostics.c diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 9fc6d56f..28365fd3 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -31,6 +31,7 @@ set(BONGO_CAT_RENDER_SOURCES set(BONGO_CAT_RUNTIME_LIFECYCLE_SOURCES src/runtime/lifecycle/app.c src/runtime/lifecycle/app_entry.c + src/runtime/lifecycle/app_input.c src/runtime/lifecycle/assets.c src/runtime/lifecycle/config_store.c src/runtime/lifecycle/frame_clock.c @@ -47,7 +48,9 @@ set(BONGO_CAT_RUNTIME_DIAGNOSTIC_SOURCES src/runtime/diagnostics/frame_audit.c src/runtime/diagnostics/frame_presentation_audit.c src/runtime/diagnostics/live2d_audit.c + src/runtime/diagnostics/live2d_audit_scenario.c src/runtime/diagnostics/live2d_viewer_audit.c + src/runtime/diagnostics/live2d_viewer_timing.c src/runtime/diagnostics/live2d_visual_audit.c) set(BONGO_CAT_RUNTIME_INPUT_SOURCES @@ -61,7 +64,9 @@ set(BONGO_CAT_RUNTIME_MODEL_SOURCES src/runtime/model/model_catalog_runtime.c src/runtime/model/model_catalog_refresh.c src/runtime/model/model_files.c + src/runtime/model/model_geometry.c src/runtime/model/multi_pet.c + src/runtime/model/multi_pet_selection.c src/runtime/model/multi_pet_process.c src/runtime/model/multi_pet_settings.c src/runtime/model/model_storage.c @@ -71,6 +76,7 @@ set(BONGO_CAT_RUNTIME_IMPORT_SOURCES src/runtime/model/import/model_import.c src/runtime/model/import/model_import_adapter.c src/runtime/model/import/model_import_digest.c + src/runtime/model/import/model_import_digest_api.c src/runtime/model/import/model_import_digest_cache.c src/runtime/model/import/model_import_discovery.c src/runtime/model/import/model_import_format.c @@ -141,6 +147,7 @@ set(BONGO_CAT_UI_PREFERENCES_SOURCES src/ui/preferences/preferences.c src/ui/preferences/preferences_about.c src/ui/preferences/preferences_about_community.c + src/ui/preferences/preferences_about_page.c src/ui/preferences/preferences_assets.c src/ui/preferences/preferences_behavior_dialog.c src/ui/preferences/preferences_behavior_rename.c @@ -163,6 +170,7 @@ set(BONGO_CAT_UI_PREFERENCES_SOURCES src/ui/preferences/preferences_model_rename.c src/ui/preferences/preferences_notice.c src/ui/preferences/preferences_overlay.c + src/ui/preferences/preferences_page_dispatch.c src/ui/preferences/preferences_pages.c src/ui/preferences/preferences_render.c src/ui/preferences/preferences_scale.c @@ -174,6 +182,7 @@ set(BONGO_CAT_UI_PREFERENCES_SOURCES src/ui/preferences/preferences_text_session.c src/ui/preferences/preferences_theme.c src/ui/preferences/preferences_toggle.c + src/ui/preferences/preferences_widget_shortcut.c src/ui/preferences/preferences_widgets.c src/ui/preferences/preferences_widgets_compound.c) diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index 1021914f..65548a29 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -2,6 +2,7 @@ if(BUILD_TESTING) add_executable(bongo_cat_core_tests tests/core/test_main.c tests/core/test_config.c + tests/core/test_config_validation.c tests/core/test_language.c tests/core/test_input.c tests/core/test_models.c @@ -45,6 +46,7 @@ if(BUILD_TESTING) set(BONGO_CAT_MVER_IMPORT_TEST_SOURCES tests/model_import/test_mver_import.c + tests/model_import/test_mver_container.c tests/model_import/test_mver_missing_motion.c tests/model_import/test_mver_pointer_import.c tests/ui/test_preferences_text.c diff --git a/cmake/ValidationTools.cmake b/cmake/ValidationTools.cmake index 6d7715a6..9205c5d6 100644 --- a/cmake/ValidationTools.cmake +++ b/cmake/ValidationTools.cmake @@ -5,7 +5,8 @@ if(WIN32) target_link_libraries(bongo_cat_tool_support PUBLIC bongo_cat_warnings) add_executable(cubism_viewer_desktop_capture EXCLUDE_FROM_ALL - src/tools/cubism_viewer_desktop_capture.c) + src/tools/cubism_viewer_desktop_capture.c + src/tools/cubism_viewer_desktop_capture_dxgi.c) target_compile_definitions(cubism_viewer_desktop_capture PRIVATE _CRT_SECURE_NO_WARNINGS) target_link_libraries(cubism_viewer_desktop_capture PRIVATE @@ -26,7 +27,8 @@ if(WIN32) user32 gdi32 ole32) add_executable(cubism_viewer_blind_test EXCLUDE_FROM_ALL - src/tools/cubism_viewer_blind_test.c) + src/tools/cubism_viewer_blind_test.c + src/tools/cubism_viewer_blind_frames.c) target_compile_definitions(cubism_viewer_blind_test PRIVATE _CRT_SECURE_NO_WARNINGS) target_link_libraries(cubism_viewer_blind_test PRIVATE @@ -40,7 +42,8 @@ if(WIN32) bongo_cat_validation_image bongo_cat_warnings ole32) add_executable(mver_phase_metrics EXCLUDE_FROM_ALL - src/tools/mver_phase_metrics.c) + src/tools/mver_phase_metrics.c + src/tools/mver_phase_frames.c) target_compile_definitions(mver_phase_metrics PRIVATE _CRT_SECURE_NO_WARNINGS) target_link_libraries(mver_phase_metrics PRIVATE diff --git a/src/live2d/cubism_bridge.cpp b/src/live2d/cubism_bridge.cpp index 4966ecb8..57c043fd 100644 --- a/src/live2d/cubism_bridge.cpp +++ b/src/live2d/cubism_bridge.cpp @@ -3,7 +3,7 @@ #if defined(CSM_TARGET_WIN_GL) || defined(CSM_TARGET_LINUX_GL) #include #endif -#include "cubism_model.hpp" +#include "cubism_runtime.hpp" #include #include @@ -116,13 +116,6 @@ void stop_framework() { } // namespace -struct BongoCatLive2D { - bongo_cat::NativeModel *model; - int width = 612; - int height = 354; - bool cover_runtime = false; -}; - static BongoCatLive2D *create_runtime(const char *asset_root, bool cover_runtime, BongoCatError *error) { resource_root = asset_root ? asset_root : ""; @@ -295,6 +288,3 @@ extern "C" bool bongo_cat_live2d_prepare_cover(BongoCatLive2D *runtime) { try { return runtime && runtime->cover_runtime && runtime->model && runtime->model->prepare_cover(); } catch (...) { return false; } } -extern "C" bool bongo_cat_live2d_visual_state(const BongoCatLive2D *runtime, - BongoCatLive2DVisualState *state) { - return runtime && runtime->model && runtime->model->visual_state(state); } diff --git a/src/live2d/cubism_bridge_visual.cpp b/src/live2d/cubism_bridge_visual.cpp new file mode 100644 index 00000000..acc20b43 --- /dev/null +++ b/src/live2d/cubism_bridge_visual.cpp @@ -0,0 +1,6 @@ +#include "cubism_runtime.hpp" + +extern "C" bool bongo_cat_live2d_visual_state( + const BongoCatLive2D *runtime, BongoCatLive2DVisualState *state) { + return runtime && runtime->model && runtime->model->visual_state(state); +} diff --git a/src/live2d/cubism_model_idle.cpp b/src/live2d/cubism_model_idle.cpp new file mode 100644 index 00000000..d8d4d175 --- /dev/null +++ b/src/live2d/cubism_model_idle.cpp @@ -0,0 +1,22 @@ +#include "cubism_model.hpp" + +#include +#include + +namespace bongo_cat { + +void NativeModel::start_idle_motion() { + if (idle_motion_keys_.empty()) return; + constexpr int priority = 1; + if (!_motionManager->ReserveMotion(priority)) return; + int next = std::rand() % (int)idle_motion_keys_.size(); + if (next == last_idle_motion_) + next = (next + 1) % (int)idle_motion_keys_.size(); + last_idle_motion_ = next; + const std::string &key = idle_motion_keys_[(size_t)next]; + auto found = motions_.find(key); + if (found != motions_.end()) + _motionManager->StartMotionPriority(found->second, false, priority); +} + +} // namespace bongo_cat diff --git a/src/live2d/cubism_model_update.cpp b/src/live2d/cubism_model_update.cpp index 70a7a815..37188024 100644 --- a/src/live2d/cubism_model_update.cpp +++ b/src/live2d/cubism_model_update.cpp @@ -10,7 +10,6 @@ #include #include #include -#include namespace bongo_cat { void NativeModel::resize(int width, int height) { @@ -179,19 +178,6 @@ bool NativeModel::parameter(const char *id, float *minimum, float *maximum, floa return true; } -void NativeModel::start_idle_motion() { - if (idle_motion_keys_.empty()) return; - constexpr int priority = 1; - if (!_motionManager->ReserveMotion(priority)) return; - int next = std::rand() % (int)idle_motion_keys_.size(); - if (next == last_idle_motion_) next = (next + 1) % (int)idle_motion_keys_.size(); - last_idle_motion_ = next; - const std::string &key = idle_motion_keys_[(size_t)next]; - auto found = motions_.find(key); - if (found != motions_.end()) - _motionManager->StartMotionPriority(found->second, false, priority); -} - void NativeModel::capture_motion_preview() { if (!_model || motion_preview_active_) return; const int parameter_count = _model->GetParameterCount(); diff --git a/src/live2d/cubism_motion_selection.cpp b/src/live2d/cubism_motion_selection.cpp new file mode 100644 index 00000000..ac93cf0b --- /dev/null +++ b/src/live2d/cubism_motion_selection.cpp @@ -0,0 +1,22 @@ +#include "cubism_model.hpp" + +namespace bongo_cat { + +void NativeModel::select_motion(const std::string &key, bool selected) { + auto owner = motion_toggle_owners_.find(key); + const std::string &canonical = owner == motion_toggle_owners_.end() ? + key : owner->second; + if (!selected) { selected_motion_keys_.erase(canonical); return; } + const std::string &signature = motion_signatures_[canonical]; + for (auto item = selected_motion_keys_.begin(); + item != selected_motion_keys_.end();) { + auto existing = motion_signatures_.find(*item); + if (!signature.empty() && existing != motion_signatures_.end() && + existing->second == signature) + item = selected_motion_keys_.erase(item); + else ++item; + } + selected_motion_keys_.insert(canonical); +} + +} // namespace bongo_cat diff --git a/src/live2d/cubism_motion_state.cpp b/src/live2d/cubism_motion_state.cpp index b8f54a19..d148f544 100644 --- a/src/live2d/cubism_motion_state.cpp +++ b/src/live2d/cubism_motion_state.cpp @@ -281,20 +281,4 @@ bool NativeModel::restore_motion_state(const char *group, int index) { return true; } -void NativeModel::select_motion(const std::string &key, bool selected) { - auto owner = motion_toggle_owners_.find(key); - const std::string &canonical = owner == motion_toggle_owners_.end() ? - key : owner->second; - if (!selected) { selected_motion_keys_.erase(canonical); return; } - const std::string &signature = motion_signatures_[canonical]; - for (auto item = selected_motion_keys_.begin(); - item != selected_motion_keys_.end();) { - auto existing = motion_signatures_.find(*item); - if (!signature.empty() && existing != motion_signatures_.end() && - existing->second == signature) item = selected_motion_keys_.erase(item); - else ++item; - } - selected_motion_keys_.insert(canonical); -} - } // namespace bongo_cat diff --git a/src/live2d/cubism_runtime.hpp b/src/live2d/cubism_runtime.hpp new file mode 100644 index 00000000..d0c277dc --- /dev/null +++ b/src/live2d/cubism_runtime.hpp @@ -0,0 +1,13 @@ +#ifndef BONGO_CAT_CUBISM_RUNTIME_HPP +#define BONGO_CAT_CUBISM_RUNTIME_HPP + +#include "cubism_model.hpp" + +struct BongoCatLive2D { + bongo_cat::NativeModel *model; + int width = 612; + int height = 354; + bool cover_runtime = false; +}; + +#endif diff --git a/src/platform/macos/macos.m b/src/platform/macos/macos.m index fbd5ca62..7a865876 100644 --- a/src/platform/macos/macos.m +++ b/src/platform/macos/macos.m @@ -281,32 +281,6 @@ void bongo_cat_platform_single_instance_end(void) { [instance_observer release]; instance_observer = nil; } } -BongoCatResult bongo_cat_platform_set_autostart(bool enabled, BongoCatError *error) { - @autoreleasepool { - NSString *directory = [NSHomeDirectory() - stringByAppendingPathComponent:@"Library/LaunchAgents"]; - NSString *path = [directory - stringByAppendingPathComponent:@"com.bongocat.desktop.plist"]; - NSFileManager *files = [NSFileManager defaultManager]; - if (!enabled) { - if (![files fileExistsAtPath:path] || [files removeItemAtPath:path error:nil]) - return BONGO_CAT_OK; - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, "Cannot remove macOS launch agent"); - return BONGO_CAT_ERROR_IO; - } - NSString *executable = [[NSBundle mainBundle] executablePath]; - if (!executable || ![files createDirectoryAtPath:directory - withIntermediateDirectories:YES attributes:nil error:nil]) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, "Cannot create macOS launch agent directory"); - return BONGO_CAT_ERROR_IO; - } - NSDictionary *plist = @{ @"Label": @"com.bongocat.desktop", - @"ProgramArguments": @[executable, @"--autostart"], @"RunAtLoad": @YES }; - if ([plist writeToFile:path atomically:YES]) return BONGO_CAT_OK; - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, "Cannot write macOS launch agent"); - return BONGO_CAT_ERROR_IO; - } -} BongoCatMenuAction bongo_cat_platform_context_menu(BongoCatPlatform *platform, const BongoCatMenuLabels *labels) { return bongo_cat_macos_context_menu(platform, labels); diff --git a/src/platform/macos/macos_preferences.m b/src/platform/macos/macos_preferences.m index aaaea5e9..14dfd7b7 100644 --- a/src/platform/macos/macos_preferences.m +++ b/src/platform/macos/macos_preferences.m @@ -5,6 +5,38 @@ #include #include +BongoCatResult bongo_cat_platform_set_autostart(bool enabled, + BongoCatError *error) { + @autoreleasepool { + NSString *directory = [NSHomeDirectory() + stringByAppendingPathComponent:@"Library/LaunchAgents"]; + NSString *path = [directory + stringByAppendingPathComponent:@"com.bongocat.desktop.plist"]; + NSFileManager *files = [NSFileManager defaultManager]; + if (!enabled) { + if (![files fileExistsAtPath:path] || + [files removeItemAtPath:path error:nil]) return BONGO_CAT_OK; + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot remove macOS launch agent"); + return BONGO_CAT_ERROR_IO; + } + NSString *executable = [[NSBundle mainBundle] executablePath]; + if (!executable || ![files createDirectoryAtPath:directory + withIntermediateDirectories:YES attributes:nil error:nil]) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot create macOS launch agent directory"); + return BONGO_CAT_ERROR_IO; + } + NSDictionary *plist = @{ @"Label": @"com.bongocat.desktop", + @"ProgramArguments": @[executable, @"--autostart"], + @"RunAtLoad": @YES }; + if ([plist writeToFile:path atomically:YES]) return BONGO_CAT_OK; + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot write macOS launch agent"); + return BONGO_CAT_ERROR_IO; + } +} + void bongo_cat_platform_configure_preferences_window(SDL_Window *sdl_window) { if (!sdl_window) return; NSWindow *window = (__bridge NSWindow *)SDL_GetPointerProperty( diff --git a/src/platform/windows/windows_layered.c b/src/platform/windows/windows_layered.c index 6ed07604..83b21aed 100644 --- a/src/platform/windows/windows_layered.c +++ b/src/platform/windows/windows_layered.c @@ -193,9 +193,6 @@ bool bongo_cat_platform_set_opacity(BongoCatPlatform *platform, float opacity) { return SDL_SetWindowOpacity(platform->window, platform->window_opacity); } -float bongo_cat_platform_get_opacity(const BongoCatPlatform *platform) { - return platform ? platform->window_opacity : 1.0f; -} bool bongo_cat_platform_frame_alpha(const BongoCatPlatform *platform, int width, int height, int x, int y, uint8_t *alpha) { diff --git a/src/platform/windows/windows_opacity.c b/src/platform/windows/windows_opacity.c new file mode 100644 index 00000000..4ef1077b --- /dev/null +++ b/src/platform/windows/windows_opacity.c @@ -0,0 +1,7 @@ +#include "bongo_cat/platform.h" + +#ifdef _WIN32 +float bongo_cat_platform_get_opacity(const BongoCatPlatform *platform) { + return platform ? platform->window_opacity : 1.0f; +} +#endif diff --git a/src/runtime/diagnostics/live2d_audit.c b/src/runtime/diagnostics/live2d_audit.c index d1267f2a..60fa6bee 100644 --- a/src/runtime/diagnostics/live2d_audit.c +++ b/src/runtime/diagnostics/live2d_audit.c @@ -1,4 +1,5 @@ #include "runtime.h" +#include "live2d_audit_scenario.h" #include "bongo_cat/file.h" #include "bongo_cat/path.h" @@ -6,6 +7,10 @@ #include #include +#define input bongo_cat_live2d_audit_input +#define motion bongo_cat_live2d_audit_motion +#define parameter_override_scenario bongo_cat_live2d_audit_parameter_override + typedef struct PointerAudit { bool ran; bool has_mouse; @@ -28,13 +33,6 @@ static bool value(BongoCatApp *app, const char *id, float *output) { return true; } -static void input(BongoCatApp *app, BongoCatInputKind kind, const char *name, float value) { - BongoCatInputEvent event = {.kind = kind, .value = value}; - snprintf(event.name, sizeof(event.name), "%s", name); - bongo_cat_app_shortcuts(app, &event); - bongo_cat_app_apply_input(app, &event); -} - static bool pointer_test_center(BongoCatApp *app, SDL_Rect *bounds, double *center_x, double *center_y) { int window_x, window_y, width, height; @@ -49,16 +47,6 @@ static bool pointer_test_center(BongoCatApp *app, SDL_Rect *bounds, bounds->w > 0 && bounds->h > 0; } -static bool motion(BongoCatApp *app, const char *scenario) { - if (strcmp(scenario, "motion-0") == 0) - return bongo_cat_live2d_start_motion(app->live2d, "CAT_motion", 0); - if (strcmp(scenario, "motion-1") == 0) - return bongo_cat_live2d_start_motion(app->live2d, "CAT_motion", 1); - if (strncmp(scenario, "expression-", 11) == 0) - return bongo_cat_live2d_set_expression(app->live2d, atoi(scenario + 11)); - return true; -} - static bool pointer(BongoCatApp *app, bool mirror) { SDL_Rect bounds; double center_x, center_y; @@ -187,14 +175,6 @@ static bool inactive(BongoCatApp *app, const char *id) { return value(app, id, ¤t) && current <= 0.5f; } -static bool parameter_override_scenario(const char *scenario) { - return strncmp(scenario, "key-", 4) == 0 || - strncmp(scenario, "keys-", 5) == 0 || - strncmp(scenario, "gamepad-", 8) == 0 || - strcmp(scenario, "mouse-left") == 0 || - strcmp(scenario, "mouse-right") == 0; -} - static bool signed_value(BongoCatApp *app, const char *id, bool positive) { float current = 0.0f; return value(app, id, ¤t) && (positive ? current > 0.05f : current < -0.05f); diff --git a/src/runtime/diagnostics/live2d_audit_scenario.c b/src/runtime/diagnostics/live2d_audit_scenario.c new file mode 100644 index 00000000..15521394 --- /dev/null +++ b/src/runtime/diagnostics/live2d_audit_scenario.c @@ -0,0 +1,32 @@ +#include "live2d_audit_scenario.h" + +#include +#include +#include + +void bongo_cat_live2d_audit_input(BongoCatApp *app, BongoCatInputKind kind, + const char *name, float value) { + BongoCatInputEvent event = {.kind = kind, .value = value}; + snprintf(event.name, sizeof(event.name), "%s", name); + bongo_cat_app_shortcuts(app, &event); + bongo_cat_app_apply_input(app, &event); +} + +bool bongo_cat_live2d_audit_motion(BongoCatApp *app, const char *scenario) { + if (strcmp(scenario, "motion-0") == 0) + return bongo_cat_live2d_start_motion(app->live2d, "CAT_motion", 0); + if (strcmp(scenario, "motion-1") == 0) + return bongo_cat_live2d_start_motion(app->live2d, "CAT_motion", 1); + if (strncmp(scenario, "expression-", 11) == 0) + return bongo_cat_live2d_set_expression(app->live2d, + atoi(scenario + 11)); + return true; +} + +bool bongo_cat_live2d_audit_parameter_override(const char *scenario) { + return strncmp(scenario, "key-", 4) == 0 || + strncmp(scenario, "keys-", 5) == 0 || + strncmp(scenario, "gamepad-", 8) == 0 || + strcmp(scenario, "mouse-left") == 0 || + strcmp(scenario, "mouse-right") == 0; +} diff --git a/src/runtime/diagnostics/live2d_audit_scenario.h b/src/runtime/diagnostics/live2d_audit_scenario.h new file mode 100644 index 00000000..5fcee1f9 --- /dev/null +++ b/src/runtime/diagnostics/live2d_audit_scenario.h @@ -0,0 +1,11 @@ +#ifndef BONGO_CAT_LIVE2D_AUDIT_SCENARIO_H +#define BONGO_CAT_LIVE2D_AUDIT_SCENARIO_H + +#include "runtime.h" + +void bongo_cat_live2d_audit_input(BongoCatApp *app, BongoCatInputKind kind, + const char *name, float value); +bool bongo_cat_live2d_audit_motion(BongoCatApp *app, const char *scenario); +bool bongo_cat_live2d_audit_parameter_override(const char *scenario); + +#endif diff --git a/src/runtime/diagnostics/live2d_viewer_audit.c b/src/runtime/diagnostics/live2d_viewer_audit.c index fe1439bc..6e531c81 100644 --- a/src/runtime/diagnostics/live2d_viewer_audit.c +++ b/src/runtime/diagnostics/live2d_viewer_audit.c @@ -1,11 +1,11 @@ #include "runtime.h" +#include "live2d_viewer_timing.h" #include "bongo_cat/file.h" #include "bongo_cat/path.h" #include #include #include -#include #include static const char *metric_parameters[] = { @@ -23,39 +23,6 @@ static const char *track_names[] = {"track-001", "track-002", "track-004", "track-008", "track-015", "track-030"}; static const char *return_names[] = {"return-001", "return-002", "return-004", "return-008", "return-015", "return-030"}; -static bool viewer_timing(const char *path, int *track, int *returning) { - if (!path || !path[0]) return true; - FILE *file = bongo_cat_file_open(path, "rb"); - if (!file) return false; - bool found_track[6] = {0}, found_return[6] = {0}; - char line[512], name[32]; - while (fgets(line, sizeof(line), file)) { - char *first = strchr(line, ','); - size_t name_length = first ? (size_t)(first - line) : 0; - if (!first || !name_length || name_length >= sizeof(name)) continue; - memcpy(name, line, name_length); name[name_length] = '\0'; - char *after_phase = NULL, *after_mid = NULL; - (void)strtod(first + 1, &after_phase); - if (after_phase == first + 1 || *after_phase != ',') continue; - double capture_mid_ms = strtod(after_phase + 1, &after_mid); - if (after_mid == after_phase + 1) continue; - int frame = (int)lround(capture_mid_ms * 60.0 / 1000.0); - if (frame < 1) frame = 1; - for (size_t i = 0; i < 6; ++i) { - if (strcmp(name, track_names[i]) == 0) { - track[i] = frame; found_track[i] = true; - } else if (strcmp(name, return_names[i]) == 0) { - returning[i] = frame; found_return[i] = true; - } - } - } - fclose(file); - for (size_t i = 0; i < 6; ++i) - if (!found_track[i] || !found_return[i] || - (i && (track[i] < track[i - 1] || - returning[i] < returning[i - 1]))) return false; - return true; -} static bool parameter(BongoCatApp *app, const char *id, float *value) { BongoCatParameterRange range; if (!bongo_cat_live2d_parameter(app->live2d, id, &range)) return false; @@ -247,7 +214,8 @@ bool bongo_cat_live2d_viewer_audit_run(BongoCatApp *app) { float previous_x = 0.0f, maximum_step = 0.0f; int track_frames[] = {1, 2, 4, 8, 15, 30}; int return_frames[] = {1, 2, 4, 8, 15, 30}; - passed = viewer_timing(app->smoke_viewer_trace, track_frames, + passed = bongo_cat_live2d_viewer_timing(app->smoke_viewer_trace, + track_frames, return_frames) && passed; passed = parameter(app, "ParamAngleX", &previous_x) && passed; int previous = 0; diff --git a/src/runtime/diagnostics/live2d_viewer_timing.c b/src/runtime/diagnostics/live2d_viewer_timing.c new file mode 100644 index 00000000..29cffcf9 --- /dev/null +++ b/src/runtime/diagnostics/live2d_viewer_timing.c @@ -0,0 +1,46 @@ +#include "live2d_viewer_timing.h" +#include "bongo_cat/file.h" + +#include +#include +#include +#include + +bool bongo_cat_live2d_viewer_timing(const char *path, int *track, + int *returning) { + static const char *track_names[] = {"track-001", "track-002", + "track-004", "track-008", "track-015", "track-030"}; + static const char *return_names[] = {"return-001", "return-002", + "return-004", "return-008", "return-015", "return-030"}; + if (!path || !path[0]) return true; + FILE *file = bongo_cat_file_open(path, "rb"); + if (!file) return false; + bool found_track[6] = {0}, found_return[6] = {0}; + char line[512], name[32]; + while (fgets(line, sizeof(line), file)) { + char *first = strchr(line, ','); + size_t name_length = first ? (size_t)(first - line) : 0; + if (!first || !name_length || name_length >= sizeof(name)) continue; + memcpy(name, line, name_length); name[name_length] = '\0'; + char *after_phase = NULL, *after_mid = NULL; + (void)strtod(first + 1, &after_phase); + if (after_phase == first + 1 || *after_phase != ',') continue; + double capture_mid_ms = strtod(after_phase + 1, &after_mid); + if (after_mid == after_phase + 1) continue; + int frame = (int)lround(capture_mid_ms * 60.0 / 1000.0); + if (frame < 1) frame = 1; + for (size_t i = 0; i < 6; ++i) { + if (strcmp(name, track_names[i]) == 0) { + track[i] = frame; found_track[i] = true; + } else if (strcmp(name, return_names[i]) == 0) { + returning[i] = frame; found_return[i] = true; + } + } + } + fclose(file); + for (size_t i = 0; i < 6; ++i) + if (!found_track[i] || !found_return[i] || + (i && (track[i] < track[i - 1] || + returning[i] < returning[i - 1]))) return false; + return true; +} diff --git a/src/runtime/diagnostics/live2d_viewer_timing.h b/src/runtime/diagnostics/live2d_viewer_timing.h new file mode 100644 index 00000000..e1737070 --- /dev/null +++ b/src/runtime/diagnostics/live2d_viewer_timing.h @@ -0,0 +1,9 @@ +#ifndef BONGO_CAT_LIVE2D_VIEWER_TIMING_H +#define BONGO_CAT_LIVE2D_VIEWER_TIMING_H + +#include + +bool bongo_cat_live2d_viewer_timing(const char *path, int *track, + int *returning); + +#endif diff --git a/src/runtime/lifecycle/app.c b/src/runtime/lifecycle/app.c index 9887daca..46bce43a 100644 --- a/src/runtime/lifecycle/app.c +++ b/src/runtime/lifecycle/app.c @@ -160,33 +160,6 @@ static void handle_event(BongoCatApp *app, const SDL_Event *event) { if (event->type >= SDL_EVENT_GAMEPAD_AXIS_MOTION && event->type <= SDL_EVENT_GAMEPAD_TOUCHPAD_UP) bongo_cat_gamepad_event(app, event); } -void bongo_cat_app_drain_input(BongoCatApp *app, bool allow_shortcuts) { - if (app && app->secondary_pet) allow_shortcuts = false; - BongoCatInputEvent event; - while (bongo_cat_input_pop(&app->input, &event)) { - if (app->smoke_ignore_global_input) continue; - if (app->smoke_input_audit) { - char path[BONGO_CAT_PATH_CAP]; - bongo_cat_path_join(path, sizeof(path), app->state_root, "input-audit.txt"); - FILE *file = bongo_cat_file_open(path, "ab"); - if (file) { fprintf(file, "kind=%d name=%s value=%.3f\n", - event.kind, event.name, event.value); fclose(file); } - } - if (strcmp(event.name, "CapsLock") == 0) - bongo_cat_input_schedule_release(&app->input, &event, 100); - if (allow_shortcuts && !bongo_cat_preferences_shortcuts_blocked(app->preferences)) - bongo_cat_app_shortcuts(app, &event); - bongo_cat_app_apply_input(app, &event); - } - uint64_t now = SDL_GetTicks(); - while (bongo_cat_input_take_scheduled_release( - &app->input, now, &event)) { - if (allow_shortcuts && !bongo_cat_preferences_shortcuts_blocked(app->preferences)) - bongo_cat_app_shortcuts(app, &event); - bongo_cat_app_apply_input(app, &event); - } - if (!app->smoke_ignore_global_input) bongo_cat_app_apply_mouse(app); -} static void update_model(BongoCatApp *app, uint64_t now) { float elapsed = (float)((now - app->last_frame_ns) / 1000000000.0); if (elapsed > 0.25f) elapsed = 0.25f; diff --git a/src/runtime/lifecycle/app_input.c b/src/runtime/lifecycle/app_input.c new file mode 100644 index 00000000..1a4d020f --- /dev/null +++ b/src/runtime/lifecycle/app_input.c @@ -0,0 +1,40 @@ +#include "runtime.h" +#include "bongo_cat/file.h" +#include "bongo_cat/path.h" +#include "bongo_cat/preferences.h" + +#include +#include + +void bongo_cat_app_drain_input(BongoCatApp *app, bool allow_shortcuts) { + if (app && app->secondary_pet) allow_shortcuts = false; + BongoCatInputEvent event; + while (bongo_cat_input_pop(&app->input, &event)) { + if (app->smoke_ignore_global_input) continue; + if (app->smoke_input_audit) { + char path[BONGO_CAT_PATH_CAP]; + bongo_cat_path_join(path, sizeof(path), app->state_root, + "input-audit.txt"); + FILE *file = bongo_cat_file_open(path, "ab"); + if (file) { + fprintf(file, "kind=%d name=%s value=%.3f\n", + event.kind, event.name, event.value); + fclose(file); + } + } + if (strcmp(event.name, "CapsLock") == 0) + bongo_cat_input_schedule_release(&app->input, &event, 100); + if (allow_shortcuts && + !bongo_cat_preferences_shortcuts_blocked(app->preferences)) + bongo_cat_app_shortcuts(app, &event); + bongo_cat_app_apply_input(app, &event); + } + uint64_t now = SDL_GetTicks(); + while (bongo_cat_input_take_scheduled_release(&app->input, now, &event)) { + if (allow_shortcuts && + !bongo_cat_preferences_shortcuts_blocked(app->preferences)) + bongo_cat_app_shortcuts(app, &event); + bongo_cat_app_apply_input(app, &event); + } + if (!app->smoke_ignore_global_input) bongo_cat_app_apply_mouse(app); +} diff --git a/src/runtime/model/import/model_import_digest.c b/src/runtime/model/import/model_import_digest.c index efdcac9a..8f67fb81 100644 --- a/src/runtime/model/import/model_import_digest.c +++ b/src/runtime/model/import/model_import_digest.c @@ -287,14 +287,3 @@ bool bongo_cat_import_candidate_inspect_cached( *placeholder = candidate_placeholder(candidate, &content, &overrides); return true; } - -bool bongo_cat_import_candidate_inspect(const BongoCatImportCandidate *candidate, - char output[65], bool *placeholder, BongoCatError *error) { - return bongo_cat_import_candidate_inspect_cached(candidate, output, - placeholder, NULL, error); -} - -bool bongo_cat_import_candidate_digest(const BongoCatImportCandidate *candidate, - char output[65], BongoCatError *error) { - return bongo_cat_import_candidate_inspect(candidate, output, NULL, error); -} diff --git a/src/runtime/model/import/model_import_digest_api.c b/src/runtime/model/import/model_import_digest_api.c new file mode 100644 index 00000000..e921080b --- /dev/null +++ b/src/runtime/model/import/model_import_digest_api.c @@ -0,0 +1,12 @@ +#include "model_import.h" + +bool bongo_cat_import_candidate_inspect(const BongoCatImportCandidate *candidate, + char output[65], bool *placeholder, BongoCatError *error) { + return bongo_cat_import_candidate_inspect_cached(candidate, output, + placeholder, NULL, error); +} + +bool bongo_cat_import_candidate_digest(const BongoCatImportCandidate *candidate, + char output[65], BongoCatError *error) { + return bongo_cat_import_candidate_inspect(candidate, output, NULL, error); +} diff --git a/src/runtime/model/model_files.c b/src/runtime/model/model_files.c index e13ae059..09f50e13 100644 --- a/src/runtime/model/model_files.c +++ b/src/runtime/model/model_files.c @@ -1,5 +1,6 @@ #include "runtime.h" #include "model_import.h" +#include "model_geometry.h" #include "bongo_cat/audio.h" #include "bongo_cat/overlay.h" #include "bongo_cat/preferences.h" @@ -115,82 +116,6 @@ static void model_load_progress(void *userdata, float progress) { bongo_cat_app_render_now(app); } -typedef struct ModelContentAnchor { - int x, y; - bool valid; -} ModelContentAnchor; - -static ModelContentAnchor capture_model_content_anchor(BongoCatApp *app) { - ModelContentAnchor anchor = {0}; - int window_x = 0, window_y = 0; - if (!app || !app->window || - !SDL_GetWindowPosition(app->window, &window_x, &window_y)) return anchor; - int left = 0, top = 0, ignored_width = 0, ignored_height = 0; - int content_width = app->session.window.content_width > 0 - ? app->session.window.content_width : app->session.window.width; - int content_height = app->session.window.content_height > 0 - ? app->session.window.content_height : app->session.window.height; - if (!bongo_cat_window_frame_size(app, content_width, content_height, - &ignored_width, &ignored_height, &left, &top)) return anchor; - anchor.x = window_x + left + content_width / 2; - anchor.y = window_y + top + content_height / 2; - anchor.valid = true; - return anchor; -} - -static bool apply_model_aspect(BongoCatApp *app, - const BongoCatLive2DRenderOptions *options, - const ModelContentAnchor *anchor, bool replacing_model) { - if (!app || !app->window) return false; - int reference_width = 612; - int reference_height = 354; - if (options && options->mver_projection) { - reference_width = options->reference_width; - reference_height = options->reference_height; - } else { - int canvas_width = 0, canvas_height = 0; - if (bongo_cat_live2d_canvas_size(app->live2d, - &canvas_width, &canvas_height)) { - reference_width = canvas_width; - reference_height = canvas_height; - } - } - int x, y, width, height; - if (reference_width <= 0 || reference_height <= 0 || - !SDL_GetWindowPosition(app->window, &x, &y) || - !SDL_GetWindowSize(app->window, &width, &height)) - return false; - int content_height = app->session.window.content_height > 0 - ? app->session.window.content_height : height; - int content_width = (int)((double)content_height * reference_width / - reference_height + 0.5); - if (content_width < 64) content_width = 64; - if (content_width > 8192) content_width = 8192; - if (content_height < 64) content_height = 64; - if (content_height > 8192) content_height = 8192; - int next_width = 0, next_height = 0, left = 0, top = 0; - if (!bongo_cat_window_frame_size(app, content_width, content_height, - &next_width, &next_height, &left, &top)) return false; - bool restored_frame = !replacing_model && - SDL_abs(width - next_width) <= 1 && SDL_abs(height - next_height) <= 1; - int next_x = x, next_y = y; - if (anchor && anchor->valid && !restored_frame) { - next_x = anchor->x - left - content_width / 2; - next_y = anchor->y - top - content_height / 2; - } - app->session.window.content_width = content_width; - app->session.window.content_height = content_height; - if (next_x == x && next_y == y && next_width == width && - next_height == height) return false; - bool changed = bongo_cat_window_apply_geometry(app, next_x, next_y, - app->session.window.scale_percent, next_width, next_height); - if (changed) { - app->session.window.content_width = content_width; - app->session.window.content_height = content_height; - } - return changed; -} - static void commit_model(BongoCatApp *app, const BongoCatModelEntry *entry, bool force_refresh, bool reveal) { bool changed = strcmp(app->session.active_model_id, entry->id) != 0 || @@ -246,7 +171,8 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, "force_mouse=%d left_handed=%d pointer_bounds=%d", render_options.projection_scale, render_options.mouse_force_move, render_options.pointer_left_handed, render_options.custom_pointer_bounds); - ModelContentAnchor content_anchor = capture_model_content_anchor(app); + BongoCatModelContentAnchor content_anchor = + bongo_cat_model_content_anchor(app); int pixel_width = app->session.window.width, pixel_height = app->session.window.height; if (app->window) SDL_GetWindowSizeInPixels(app->window, &pixel_width, &pixel_height); SDL_Window *previous_window = SDL_GL_GetCurrentWindow(); @@ -316,7 +242,7 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, bongo_cat_app_restore_behavior_state(app, entry->id); bongo_cat_random_expression_reset(app); app->pointer_known = false; - bool geometry_changed = apply_model_aspect(app, &render_options, + bool geometry_changed = bongo_cat_model_apply_aspect(app, &render_options, &content_anchor, replacing_model); if (app->window) { if (geometry_changed) SDL_SyncWindow(app->window); diff --git a/src/runtime/model/model_geometry.c b/src/runtime/model/model_geometry.c new file mode 100644 index 00000000..c45247b4 --- /dev/null +++ b/src/runtime/model/model_geometry.c @@ -0,0 +1,73 @@ +#include "model_geometry.h" + +#include + +BongoCatModelContentAnchor bongo_cat_model_content_anchor(BongoCatApp *app) { + BongoCatModelContentAnchor anchor = {0}; + int window_x = 0, window_y = 0; + if (!app || !app->window || + !SDL_GetWindowPosition(app->window, &window_x, &window_y)) return anchor; + int left = 0, top = 0, ignored_width = 0, ignored_height = 0; + int content_width = app->session.window.content_width > 0 + ? app->session.window.content_width : app->session.window.width; + int content_height = app->session.window.content_height > 0 + ? app->session.window.content_height : app->session.window.height; + if (!bongo_cat_window_frame_size(app, content_width, content_height, + &ignored_width, &ignored_height, &left, &top)) return anchor; + anchor.x = window_x + left + content_width / 2; + anchor.y = window_y + top + content_height / 2; + anchor.valid = true; + return anchor; +} + +bool bongo_cat_model_apply_aspect(BongoCatApp *app, + const BongoCatLive2DRenderOptions *options, + const BongoCatModelContentAnchor *anchor, bool replacing_model) { + if (!app || !app->window) return false; + int reference_width = 612; + int reference_height = 354; + if (options && options->mver_projection) { + reference_width = options->reference_width; + reference_height = options->reference_height; + } else { + int canvas_width = 0, canvas_height = 0; + if (bongo_cat_live2d_canvas_size(app->live2d, + &canvas_width, &canvas_height)) { + reference_width = canvas_width; + reference_height = canvas_height; + } + } + int x, y, width, height; + if (reference_width <= 0 || reference_height <= 0 || + !SDL_GetWindowPosition(app->window, &x, &y) || + !SDL_GetWindowSize(app->window, &width, &height)) return false; + int content_height = app->session.window.content_height > 0 + ? app->session.window.content_height : height; + int content_width = (int)((double)content_height * reference_width / + reference_height + 0.5); + if (content_width < 64) content_width = 64; + if (content_width > 8192) content_width = 8192; + if (content_height < 64) content_height = 64; + if (content_height > 8192) content_height = 8192; + int next_width = 0, next_height = 0, left = 0, top = 0; + if (!bongo_cat_window_frame_size(app, content_width, content_height, + &next_width, &next_height, &left, &top)) return false; + bool restored_frame = !replacing_model && + SDL_abs(width - next_width) <= 1 && SDL_abs(height - next_height) <= 1; + int next_x = x, next_y = y; + if (anchor && anchor->valid && !restored_frame) { + next_x = anchor->x - left - content_width / 2; + next_y = anchor->y - top - content_height / 2; + } + app->session.window.content_width = content_width; + app->session.window.content_height = content_height; + if (next_x == x && next_y == y && next_width == width && + next_height == height) return false; + bool changed = bongo_cat_window_apply_geometry(app, next_x, next_y, + app->session.window.scale_percent, next_width, next_height); + if (changed) { + app->session.window.content_width = content_width; + app->session.window.content_height = content_height; + } + return changed; +} diff --git a/src/runtime/model/model_geometry.h b/src/runtime/model/model_geometry.h new file mode 100644 index 00000000..1616c46d --- /dev/null +++ b/src/runtime/model/model_geometry.h @@ -0,0 +1,16 @@ +#ifndef BONGO_CAT_MODEL_GEOMETRY_H +#define BONGO_CAT_MODEL_GEOMETRY_H + +#include "runtime.h" + +typedef struct BongoCatModelContentAnchor { + int x, y; + bool valid; +} BongoCatModelContentAnchor; + +BongoCatModelContentAnchor bongo_cat_model_content_anchor(BongoCatApp *app); +bool bongo_cat_model_apply_aspect(BongoCatApp *app, + const BongoCatLive2DRenderOptions *options, + const BongoCatModelContentAnchor *anchor, bool replacing_model); + +#endif diff --git a/src/runtime/model/multi_pet.c b/src/runtime/model/multi_pet.c index 64888f25..0c1bfe1e 100644 --- a/src/runtime/model/multi_pet.c +++ b/src/runtime/model/multi_pet.c @@ -222,18 +222,6 @@ void bongo_cat_multi_pet_update(BongoCatApp *app, uint64_t now) { else bongo_cat_multi_pet_primary_update(app, now); } -bool bongo_cat_app_model_active(const BongoCatApp *app, const char *id) { - if (!app || !id) return false; - if (!strcmp(app->session.active_model_id, id)) return true; - return app->settings.model.multiple_pets && - bongo_cat_session_model_active(&app->session, id); -} - -size_t bongo_cat_app_active_model_count(const BongoCatApp *app) { - return app && app->settings.model.multiple_pets - ? 1 + app->session.additional_model_count : app ? 1 : 0; -} - static bool load_pet_window(const BongoCatApp *app, const char *model_id, BongoCatWindowState *window) { char directory[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; diff --git a/src/runtime/model/multi_pet_selection.c b/src/runtime/model/multi_pet_selection.c new file mode 100644 index 00000000..89e68a17 --- /dev/null +++ b/src/runtime/model/multi_pet_selection.c @@ -0,0 +1,15 @@ +#include "runtime.h" + +#include + +bool bongo_cat_app_model_active(const BongoCatApp *app, const char *id) { + if (!app || !id) return false; + if (!strcmp(app->session.active_model_id, id)) return true; + return app->settings.model.multiple_pets && + bongo_cat_session_model_active(&app->session, id); +} + +size_t bongo_cat_app_active_model_count(const BongoCatApp *app) { + return app && app->settings.model.multiple_pets + ? 1 + app->session.additional_model_count : app ? 1 : 0; +} diff --git a/src/tools/cubism_viewer_blind_frames.c b/src/tools/cubism_viewer_blind_frames.c new file mode 100644 index 00000000..eed2fb66 --- /dev/null +++ b/src/tools/cubism_viewer_blind_frames.c @@ -0,0 +1,114 @@ +#define WIN32_LEAN_AND_MEAN +#include + +#include "cubism_viewer_blind_frames.h" + +#include +#include + +wchar_t *blind_join_path(const wchar_t *directory, const wchar_t *name) { + size_t a = wcslen(directory), b = wcslen(name); + wchar_t *result = (wchar_t *)malloc((a + b + 2) * sizeof(*result)); + if (!result) return NULL; + memcpy(result, directory, a * sizeof(*result)); + result[a] = L'\\'; + memcpy(result + a + 1, name, (b + 1) * sizeof(*result)); + return result; +} + +wchar_t *blind_join_suffix(const wchar_t *directory, const wchar_t *name, + const wchar_t *suffix) { + size_t a = wcslen(directory), b = wcslen(name), c = wcslen(suffix); + wchar_t *result = (wchar_t *)malloc((a + b + c + 3) * sizeof(*result)); + if (!result) return NULL; + swprintf(result, a + b + c + 3, L"%ls\\%ls%ls", directory, name, suffix); + return result; +} + +static int reserve_frames(FrameList *list) { + size_t capacity = list->capacity ? list->capacity * 2 : 32; + Frame *items; + if (capacity < list->capacity || capacity > SIZE_MAX / sizeof(*items)) return 0; + items = (Frame *)realloc(list->items, capacity * sizeof(*items)); + if (!items) return 0; + list->items = items; list->capacity = capacity; + return 1; +} + +void blind_free_frames(FrameList *list) { + size_t index; + for (index = 0; index < list->count; ++index) { + free(list->items[index].name); + free(list->items[index].viewer_path); + free(list->items[index].native_path); + bongo_cat_validation_image_free(&list->items[index].viewer); + bongo_cat_validation_image_free(&list->items[index].native); + } + free(list->items); + memset(list, 0, sizeof(*list)); +} + +static wchar_t *without_extension(const wchar_t *name) { + const wchar_t *dot = wcsrchr(name, L'.'); + size_t length = dot ? (size_t)(dot - name) : wcslen(name); + wchar_t *result = (wchar_t *)malloc((length + 1) * sizeof(*result)); + if (result) { memcpy(result, name, length * sizeof(*result)); result[length] = L'\0'; } + return result; +} + +static wchar_t *find_native(const wchar_t *directory, const wchar_t *name) { + static const wchar_t *extensions[] = {L".bmp", L".png"}; + size_t index; + for (index = 0; index < sizeof(extensions) / sizeof(extensions[0]); ++index) { + wchar_t *path = blind_join_suffix(directory, name, extensions[index]); + if (!path) return NULL; + if (_waccess(path, 0) == 0) return path; + free(path); + } + return NULL; +} + +static int compare_frames(const void *left, const void *right) { + const Frame *a = (const Frame *)left, *b = (const Frame *)right; + return wcscmp(a->name, b->name); +} + +int blind_collect_frames(const wchar_t *viewer_directory, + const wchar_t *native_directory, FrameList *list) { + WIN32_FIND_DATAW entry; + HANDLE find; + wchar_t *pattern; + static const wchar_t *extensions[] = {L"*.png", L"*.bmp"}; + size_t extension; + for (extension = 0; extension < sizeof(extensions) / sizeof(extensions[0]); ++extension) { + pattern = blind_join_path(viewer_directory, extensions[extension]); + if (!pattern) return 0; + find = FindFirstFileW(pattern, &entry); + free(pattern); + if (find == INVALID_HANDLE_VALUE) continue; + do { + wchar_t *name, *viewer_path, *native_path; + Frame *frame; + if (entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) continue; + name = without_extension(entry.cFileName); + viewer_path = blind_join_path(viewer_directory, entry.cFileName); + native_path = name ? find_native(native_directory, name) : NULL; + if (!name || !viewer_path) { + free(name); free(viewer_path); free(native_path); + FindClose(find); return 0; + } + if (!native_path) { free(name); free(viewer_path); continue; } + if (list->count == list->capacity && !reserve_frames(list)) { + free(name); free(viewer_path); free(native_path); + FindClose(find); return 0; + } + frame = &list->items[list->count++]; + memset(frame, 0, sizeof(*frame)); + frame->name = name; frame->viewer_path = viewer_path; + frame->native_path = native_path; + } while (FindNextFileW(find, &entry)); + FindClose(find); + } + qsort(list->items, list->count, sizeof(*list->items), compare_frames); + return list->count != 0; +} diff --git a/src/tools/cubism_viewer_blind_frames.h b/src/tools/cubism_viewer_blind_frames.h new file mode 100644 index 00000000..c83ffb09 --- /dev/null +++ b/src/tools/cubism_viewer_blind_frames.h @@ -0,0 +1,30 @@ +#ifndef BONGO_CAT_CUBISM_VIEWER_BLIND_FRAMES_H +#define BONGO_CAT_CUBISM_VIEWER_BLIND_FRAMES_H + +#include "validation_image.h" + +#include +#include + +typedef struct Frame { + wchar_t *name; + wchar_t *viewer_path; + wchar_t *native_path; + BongoCatValidationImage viewer; + BongoCatValidationImage native; +} Frame; + +typedef struct FrameList { + Frame *items; + size_t count; + size_t capacity; +} FrameList; + +wchar_t *blind_join_path(const wchar_t *directory, const wchar_t *name); +wchar_t *blind_join_suffix(const wchar_t *directory, const wchar_t *name, + const wchar_t *suffix); +void blind_free_frames(FrameList *list); +int blind_collect_frames(const wchar_t *viewer_directory, + const wchar_t *native_directory, FrameList *list); + +#endif diff --git a/src/tools/cubism_viewer_blind_test.c b/src/tools/cubism_viewer_blind_test.c index 7c2e8d04..f9d23df9 100644 --- a/src/tools/cubism_viewer_blind_test.c +++ b/src/tools/cubism_viewer_blind_test.c @@ -2,10 +2,10 @@ #include #include +#include "cubism_viewer_blind_frames.h" #include "validation_image.h" #include "windows_tool.h" -#include #include #include #include @@ -16,20 +16,6 @@ enum { CANVAS = 800, MARGIN = 40 }; -typedef struct Frame { - wchar_t *name; - wchar_t *viewer_path; - wchar_t *native_path; - BongoCatValidationImage viewer; - BongoCatValidationImage native; -} Frame; - -typedef struct FrameList { - Frame *items; - size_t count; - size_t capacity; -} FrameList; - typedef struct Region { const char *name; int left, top, right, bottom; @@ -41,113 +27,6 @@ typedef struct DotNetRandom { int inextp; } DotNetRandom; -static wchar_t *join_path(const wchar_t *directory, const wchar_t *name) { - size_t a = wcslen(directory), b = wcslen(name); - wchar_t *result = (wchar_t *)malloc((a + b + 2) * sizeof(*result)); - if (!result) return NULL; - memcpy(result, directory, a * sizeof(*result)); - result[a] = L'\\'; - memcpy(result + a + 1, name, (b + 1) * sizeof(*result)); - return result; -} - -static wchar_t *join_suffix(const wchar_t *directory, const wchar_t *name, - const wchar_t *suffix) { - size_t a = wcslen(directory), b = wcslen(name), c = wcslen(suffix); - wchar_t *result = (wchar_t *)malloc((a + b + c + 3) * sizeof(*result)); - if (!result) return NULL; - swprintf(result, a + b + c + 3, L"%ls\\%ls%ls", directory, name, suffix); - return result; -} - -static int reserve_frames(FrameList *list) { - size_t capacity = list->capacity ? list->capacity * 2 : 32; - Frame *items; - if (capacity < list->capacity || capacity > SIZE_MAX / sizeof(*items)) return 0; - items = (Frame *)realloc(list->items, capacity * sizeof(*items)); - if (!items) return 0; - list->items = items; list->capacity = capacity; - return 1; -} - -static void free_frames(FrameList *list) { - size_t index; - for (index = 0; index < list->count; ++index) { - free(list->items[index].name); - free(list->items[index].viewer_path); - free(list->items[index].native_path); - bongo_cat_validation_image_free(&list->items[index].viewer); - bongo_cat_validation_image_free(&list->items[index].native); - } - free(list->items); - memset(list, 0, sizeof(*list)); -} - -static wchar_t *without_extension(const wchar_t *name) { - const wchar_t *dot = wcsrchr(name, L'.'); - size_t length = dot ? (size_t)(dot - name) : wcslen(name); - wchar_t *result = (wchar_t *)malloc((length + 1) * sizeof(*result)); - if (result) { memcpy(result, name, length * sizeof(*result)); result[length] = L'\0'; } - return result; -} - -static wchar_t *find_native(const wchar_t *directory, const wchar_t *name) { - static const wchar_t *extensions[] = {L".bmp", L".png"}; - size_t index; - for (index = 0; index < sizeof(extensions) / sizeof(extensions[0]); ++index) { - wchar_t *path = join_suffix(directory, name, extensions[index]); - if (!path) return NULL; - if (_waccess(path, 0) == 0) return path; - free(path); - } - return NULL; -} - -static int compare_frames(const void *left, const void *right) { - const Frame *a = (const Frame *)left, *b = (const Frame *)right; - return wcscmp(a->name, b->name); -} - -static int collect_frames(const wchar_t *viewer_directory, - const wchar_t *native_directory, FrameList *list) { - WIN32_FIND_DATAW entry; - HANDLE find; - wchar_t *pattern; - static const wchar_t *extensions[] = {L"*.png", L"*.bmp"}; - size_t extension; - for (extension = 0; extension < sizeof(extensions) / sizeof(extensions[0]); ++extension) { - pattern = join_path(viewer_directory, extensions[extension]); - if (!pattern) return 0; - find = FindFirstFileW(pattern, &entry); - free(pattern); - if (find == INVALID_HANDLE_VALUE) continue; - do { - wchar_t *name, *viewer_path, *native_path; - Frame *frame; - if (entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) continue; - name = without_extension(entry.cFileName); - viewer_path = join_path(viewer_directory, entry.cFileName); - native_path = name ? find_native(native_directory, name) : NULL; - if (!name || !viewer_path) { - free(name); free(viewer_path); free(native_path); - FindClose(find); return 0; - } - if (!native_path) { free(name); free(viewer_path); continue; } - if (list->count == list->capacity && !reserve_frames(list)) { - free(name); free(viewer_path); free(native_path); - FindClose(find); return 0; - } - frame = &list->items[list->count++]; - memset(frame, 0, sizeof(*frame)); - frame->name = name; frame->viewer_path = viewer_path; - frame->native_path = native_path; - } while (FindNextFileW(find, &entry)); - FindClose(find); - } - qsort(list->items, list->count, sizeof(*list->items), compare_frames); - return list->count != 0; -} - static int foreground_pixel(const unsigned char *pixel) { return min(pixel[0], min(pixel[1], pixel[2])) < 237; } @@ -285,20 +164,20 @@ int wmain(int argc, wchar_t **argv) { if (seed == 0) seed = (int)(GetTickCount() & INT_MAX); if (FAILED(CoInitializeEx(NULL, COINIT_MULTITHREADED))) return 1; if (!bongo_cat_tool_ensure_directory(argv[3]) || - !collect_frames(argv[1], argv[2], &frames)) + !blind_collect_frames(argv[1], argv[2], &frames)) goto done; - normalized = join_path(argv[3], L"normalized"); - ballots = join_path(argv[3], L"ballots"); - metrics_path = join_path(argv[3], L"metrics.csv"); - key_path = join_path(argv[3], L"answer-key.csv"); - summary_path = join_path(argv[3], L"summary.json"); + normalized = blind_join_path(argv[3], L"normalized"); + ballots = blind_join_path(argv[3], L"ballots"); + metrics_path = blind_join_path(argv[3], L"metrics.csv"); + key_path = blind_join_path(argv[3], L"answer-key.csv"); + summary_path = blind_join_path(argv[3], L"summary.json"); if (!normalized || !ballots || !metrics_path || !key_path || !summary_path || !bongo_cat_tool_ensure_directory(normalized) || !bongo_cat_tool_ensure_directory(ballots)) goto done; for (index = 0; index < frames.count; ++index) if (wcscmp(frames.items[index].name, L"track-000") == 0) baseline_index = index; { - wchar_t *idle = join_suffix(argv[1], L"idle", L".png"); + wchar_t *idle = blind_join_suffix(argv[1], L"idle", L".png"); int have_idle = idle && _waccess(idle, 0) == 0; wchar_t *viewer_bounds_path = have_idle ? idle : frames.items[baseline_index].viewer_path; BongoCatValidationImage bounds_image = {0}; @@ -326,8 +205,8 @@ int wmain(int argc, wchar_t **argv) { !bongo_cat_validation_image_keep_largest(&frames.items[index].native)) { goto done; } - viewer_output = join_suffix(normalized, frames.items[index].name, L"-viewer.png"); - native_output = join_suffix(normalized, frames.items[index].name, L"-native.png"); + viewer_output = blind_join_suffix(normalized, frames.items[index].name, L"-viewer.png"); + native_output = blind_join_suffix(normalized, frames.items[index].name, L"-native.png"); if (!viewer_output || !native_output || !bongo_cat_validation_image_save(viewer_output, &frames.items[index].viewer, 1) || !bongo_cat_validation_image_save(native_output, &frames.items[index].native, 1)) { @@ -353,7 +232,7 @@ int wmain(int argc, wchar_t **argv) { } { int viewer_first = random_next_two(&random) == 0; - wchar_t *ballot = join_suffix(ballots, frames.items[index].name, L".png"); + wchar_t *ballot = blind_join_suffix(ballots, frames.items[index].name, L".png"); if (!ballot || !write_ballot(ballot, &frames.items[index], viewer_first)) { free(ballot); fclose(metrics); fclose(key); goto done; } @@ -369,7 +248,7 @@ int wmain(int argc, wchar_t **argv) { result = 0; done: free(normalized); free(ballots); free(metrics_path); free(key_path); free(summary_path); - free_frames(&frames); + blind_free_frames(&frames); CoUninitialize(); if (result) fwprintf(stderr, L"blind test failed\n"); return result; diff --git a/src/tools/cubism_viewer_desktop_capture.c b/src/tools/cubism_viewer_desktop_capture.c index 5af416b9..7e249ac8 100644 --- a/src/tools/cubism_viewer_desktop_capture.c +++ b/src/tools/cubism_viewer_desktop_capture.c @@ -1,8 +1,5 @@ -#define COBJMACROS #define WIN32_LEAN_AND_MEAN #include -#include -#include #include #include @@ -10,25 +7,11 @@ #include #include +#include "cubism_viewer_desktop_capture.h" #include "windows_tool.h" typedef struct WindowSearch { DWORD process; HWND window; } WindowSearch; -typedef struct Frame { - LONGLONG present; - unsigned char *pixels; - size_t size; -} Frame; - -typedef struct DesktopCapture { - RECT crop; - RECT output; - ID3D11Device *device; - ID3D11DeviceContext *context; - IDXGIOutputDuplication *duplication; - ID3D11Texture2D *staging; -} DesktopCapture; - typedef struct Sample { char name[24]; Frame frame; @@ -38,10 +21,6 @@ typedef struct Sample { static LARGE_INTEGER timer_frequency; -static void release_unknown(IUnknown *value) { - if (value) IUnknown_Release(value); -} - static LONGLONG ticks(void) { LARGE_INTEGER value; QueryPerformanceCounter(&value); @@ -110,146 +89,6 @@ static int prepare_viewer(HWND window, int expression) { return 1; } -static void desktop_destroy(DesktopCapture *capture) { - release_unknown((IUnknown *)capture->staging); - release_unknown((IUnknown *)capture->duplication); - release_unknown((IUnknown *)capture->context); - release_unknown((IUnknown *)capture->device); - memset(capture, 0, sizeof(*capture)); -} - -static int desktop_init(DesktopCapture *capture, RECT crop) { - IDXGIFactory1 *factory = NULL; - UINT adapter_index; - HRESULT hr = CreateDXGIFactory1(&IID_IDXGIFactory1, (void **)&factory); - memset(capture, 0, sizeof(*capture)); capture->crop = crop; - if (FAILED(hr)) return 0; - for (adapter_index = 0; ; ++adapter_index) { - IDXGIAdapter1 *adapter = NULL; - UINT output_index; - hr = IDXGIFactory1_EnumAdapters1(factory, adapter_index, &adapter); - if (hr == DXGI_ERROR_NOT_FOUND) break; - if (FAILED(hr)) continue; - for (output_index = 0; ; ++output_index) { - IDXGIOutput *output = NULL; - IDXGIOutput1 *output1 = NULL; - DXGI_OUTPUT_DESC description; - D3D_FEATURE_LEVEL levels[] = {D3D_FEATURE_LEVEL_11_0, - D3D_FEATURE_LEVEL_10_1, D3D_FEATURE_LEVEL_10_0}; - D3D_FEATURE_LEVEL level; - hr = IDXGIAdapter1_EnumOutputs(adapter, output_index, &output); - if (hr == DXGI_ERROR_NOT_FOUND) break; - if (FAILED(hr)) continue; - memset(&description, 0, sizeof(description)); - IDXGIOutput_GetDesc(output, &description); - if (!description.AttachedToDesktop || crop.left < - description.DesktopCoordinates.left || crop.top < - description.DesktopCoordinates.top || crop.right > - description.DesktopCoordinates.right || crop.bottom > - description.DesktopCoordinates.bottom) { - release_unknown((IUnknown *)output); continue; - } - hr = D3D11CreateDevice((IDXGIAdapter *)adapter, - D3D_DRIVER_TYPE_UNKNOWN, NULL, D3D11_CREATE_DEVICE_BGRA_SUPPORT, - levels, sizeof(levels) / sizeof(levels[0]), D3D11_SDK_VERSION, - &capture->device, &level, &capture->context); - if (FAILED(hr)) { release_unknown((IUnknown *)output); continue; } - hr = IDXGIOutput_QueryInterface(output, &IID_IDXGIOutput1, - (void **)&output1); - if (SUCCEEDED(hr)) hr = IDXGIOutput1_DuplicateOutput(output1, - (IUnknown *)capture->device, &capture->duplication); - release_unknown((IUnknown *)output1); - release_unknown((IUnknown *)output); - if (SUCCEEDED(hr)) { - capture->output = description.DesktopCoordinates; - release_unknown((IUnknown *)adapter); - release_unknown((IUnknown *)factory); - return 1; - } - release_unknown((IUnknown *)capture->context); capture->context = NULL; - release_unknown((IUnknown *)capture->device); capture->device = NULL; - } - release_unknown((IUnknown *)adapter); - } - release_unknown((IUnknown *)factory); - desktop_destroy(capture); - return 0; -} - -static int copy_frame(DesktopCapture *capture, ID3D11Texture2D *source, - Frame *frame) { - D3D11_TEXTURE2D_DESC source_description; - D3D11_MAPPED_SUBRESOURCE mapped; - D3D11_BOX box; - int width = capture->crop.right - capture->crop.left; - int height = capture->crop.bottom - capture->crop.top; - int y; - ID3D11Texture2D_GetDesc(source, &source_description); - if (source_description.Format != DXGI_FORMAT_B8G8R8A8_UNORM) return 0; - if (!capture->staging) { - D3D11_TEXTURE2D_DESC description; - memset(&description, 0, sizeof(description)); - description.Width = (UINT)width; description.Height = (UINT)height; - description.MipLevels = description.ArraySize = 1; - description.Format = source_description.Format; - description.SampleDesc.Count = 1; - description.Usage = D3D11_USAGE_STAGING; - description.CPUAccessFlags = D3D11_CPU_ACCESS_READ; - if (FAILED(ID3D11Device_CreateTexture2D(capture->device, &description, - NULL, &capture->staging))) return 0; - } - box.left = (UINT)(capture->crop.left - capture->output.left); - box.top = (UINT)(capture->crop.top - capture->output.top); box.front = 0; - box.right = (UINT)(capture->crop.right - capture->output.left); - box.bottom = (UINT)(capture->crop.bottom - capture->output.top); box.back = 1; - ID3D11DeviceContext_CopySubresourceRegion(capture->context, - (ID3D11Resource *)capture->staging, 0, 0, 0, 0, - (ID3D11Resource *)source, 0, &box); - memset(&mapped, 0, sizeof(mapped)); - if (FAILED(ID3D11DeviceContext_Map(capture->context, - (ID3D11Resource *)capture->staging, 0, D3D11_MAP_READ, 0, &mapped))) - return 0; - frame->size = (size_t)width * height * 4; - frame->pixels = (unsigned char *)malloc(frame->size); - if (!frame->pixels) { - ID3D11DeviceContext_Unmap(capture->context, - (ID3D11Resource *)capture->staging, 0); return 0; - } - for (y = 0; y < height; ++y) - memcpy(frame->pixels + (size_t)y * width * 4, - (const unsigned char *)mapped.pData + (size_t)y * mapped.RowPitch, - (size_t)width * 4); - ID3D11DeviceContext_Unmap(capture->context, - (ID3D11Resource *)capture->staging, 0); - return 1; -} - -static int capture_at(DesktopCapture *capture, LONGLONG target, Frame *frame) { - for (;;) { - DXGI_OUTDUPL_FRAME_INFO info; - IDXGIResource *resource = NULL; - HRESULT hr; - int selected, copied = 0; - memset(&info, 0, sizeof(info)); - hr = IDXGIOutputDuplication_AcquireNextFrame(capture->duplication, - 1000, &info, &resource); - if (hr == DXGI_ERROR_WAIT_TIMEOUT) continue; - if (FAILED(hr)) return 0; - selected = info.LastPresentTime.QuadPart >= target; - if (selected) { - ID3D11Texture2D *texture = NULL; - hr = IDXGIResource_QueryInterface(resource, &IID_ID3D11Texture2D, - (void **)&texture); - if (SUCCEEDED(hr)) copied = copy_frame(capture, texture, frame); - release_unknown((IUnknown *)texture); - frame->present = info.LastPresentTime.QuadPart; - } - release_unknown((IUnknown *)resource); - IDXGIOutputDuplication_ReleaseFrame(capture->duplication); - if (selected) return copied; - } -} - #pragma pack(push, 1) typedef struct BitmapFileHeader { uint16_t type; @@ -330,13 +169,13 @@ int wmain(int argc, wchar_t **argv) { Sleep(800); { RECT crop = {left, top, left + width, top + height}; - if (!desktop_init(&capture, crop)) goto done; + if (!desktop_capture_init(&capture, crop)) goto done; } strcpy_s(samples[count].name, sizeof(samples[count].name), "idle"); - if (!capture_at(&capture, ticks(), &samples[count++].frame)) goto done; + if (!desktop_capture_at(&capture, ticks(), &samples[count++].frame)) goto done; if (!mouse_input(MOUSEEVENTF_LEFTDOWN)) goto done; Sleep(settle); strcpy_s(samples[count].name, sizeof(samples[count].name), "track-000"); - if (!capture_at(&capture, ticks(), &samples[count++].frame)) goto done; + if (!desktop_capture_at(&capture, ticks(), &samples[count++].frame)) goto done; if (!move_cursor(target_x, target_y)) goto done; track_start = ticks(); for (index = 0; index < 6; ++index) { @@ -346,7 +185,7 @@ int wmain(int argc, wchar_t **argv) { sample_name(samples[count].name, sizeof(samples[count].name), "track", frame_numbers[index]); samples[count].phase = track_start; samples[count].requested = requested; - if (!capture_at(&capture, target, &samples[count++].frame)) goto done; + if (!desktop_capture_at(&capture, target, &samples[count++].frame)) goto done; } if (!mouse_input(MOUSEEVENTF_LEFTUP)) goto done; return_start = ticks(); @@ -357,7 +196,7 @@ int wmain(int argc, wchar_t **argv) { sample_name(samples[count].name, sizeof(samples[count].name), "return", frame_numbers[index]); samples[count].phase = return_start; samples[count].requested = requested; - if (!capture_at(&capture, target, &samples[count++].frame)) goto done; + if (!desktop_capture_at(&capture, target, &samples[count++].frame)) goto done; } mouse_input(MOUSEEVENTF_LEFTUP); trace_path = sample_path(argv[1], "trace", L".csv"); @@ -390,7 +229,7 @@ int wmain(int argc, wchar_t **argv) { if (trace) fclose(trace); free(trace_path); for (index = 0; index < count; ++index) free(samples[index].frame.pixels); - desktop_destroy(&capture); + desktop_capture_destroy(&capture); if (result) fwprintf(stderr, L"desktop capture failed\n"); return result; } diff --git a/src/tools/cubism_viewer_desktop_capture.h b/src/tools/cubism_viewer_desktop_capture.h new file mode 100644 index 00000000..77b63b2a --- /dev/null +++ b/src/tools/cubism_viewer_desktop_capture.h @@ -0,0 +1,31 @@ +#ifndef BONGO_CAT_CUBISM_VIEWER_DESKTOP_CAPTURE_H +#define BONGO_CAT_CUBISM_VIEWER_DESKTOP_CAPTURE_H + +#define COBJMACROS +#define WIN32_LEAN_AND_MEAN +#include +#include +#include + +#include + +typedef struct Frame { + LONGLONG present; + unsigned char *pixels; + size_t size; +} Frame; + +typedef struct DesktopCapture { + RECT crop; + RECT output; + ID3D11Device *device; + ID3D11DeviceContext *context; + IDXGIOutputDuplication *duplication; + ID3D11Texture2D *staging; +} DesktopCapture; + +void desktop_capture_destroy(DesktopCapture *capture); +int desktop_capture_init(DesktopCapture *capture, RECT crop); +int desktop_capture_at(DesktopCapture *capture, LONGLONG target, Frame *frame); + +#endif diff --git a/src/tools/cubism_viewer_desktop_capture_dxgi.c b/src/tools/cubism_viewer_desktop_capture_dxgi.c new file mode 100644 index 00000000..fa52d52d --- /dev/null +++ b/src/tools/cubism_viewer_desktop_capture_dxgi.c @@ -0,0 +1,148 @@ +#include "cubism_viewer_desktop_capture.h" + +#include +#include + +static void release_unknown(IUnknown *value) { + if (value) IUnknown_Release(value); +} + +void desktop_capture_destroy(DesktopCapture *capture) { + release_unknown((IUnknown *)capture->staging); + release_unknown((IUnknown *)capture->duplication); + release_unknown((IUnknown *)capture->context); + release_unknown((IUnknown *)capture->device); + memset(capture, 0, sizeof(*capture)); +} + +int desktop_capture_init(DesktopCapture *capture, RECT crop) { + IDXGIFactory1 *factory = NULL; + UINT adapter_index; + HRESULT hr = CreateDXGIFactory1(&IID_IDXGIFactory1, (void **)&factory); + memset(capture, 0, sizeof(*capture)); capture->crop = crop; + if (FAILED(hr)) return 0; + for (adapter_index = 0; ; ++adapter_index) { + IDXGIAdapter1 *adapter = NULL; + UINT output_index; + hr = IDXGIFactory1_EnumAdapters1(factory, adapter_index, &adapter); + if (hr == DXGI_ERROR_NOT_FOUND) break; + if (FAILED(hr)) continue; + for (output_index = 0; ; ++output_index) { + IDXGIOutput *output = NULL; + IDXGIOutput1 *output1 = NULL; + DXGI_OUTPUT_DESC description; + D3D_FEATURE_LEVEL levels[] = {D3D_FEATURE_LEVEL_11_0, + D3D_FEATURE_LEVEL_10_1, D3D_FEATURE_LEVEL_10_0}; + D3D_FEATURE_LEVEL level; + hr = IDXGIAdapter1_EnumOutputs(adapter, output_index, &output); + if (hr == DXGI_ERROR_NOT_FOUND) break; + if (FAILED(hr)) continue; + memset(&description, 0, sizeof(description)); + IDXGIOutput_GetDesc(output, &description); + if (!description.AttachedToDesktop || crop.left < + description.DesktopCoordinates.left || crop.top < + description.DesktopCoordinates.top || crop.right > + description.DesktopCoordinates.right || crop.bottom > + description.DesktopCoordinates.bottom) { + release_unknown((IUnknown *)output); continue; + } + hr = D3D11CreateDevice((IDXGIAdapter *)adapter, + D3D_DRIVER_TYPE_UNKNOWN, NULL, D3D11_CREATE_DEVICE_BGRA_SUPPORT, + levels, sizeof(levels) / sizeof(levels[0]), D3D11_SDK_VERSION, + &capture->device, &level, &capture->context); + if (FAILED(hr)) { release_unknown((IUnknown *)output); continue; } + hr = IDXGIOutput_QueryInterface(output, &IID_IDXGIOutput1, + (void **)&output1); + if (SUCCEEDED(hr)) hr = IDXGIOutput1_DuplicateOutput(output1, + (IUnknown *)capture->device, &capture->duplication); + release_unknown((IUnknown *)output1); + release_unknown((IUnknown *)output); + if (SUCCEEDED(hr)) { + capture->output = description.DesktopCoordinates; + release_unknown((IUnknown *)adapter); + release_unknown((IUnknown *)factory); + return 1; + } + release_unknown((IUnknown *)capture->context); capture->context = NULL; + release_unknown((IUnknown *)capture->device); capture->device = NULL; + } + release_unknown((IUnknown *)adapter); + } + release_unknown((IUnknown *)factory); + desktop_capture_destroy(capture); + return 0; +} + +static int copy_frame(DesktopCapture *capture, ID3D11Texture2D *source, + Frame *frame) { + D3D11_TEXTURE2D_DESC source_description; + D3D11_MAPPED_SUBRESOURCE mapped; + D3D11_BOX box; + int width = capture->crop.right - capture->crop.left; + int height = capture->crop.bottom - capture->crop.top; + int y; + ID3D11Texture2D_GetDesc(source, &source_description); + if (source_description.Format != DXGI_FORMAT_B8G8R8A8_UNORM) return 0; + if (!capture->staging) { + D3D11_TEXTURE2D_DESC description; + memset(&description, 0, sizeof(description)); + description.Width = (UINT)width; description.Height = (UINT)height; + description.MipLevels = description.ArraySize = 1; + description.Format = source_description.Format; + description.SampleDesc.Count = 1; + description.Usage = D3D11_USAGE_STAGING; + description.CPUAccessFlags = D3D11_CPU_ACCESS_READ; + if (FAILED(ID3D11Device_CreateTexture2D(capture->device, &description, + NULL, &capture->staging))) return 0; + } + box.left = (UINT)(capture->crop.left - capture->output.left); + box.top = (UINT)(capture->crop.top - capture->output.top); box.front = 0; + box.right = (UINT)(capture->crop.right - capture->output.left); + box.bottom = (UINT)(capture->crop.bottom - capture->output.top); box.back = 1; + ID3D11DeviceContext_CopySubresourceRegion(capture->context, + (ID3D11Resource *)capture->staging, 0, 0, 0, 0, + (ID3D11Resource *)source, 0, &box); + memset(&mapped, 0, sizeof(mapped)); + if (FAILED(ID3D11DeviceContext_Map(capture->context, + (ID3D11Resource *)capture->staging, 0, D3D11_MAP_READ, 0, &mapped))) + return 0; + frame->size = (size_t)width * height * 4; + frame->pixels = (unsigned char *)malloc(frame->size); + if (!frame->pixels) { + ID3D11DeviceContext_Unmap(capture->context, + (ID3D11Resource *)capture->staging, 0); return 0; + } + for (y = 0; y < height; ++y) + memcpy(frame->pixels + (size_t)y * width * 4, + (const unsigned char *)mapped.pData + (size_t)y * mapped.RowPitch, + (size_t)width * 4); + ID3D11DeviceContext_Unmap(capture->context, + (ID3D11Resource *)capture->staging, 0); + return 1; +} + +int desktop_capture_at(DesktopCapture *capture, LONGLONG target, Frame *frame) { + for (;;) { + DXGI_OUTDUPL_FRAME_INFO info; + IDXGIResource *resource = NULL; + HRESULT hr; + int selected, copied = 0; + memset(&info, 0, sizeof(info)); + hr = IDXGIOutputDuplication_AcquireNextFrame(capture->duplication, + 1000, &info, &resource); + if (hr == DXGI_ERROR_WAIT_TIMEOUT) continue; + if (FAILED(hr)) return 0; + selected = info.LastPresentTime.QuadPart >= target; + if (selected) { + ID3D11Texture2D *texture = NULL; + hr = IDXGIResource_QueryInterface(resource, &IID_ID3D11Texture2D, + (void **)&texture); + if (SUCCEEDED(hr)) copied = copy_frame(capture, texture, frame); + release_unknown((IUnknown *)texture); + frame->present = info.LastPresentTime.QuadPart; + } + release_unknown((IUnknown *)resource); + IDXGIOutputDuplication_ReleaseFrame(capture->duplication); + if (selected) return copied; + } +} diff --git a/src/tools/mver_phase_frames.c b/src/tools/mver_phase_frames.c new file mode 100644 index 00000000..cd822d05 --- /dev/null +++ b/src/tools/mver_phase_frames.c @@ -0,0 +1,105 @@ +#define WIN32_LEAN_AND_MEAN +#include + +#include "mver_phase_frames.h" +#include "validation_image.h" + +#include +#include +#include + +static wchar_t *join_path(const wchar_t *directory, const wchar_t *name) { + size_t a = wcslen(directory), b = wcslen(name); + wchar_t *result = (wchar_t *)malloc((a + b + 2) * sizeof(*result)); + if (result) swprintf(result, a + b + 2, L"%ls\\%ls", directory, name); + return result; +} + +static int reserve_paths(PathList *list) { + size_t capacity = list->capacity ? list->capacity * 2 : 32; + wchar_t **items; + if (capacity < list->capacity || capacity > SIZE_MAX / sizeof(*items)) return 0; + items = (wchar_t **)realloc(list->items, capacity * sizeof(*items)); + if (!items) return 0; + list->items = items; list->capacity = capacity; + return 1; +} + +void phase_free_paths(PathList *list) { + size_t index; + for (index = 0; index < list->count; ++index) free(list->items[index]); + free(list->items); memset(list, 0, sizeof(*list)); +} + +static int compare_paths(const void *left, const void *right) { + return wcscmp(*(const wchar_t *const *)left, *(const wchar_t *const *)right); +} + +int phase_collect_paths(const wchar_t *directory, PathList *list) { + static const wchar_t *patterns[] = {L"*.png", L"*.bmp"}; + size_t pattern_index; + for (pattern_index = 0; pattern_index < sizeof(patterns) / sizeof(patterns[0]); + ++pattern_index) { + WIN32_FIND_DATAW entry; + wchar_t *pattern = join_path(directory, patterns[pattern_index]); + HANDLE find; + if (!pattern) return 0; + find = FindFirstFileW(pattern, &entry); free(pattern); + if (find == INVALID_HANDLE_VALUE) continue; + do { + wchar_t *path; + if (entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) continue; + path = join_path(directory, entry.cFileName); + if (!path || (list->count == list->capacity && !reserve_paths(list))) { + free(path); FindClose(find); return 0; + } + list->items[list->count++] = path; + } while (FindNextFileW(find, &entry)); + FindClose(find); + } + qsort(list->items, list->count, sizeof(*list->items), compare_paths); + return list->count != 0; +} + +static int load_frame(const wchar_t *path, Frame *frame) { + BongoCatValidationImage image = {0}; + int gx, gy; + if (!bongo_cat_validation_image_load(path, &image)) return 0; + frame->width = image.width; frame->height = image.height; + frame->grid_width = (image.width + PIXEL_STEP - 1) / PIXEL_STEP; + frame->grid_height = (image.height + PIXEL_STEP - 1) / PIXEL_STEP; + frame->bgr = (unsigned char *)malloc((size_t)frame->grid_width * + frame->grid_height * 3); + if (!frame->bgr) { bongo_cat_validation_image_free(&image); return 0; } + for (gy = 0; gy < frame->grid_height; ++gy) + for (gx = 0; gx < frame->grid_width; ++gx) { + int sx = min(image.width - 1, gx * PIXEL_STEP); + int sy = min(image.height - 1, gy * PIXEL_STEP); + const unsigned char *source = image.bgra + ((size_t)sy * image.width + sx) * 4; + unsigned char *output = frame->bgr + + ((size_t)gy * frame->grid_width + gx) * 3; + memcpy(output, source, 3); + } + bongo_cat_validation_image_free(&image); + return 1; +} + +void phase_free_frames(Frame *frames, size_t count) { + size_t index; + for (index = 0; index < count; ++index) free(frames[index].bgr); + free(frames); +} + +Frame *phase_load_frames(const PathList *paths) { + Frame *frames = (Frame *)calloc(paths->count, sizeof(*frames)); + size_t index; + if (!frames) return NULL; + for (index = 0; index < paths->count; ++index) { + if (!load_frame(paths->items[index], &frames[index]) || (index && + (frames[index].width != frames[0].width || + frames[index].height != frames[0].height))) { + phase_free_frames(frames, paths->count); return NULL; + } + } + return frames; +} diff --git a/src/tools/mver_phase_frames.h b/src/tools/mver_phase_frames.h new file mode 100644 index 00000000..e412274e --- /dev/null +++ b/src/tools/mver_phase_frames.h @@ -0,0 +1,25 @@ +#ifndef BONGO_CAT_MVER_PHASE_FRAMES_H +#define BONGO_CAT_MVER_PHASE_FRAMES_H + +#include +#include + +enum { PIXEL_STEP = 4 }; + +typedef struct PathList { + wchar_t **items; + size_t count; + size_t capacity; +} PathList; + +typedef struct Frame { + int width, height, grid_width, grid_height; + unsigned char *bgr; +} Frame; + +int phase_collect_paths(const wchar_t *directory, PathList *list); +void phase_free_paths(PathList *list); +Frame *phase_load_frames(const PathList *paths); +void phase_free_frames(Frame *frames, size_t count); + +#endif diff --git a/src/tools/mver_phase_metrics.c b/src/tools/mver_phase_metrics.c index c4acd8c5..7efa4e8f 100644 --- a/src/tools/mver_phase_metrics.c +++ b/src/tools/mver_phase_metrics.c @@ -2,7 +2,7 @@ #include #include -#include "validation_image.h" +#include "mver_phase_frames.h" #include #include @@ -11,18 +11,7 @@ #include #include -enum { PIXEL_STEP = 4, REGION_COUNT = 4 }; - -typedef struct PathList { - wchar_t **items; - size_t count; - size_t capacity; -} PathList; - -typedef struct Frame { - int width, height, grid_width, grid_height; - unsigned char *bgr; -} Frame; +enum { REGION_COUNT = 4 }; typedef struct Score { long long absolute[REGION_COUNT]; @@ -43,102 +32,6 @@ typedef struct Candidate { double motion[REGION_COUNT]; } Candidate; -static wchar_t *join_path(const wchar_t *directory, const wchar_t *name) { - size_t a = wcslen(directory), b = wcslen(name); - wchar_t *result = (wchar_t *)malloc((a + b + 2) * sizeof(*result)); - if (result) swprintf(result, a + b + 2, L"%ls\\%ls", directory, name); - return result; -} - -static int reserve_paths(PathList *list) { - size_t capacity = list->capacity ? list->capacity * 2 : 32; - wchar_t **items; - if (capacity < list->capacity || capacity > SIZE_MAX / sizeof(*items)) return 0; - items = (wchar_t **)realloc(list->items, capacity * sizeof(*items)); - if (!items) return 0; - list->items = items; list->capacity = capacity; - return 1; -} - -static void free_paths(PathList *list) { - size_t index; - for (index = 0; index < list->count; ++index) free(list->items[index]); - free(list->items); memset(list, 0, sizeof(*list)); -} - -static int compare_paths(const void *left, const void *right) { - return wcscmp(*(const wchar_t *const *)left, *(const wchar_t *const *)right); -} - -static int collect_paths(const wchar_t *directory, PathList *list) { - static const wchar_t *patterns[] = {L"*.png", L"*.bmp"}; - size_t pattern_index; - for (pattern_index = 0; pattern_index < sizeof(patterns) / sizeof(patterns[0]); - ++pattern_index) { - WIN32_FIND_DATAW entry; - wchar_t *pattern = join_path(directory, patterns[pattern_index]); - HANDLE find; - if (!pattern) return 0; - find = FindFirstFileW(pattern, &entry); free(pattern); - if (find == INVALID_HANDLE_VALUE) continue; - do { - wchar_t *path; - if (entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) continue; - path = join_path(directory, entry.cFileName); - if (!path || (list->count == list->capacity && !reserve_paths(list))) { - free(path); FindClose(find); return 0; - } - list->items[list->count++] = path; - } while (FindNextFileW(find, &entry)); - FindClose(find); - } - qsort(list->items, list->count, sizeof(*list->items), compare_paths); - return list->count != 0; -} - -static int load_frame(const wchar_t *path, Frame *frame) { - BongoCatValidationImage image = {0}; - int gx, gy; - if (!bongo_cat_validation_image_load(path, &image)) return 0; - frame->width = image.width; frame->height = image.height; - frame->grid_width = (image.width + PIXEL_STEP - 1) / PIXEL_STEP; - frame->grid_height = (image.height + PIXEL_STEP - 1) / PIXEL_STEP; - frame->bgr = (unsigned char *)malloc((size_t)frame->grid_width * - frame->grid_height * 3); - if (!frame->bgr) { bongo_cat_validation_image_free(&image); return 0; } - for (gy = 0; gy < frame->grid_height; ++gy) - for (gx = 0; gx < frame->grid_width; ++gx) { - int sx = min(image.width - 1, gx * PIXEL_STEP); - int sy = min(image.height - 1, gy * PIXEL_STEP); - const unsigned char *source = image.bgra + ((size_t)sy * image.width + sx) * 4; - unsigned char *output = frame->bgr + - ((size_t)gy * frame->grid_width + gx) * 3; - memcpy(output, source, 3); - } - bongo_cat_validation_image_free(&image); - return 1; -} - -static void free_frames(Frame *frames, size_t count) { - size_t index; - for (index = 0; index < count; ++index) free(frames[index].bgr); - free(frames); -} - -static Frame *load_all(const PathList *paths) { - Frame *frames = (Frame *)calloc(paths->count, sizeof(*frames)); - size_t index; - if (!frames) return NULL; - for (index = 0; index < paths->count; ++index) { - if (!load_frame(paths->items[index], &frames[index]) || (index && - (frames[index].width != frames[0].width || - frames[index].height != frames[0].height))) { - free_frames(frames, paths->count); return NULL; - } - } - return frames; -} - static int in_region(int region, int x, int y, int width, int height) { double nx = x / (double)width, ny = y / (double)height; if (region == 0) return 1; @@ -277,10 +170,12 @@ int wmain(int argc, wchar_t **argv) { interval_ms = argc > 5 ? _wtoi(argv[5]) : 17; if (max_lag < 0 || minimum_overlap < 2) return 2; if (FAILED(CoInitializeEx(NULL, COINIT_MULTITHREADED))) return 1; - if (!collect_paths(argv[1], &mver_paths) || !collect_paths(argv[2], &native_paths) || + if (!phase_collect_paths(argv[1], &mver_paths) || + !phase_collect_paths(argv[2], &native_paths) || mver_paths.count < (size_t)minimum_overlap || native_paths.count < (size_t)minimum_overlap) goto done; - mver = load_all(&mver_paths); native = load_all(&native_paths); + mver = phase_load_frames(&mver_paths); + native = phase_load_frames(&native_paths); if (!mver || !native) goto done; coarse = (Candidate *)malloc((size_t)(max_lag * 2 + 1) * sizeof(*coarse)); fine = (Candidate *)malloc(8 * sizeof(*fine)); @@ -313,8 +208,9 @@ int wmain(int argc, wchar_t **argv) { result = 0; done: free(coarse); free(fine); - free_frames(mver, mver_paths.count); free_frames(native, native_paths.count); - free_paths(&mver_paths); free_paths(&native_paths); + phase_free_frames(mver, mver_paths.count); + phase_free_frames(native, native_paths.count); + phase_free_paths(&mver_paths); phase_free_paths(&native_paths); CoUninitialize(); if (result) fwprintf(stderr, L"phase analysis failed\n"); return result; diff --git a/src/ui/preferences/preferences_about.c b/src/ui/preferences/preferences_about.c index a342d69b..41d2c973 100644 --- a/src/ui/preferences/preferences_about.c +++ b/src/ui/preferences/preferences_about.c @@ -212,7 +212,8 @@ static void footer(BongoCatPreferences *value, struct nk_context *context, open_url("https://github.com/vladelaina/BongoCat/issues"); } -static void hero(BongoCatPreferences *value, struct nk_context *context) { +void bongo_cat_preferences_about_hero(BongoCatPreferences *value, + struct nk_context *context) { struct nk_rect bounds; nk_layout_row_dynamic(context, 320, 1); if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return; @@ -276,7 +277,8 @@ static void project(BongoCatPreferences *value, struct nk_context *context, if (hit(context, bounds)) open_url(url); } -static void projects(BongoCatPreferences *value, struct nk_context *context) { +void bongo_cat_preferences_about_projects(BongoCatPreferences *value, + struct nk_context *context) { struct nk_rect bounds; nk_layout_row_dynamic(context, 330, 1); if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return; @@ -294,10 +296,3 @@ static void projects(BongoCatPreferences *value, struct nk_context *context) { value->vlaina_height, "vlaina", "https://vlaina.com", true, p); } -void bongo_cat_preferences_page_about(BongoCatPreferences *value, - struct nk_context *context) { - bongo_cat_preferences_support_assets_load(value); - hero(value, context); - projects(value, context); - bongo_cat_preferences_about_community(value, context); -} diff --git a/src/ui/preferences/preferences_about_page.c b/src/ui/preferences/preferences_about_page.c new file mode 100644 index 00000000..0d89ddc0 --- /dev/null +++ b/src/ui/preferences/preferences_about_page.c @@ -0,0 +1,15 @@ +#include "preferences_about_community.h" +#include "preferences_state.h" + +void bongo_cat_preferences_about_hero(BongoCatPreferences *value, + struct nk_context *context); +void bongo_cat_preferences_about_projects(BongoCatPreferences *value, + struct nk_context *context); + +void bongo_cat_preferences_page_about(BongoCatPreferences *value, + struct nk_context *context) { + bongo_cat_preferences_support_assets_load(value); + bongo_cat_preferences_about_hero(value, context); + bongo_cat_preferences_about_projects(value, context); + bongo_cat_preferences_about_community(value, context); +} diff --git a/src/ui/preferences/preferences_page_dispatch.c b/src/ui/preferences/preferences_page_dispatch.c new file mode 100644 index 00000000..8954a813 --- /dev/null +++ b/src/ui/preferences/preferences_page_dispatch.c @@ -0,0 +1,12 @@ +#include "preferences_page_dispatch.h" + +void bongo_cat_preferences_draw_page(BongoCatPreferences *value, + struct nk_context *context) { + switch (value->page) { + case 0: bongo_cat_preferences_page_cat(value->app, context); break; + case 1: bongo_cat_preferences_page_general(value->app, context); break; + case 2: bongo_cat_preferences_page_model(value, context); break; + case 3: bongo_cat_preferences_page_shortcuts(value, context); break; + default: bongo_cat_preferences_page_about(value, context); break; + } +} diff --git a/src/ui/preferences/preferences_page_dispatch.h b/src/ui/preferences/preferences_page_dispatch.h new file mode 100644 index 00000000..f69f3463 --- /dev/null +++ b/src/ui/preferences/preferences_page_dispatch.h @@ -0,0 +1,9 @@ +#ifndef BONGO_CAT_PREFERENCES_PAGE_DISPATCH_H +#define BONGO_CAT_PREFERENCES_PAGE_DISPATCH_H + +#include "preferences_state.h" + +void bongo_cat_preferences_draw_page(BongoCatPreferences *value, + struct nk_context *context); + +#endif diff --git a/src/ui/preferences/preferences_render.c b/src/ui/preferences/preferences_render.c index c3d60259..90a00aa5 100644 --- a/src/ui/preferences/preferences_render.c +++ b/src/ui/preferences/preferences_render.c @@ -1,4 +1,5 @@ #include "preferences_state.h" +#include "preferences_page_dispatch.h" #include "preferences_controls.h" #include "preferences_notice.h" #include "ui_catime.h" @@ -17,15 +18,6 @@ static const char *tr(const BongoCatPreferences *value, const char *key, const c static void draw_icon(void *userdata, struct nk_command_buffer *canvas, int icon, struct nk_rect bounds, struct nk_color color) { bongo_cat_preferences_icon_draw(userdata, canvas, icon, bounds, color); } -static void draw_page(BongoCatPreferences *value, struct nk_context *context) { - switch (value->page) { - case 0: bongo_cat_preferences_page_cat(value->app, context); break; - case 1: bongo_cat_preferences_page_general(value->app, context); break; - case 2: bongo_cat_preferences_page_model(value, context); break; - case 3: bongo_cat_preferences_page_shortcuts(value, context); break; - default: bongo_cat_preferences_page_about(value, context); break; - } -} static RootStyle root_style_save(struct nk_context *context) { RootStyle saved; saved.padding = context->style.window.padding; @@ -149,7 +141,7 @@ static bool draw_shell(BongoCatPreferences *value, struct nk_context *context, context->input.mouse.scroll_delta = nk_vec2(0, 0); } if (nk_group_begin(context, page_ids[value->page], 0)) { - draw_page(value, context); + bongo_cat_preferences_draw_page(value, context); float wheel = context->input.mouse.scroll_delta.y; struct nk_panel *layout = context->current->layout; float maximum = layout ? NK_MAX(0.0f, layout->at_y + layout->row.height - layout->bounds.y - layout->bounds.h) : 0.0f; diff --git a/src/ui/preferences/preferences_widget_shortcut.c b/src/ui/preferences/preferences_widget_shortcut.c new file mode 100644 index 00000000..0df396d6 --- /dev/null +++ b/src/ui/preferences/preferences_widget_shortcut.c @@ -0,0 +1,102 @@ +#include "preferences_widgets.h" +#include "preferences_widgets_internal.h" +#include "preferences_shortcut_clear.h" +#include "ui_animation.h" +#include "ui_backend.h" +#include "ui_catime.h" +#include "ui_icons.h" +#include "ui_paint.h" + +#include +#include +#include + +int bongo_cat_pref_edit(struct nk_context *context, const char *id, + const char *title, const char *detail, const char *value, + bool recording, const char *idle_hint, const char *record_hint) { + int lines = bongo_cat_pref_detail_lines(context, detail); FormStyle saved; + if (!bongo_cat_pref_form_begin(context, id, lines, &saved)) return false; + const char *shown = recording ? record_hint : + (value && value[0] ? value : idle_hint); + const struct nk_user_font *font = bongo_cat_ui_body_font(context); + float width = font->width(font->userdata, font->height, shown, + nk_strlen(shown)); + float control_width = NK_CLAMP(180.0f, width + 64.0f, 260.0f); + bongo_cat_pref_form_title_sized(context, title, control_width); + struct nk_rect bounds; + if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) { + nk_layout_row_end(context); + bongo_cat_pref_form_end(context, &saved); + return false; + } + const float effect_margin = 6.0f; + bounds = nk_rect(bounds.x + bounds.w - control_width, bounds.y, + control_width - effect_margin, bounds.h); + BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); + bool hover = nk_input_is_mouse_hovering_rect(&context->input, bounds); + char hover_id[80]; + snprintf(hover_id, sizeof(hover_id), "shortcut-hover-%s", id); + float hover_amount = bongo_cat_ui_animate_eased(context, hover_id, + hover ? 1.0f : 0.0f, 200, BONGO_CAT_UI_EASE_STANDARD); + struct nk_command_buffer *canvas = nk_window_get_canvas(context); + nk_fill_rect(canvas, bounds, 10, recording ? p.hover_pink : + bongo_cat_ui_color_mix(p.field, p.hover, hover_amount)); + nk_stroke_rect(canvas, bounds, 10, recording ? 2.0f : 1.0f, + recording ? p.pink : bongo_cat_ui_color_mix( + p.border_subtle, p.accent, hover_amount)); + if (recording) { + float phase = (float)(SDL_GetTicksNS() % 1500000000ULL) / + 1500000000.0f; + float pulse = .5f - .5f * cosf(phase * 6.2831853f); + struct nk_rect glow = nk_rect(bounds.x - pulse * 5, + bounds.y - pulse * 5, bounds.w + pulse * 10, + bounds.h + pulse * 10); + nk_stroke_rect(canvas, glow, 12, 2, + nk_rgba(p.pink.r, p.pink.g, p.pink.b, + (nk_byte)(150 * (1.0f - pulse)))); + } + bool has_shortcut = value && value[0]; + bool can_clear = !recording && has_shortcut; + bool show_keyboard = recording || !has_shortcut; + float icon_width = show_keyboard ? 26.0f : 0.0f; + float text_width = NK_MIN(width, bounds.w - icon_width - + (can_clear ? 34.0f : 16.0f)); + float group_width = icon_width + text_width + (can_clear ? 20.0f : 0.0f); + float group_x = bounds.x + NK_MAX(14.0f, (bounds.w - group_width) * .5f); + float text_x = group_x + icon_width; + struct nk_color icon_color = recording ? p.pink : + bongo_cat_ui_color_mix(p.muted, p.accent, hover_amount); + if (show_keyboard) { + struct nk_rect keyboard = nk_rect(group_x, bounds.y + 10, 18, 18); + if (!bongo_cat_ui_draw_icon(canvas, BONGO_CAT_UI_ICON_KEYBOARD, + keyboard, icon_color)) { + nk_stroke_rect(canvas, keyboard, 3, 1.5f, icon_color); + for (int i = 0; i < 3; ++i) + nk_stroke_line(canvas, keyboard.x + 3 + i * 3, + keyboard.y + 4, keyboard.x + 4 + i * 3, + keyboard.y + 4, 1, icon_color); + nk_stroke_line(canvas, keyboard.x + 3, keyboard.y + 8, + keyboard.x + 10, keyboard.y + 8, 1, icon_color); + } + } + struct nk_rect text = nk_rect(text_x, + bounds.y + (bounds.h - font->height) * .5f, + NK_MIN(width + 1, bounds.x + bounds.w - text_x - + (can_clear ? 24.0f : 8.0f)), font->height); + nk_draw_text(canvas, text, shown, nk_strlen(shown), font, + nk_rgba(0, 0, 0, 0), recording ? p.pink : + (has_shortcut ? p.text : + bongo_cat_ui_color_mix(p.muted, p.accent, hover_amount))); + struct nk_rect clear_bounds = nk_rect(bounds.x + bounds.w - 25, + bounds.y + 8, 17, 20); + bool clear = bongo_cat_pref_shortcut_clear(context, canvas, id, + clear_bounds, p, 1.0f, can_clear); + if (hover) bongo_cat_ui_cursor_hover_rect(context, bounds, + BONGO_CAT_UI_CURSOR_POINTER); + bool clicked = !clear && nk_input_is_mouse_click_in_rect(&context->input, + NK_BUTTON_LEFT, bounds) != 0; + nk_layout_row_end(context); + bongo_cat_pref_description(context, detail, lines); + bongo_cat_pref_form_end(context, &saved); + return clear ? -1 : clicked; +} diff --git a/src/ui/preferences/preferences_widgets.c b/src/ui/preferences/preferences_widgets.c index bc68c48e..1b6b9ce5 100644 --- a/src/ui/preferences/preferences_widgets.c +++ b/src/ui/preferences/preferences_widgets.c @@ -1,16 +1,10 @@ #include "preferences_widgets.h" #include "preferences_widgets_internal.h" #include "preferences_controls.h" -#include "preferences_shortcut_clear.h" -#include "ui_animation.h" #include "ui_backend.h" #include "ui_catime.h" -#include "ui_icons.h" #include "ui_paint.h" #include "ui_tooltip.h" -#include -#include -#include static struct nk_context *section_context; static bool section_first; static const float description_line_height = 19.0f; @@ -27,7 +21,8 @@ static int wrapped_line_count(const struct nk_user_font *font, float measured = font->width(font->userdata, font->height, text, length); return (int)(measured / width) + 1; } -static int detail_lines(const struct nk_context *context, const char *text) { +int bongo_cat_pref_detail_lines(const struct nk_context *context, + const char *text) { if (!text || !text[0]) return 0; const struct nk_user_font *font = bongo_cat_ui_caption_font(context); float width = nk_window_get_content_region(context).w - @@ -85,7 +80,8 @@ void bongo_cat_pref_form_end(struct nk_context *context, static float left_width(struct nk_context *context) { return NK_MAX(220.0f, nk_window_get_content_region(context).w - 228.0f); } -static void form_title_sized(struct nk_context *context, const char *title, +void bongo_cat_pref_form_title_sized(struct nk_context *context, + const char *title, float control_width) { float available = nk_window_get_content_region(context).w; float left = NK_MAX(220.0f, available - control_width - 8.0f); @@ -102,8 +98,9 @@ void bongo_cat_pref_form_label(struct nk_context *context, const char *title) { context->style.text.padding = text_padding; nk_style_pop_font(context); } -static void form_title(struct nk_context *context, const char *title) { form_title_sized(context, title, 220.0f); } -static void description(struct nk_context *context, const char *text, +static void form_title(struct nk_context *context, const char *title) { + bongo_cat_pref_form_title_sized(context, title, 220.0f); } +void bongo_cat_pref_description(struct nk_context *context, const char *text, int lines) { if (!lines) return; BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); @@ -176,11 +173,11 @@ void bongo_cat_pref_section(struct nk_context *context, const char *title) { } bool bongo_cat_pref_toggle(struct nk_context *context, const char *id, const char *title, const char *detail, bool *value) { - int lines = detail_lines(context, detail); FormStyle saved; + int lines = bongo_cat_pref_detail_lines(context, detail); FormStyle saved; if (!form_begin(context, id, lines, &saved)) return false; - form_title_sized(context, title, 80.0f); + bongo_cat_pref_form_title_sized(context, title, 80.0f); bool changed = bongo_cat_pref_control_toggle(context, id, value); - nk_layout_row_end(context); description(context, detail, lines); + nk_layout_row_end(context); bongo_cat_pref_description(context, detail, lines); form_end(context, &saved); return changed; } bool bongo_cat_pref_obs_background(struct nk_context *context, const char *id, @@ -188,7 +185,7 @@ bool bongo_cat_pref_obs_background(struct nk_context *context, const char *id, BongoCatObsBackgroundColor *color) { int lines = question && question[0] ? 1 : 0; FormStyle saved; if (!form_begin(context, id, lines, &saved)) return false; - form_title_sized(context, title, 230.0f); + bongo_cat_pref_form_title_sized(context, title, 230.0f); bool changed = bongo_cat_pref_control_obs_background(context, id, enabled, color); nk_layout_row_end(context); bongo_cat_ui_question_tooltip(context, question, reply); @@ -197,138 +194,58 @@ bool bongo_cat_pref_obs_background(struct nk_context *context, const char *id, bool bongo_cat_pref_float(struct nk_context *context, const char *id, const char *title, const char *detail, float minimum, float *value, float maximum, float step, float default_value) { - int lines = detail_lines(context, detail); FormStyle saved; + int lines = bongo_cat_pref_detail_lines(context, detail); FormStyle saved; if (!form_begin(context, id, lines, &saved)) return false; form_title(context, title); bool changed = bongo_cat_pref_control_float(context, id, minimum, value, maximum, step, default_value); - nk_layout_row_end(context); description(context, detail, lines); + nk_layout_row_end(context); bongo_cat_pref_description(context, detail, lines); form_end(context, &saved); return changed; } bool bongo_cat_pref_int(struct nk_context *context, const char *id, const char *title, const char *detail, int minimum, int *value, int maximum, int step, int default_value) { - int lines = detail_lines(context, detail); FormStyle saved; + int lines = bongo_cat_pref_detail_lines(context, detail); FormStyle saved; if (!form_begin(context, id, lines, &saved)) return false; form_title(context, title); bool changed = bongo_cat_pref_control_int(context, id, minimum, value, maximum, step, default_value); - nk_layout_row_end(context); description(context, detail, lines); + nk_layout_row_end(context); bongo_cat_pref_description(context, detail, lines); form_end(context, &saved); return changed; } bool bongo_cat_pref_slider(struct nk_context *context, const char *id, const char *title, const char *detail, float minimum, float *value, float maximum, float step, float default_value) { - int lines = detail_lines(context, detail); FormStyle saved; + int lines = bongo_cat_pref_detail_lines(context, detail); FormStyle saved; if (!form_begin(context, id, lines, &saved)) return false; form_title(context, title); bool changed = bongo_cat_pref_control_slider(context, id, minimum, value, maximum, step, default_value); - nk_layout_row_end(context); description(context, detail, lines); + nk_layout_row_end(context); bongo_cat_pref_description(context, detail, lines); form_end(context, &saved); return changed; } int bongo_cat_pref_combo(struct nk_context *context, const char *id, const char *title, const char *detail, const char *const *items, int count, int selected) { - int lines = detail_lines(context, detail); FormStyle saved; + int lines = bongo_cat_pref_detail_lines(context, detail); FormStyle saved; if (!form_begin(context, id, lines, &saved)) return selected; - form_title_sized(context, title, 176.0f); + bongo_cat_pref_form_title_sized(context, title, 176.0f); selected = bongo_cat_pref_control_combo(context, id, items, count, selected); - nk_layout_row_end(context); description(context, detail, lines); + nk_layout_row_end(context); bongo_cat_pref_description(context, detail, lines); form_end(context, &saved); return selected; } -int bongo_cat_pref_edit(struct nk_context *context, const char *id, - const char *title, const char *detail, const char *value, - bool recording, const char *idle_hint, const char *record_hint) { - int lines = detail_lines(context, detail); FormStyle saved; - if (!form_begin(context, id, lines, &saved)) return false; - const char *shown = recording ? record_hint : (value && value[0] ? value : idle_hint); - const struct nk_user_font *font = bongo_cat_ui_body_font(context); - float width = font->width(font->userdata, font->height, shown, nk_strlen(shown)); - float control_width = NK_CLAMP(180.0f, width + 64.0f, 260.0f); - form_title_sized(context, title, control_width); - struct nk_rect bounds; - if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) { - nk_layout_row_end(context); form_end(context, &saved); return false; - } - const float effect_margin = 6.0f; - bounds = nk_rect(bounds.x + bounds.w - control_width, bounds.y, - control_width - effect_margin, bounds.h); - BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); - bool hover = nk_input_is_mouse_hovering_rect(&context->input, bounds); - char hover_id[80]; snprintf(hover_id, sizeof(hover_id), "shortcut-hover-%s", id); - float hover_amount = bongo_cat_ui_animate_eased(context, hover_id, - hover ? 1.0f : 0.0f, 200, BONGO_CAT_UI_EASE_STANDARD); - struct nk_command_buffer *canvas = nk_window_get_canvas(context); - nk_fill_rect(canvas, bounds, 10, recording ? p.hover_pink : - bongo_cat_ui_color_mix(p.field, p.hover, hover_amount)); - nk_stroke_rect(canvas, bounds, 10, recording ? 2.0f : 1.0f, - recording ? p.pink : bongo_cat_ui_color_mix( - p.border_subtle, p.accent, hover_amount)); - if (recording) { - float phase = (float)(SDL_GetTicksNS() % 1500000000ULL) / - 1500000000.0f; - float pulse = .5f - .5f * cosf(phase * 6.2831853f); - struct nk_rect glow = nk_rect(bounds.x - pulse * 5, - bounds.y - pulse * 5, bounds.w + pulse * 10, - bounds.h + pulse * 10); - nk_stroke_rect(canvas, glow, 12, 2, - nk_rgba(p.pink.r, p.pink.g, p.pink.b, - (nk_byte)(150 * (1.0f - pulse)))); - } - bool has_shortcut = value && value[0]; - bool can_clear = !recording && has_shortcut; - bool show_keyboard = recording || !has_shortcut; - float icon_width = show_keyboard ? 26.0f : 0.0f; - float text_width = NK_MIN(width, bounds.w - icon_width - - (can_clear ? 34.0f : 16.0f)); - float group_width = icon_width + text_width + (can_clear ? 20.0f : 0.0f); - float group_x = bounds.x + NK_MAX(14.0f, (bounds.w - group_width) * .5f); - float text_x = group_x + icon_width; - struct nk_color icon_color = recording ? p.pink : - bongo_cat_ui_color_mix(p.muted, p.accent, hover_amount); - if (show_keyboard) { - struct nk_rect keyboard = nk_rect(group_x, bounds.y + 10, 18, 18); - if (!bongo_cat_ui_draw_icon(canvas, BONGO_CAT_UI_ICON_KEYBOARD, - keyboard, icon_color)) { - nk_stroke_rect(canvas, keyboard, 3, 1.5f, icon_color); - for (int i = 0; i < 3; ++i) - nk_stroke_line(canvas, keyboard.x + 3 + i * 3, keyboard.y + 4, - keyboard.x + 4 + i * 3, keyboard.y + 4, 1, icon_color); - nk_stroke_line(canvas, keyboard.x + 3, keyboard.y + 8, - keyboard.x + 10, keyboard.y + 8, 1, icon_color); - } - } - struct nk_rect text = nk_rect(text_x, bounds.y + (bounds.h - font->height) * .5f, - NK_MIN(width + 1, bounds.x + bounds.w - text_x - - (can_clear ? 24.0f : 8.0f)), font->height); - nk_draw_text(canvas, text, shown, nk_strlen(shown), font, nk_rgba(0, 0, 0, 0), - recording ? p.pink : (has_shortcut ? p.text : - bongo_cat_ui_color_mix(p.muted, p.accent, hover_amount))); - struct nk_rect clear_bounds = nk_rect(bounds.x + bounds.w - 25, - bounds.y + 8, 17, 20); - bool clear = bongo_cat_pref_shortcut_clear(context, canvas, id, - clear_bounds, p, 1.0f, can_clear); - if (hover) bongo_cat_ui_cursor_hover_rect(context, bounds, - BONGO_CAT_UI_CURSOR_POINTER); - bool clicked = !clear && nk_input_is_mouse_click_in_rect(&context->input, - NK_BUTTON_LEFT, bounds) != 0; - nk_layout_row_end(context); description(context, detail, lines); - form_end(context, &saved); - return clear ? -1 : clicked; -} bool bongo_cat_pref_button(struct nk_context *context, const char *id, const char *title, const char *detail, const char *button) { - int lines = detail_lines(context, detail); FormStyle saved; + int lines = bongo_cat_pref_detail_lines(context, detail); FormStyle saved; if (!form_begin(context, id, lines, &saved)) return false; form_title(context, title); bool result = secondary_button(context, button); - nk_layout_row_end(context); description(context, detail, lines); + nk_layout_row_end(context); bongo_cat_pref_description(context, detail, lines); form_end(context, &saved); return result; } void bongo_cat_pref_status(struct nk_context *context, const char *id, const char *title, const char *detail) { - int lines = detail_lines(context, detail); FormStyle saved; + int lines = bongo_cat_pref_detail_lines(context, detail); FormStyle saved; if (!form_begin(context, id, lines, &saved)) return; BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); nk_layout_row_dynamic(context, 44, 1); @@ -340,5 +257,6 @@ void bongo_cat_pref_status(struct nk_context *context, const char *id, row.y + (row.h - font->height) * .5f, row.w - 20, font->height); nk_draw_text(nk_window_get_canvas(context), text, title, nk_strlen(title), font, nk_rgba(0, 0, 0, 0), p.text); - description(context, detail, lines); form_end(context, &saved); + bongo_cat_pref_description(context, detail, lines); + form_end(context, &saved); } diff --git a/src/ui/preferences/preferences_widgets_internal.h b/src/ui/preferences/preferences_widgets_internal.h index c3fc47a7..e001f569 100644 --- a/src/ui/preferences/preferences_widgets_internal.h +++ b/src/ui/preferences/preferences_widgets_internal.h @@ -18,5 +18,11 @@ bool bongo_cat_pref_form_begin(struct nk_context *context, const char *id, void bongo_cat_pref_form_end(struct nk_context *context, const FormStyle *saved); void bongo_cat_pref_form_label(struct nk_context *context, const char *title); +int bongo_cat_pref_detail_lines(const struct nk_context *context, + const char *text); +void bongo_cat_pref_form_title_sized(struct nk_context *context, + const char *title, float control_width); +void bongo_cat_pref_description(struct nk_context *context, + const char *text, int lines); #endif diff --git a/tests/core/test_config.c b/tests/core/test_config.c index 6eda2f42..5faaadd0 100644 --- a/tests/core/test_config.c +++ b/tests/core/test_config.c @@ -1,10 +1,10 @@ #include "test.h" +#include "test_config_validation.h" #include "bongo_cat/config.h" #include "bongo_cat/file.h" #include "bongo_cat/model.h" #include "bongo_cat/path.h" -#include #include #include @@ -24,124 +24,8 @@ static bool contains_text(const char *path, const char *needle) { return strstr(content, needle) != NULL; } -static void check_defaults_and_validation(void) { - static BongoCatSettings settings; - static BongoCatSessionState session; - bongo_cat_settings_defaults(&settings); - bongo_cat_session_defaults(&session); - CHECK(settings.model.max_fps == 60 && settings.model.mouse_centered && - !settings.model.multiple_pets); - CHECK(settings.window.always_on_top && !settings.window.keep_in_screen); - CHECK(!settings.window.obs_background); - CHECK(!settings.window.random_expression && - settings.window.random_expression_interval_seconds == - BONGO_CAT_DEFAULT_RANDOM_EXPRESSION_SECONDS); - CHECK(settings.window.obs_background_color == - BONGO_CAT_OBS_BACKGROUND_GREEN); - CHECK(session.window.visible && session.window.width == 612 && - session.window.height == 354 && session.window.content_width == 612 && - session.window.content_height == 354); - CHECK(strcmp(session.active_model_id, "standard") == 0); - CHECK(session.additional_model_count == 0); - CHECK(bongo_cat_session_add_model(&session, "keyboard")); - CHECK(bongo_cat_session_add_model(&session, "keyboard")); - CHECK(bongo_cat_session_add_model(&session, "gamepad")); - CHECK(session.additional_model_count == 2 && - bongo_cat_session_model_active(&session, "standard") && - bongo_cat_session_model_active(&session, "keyboard")); - CHECK(bongo_cat_session_remove_model(&session, "keyboard")); - CHECK(session.additional_model_count == 1 && - !bongo_cat_session_model_active(&session, "keyboard")); - bongo_cat_session_clear_additional_models(&session); - settings.model.max_fps = 900; - settings.window.obs_background_color = BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT; - settings.window.hide_delay_seconds = NAN; - settings.window.random_expression_interval_seconds = NAN; - session.window.scale_percent = -2.0f; - session.window.opacity_percent = NAN; - session.active_behavior_count = 3; - memcpy(session.active_behaviors[0].model_id, "model", - sizeof("model")); - memcpy(session.active_behaviors[0].behavior_id, "model:motion:Tap:0", - sizeof("model:motion:Tap:0")); - session.active_behaviors[1] = session.active_behaviors[0]; - memcpy(session.active_behaviors[2].model_id, "other", - sizeof("other")); - bongo_cat_settings_validate(&settings); - bongo_cat_session_validate(&session); - CHECK(settings.model.max_fps == 240); - CHECK(settings.window.hide_delay_seconds == 0.0f); - CHECK(settings.window.random_expression_interval_seconds == - BONGO_CAT_DEFAULT_RANDOM_EXPRESSION_SECONDS); - CHECK(settings.window.obs_background_color == - BONGO_CAT_OBS_BACKGROUND_GREEN); - CHECK(session.window.scale_percent == 10.0f); - CHECK(session.window.opacity_percent == - BONGO_CAT_DEFAULT_WINDOW_OPACITY_PERCENT); - CHECK(session.active_behavior_count == 1); - CHECK(strcmp(session.active_behaviors[0].model_id, "model") == 0); -} - -static void check_override_canonicalization(void) { - static BongoCatSettings settings; - bongo_cat_settings_defaults(&settings); - settings.behavior_shortcut_count = 4; - memcpy(settings.behavior_shortcuts[0].id, "motion:1", - sizeof("motion:1")); - memcpy(settings.behavior_shortcuts[0].shortcut, "Control+1", - sizeof("Control+1")); - memcpy(settings.behavior_shortcuts[0].label, "First", sizeof("First")); - memcpy(settings.behavior_shortcuts[1].id, "motion:1", - sizeof("motion:1")); - memcpy(settings.behavior_shortcuts[1].shortcut, "Control+2", - sizeof("Control+2")); - memcpy(settings.behavior_shortcuts[2].id, "empty", sizeof("empty")); - memcpy(settings.behavior_shortcuts[3].id, "motion:2", - sizeof("motion:2")); - memcpy(settings.behavior_shortcuts[3].label, "Second", sizeof("Second")); - settings.model_label_count = 2; - memcpy(settings.model_labels[0].id, "model", sizeof("model")); - memcpy(settings.model_labels[0].label, "Old", sizeof("Old")); - memcpy(settings.model_labels[1].id, "model", sizeof("model")); - memcpy(settings.model_labels[1].label, "New", sizeof("New")); - bongo_cat_settings_validate(&settings); - CHECK(settings.behavior_shortcut_count == 2); - CHECK(strcmp(settings.behavior_shortcuts[0].shortcut, "Control+2") == 0); - CHECK(strcmp(settings.behavior_shortcuts[0].label, "First") == 0); - CHECK(strcmp(settings.behavior_shortcuts[1].id, "motion:2") == 0); - CHECK(settings.model_label_count == 1); - CHECK(strcmp(settings.model_labels[0].label, "New") == 0); -} - -static void check_shortcuts(void) { - static BongoCatSettings settings; - bongo_cat_settings_defaults(&settings); - memcpy(settings.shortcuts.toggle_pet_visibility, "Control+B", - sizeof("Control+B")); - memcpy(settings.shortcuts.visible_preferences, "control+b", - sizeof("control+b")); - settings.behavior_shortcut_count = 2; - memcpy(settings.behavior_shortcuts[0].id, "motion:0", sizeof("motion:0")); - memcpy(settings.behavior_shortcuts[0].shortcut, "CONTROL+B", - sizeof("CONTROL+B")); - memcpy(settings.behavior_shortcuts[1].id, "motion:1", sizeof("motion:1")); - memcpy(settings.behavior_shortcuts[1].shortcut, "Control+M", - sizeof("Control+M")); - bongo_cat_settings_validate(&settings); - CHECK(strcmp(settings.shortcuts.toggle_pet_visibility, "Control+B") == 0); - CHECK(!settings.shortcuts.visible_preferences[0]); - CHECK(settings.behavior_shortcut_count == 1); - CHECK(strcmp(settings.behavior_shortcuts[0].id, "motion:1") == 0); - CHECK(strcmp(settings.behavior_shortcuts[0].shortcut, "Control+M") == 0); - CHECK(bongo_cat_settings_shortcut_conflicts(&settings, "control+b", NULL)); - CHECK(!bongo_cat_settings_shortcut_conflicts(&settings, "control+b", - settings.shortcuts.toggle_pet_visibility)); -} - void test_config(void) { - check_defaults_and_validation(); - check_shortcuts(); - check_override_canonicalization(); + test_config_validation(); BongoCatLanguage language; CHECK(!strcmp(bongo_cat_language_name(BONGO_CAT_LANG_ZH_HANT), "zh-Hant")); diff --git a/tests/core/test_config_validation.c b/tests/core/test_config_validation.c new file mode 100644 index 00000000..1415189c --- /dev/null +++ b/tests/core/test_config_validation.c @@ -0,0 +1,122 @@ +#include "test.h" +#include "test_config_validation.h" +#include "bongo_cat/config.h" +#include "bongo_cat/model.h" + +#include +#include + +static void check_defaults_and_validation(void) { + static BongoCatSettings settings; + static BongoCatSessionState session; + bongo_cat_settings_defaults(&settings); + bongo_cat_session_defaults(&session); + CHECK(settings.model.max_fps == 60 && settings.model.mouse_centered && + !settings.model.multiple_pets); + CHECK(settings.window.always_on_top && !settings.window.keep_in_screen); + CHECK(!settings.window.obs_background); + CHECK(!settings.window.random_expression && + settings.window.random_expression_interval_seconds == + BONGO_CAT_DEFAULT_RANDOM_EXPRESSION_SECONDS); + CHECK(settings.window.obs_background_color == + BONGO_CAT_OBS_BACKGROUND_GREEN); + CHECK(session.window.visible && session.window.width == 612 && + session.window.height == 354 && session.window.content_width == 612 && + session.window.content_height == 354); + CHECK(strcmp(session.active_model_id, "standard") == 0); + CHECK(session.additional_model_count == 0); + CHECK(bongo_cat_session_add_model(&session, "keyboard")); + CHECK(bongo_cat_session_add_model(&session, "keyboard")); + CHECK(bongo_cat_session_add_model(&session, "gamepad")); + CHECK(session.additional_model_count == 2 && + bongo_cat_session_model_active(&session, "standard") && + bongo_cat_session_model_active(&session, "keyboard")); + CHECK(bongo_cat_session_remove_model(&session, "keyboard")); + CHECK(session.additional_model_count == 1 && + !bongo_cat_session_model_active(&session, "keyboard")); + bongo_cat_session_clear_additional_models(&session); + settings.model.max_fps = 900; + settings.window.obs_background_color = BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT; + settings.window.hide_delay_seconds = NAN; + settings.window.random_expression_interval_seconds = NAN; + session.window.scale_percent = -2.0f; + session.window.opacity_percent = NAN; + session.active_behavior_count = 3; + memcpy(session.active_behaviors[0].model_id, "model", sizeof("model")); + memcpy(session.active_behaviors[0].behavior_id, "model:motion:Tap:0", + sizeof("model:motion:Tap:0")); + session.active_behaviors[1] = session.active_behaviors[0]; + memcpy(session.active_behaviors[2].model_id, "other", sizeof("other")); + bongo_cat_settings_validate(&settings); + bongo_cat_session_validate(&session); + CHECK(settings.model.max_fps == 240); + CHECK(settings.window.hide_delay_seconds == 0.0f); + CHECK(settings.window.random_expression_interval_seconds == + BONGO_CAT_DEFAULT_RANDOM_EXPRESSION_SECONDS); + CHECK(settings.window.obs_background_color == + BONGO_CAT_OBS_BACKGROUND_GREEN); + CHECK(session.window.scale_percent == 10.0f); + CHECK(session.window.opacity_percent == + BONGO_CAT_DEFAULT_WINDOW_OPACITY_PERCENT); + CHECK(session.active_behavior_count == 1); + CHECK(strcmp(session.active_behaviors[0].model_id, "model") == 0); +} + +static void check_override_canonicalization(void) { + static BongoCatSettings settings; + bongo_cat_settings_defaults(&settings); + settings.behavior_shortcut_count = 4; + memcpy(settings.behavior_shortcuts[0].id, "motion:1", sizeof("motion:1")); + memcpy(settings.behavior_shortcuts[0].shortcut, "Control+1", + sizeof("Control+1")); + memcpy(settings.behavior_shortcuts[0].label, "First", sizeof("First")); + memcpy(settings.behavior_shortcuts[1].id, "motion:1", sizeof("motion:1")); + memcpy(settings.behavior_shortcuts[1].shortcut, "Control+2", + sizeof("Control+2")); + memcpy(settings.behavior_shortcuts[2].id, "empty", sizeof("empty")); + memcpy(settings.behavior_shortcuts[3].id, "motion:2", sizeof("motion:2")); + memcpy(settings.behavior_shortcuts[3].label, "Second", sizeof("Second")); + settings.model_label_count = 2; + memcpy(settings.model_labels[0].id, "model", sizeof("model")); + memcpy(settings.model_labels[0].label, "Old", sizeof("Old")); + memcpy(settings.model_labels[1].id, "model", sizeof("model")); + memcpy(settings.model_labels[1].label, "New", sizeof("New")); + bongo_cat_settings_validate(&settings); + CHECK(settings.behavior_shortcut_count == 2); + CHECK(strcmp(settings.behavior_shortcuts[0].shortcut, "Control+2") == 0); + CHECK(strcmp(settings.behavior_shortcuts[0].label, "First") == 0); + CHECK(strcmp(settings.behavior_shortcuts[1].id, "motion:2") == 0); + CHECK(settings.model_label_count == 1); + CHECK(strcmp(settings.model_labels[0].label, "New") == 0); +} + +static void check_shortcuts(void) { + static BongoCatSettings settings; + bongo_cat_settings_defaults(&settings); + memcpy(settings.shortcuts.toggle_pet_visibility, "Control+B", + sizeof("Control+B")); + memcpy(settings.shortcuts.visible_preferences, "control+b", + sizeof("control+b")); + settings.behavior_shortcut_count = 2; + memcpy(settings.behavior_shortcuts[0].id, "motion:0", sizeof("motion:0")); + memcpy(settings.behavior_shortcuts[0].shortcut, "CONTROL+B", + sizeof("CONTROL+B")); + memcpy(settings.behavior_shortcuts[1].id, "motion:1", sizeof("motion:1")); + memcpy(settings.behavior_shortcuts[1].shortcut, "Control+M", + sizeof("Control+M")); + bongo_cat_settings_validate(&settings); + CHECK(strcmp(settings.shortcuts.toggle_pet_visibility, "Control+B") == 0); + CHECK(!settings.shortcuts.visible_preferences[0]); + CHECK(settings.behavior_shortcut_count == 1); + CHECK(strcmp(settings.behavior_shortcuts[0].id, "motion:1") == 0); + CHECK(strcmp(settings.behavior_shortcuts[0].shortcut, "Control+M") == 0); + CHECK(bongo_cat_settings_shortcut_conflicts(&settings, "control+b", NULL)); + CHECK(!bongo_cat_settings_shortcut_conflicts(&settings, "control+b", + settings.shortcuts.toggle_pet_visibility)); +} + +void test_config_validation(void) { + check_defaults_and_validation(); + check_shortcuts(); + check_override_canonicalization(); +} diff --git a/tests/core/test_config_validation.h b/tests/core/test_config_validation.h new file mode 100644 index 00000000..a71457d0 --- /dev/null +++ b/tests/core/test_config_validation.h @@ -0,0 +1,6 @@ +#ifndef BONGO_CAT_TEST_CONFIG_VALIDATION_H +#define BONGO_CAT_TEST_CONFIG_VALIDATION_H + +void test_config_validation(void); + +#endif diff --git a/tests/model_import/test_mver_container.c b/tests/model_import/test_mver_container.c new file mode 100644 index 00000000..26fc798a --- /dev/null +++ b/tests/model_import/test_mver_container.c @@ -0,0 +1,71 @@ +#include "test_mver_import_internal.h" +#include "test_mver_support.h" +#include "model_import_mver_internal.h" +#include "model_storage.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +void test_mver_container_discovery(void) { + char root[BONGO_CAT_PATH_CAP], package[BONGO_CAT_PATH_CAP]; + char mode[BONGO_CAT_PATH_CAP]; + char bundle[BONGO_CAT_PATH_CAP], nested_package[BONGO_CAT_PATH_CAP]; + char variant[BONGO_CAT_PATH_CAP], variant_model[BONGO_CAT_PATH_CAP]; + char variant_img[BONGO_CAT_PATH_CAP], variant_standard[BONGO_CAT_PATH_CAP]; + char variant_hand[BONGO_CAT_PATH_CAP], source_hand[BONGO_CAT_PATH_CAP]; + char *temporary = SDL_GetCurrentDirectory(); + CHECK(temporary != NULL); + if (!temporary) return; + unsigned long long stamp = (unsigned long long)SDL_GetTicksNS(); + snprintf(root, sizeof(root), "%s/bongo-cat-container-%llu", temporary, + stamp); + bongo_cat_model_remove_tree(root, NULL); + CHECK(SDL_CreateDirectory(root)); + CHECK(child(package, sizeof(package), root, "app", true)); + CHECK(mver_fixture(package)); + CHECK(child(bundle, sizeof(bundle), root, "bundle", true)); + CHECK(child(nested_package, sizeof(nested_package), bundle, "model", true)); + CHECK(mver_fixture(nested_package)); + char nested_config[BONGO_CAT_PATH_CAP], slim_config[BONGO_CAT_PATH_CAP]; + CHECK(child(nested_config, sizeof(nested_config), nested_package, + "config.json", false)); + CHECK(child(slim_config, sizeof(slim_config), nested_package, + BONGO_CAT_SKIN_CONFIG_FILE, false)); + CHECK(SDL_RenamePath(nested_config, slim_config)); + CHECK(child(variant, sizeof(variant), bundle, "variant", true)); + CHECK(child(variant_model, sizeof(variant_model), variant, "model", true)); + CHECK(child(variant_img, sizeof(variant_img), variant_model, "img", true)); + CHECK(child(variant_standard, sizeof(variant_standard), variant_img, + "standard", true)); + CHECK(child(variant_hand, sizeof(variant_hand), variant_standard, + "hand", true)); + CHECK(child(source_hand, sizeof(source_hand), nested_package, + "img/standard/hand/0.png", false)); + CHECK(child(mode, sizeof(mode), variant_hand, "0.png", false) && + SDL_CopyFile(source_hand, mode)); + char normalized[BONGO_CAT_PATH_CAP]; + BongoCatError source_error = {0}; + CHECK(bongo_cat_import_source_directory(mode, normalized, + sizeof(normalized), &source_error) == BONGO_CAT_OK); + CHECK(strcmp(normalized, variant_model) == 0); + BongoCatImportDiscovery discovery = {0}; + BongoCatError error = {0}; + CHECK(bongo_cat_import_mver_discover_exact(package, &discovery, &error) == 1); + CHECK(discovery.count == 1); + CHECK(child(mode, sizeof(mode), package, "img/standard", false)); + BongoCatImportDiscovery nested = {0}; + CHECK(bongo_cat_import_mver_discover_exact(mode, &nested, &error) == 0); + CHECK(bongo_cat_import_mver_discover(mode, &nested, &error) == 1); + BongoCatImportDiscovery selected_container = {0}; + CHECK(bongo_cat_import_discover(root, &selected_container, &error)); + CHECK(selected_container.count == 3); + size_t patch_count = 0; + for (size_t i = 0; i < selected_container.count; ++i) + if (selected_container.candidates[i].format == + BONGO_CAT_IMPORT_MVER_PATCH) patch_count++; + CHECK(patch_count == 1); + CHECK(bongo_cat_model_remove_tree(root, NULL)); + SDL_free(temporary); +} diff --git a/tests/model_import/test_mver_import.c b/tests/model_import/test_mver_import.c index 2f216b1f..5f54481f 100644 --- a/tests/model_import/test_mver_import.c +++ b/tests/model_import/test_mver_import.c @@ -5,17 +5,14 @@ #include "ui_font_atlas.h" #include "runtime.h" #include "test_mver_support.h" +#include "test_mver_import_internal.h" #include "bongo_cat/path.h" #include #include #include #include #include -static int failures; -#define CHECK(value) do { if (!(value)) { \ - fprintf(stderr, "%s:%d: check failed: %s\n", __FILE__, __LINE__, #value); \ - failures++; \ -} } while (0) +int failures; int test_preferences_text(void); int test_mver_nearby_identity(void); int test_mver_nearby_refresh(void); int test_mver_missing_motion_groups(void); @@ -188,66 +185,6 @@ static void model_visual_curve(void) { CHECK(complete == 1.0f && !value.model_load_visual_active); } -static void container_discovery(void) { - char root[BONGO_CAT_PATH_CAP], package[BONGO_CAT_PATH_CAP]; - char mode[BONGO_CAT_PATH_CAP]; - char bundle[BONGO_CAT_PATH_CAP], nested_package[BONGO_CAT_PATH_CAP]; - char variant[BONGO_CAT_PATH_CAP], variant_model[BONGO_CAT_PATH_CAP]; - char variant_img[BONGO_CAT_PATH_CAP], variant_standard[BONGO_CAT_PATH_CAP]; - char variant_hand[BONGO_CAT_PATH_CAP], source_hand[BONGO_CAT_PATH_CAP]; - char *temporary = SDL_GetCurrentDirectory(); - CHECK(temporary != NULL); - if (!temporary) return; - unsigned long long stamp = (unsigned long long)SDL_GetTicksNS(); - snprintf(root, sizeof(root), "%s/bongo-cat-container-%llu", temporary, - stamp); - bongo_cat_model_remove_tree(root, NULL); - CHECK(SDL_CreateDirectory(root)); - CHECK(child(package, sizeof(package), root, "app", true)); - CHECK(mver_fixture(package)); - CHECK(child(bundle, sizeof(bundle), root, "bundle", true)); - CHECK(child(nested_package, sizeof(nested_package), bundle, "model", true)); - CHECK(mver_fixture(nested_package)); - char nested_config[BONGO_CAT_PATH_CAP], slim_config[BONGO_CAT_PATH_CAP]; - CHECK(child(nested_config, sizeof(nested_config), nested_package, - "config.json", false)); - CHECK(child(slim_config, sizeof(slim_config), nested_package, - BONGO_CAT_SKIN_CONFIG_FILE, false)); - CHECK(SDL_RenamePath(nested_config, slim_config)); - CHECK(child(variant, sizeof(variant), bundle, "variant", true)); - CHECK(child(variant_model, sizeof(variant_model), variant, "model", true)); - CHECK(child(variant_img, sizeof(variant_img), variant_model, "img", true)); - CHECK(child(variant_standard, sizeof(variant_standard), variant_img, "standard", true)); - CHECK(child(variant_hand, sizeof(variant_hand), variant_standard, "hand", true)); - CHECK(child(source_hand, sizeof(source_hand), nested_package, - "img/standard/hand/0.png", false)); - CHECK(child(mode, sizeof(mode), variant_hand, "0.png", false) && - SDL_CopyFile(source_hand, mode)); - char normalized[BONGO_CAT_PATH_CAP]; - BongoCatError source_error = {0}; - CHECK(bongo_cat_import_source_directory(mode, normalized, - sizeof(normalized), &source_error) == BONGO_CAT_OK); - CHECK(strcmp(normalized, variant_model) == 0); - BongoCatImportDiscovery discovery = {0}; - BongoCatError error = {0}; - CHECK(bongo_cat_import_mver_discover_exact(package, &discovery, &error) == 1); - CHECK(discovery.count == 1); - CHECK(child(mode, sizeof(mode), package, "img/standard", false)); - BongoCatImportDiscovery nested = {0}; - CHECK(bongo_cat_import_mver_discover_exact(mode, &nested, &error) == 0); - CHECK(bongo_cat_import_mver_discover(mode, &nested, &error) == 1); - BongoCatImportDiscovery selected_container = {0}; - CHECK(bongo_cat_import_discover(root, &selected_container, &error)); - CHECK(selected_container.count == 3); - size_t patch_count = 0; - for (size_t i = 0; i < selected_container.count; ++i) - if (selected_container.candidates[i].format == BONGO_CAT_IMPORT_MVER_PATCH) - patch_count++; - CHECK(patch_count == 1); - CHECK(bongo_cat_model_remove_tree(root, NULL)); - SDL_free(temporary); -} - static void tauri_exact_discovery(void) { char *temporary = SDL_GetCurrentDirectory(); CHECK(temporary != NULL); @@ -301,7 +238,7 @@ int main(void) { failures += test_mver_nearby_identity(); failures += test_mver_nearby_refresh(); failures += test_model_import_identity(); - container_discovery(); + test_mver_container_discovery(); tauri_exact_discovery(); failures += test_slim_package(); return failures ? 1 : 0; diff --git a/tests/model_import/test_mver_import_internal.h b/tests/model_import/test_mver_import_internal.h new file mode 100644 index 00000000..e090e9bf --- /dev/null +++ b/tests/model_import/test_mver_import_internal.h @@ -0,0 +1,16 @@ +#ifndef BONGO_CAT_TEST_MVER_IMPORT_INTERNAL_H +#define BONGO_CAT_TEST_MVER_IMPORT_INTERNAL_H + +#include + +extern int failures; + +#define CHECK(value) do { if (!(value)) { \ + fprintf(stderr, "%s:%d: check failed: %s\n", \ + __FILE__, __LINE__, #value); \ + failures++; \ +} } while (0) + +void test_mver_container_discovery(void); + +#endif From e3419acdf2765a4aac19448f21943a391245048b Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 24 Aug 2026 11:58:20 +0800 Subject: [PATCH 091/334] fix: restore viewer audit allocation declarations --- src/runtime/diagnostics/live2d_viewer_audit.c | 1 + 1 file changed, 1 insertion(+) diff --git a/src/runtime/diagnostics/live2d_viewer_audit.c b/src/runtime/diagnostics/live2d_viewer_audit.c index 6e531c81..b2ed6da3 100644 --- a/src/runtime/diagnostics/live2d_viewer_audit.c +++ b/src/runtime/diagnostics/live2d_viewer_audit.c @@ -6,6 +6,7 @@ #include #include #include +#include #include static const char *metric_parameters[] = { From c405dc968a3a27135732dcea53c8fcfec7f15e12 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 24 Aug 2026 12:27:23 +0800 Subject: [PATCH 092/334] fix: guard i18n test path construction --- tests/i18n/test_i18n.c | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/tests/i18n/test_i18n.c b/tests/i18n/test_i18n.c index 0fbc0a48..032445aa 100644 --- a/tests/i18n/test_i18n.c +++ b/tests/i18n/test_i18n.c @@ -1,4 +1,5 @@ #include "bongo_cat/i18n.h" +#include "bongo_cat/path.h" #include "bongo_cat/utf8.h" #include @@ -6,9 +7,19 @@ #include #include +static bool locale_path(char *output, size_t capacity, const char *root, + const char *name) { + char filename[32]; + size_t length = strlen(name); + if (length > sizeof(filename) - sizeof(".json")) return false; + memcpy(filename, name, length); + memcpy(filename + length, ".json", sizeof(".json")); + return bongo_cat_path_join(output, capacity, root, filename); +} + static bool locale_encoding_valid(const char *root, const char *name) { char path[BONGO_CAT_PATH_CAP]; - snprintf(path, sizeof(path), "%s/%s.json", root, name); + if (!locale_path(path, sizeof(path), root, name)) return false; FILE *file = fopen(path, "rb"); if (!file || fseek(file, 0, SEEK_END) != 0) { if (file) fclose(file); @@ -38,7 +49,7 @@ static bool locale_encoding_valid(const char *root, const char *name) { static yyjson_doc *load(const char *root, const char *name) { char path[BONGO_CAT_PATH_CAP]; - snprintf(path, sizeof(path), "%s/%s.json", root, name); + if (!locale_path(path, sizeof(path), root, name)) return NULL; return yyjson_read_file(path, 0, NULL, NULL); } @@ -59,7 +70,17 @@ static bool same_shape(yyjson_val *reference, yyjson_val *candidate, const char *name = yyjson_get_str(key); yyjson_val *next = yyjson_obj_get(candidate, name); char child[BONGO_CAT_PATH_CAP]; - snprintf(child, sizeof(child), "%s%s%s", path, path[0] ? "." : "", name); + size_t path_length = strlen(path), name_length = strlen(name); + size_t separator = path_length ? 1 : 0; + size_t available = path_length < sizeof(child) + ? sizeof(child) - path_length : 0; + if (separator >= available || name_length >= available - separator) { + fprintf(stderr, "Locale key path is too long\n"); + return false; + } + memcpy(child, path, path_length); + if (separator) child[path_length] = '.'; + memcpy(child + path_length + separator, name, name_length + 1); if (!same_shape(value, next, child)) return false; } return true; @@ -125,7 +146,8 @@ static bool contains_replacement(yyjson_val *value) { int main(void) { char root[BONGO_CAT_PATH_CAP]; - snprintf(root, sizeof(root), "%s/resources/assets/locales", BONGO_CAT_NATIVE_SOURCE_DIR); + if (!bongo_cat_path_join(root, sizeof(root), BONGO_CAT_NATIVE_SOURCE_DIR, + "resources/assets/locales")) return 1; yyjson_doc *reference = load(root, "zh-CN"); if (!reference) return 1; const char *required_ui_keys[] = { From 4fb4d5ceaca4308c56a7b647ce4638fa064d40a5 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 24 Aug 2026 12:39:31 +0800 Subject: [PATCH 093/334] fix: run store packaging on canonical repository --- .github/scripts/check-publish-guards.ps1 | 1 - .github/workflows/ci.yml | 2 +- packaging/microsoft-store/README.md | 5 +++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/scripts/check-publish-guards.ps1 b/.github/scripts/check-publish-guards.ps1 index 657e516c..1bdb5972 100644 --- a/.github/scripts/check-publish-guards.ps1 +++ b/.github/scripts/check-publish-guards.ps1 @@ -5,7 +5,6 @@ $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest $upstreamGuards = @( - "github.repository == 'bongocat-pet/BongoCat'", "github.repository == 'vladelaina/BongoCat'" ) $workflowDirectory = Join-Path (Split-Path $PSScriptRoot -Parent) 'workflows' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60712fac..dd7a5c9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -258,7 +258,7 @@ jobs: store-package: name: package / microsoft-store if: >- - github.repository == 'bongocat-pet/BongoCat' && + github.repository == 'vladelaina/BongoCat' && github.event_name == 'push' && github.ref == 'refs/heads/main' needs: [publishing-policy, quality] diff --git a/packaging/microsoft-store/README.md b/packaging/microsoft-store/README.md index 8b147a9e..3e42fc42 100644 --- a/packaging/microsoft-store/README.md +++ b/packaging/microsoft-store/README.md @@ -2,8 +2,9 @@ The protected `store-package` job in `.github/workflows/ci.yml` creates a raw `bongocat__x64.msix` artifact on pushes to the upstream `main` -branch. The artifact is intentionally unsigned: Partner Center signs accepted -MSIX packages with the Microsoft Store certificate during submission. +branch of `vladelaina/BongoCat`. The artifact is intentionally unsigned: +Partner Center signs accepted MSIX packages with the Microsoft Store +certificate during submission. ## Store identity From ccf8fac11be855113304970330daf4079740ec38 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 24 Aug 2026 13:48:45 +0800 Subject: [PATCH 094/334] fix: restore production Cubism builds in Actions --- .github/scripts/restore-cubism-sdk.ps1 | 75 +++++++++++++++++++++----- .github/workflows/ci.yml | 17 +++--- .github/workflows/release.yml | 2 + cmake/DiagnosticBuildNotice.txt | 7 +++ cmake/Packaging.cmake | 10 +++- packaging/microsoft-store/README.md | 20 ++++--- src/live2d/live2d_stub.c | 9 ++++ 7 files changed, 107 insertions(+), 33 deletions(-) create mode 100644 cmake/DiagnosticBuildNotice.txt diff --git a/.github/scripts/restore-cubism-sdk.ps1 b/.github/scripts/restore-cubism-sdk.ps1 index 3ea52d50..bda40a4d 100644 --- a/.github/scripts/restore-cubism-sdk.ps1 +++ b/.github/scripts/restore-cubism-sdk.ps1 @@ -1,7 +1,25 @@ [CmdletBinding()] param( - [string]$ArchiveUrl = $env:CUBISM_SDK_ARCHIVE_URL, - [string]$ExpectedSha256 = $env:CUBISM_SDK_ARCHIVE_SHA256, + [string]$ArchiveUrl = $(if ($env:CUBISM_SDK_ARCHIVE_URL) { + $env:CUBISM_SDK_ARCHIVE_URL + } else { + 'https://cubism.live2d.com/sdk-native/bin/CubismSdkForNative-5-r.5.zip' + }), + [string]$ExpectedSha256 = $(if ($env:CUBISM_SDK_ARCHIVE_SHA256) { + $env:CUBISM_SDK_ARCHIVE_SHA256 + } else { + '7FF3A4BBC19C0A8728965AA522AB77EB11B252916453E68A8A78D3B71188BB12' + }), + [string]$GlewArchiveUrl = $(if ($env:CUBISM_GLEW_ARCHIVE_URL) { + $env:CUBISM_GLEW_ARCHIVE_URL + } else { + 'https://github.com/nigels-com/glew/releases/download/glew-2.2.0/glew-2.2.0.zip' + }), + [string]$ExpectedGlewSha256 = $(if ($env:CUBISM_GLEW_ARCHIVE_SHA256) { + $env:CUBISM_GLEW_ARCHIVE_SHA256 + } else { + 'A9046A913774395A095EDCC0B0AC2D81C3AACCA61787B39839B941E9BE14E0D4' + }), [string]$Destination ) @@ -14,19 +32,20 @@ if (-not $Destination) { } if ([string]::IsNullOrWhiteSpace($ArchiveUrl) -or - [string]::IsNullOrWhiteSpace($ExpectedSha256)) { - throw @" -The Store workflow needs the licensed Cubism SDK archive. Configure the -CUBISM_SDK_ARCHIVE_URL and CUBISM_SDK_ARCHIVE_SHA256 repository secrets with a -private URL and SHA-256 for CubismSdkForNative 5 r.5. The SDK is intentionally -not committed to this repository. -"@ + [string]::IsNullOrWhiteSpace($ExpectedSha256) -or + [string]::IsNullOrWhiteSpace($GlewArchiveUrl) -or + [string]::IsNullOrWhiteSpace($ExpectedGlewSha256)) { + throw 'Cubism SDK and GLEW archive URLs and SHA-256 digests are required.' } $ExpectedSha256 = $ExpectedSha256.Trim().ToUpperInvariant() if ($ExpectedSha256 -notmatch '^[0-9A-F]{64}$') { throw "CUBISM_SDK_ARCHIVE_SHA256 must be a 64-character SHA-256 digest." } +$ExpectedGlewSha256 = $ExpectedGlewSha256.Trim().ToUpperInvariant() +if ($ExpectedGlewSha256 -notmatch '^[0-9A-F]{64}$') { + throw "CUBISM_GLEW_ARCHIVE_SHA256 must be a 64-character SHA-256 digest." +} $Destination = [System.IO.Path]::GetFullPath($Destination) $repositoryPrefix = $repositoryRoot.TrimEnd('\', '/') + @@ -38,11 +57,12 @@ if (-not $Destination.StartsWith($repositoryPrefix, } $temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("BongoCatCubism_" + [guid]::NewGuid()) $archivePath = Join-Path $temporaryRoot "cubism-sdk.zip" +$glewArchivePath = Join-Path $temporaryRoot "glew.zip" $extractPath = Join-Path $temporaryRoot "extracted" try { New-Item -ItemType Directory -Force -Path $temporaryRoot, $extractPath | Out-Null - Write-Host "Downloading the private Cubism SDK archive..." + Write-Host "Downloading the pinned Cubism SDK archive..." Invoke-WebRequest -Uri $ArchiveUrl -OutFile $archivePath -UseBasicParsing $actualSha256 = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToUpperInvariant() @@ -64,8 +84,7 @@ try { $required = @( (Join-Path $rootCandidate.FullName "Core\include\Live2DCubismCore.h"), (Join-Path $rootCandidate.FullName "Core\lib\windows\x86_64\143\Live2DCubismCore_MT.lib"), - (Join-Path $rootCandidate.FullName "Framework\CMakeLists.txt"), - (Join-Path $rootCandidate.FullName "Samples\OpenGL\thirdParty\glew\build\cmake\CMakeLists.txt") + (Join-Path $rootCandidate.FullName "Framework\CMakeLists.txt") ) foreach ($path in $required) { if (-not (Test-Path -LiteralPath $path)) { @@ -73,9 +92,41 @@ try { } } + Write-Host "Downloading the pinned GLEW 2.2.0 archive..." + Invoke-WebRequest -Uri $GlewArchiveUrl -OutFile $glewArchivePath -UseBasicParsing + $actualGlewSha256 = (Get-FileHash -LiteralPath $glewArchivePath -Algorithm SHA256).Hash.ToUpperInvariant() + if ($actualGlewSha256 -ne $ExpectedGlewSha256) { + throw "GLEW archive hash mismatch: expected $ExpectedGlewSha256, got $actualGlewSha256." + } + Expand-Archive -LiteralPath $glewArchivePath -DestinationPath $extractPath -Force + $glewRoot = Get-ChildItem -LiteralPath $extractPath -Directory -Recurse | + Where-Object { + (Test-Path (Join-Path $_.FullName 'build\cmake\CMakeLists.txt')) -and + (Test-Path (Join-Path $_.FullName 'include\GL\glew.h')) -and + (Test-Path (Join-Path $_.FullName 'src\glew.c')) + } | + Select-Object -First 1 + if (-not $glewRoot) { + throw 'The GLEW archive does not contain the expected CMake, header, and source trees.' + } + Remove-Item -LiteralPath $Destination -Recurse -Force -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $Destination | Out-Null Get-ChildItem -LiteralPath $rootCandidate.FullName -Force | Copy-Item -Destination $Destination -Recurse -Force + $glewDestination = Join-Path $Destination 'Samples\OpenGL\thirdParty\glew' + New-Item -ItemType Directory -Force -Path $glewDestination | Out-Null + Get-ChildItem -LiteralPath $glewRoot.FullName -Force | + Copy-Item -Destination $glewDestination -Recurse -Force + $requiredGlew = @( + (Join-Path $glewDestination 'build\cmake\CMakeLists.txt'), + (Join-Path $glewDestination 'include\GL\glew.h'), + (Join-Path $glewDestination 'src\glew.c') + ) + foreach ($path in $requiredGlew) { + if (-not (Test-Path -LiteralPath $path)) { + throw "The restored GLEW tree is incomplete; missing $path." + } + } Write-Host "Cubism SDK restored to $Destination" } finally { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd7a5c9e..a9f9424e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -189,7 +189,7 @@ jobs: shell: pwsh run: | $name = (Get-Content build/bongo-cat-package-name.txt -Raw).Trim() - if ($name -notmatch '^BongoCat-\d+\.\d+\.\d+-(windows|linux|macos)-(x64|x86|arm64|universal)$') { + if ($name -notmatch '^BongoCat-Diagnostic-\d+\.\d+\.\d+-(windows|linux|macos)-(x64|x86|arm64|universal)$') { throw "Unexpected release package name: $name" } "name=$name" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 @@ -211,11 +211,6 @@ jobs: if (-not (Test-Path -LiteralPath "$($archives[0].FullName).sha256")) { throw "Release archive checksum is missing" } - @' - This CI artifact uses the diagnostic renderer because the licensed - Cubism SDK is not present in the public source checkout. It is for - startup and platform diagnostics, not end-user distribution. - '@ | Set-Content build/dist/DIAGNOSTIC-NO-CUBISM.txt -Encoding ascii - name: Package Windows installer if: runner.os == 'Windows' @@ -235,10 +230,8 @@ jobs: - name: Upload portable release uses: actions/upload-artifact@v7 with: - name: portable-${{ steps.package.outputs.name }} - path: | - build/dist/${{ steps.package.outputs.name }}.* - build/dist/DIAGNOSTIC-NO-CUBISM.txt + name: diagnostic-portable-${{ steps.package.outputs.name }} + path: build/dist/${{ steps.package.outputs.name }}.* if-no-files-found: error retention-days: 30 compression-level: 0 @@ -247,7 +240,7 @@ jobs: if: runner.os == 'Windows' uses: actions/upload-artifact@v7 with: - name: installer-${{ steps.package.outputs.name }} + name: diagnostic-installer-${{ steps.package.outputs.name }} path: | build/dist/${{ steps.package.outputs.name }}-setup.exe build/dist/${{ steps.package.outputs.name }}-setup.exe.sha256 @@ -266,6 +259,8 @@ jobs: env: CUBISM_SDK_ARCHIVE_URL: ${{ secrets.CUBISM_SDK_ARCHIVE_URL }} CUBISM_SDK_ARCHIVE_SHA256: ${{ secrets.CUBISM_SDK_ARCHIVE_SHA256 }} + CUBISM_GLEW_ARCHIVE_URL: ${{ secrets.CUBISM_GLEW_ARCHIVE_URL }} + CUBISM_GLEW_ARCHIVE_SHA256: ${{ secrets.CUBISM_GLEW_ARCHIVE_SHA256 }} steps: - name: Checkout source uses: actions/checkout@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 619c6c66..5425b0a6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,6 +20,8 @@ jobs: env: CUBISM_SDK_ARCHIVE_URL: ${{ secrets.CUBISM_SDK_ARCHIVE_URL }} CUBISM_SDK_ARCHIVE_SHA256: ${{ secrets.CUBISM_SDK_ARCHIVE_SHA256 }} + CUBISM_GLEW_ARCHIVE_URL: ${{ secrets.CUBISM_GLEW_ARCHIVE_URL }} + CUBISM_GLEW_ARCHIVE_SHA256: ${{ secrets.CUBISM_GLEW_ARCHIVE_SHA256 }} steps: - name: Checkout source diff --git a/cmake/DiagnosticBuildNotice.txt b/cmake/DiagnosticBuildNotice.txt new file mode 100644 index 00000000..bbf7d34c --- /dev/null +++ b/cmake/DiagnosticBuildNotice.txt @@ -0,0 +1,7 @@ +BongoCat diagnostic build + +This package was built without the Live2D Cubism SDK. Live2D rendering, +animation, pointer tracking, imported-model rendering, and cover generation +are disabled. This build is only for startup and platform diagnostics. + +Do not distribute this package to end users or submit it to an app store. diff --git a/cmake/Packaging.cmake b/cmake/Packaging.cmake index fa4b2171..85ad561b 100644 --- a/cmake/Packaging.cmake +++ b/cmake/Packaging.cmake @@ -3,6 +3,12 @@ install(TARGETS bongo_cat BUNDLE DESTINATION . COMPONENT Runtime) install(FILES LICENSE DESTINATION . COMPONENT Runtime) install(FILES resources/assets/models/LICENSE DESTINATION assets/models COMPONENT Runtime) +set(BONGO_CAT_PACKAGE_PRODUCT "BongoCat") +if(NOT BONGO_CAT_CUBISM_ENABLED) + set(BONGO_CAT_PACKAGE_PRODUCT "BongoCat-Diagnostic") + install(FILES cmake/DiagnosticBuildNotice.txt DESTINATION . + COMPONENT Runtime) +endif() if(UNIX AND NOT APPLE) install(DIRECTORY resources/assets DESTINATION . COMPONENT Runtime PATTERN "logo-mac.png" EXCLUDE PATTERN "ui-icons.png" EXCLUDE @@ -12,11 +18,11 @@ endif() include(cmake/PackagingPlatform.cmake) set(BONGO_CAT_PACKAGE_NAME - "BongoCat-${PROJECT_VERSION}-${BONGO_CAT_PACKAGE_PLATFORM}") + "${BONGO_CAT_PACKAGE_PRODUCT}-${PROJECT_VERSION}-${BONGO_CAT_PACKAGE_PLATFORM}") file(GENERATE OUTPUT "${CMAKE_BINARY_DIR}/bongo-cat-package-name.txt" CONTENT "${BONGO_CAT_PACKAGE_NAME}\n") -set(CPACK_PACKAGE_NAME "BongoCat") +set(CPACK_PACKAGE_NAME "${BONGO_CAT_PACKAGE_PRODUCT}") set(CPACK_PACKAGE_VENDOR "BongoCat") set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "${PROJECT_DESCRIPTION}") set(CPACK_PACKAGE_HOMEPAGE_URL "${PROJECT_HOMEPAGE_URL}") diff --git a/packaging/microsoft-store/README.md b/packaging/microsoft-store/README.md index 3e42fc42..a4757a8d 100644 --- a/packaging/microsoft-store/README.md +++ b/packaging/microsoft-store/README.md @@ -27,17 +27,21 @@ version remains in the filename so GitHub artifacts are easy to identify. ## Actions SDK setup Live2D's licensed Cubism SDK is ignored by Git and cannot be checked into this -public repository. Before enabling the protected job, add these repository -secrets: +public repository. The protected job downloads the pinned Cubism 5-r.5 archive +and GLEW 2.2.0 archive from their official upstream URLs and verifies both +SHA-256 hashes before building. A repository owner may override either URL and +hash with these secrets when an approved mirror is required: -- `CUBISM_SDK_ARCHIVE_URL`: private HTTPS URL for a CubismSdkForNative 5 r.5 - ZIP archive. The archive must contain `Core`, `Framework`, and the OpenGL - sample's GLEW CMake tree. -- `CUBISM_SDK_ARCHIVE_SHA256`: SHA-256 digest of that ZIP. +- `CUBISM_SDK_ARCHIVE_URL` and `CUBISM_SDK_ARCHIVE_SHA256` +- `CUBISM_GLEW_ARCHIVE_URL` and `CUBISM_GLEW_ARCHIVE_SHA256` -The job verifies the digest and required files before configuring CMake with +Running the protected workflow is subject to the Live2D Cubism SDK licenses; +the repository owner is responsible for satisfying their release terms. + +The job verifies the digests and required files before configuring CMake with `BONGO_CAT_REQUIRE_CUBISM=ON`; it never uploads the diagnostic backend used by -the public cross-platform build. +the public cross-platform build. The ordinary CI Windows artifacts are named +`diagnostic-*` for this reason and are not suitable for Store submission. ## Local build and install check diff --git a/src/live2d/live2d_stub.c b/src/live2d/live2d_stub.c index 9376c3f7..22662d5e 100644 --- a/src/live2d/live2d_stub.c +++ b/src/live2d/live2d_stub.c @@ -1,6 +1,7 @@ #include "bongo_cat/model.h" #include "model_import.h" +#include #include struct BongoCatLive2D { @@ -12,7 +13,15 @@ struct BongoCatLive2D { static BongoCatLive2D *create_runtime(const char *asset_root, bool cover_runtime, BongoCatError *error) { + static bool warning_logged; (void)asset_root; + if (!cover_runtime && !warning_logged) { + warning_logged = true; + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Cubism SDK unavailable: diagnostic backend active; Live2D " + "rendering, animation, pointer tracking, and cover generation " + "are disabled"); + } BongoCatLive2D *value = calloc(1, sizeof(*value)); if (!value) bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, "Cannot allocate Live2D runtime"); else value->cover_runtime = cover_runtime; From fa631d81c9f239afbac51508c1203830ba8848c0 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 24 Aug 2026 15:11:56 +0800 Subject: [PATCH 095/334] feat: add log access and unify runtime logging --- cmake/RuntimeSources.cmake | 1 + include/bongo_cat/app.h | 1 - include/bongo_cat/file.h | 1 + resources/assets/locales/de-DE.json | 2 + resources/assets/locales/en-US.json | 2 + resources/assets/locales/es-ES.json | 2 + resources/assets/locales/fr-FR.json | 2 + resources/assets/locales/ja-JP.json | 2 + resources/assets/locales/ko-KR.json | 2 + resources/assets/locales/pt-BR.json | 2 + resources/assets/locales/ru-RU.json | 2 + resources/assets/locales/zh-CN.json | 2 + resources/assets/locales/zh-Hant.json | 2 + src/core/file.c | 60 ++++++ src/runtime/lifecycle/startup.c | 124 +++++++------ src/runtime/lifecycle/storage_paths.c | 7 +- src/runtime/model/model_catalog_refresh.c | 8 +- src/ui/preferences/preferences_about.c | 65 +------ .../preferences/preferences_about_community.c | 2 +- src/ui/preferences/preferences_about_footer.c | 174 ++++++++++++++++++ src/ui/preferences/preferences_about_footer.h | 10 + src/ui/preferences/preferences_state.h | 1 + tests/core/test_config.c | 8 + tests/i18n/test_i18n.c | 1 + 24 files changed, 351 insertions(+), 132 deletions(-) create mode 100644 src/ui/preferences/preferences_about_footer.c create mode 100644 src/ui/preferences/preferences_about_footer.h diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 28365fd3..cba09b69 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -147,6 +147,7 @@ set(BONGO_CAT_UI_PREFERENCES_SOURCES src/ui/preferences/preferences.c src/ui/preferences/preferences_about.c src/ui/preferences/preferences_about_community.c + src/ui/preferences/preferences_about_footer.c src/ui/preferences/preferences_about_page.c src/ui/preferences/preferences_assets.c src/ui/preferences/preferences_behavior_dialog.c diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index 19f4a94b..c4b7877e 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -58,7 +58,6 @@ typedef struct BongoCatApp { char nearby_root[BONGO_CAT_PATH_CAP]; char executable_path[BONGO_CAT_PATH_CAP]; char primary_state_root[BONGO_CAT_PATH_CAP]; - char primary_log_root[BONGO_CAT_PATH_CAP]; char secondary_model_id[BONGO_CAT_ID_CAP]; char smoke_import_path[BONGO_CAT_PATH_CAP]; char smoke_model[BONGO_CAT_ID_CAP]; diff --git a/include/bongo_cat/file.h b/include/bongo_cat/file.h index a0ebae88..328c3ea7 100644 --- a/include/bongo_cat/file.h +++ b/include/bongo_cat/file.h @@ -9,6 +9,7 @@ extern "C" { #endif FILE *bongo_cat_file_open(const char *path, const char *mode); +bool bongo_cat_file_append(const char *path, const void *data, size_t size); bool bongo_cat_file_remove(const char *path); bool bongo_cat_file_replace(const char *source, const char *target, bool durable); diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index 460e2b58..f1bec1f4 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -202,6 +202,8 @@ "version": "Anwendungsversion", "checkUpdate": "Nach Updates suchen", "feedback": "Feedback", + "openLogs": "Protokolle öffnen", + "openLogsFailed": "Protokollordner konnte nicht geöffnet werden", "latest": "Bereits aktuell", "works": "Weitere Anwendungen", "worksText": "Weitere Software von vladelaina", diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index 42dcb96f..a7fea8ce 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -202,6 +202,8 @@ "version": "App version", "checkUpdate": "Check for updates", "feedback": "Feedback", + "openLogs": "Open logs", + "openLogsFailed": "Unable to open logs folder", "latest": "Already up to date", "works": "More apps", "worksText": "More software from vladelaina", diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index bbddf971..f82b5990 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -202,6 +202,8 @@ "version": "Versión de la aplicación", "checkUpdate": "Buscar actualizaciones", "feedback": "Comentarios", + "openLogs": "Abrir registros", + "openLogsFailed": "No se pudo abrir la carpeta de registros", "latest": "Ya está actualizado", "works": "Más aplicaciones", "worksText": "Más software de vladelaina", diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index ca22167d..9e6d533c 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -202,6 +202,8 @@ "version": "Version de l'application", "checkUpdate": "Rechercher des mises à jour", "feedback": "Commentaires", + "openLogs": "Ouvrir les journaux", + "openLogsFailed": "Impossible d'ouvrir le dossier des journaux", "latest": "Déjà à jour", "works": "Plus d'applications", "worksText": "Plus de logiciels de vladelaina", diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index daf1e63d..2f664a74 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -202,6 +202,8 @@ "version": "アプリのバージョン", "checkUpdate": "更新を確認", "feedback": "フィードバック", + "openLogs": "ログを開く", + "openLogsFailed": "ログフォルダーを開けません", "latest": "最新バージョンです", "works": "その他のアプリ", "worksText": "vladelainaのその他のソフトウェア", diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index a2a5b662..427d6be0 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -202,6 +202,8 @@ "version": "앱 버전", "checkUpdate": "업데이트 확인", "feedback": "피드백", + "openLogs": "로그 열기", + "openLogsFailed": "로그 폴더를 열 수 없습니다", "latest": "최신 버전입니다", "works": "더 많은 앱", "worksText": "vladelaina의 다른 소프트웨어", diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index e16ed82d..d9f5baf9 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -202,6 +202,8 @@ "version": "Versão do aplicativo", "checkUpdate": "Verificar atualizações", "feedback": "Feedback", + "openLogs": "Abrir logs", + "openLogsFailed": "Não foi possível abrir a pasta de logs", "latest": "Já está atualizado", "works": "Mais aplicativos", "worksText": "Outros softwares de vladelaina", diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index 061f4d56..e3782512 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -202,6 +202,8 @@ "version": "Версия приложения", "checkUpdate": "Проверить обновления", "feedback": "Обратная связь", + "openLogs": "Открыть журналы", + "openLogsFailed": "Не удалось открыть папку журналов", "latest": "Установлена последняя версия", "works": "Другие приложения", "worksText": "Другие программы от vladelaina", diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index aceaa5b8..cbab7721 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -202,6 +202,8 @@ "version": "应用版本", "checkUpdate": "检查更新", "feedback": "反馈建议", + "openLogs": "打开日志", + "openLogsFailed": "无法打开日志文件夹", "latest": "已是最新版本", "works": "更多作品", "worksText": "来自 vladelaina 的其他软件", diff --git a/resources/assets/locales/zh-Hant.json b/resources/assets/locales/zh-Hant.json index 764f4947..9141cfd2 100644 --- a/resources/assets/locales/zh-Hant.json +++ b/resources/assets/locales/zh-Hant.json @@ -202,6 +202,8 @@ "version": "應用程式版本", "checkUpdate": "檢查更新", "feedback": "意見回饋", + "openLogs": "開啟日誌", + "openLogsFailed": "無法開啟日誌資料夾", "latest": "已是最新版本", "works": "更多作品", "worksText": "來自 vladelaina 的其他軟體", diff --git a/src/core/file.c b/src/core/file.c index e299d6d9..bb3d0c77 100644 --- a/src/core/file.c +++ b/src/core/file.c @@ -21,6 +21,37 @@ FILE *bongo_cat_file_open(const char *path, const char *mode) { free(path_wide); return file; } +bool bongo_cat_file_append(const char *path, const void *data, size_t size) { + if (!path || !path[0] || (!data && size)) return false; + wchar_t *wide = bongo_cat_windows_wide(path); + HANDLE file = wide ? CreateFileW(wide, GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, + OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL) : INVALID_HANDLE_VALUE; + free(wide); + if (file == INVALID_HANDLE_VALUE) return false; + OVERLAPPED lock = {0}; + bool locked = LockFileEx(file, LOCKFILE_EXCLUSIVE_LOCK, 0, + MAXDWORD, MAXDWORD, &lock) != FALSE; + LARGE_INTEGER end = {0}; + bool positioned = locked && SetFilePointerEx(file, end, NULL, + FILE_END) != FALSE; + const unsigned char *cursor = data; + size_t remaining = size; + bool written = positioned; + while (written && remaining) { + DWORD chunk = remaining > MAXDWORD ? MAXDWORD : (DWORD)remaining; + DWORD count = 0; + written = WriteFile(file, cursor, chunk, &count, NULL) != FALSE && + count > 0; + cursor += count; + remaining -= count; + } + if (locked && !UnlockFileEx(file, 0, MAXDWORD, MAXDWORD, &lock)) + written = false; + if (!CloseHandle(file)) written = false; + return written; +} + bool bongo_cat_file_remove(const char *path) { wchar_t *wide = bongo_cat_windows_wide(path); bool removed = wide && _wremove(wide) == 0; @@ -36,9 +67,38 @@ bool bongo_cat_file_replace(const char *source, const char *target, bool durable free(source_wide); free(target_wide); return replaced; } #else +#include +#include #include +#include +#include FILE *bongo_cat_file_open(const char *path, const char *mode) { return fopen(path, mode); } + +static bool lock_file(int file, int operation) { + int result; + do result = flock(file, operation); while (result != 0 && errno == EINTR); + return result == 0; +} + +bool bongo_cat_file_append(const char *path, const void *data, size_t size) { + if (!path || !path[0] || (!data && size)) return false; + int file = open(path, O_WRONLY | O_CREAT | O_APPEND, 0666); + if (file < 0) return false; + bool locked = lock_file(file, LOCK_EX); + const unsigned char *cursor = data; + size_t remaining = size; + bool written = locked; + while (written && remaining) { + ssize_t count = write(file, cursor, remaining); + if (count < 0 && errno == EINTR) continue; + if (count <= 0) written = false; + else { cursor += count; remaining -= (size_t)count; } + } + if (locked && !lock_file(file, LOCK_UN)) written = false; + if (close(file) != 0) written = false; + return written; +} bool bongo_cat_file_remove(const char *path) { return remove(path) == 0; } bool bongo_cat_file_replace(const char *source, const char *target, bool durable) { (void)durable; return rename(source, target) == 0; diff --git a/src/runtime/lifecycle/startup.c b/src/runtime/lifecycle/startup.c index 2e00b046..1d7f948e 100644 --- a/src/runtime/lifecycle/startup.c +++ b/src/runtime/lifecycle/startup.c @@ -5,63 +5,80 @@ #include "bongo_cat/path.h" #include "storage_paths.h" +#include #include #include #include -#include -#include #ifdef _WIN32 #include #endif -static char startup_log_path[BONGO_CAT_PATH_CAP]; +static char runtime_log_path[BONGO_CAT_PATH_CAP]; +static char log_source[BONGO_CAT_ID_CAP + 8] = "unknown"; static bool startup_is_ready; static bool verbose_logging; static SDL_Mutex *log_mutex; -static void append_log(const char *path, const char *timestamp, - int category, SDL_LogPriority priority, const char *message) { - FILE *file = path && path[0] ? bongo_cat_file_open(path, "ab") : NULL; - if (!file) return; - fprintf(file, "%s [%d:%d] %s\n", timestamp ? timestamp : "unknown-time", category, (int)priority, message ? message : ""); - fclose(file); +static const char *priority_name(SDL_LogPriority priority) { + switch (priority) { + case SDL_LOG_PRIORITY_TRACE: return "TRACE"; + case SDL_LOG_PRIORITY_VERBOSE: return "VERBOSE"; + case SDL_LOG_PRIORITY_DEBUG: return "DEBUG"; + case SDL_LOG_PRIORITY_INFO: return "INFO"; + case SDL_LOG_PRIORITY_WARN: return "WARN"; + case SDL_LOG_PRIORITY_ERROR: return "ERROR"; + case SDL_LOG_PRIORITY_CRITICAL: return "CRITICAL"; + default: return "UNKNOWN"; + } } -static bool essential_info(const char *message) { - return message && strncmp(message, "[runtime] ", 10) == 0; +static void append_log(const char *path, const char *timestamp, + int category, SDL_LogPriority priority, const char *message) { + if (!path || !path[0]) return; + const char *text = message ? message : ""; + int prefix = snprintf(NULL, 0, "%s [%s] [%s:%d] ", + timestamp ? timestamp : "unknown-time", log_source, + priority_name(priority), category); + size_t text_length = strlen(text); + char *line = prefix >= 0 && text_length < SIZE_MAX - (size_t)prefix - 2 + ? malloc((size_t)prefix + text_length + 2) : NULL; + if (!line) return; + snprintf(line, (size_t)prefix + 1, "%s [%s] [%s:%d] ", + timestamp ? timestamp : "unknown-time", log_source, + priority_name(priority), category); + for (size_t i = 0; i < text_length; ++i) + line[(size_t)prefix + i] = text[i] == '\r' || text[i] == '\n' + ? ' ' : text[i]; + size_t length = (size_t)prefix + text_length; + line[length++] = '\n'; + bongo_cat_file_append(path, line, length); + free(line); } static void SDLCALL log_output(void *userdata, int category, SDL_LogPriority priority, const char *message) { (void)userdata; - if (!verbose_logging && priority < SDL_LOG_PRIORITY_WARN && - !essential_info(message)) return; + if (!verbose_logging && priority < SDL_LOG_PRIORITY_INFO) return; if (log_mutex) SDL_LockMutex(log_mutex); char timestamp[64]; bongo_cat_runtime_timestamp(timestamp, sizeof(timestamp)); - append_log(startup_log_path, timestamp, category, priority, message); - fprintf(stderr, "%s [%d:%d] %s\n", timestamp, category, (int)priority, message ? message : ""); + append_log(runtime_log_path, timestamp, category, priority, message); + fprintf(stderr, "%s [%s] [%s:%d] %s\n", timestamp, log_source, + priority_name(priority), category, message ? message : ""); if (log_mutex) SDL_UnlockMutex(log_mutex); } -static void format_shutdown_timestamp(char *target, size_t capacity) { - time_t now = time(NULL); - struct tm *utc = gmtime(&now); - if (!utc || !strftime(target, capacity, "%Y-%m-%d %H:%M:%S UTC", utc)) - snprintf(target, capacity, "%lld", (long long)now); - target[capacity - 1] = '\0'; -} - void bongo_cat_runtime_clean_shutdown(BongoCatApp *app, int exit_code) { char timestamp[64], message[80]; - format_shutdown_timestamp(timestamp, sizeof(timestamp)); + bongo_cat_runtime_timestamp(timestamp, sizeof(timestamp)); snprintf(message, sizeof(message), "[runtime] Shutdown complete: exit_code=%d", exit_code); - append_log(startup_log_path, timestamp, SDL_LOG_CATEGORY_APPLICATION, + append_log(runtime_log_path, timestamp, SDL_LOG_CATEGORY_APPLICATION, SDL_LOG_PRIORITY_INFO, message); - fprintf(stderr, "%s [%d:%d] %s\n", timestamp, - SDL_LOG_CATEGORY_APPLICATION, SDL_LOG_PRIORITY_INFO, message); + fprintf(stderr, "%s [%s] [%s:%d] %s\n", timestamp, log_source, + priority_name(SDL_LOG_PRIORITY_INFO), + SDL_LOG_CATEGORY_APPLICATION, message); bongo_cat_runtime_state_clean(app, timestamp); } @@ -73,25 +90,26 @@ void bongo_cat_runtime_log_stop(void) { SDL_DestroyMutex(mutex); } -static bool reset_log(const char *path) { - FILE *file = path && path[0] ? bongo_cat_file_open(path, "wb") : NULL; - if (!file) return false; - char timestamp[64]; - bongo_cat_runtime_timestamp(timestamp, sizeof(timestamp)); - fprintf(file, "%s BongoCat %s runtime log\n", timestamp, BONGO_CAT_VERSION); - return fclose(file) == 0; +static void set_log_source(const BongoCatApp *app) { + if (app && app->secondary_pet) + snprintf(log_source, sizeof(log_source), "pet:%s", + app->secondary_model_id); + else snprintf(log_source, sizeof(log_source), "primary"); + for (size_t i = 0; log_source[i]; ++i) + if (log_source[i] == '\r' || log_source[i] == '\n') + log_source[i] = ' '; } static void begin_log(BongoCatApp *app) { - char previous[BONGO_CAT_PATH_CAP], stage_path[BONGO_CAT_PATH_CAP]; + char stage_path[BONGO_CAT_PATH_CAP]; char stage_temporary[BONGO_CAT_PATH_CAP]; char interrupted[128] = {0}; char unexpected[256] = {0}; bongo_cat_runtime_state_previous(app, unexpected, sizeof(unexpected)); - bongo_cat_path_join(startup_log_path, sizeof(startup_log_path), - app->log_root, "startup.log"); - bongo_cat_path_join(previous, sizeof(previous), app->log_root, - "startup-previous.log"); + runtime_log_path[0] = '\0'; + bongo_cat_path_join(runtime_log_path, sizeof(runtime_log_path), + app->log_root, "BongoCat.log"); + set_log_source(app); bool stage_paths_ready = bongo_cat_path_join(stage_path, sizeof(stage_path), app->state_root, "startup-stage.txt") && bongo_cat_path_join(stage_temporary, sizeof(stage_temporary), @@ -107,14 +125,12 @@ static void begin_log(BongoCatApp *app) { interrupted[ferror(stage) ? 0 : length] = '\0'; fclose(stage); } - if (bongo_cat_path_is_file(startup_log_path)) - bongo_cat_file_replace(startup_log_path, previous, true); - reset_log(startup_log_path); verbose_logging = app->smoke; if (!log_mutex) log_mutex = SDL_CreateMutex(); SDL_SetLogOutputFunction(log_output, NULL); - SDL_Log("[runtime] Startup on %s; storage=%s", SDL_GetPlatform(), app->storage_root[0] - ? app->storage_root : app->config_root); + SDL_Log("[runtime] Process started: version=%s platform=%s storage=%s", + BONGO_CAT_VERSION, SDL_GetPlatform(), app->storage_root[0] + ? app->storage_root : app->config_root); if (interrupted[0]) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Previous startup ended before readiness at stage: %s", interrupted); if (unexpected[0] && !strstr(unexpected, "stage=clean-shutdown")) @@ -156,20 +172,10 @@ void bongo_cat_startup_ready(BongoCatApp *app) { "startup-stage.txt")) bongo_cat_file_remove(path); if (bongo_cat_path_join(path, sizeof(path), app->state_root, "startup-stage.tmp")) bongo_cat_file_remove(path); - if (bongo_cat_path_join(path, sizeof(path), app->log_root, - "startup-error.log")) bongo_cat_file_remove(path); bongo_cat_runtime_stage(app, "running"); SDL_Log("[runtime] Startup ready"); } -static void write_error(BongoCatApp *app, const char *name, const char *message) { - if (!app || !app->log_root[0]) return; - char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), app->log_root, name)) return; - FILE *file = bongo_cat_file_open(path, "wb"); - if (file) { fputs(message, file); fputc('\n', file); fclose(file); } -} - #ifdef _WIN32 static void native_error_box(const char *message) { int count = MultiByteToWideChar(CP_UTF8, 0, message, -1, NULL, 0); @@ -190,7 +196,7 @@ static const char *startup_tr(BongoCatApp *app, const char *key, void bongo_cat_startup_failure(BongoCatApp *app, const BongoCatError *error) { const char *message = error && error->message[0] ? error->message : "Initialization failed"; - if (app) { bongo_cat_startup_stage(app, "failed"); write_error(app, "startup-error.log", message); } + if (app) bongo_cat_startup_stage(app, "failed"); SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Startup failed: %s", message); char body[BONGO_CAT_PATH_CAP + 384]; const char *heading = startup_tr(app, "native.startup.failed", @@ -199,10 +205,10 @@ void bongo_cat_startup_failure(BongoCatApp *app, const BongoCatError *error) { "See the diagnostic log for technical details."); const char *diagnostic = startup_tr(app, "native.startup.diagnosticLog", "Diagnostic log:"); - bool has_log = app && app->log_root[0]; + bool has_log = runtime_log_path[0] != '\0'; snprintf(body, sizeof(body), "%s\n\n%s%s%s%s%s", heading, detail, has_log ? "\n\n" : "", has_log ? diagnostic : "", - has_log ? "\n" : "", has_log ? startup_log_path : ""); + has_log ? "\n" : "", has_log ? runtime_log_path : ""); if (app && app->smoke) return; if (!SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, BONGO_CAT_NAME, body, app ? app->window : NULL)) { @@ -216,6 +222,6 @@ void bongo_cat_startup_ci_failure(BongoCatApp *app, const BongoCatError *error) { if (!app) return; app->exit_code = 1; - write_error(app, "ci-error.log", error && error->message[0] - ? error->message : "CI operation failed"); + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[runtime] CI failure: %s", + error && error->message[0] ? error->message : "CI operation failed"); } diff --git a/src/runtime/lifecycle/storage_paths.c b/src/runtime/lifecycle/storage_paths.c index caa2feaa..9dbfe04c 100644 --- a/src/runtime/lifecycle/storage_paths.c +++ b/src/runtime/lifecycle/storage_paths.c @@ -106,16 +106,11 @@ bool bongo_cat_storage_paths_prepare(BongoCatApp *app, if (resolved) { snprintf(app->primary_state_root, sizeof(app->primary_state_root), "%s", app->state_root); - snprintf(app->primary_log_root, sizeof(app->primary_log_root), - "%s", app->log_root); } if (resolved && app->secondary_pet) { resolved = bongo_cat_multi_pet_state_directory(app->state_root, sizeof(app->state_root), app->primary_state_root, - app->secondary_model_id) && - bongo_cat_multi_pet_state_directory(app->log_root, - sizeof(app->log_root), app->primary_log_root, - app->secondary_model_id); + app->secondary_model_id); } const char *roots[] = {app->config_root, app->data_root, app->cache_root, app->state_root, app->log_root}; diff --git a/src/runtime/model/model_catalog_refresh.c b/src/runtime/model/model_catalog_refresh.c index 50553081..ce9a3309 100644 --- a/src/runtime/model/model_catalog_refresh.c +++ b/src/runtime/model/model_catalog_refresh.c @@ -146,9 +146,13 @@ void bongo_cat_model_refresh_update(BongoCatApp *app) { SDL_Log("Background model refresh completed: models=%llu changed=%d " "elapsed_ms=%.1f", (unsigned long long)job->models.count, changed, (SDL_GetTicksNS() - job->started_ns) / 1000000.0); - } + } else if (!job->success) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Background model refresh failed: not enough memory"); free(job); - if (refresh->rerun) start_refresh(app); + if (refresh->rerun && !start_refresh(app)) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Cannot restart background model refresh: %s", SDL_GetError()); } bool bongo_cat_model_refresh_event(BongoCatApp *app, diff --git a/src/ui/preferences/preferences_about.c b/src/ui/preferences/preferences_about.c index 41d2c973..06987521 100644 --- a/src/ui/preferences/preferences_about.c +++ b/src/ui/preferences/preferences_about.c @@ -1,6 +1,6 @@ #include "preferences_state.h" #include "preferences_about_community.h" -#include "preferences_notice.h" +#include "preferences_about_footer.h" #include "ui_backend.h" #include "ui_catime.h" #include "ui_animation.h" @@ -151,67 +151,6 @@ static void hero_title(BongoCatPreferences *value, struct nk_context *context, link_cursor(context, link); if (hit(context, link)) open_url("https://github.com/vladelaina/BongoCat"); } -static void footer(BongoCatPreferences *value, struct nk_context *context, - struct nk_command_buffer *canvas, struct nk_rect bounds, - BongoCatUIPalette p) { - const char *version_label = tr(value, "native.support.version", "App version"); - const char *update = tr(value, "native.support.checkUpdate", "Check for updates"); - const char *feedback = tr(value, "native.support.feedback", "Feedback"); - char version[32]; snprintf(version, sizeof(version), "v%s", BONGO_CAT_VERSION); - float label_width = width(value->ui.caption_font, version_label) + 2; - float version_width = width(value->ui.label_font, version) + 2; - float update_text_width = width(value->ui.caption_font, update); - float update_content_width = 16.0f + 8.0f + update_text_width; - float update_width = NK_MAX(110.0f, update_content_width + 28.0f); - float feedback_width = NK_MAX(56.0f, - width(value->ui.caption_font, feedback) + 4.0f); - float total = label_width + version_width + update_width + - feedback_width + 36.0f; - bool stacked = total > bounds.w; - float info_width = label_width + version_width + 8.0f; - float x = bounds.x + (bounds.w - (stacked ? info_width : total)) * .5f; - float info_y = bounds.y + (stacked ? -8.0f : 0.0f); - centered(canvas, nk_rect(x, info_y, label_width, 36), version_label, - value->ui.caption_font, p.muted); - centered(canvas, nk_rect(x + label_width + 8, info_y, - version_width, 36), version, value->ui.label_font, p.text); - float actions_width = update_width + feedback_width + 14.0f; - float actions_x = stacked ? bounds.x + (bounds.w - actions_width) * .5f : - x + info_width + 14.0f; - float actions_y = bounds.y + (stacked ? 22.0f : 0.0f); - struct nk_rect update_button = nk_rect(actions_x, actions_y, update_width, 36); - if (p.effects) bongo_cat_ui_paint_shadow(context, update_button, 10, - 0, 4, 14, 0, nk_rgba(p.accent.r, p.accent.g, p.accent.b, 89)); - nk_fill_rect(canvas, update_button, 10, p.accent); - float update_content_x = update_button.x + - (update_button.w - update_content_width) * .5f; - bongo_cat_preferences_icon_draw(value, canvas, - BONGO_CAT_UI_ICON_SYNC, nk_rect(update_content_x, - update_button.y + 10, 16, 16), nk_rgb(255, 255, 255)); - centered(canvas, nk_rect(update_content_x + 24, update_button.y, - update_text_width + 1, update_button.h), update, - value->ui.caption_font, nk_rgb(255, 255, 255)); - link_cursor(context, update_button); - if (hit(context, update_button)) { - char message[160]; - snprintf(message, sizeof(message), "%s v%s", tr(value, - "native.support.latest", "Already up to date"), - BONGO_CAT_VERSION); - bongo_cat_preferences_notice_show(value->app, message, false); - } - struct nk_rect feedback_link = nk_rect(actions_x + update_width + 14, - actions_y, feedback_width, 36); - bool feedback_hover = nk_input_is_mouse_hovering_rect(&context->input, feedback_link); - float feedback_amount = bongo_cat_ui_animate_eased(context, - "support-feedback-hover", feedback_hover ? 1.0f : 0.0f, 200, - BONGO_CAT_UI_EASE_STANDARD); - centered(canvas, feedback_link, feedback, value->ui.caption_font, - bongo_cat_ui_color_mix(p.accent, p.pink, feedback_amount)); - link_cursor(context, feedback_link); - if (hit(context, feedback_link)) - open_url("https://github.com/vladelaina/BongoCat/issues"); -} - void bongo_cat_preferences_about_hero(BongoCatPreferences *value, struct nk_context *context) { struct nk_rect bounds; @@ -243,7 +182,7 @@ void bongo_cat_preferences_about_hero(BongoCatPreferences *value, tr(value, "native.support.heroText", "Thank you for your support. Every use and share helps BongoCat grow."), value->ui.caption_font, p.muted); - footer(value, context, canvas, + bongo_cat_preferences_about_footer(value, context, canvas, nk_rect(bounds.x, bounds.y + 286, bounds.w, 34), p); } static void project(BongoCatPreferences *value, struct nk_context *context, diff --git a/src/ui/preferences/preferences_about_community.c b/src/ui/preferences/preferences_about_community.c index eed2c51b..77ed32e3 100644 --- a/src/ui/preferences/preferences_about_community.c +++ b/src/ui/preferences/preferences_about_community.c @@ -74,7 +74,7 @@ void bongo_cat_preferences_about_localized_link(BongoCatPreferences *value, float amount = bongo_cat_ui_animate_eased(context, animation_id, hover ? 1.0f : 0.0f, 200, BONGO_CAT_UI_EASE_STANDARD); centered(canvas, link, label, font, - bongo_cat_ui_color_mix(palette.accent, palette.pink, amount)); + bongo_cat_ui_color_mix(palette.pink, palette.accent, amount)); if (hover) bongo_cat_ui_cursor_hover_rect(context, link, BONGO_CAT_UI_CURSOR_POINTER); if (hit(context, link)) open_url(url); diff --git a/src/ui/preferences/preferences_about_footer.c b/src/ui/preferences/preferences_about_footer.c new file mode 100644 index 00000000..e646e749 --- /dev/null +++ b/src/ui/preferences/preferences_about_footer.c @@ -0,0 +1,174 @@ +#include "preferences_about_footer.h" +#include "preferences_notice.h" +#include "preferences_state.h" +#include "ui_animation.h" +#include "ui_backend.h" +#include "ui_catime.h" +#include "ui_icons.h" +#include "ui_paint.h" + +#include +#include + +#define SUPPORT_LOGS_SCALE (4.0f / 5.0f) +#define SUPPORT_LOGS_ICON_SIZE (14.0f * SUPPORT_LOGS_SCALE) +#define SUPPORT_LOGS_GAP 4.0f + +static const char *tr(BongoCatPreferences *value, const char *key, + const char *fallback) { + return bongo_cat_i18n_get(value->app->i18n, key, fallback); +} + +static float width(const struct nk_user_font *font, const char *value) { + return font->width(font->userdata, font->height, value, nk_strlen(value)); +} + +static const struct nk_user_font *logs_font(BongoCatPreferences *value) { + value->support_logs_font = *value->ui.caption_font; + value->support_logs_font.height = + value->ui.caption_font->height * SUPPORT_LOGS_SCALE; + return &value->support_logs_font; +} + +static float logs_content_width(const struct nk_user_font *font, + const char *label) { + return SUPPORT_LOGS_ICON_SIZE + SUPPORT_LOGS_GAP + width(font, label); +} + +static void centered(struct nk_command_buffer *canvas, struct nk_rect bounds, + const char *value, const struct nk_user_font *font, struct nk_color color) { + float text_width = width(font, value); + nk_draw_text(canvas, nk_rect(bounds.x + (bounds.w - text_width) * .5f, + bounds.y + (bounds.h - font->height) * .5f, + NK_MIN(text_width + 1.0f, bounds.w), font->height), value, + nk_strlen(value), font, nk_rgba(0, 0, 0, 0), color); +} + +static bool hit(struct nk_context *context, struct nk_rect bounds) { + return nk_input_is_mouse_hovering_rect(&context->input, bounds) && + nk_input_is_mouse_click_in_rect(&context->input, NK_BUTTON_LEFT, bounds); +} + +static void link_cursor(struct nk_context *context, struct nk_rect bounds) { + if (nk_input_is_mouse_hovering_rect(&context->input, bounds)) + bongo_cat_ui_cursor_hover_rect(context, bounds, + BONGO_CAT_UI_CURSOR_POINTER); +} + +static struct nk_color link_color(struct nk_context *context, + struct nk_rect bounds, const char *animation_id, BongoCatUIPalette p) { + bool hover = nk_input_is_mouse_hovering_rect(&context->input, bounds); + float amount = bongo_cat_ui_animate_eased(context, animation_id, + hover ? 1.0f : 0.0f, 200, BONGO_CAT_UI_EASE_STANDARD); + return bongo_cat_ui_color_mix(p.accent, p.pink, amount); +} + +static void open_logs(BongoCatPreferences *value) { + if (bongo_cat_platform_open_directory(value->app->log_root)) return; + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Cannot open logs directory: %s", value->app->log_root); + bongo_cat_preferences_notice_show(value->app, tr(value, + "native.support.openLogsFailed", "Unable to open logs folder"), true); +} + +static void draw_update(BongoCatPreferences *value, + struct nk_context *context, struct nk_command_buffer *canvas, + struct nk_rect bounds, const char *label, float content_width, + BongoCatUIPalette p) { + if (p.effects) bongo_cat_ui_paint_shadow(context, bounds, 10, + 0, 4, 14, 0, nk_rgba(p.accent.r, p.accent.g, p.accent.b, 89)); + nk_fill_rect(canvas, bounds, 10, p.accent); + float content_x = bounds.x + (bounds.w - content_width) * .5f; + bongo_cat_preferences_icon_draw(value, canvas, BONGO_CAT_UI_ICON_SYNC, + nk_rect(content_x, bounds.y + 10, 16, 16), nk_rgb(255, 255, 255)); + centered(canvas, nk_rect(content_x + 24, bounds.y, + content_width - 23, bounds.h), label, value->ui.caption_font, + nk_rgb(255, 255, 255)); + link_cursor(context, bounds); + if (hit(context, bounds)) { + char message[160]; + snprintf(message, sizeof(message), "%s v%s", tr(value, + "native.support.latest", "Already up to date"), + BONGO_CAT_VERSION); + bongo_cat_preferences_notice_show(value->app, message, false); + } +} + +static void draw_links(BongoCatPreferences *value, + struct nk_context *context, struct nk_command_buffer *canvas, + struct nk_rect feedback, struct nk_rect logs, const char *feedback_label, + const char *logs_label, BongoCatUIPalette p) { + const struct nk_user_font *small_font = logs_font(value); + struct nk_color feedback_color = link_color(context, feedback, + "support-feedback-hover", p); + centered(canvas, feedback, feedback_label, value->ui.caption_font, + feedback_color); + link_cursor(context, feedback); + if (hit(context, feedback) && !SDL_OpenURL( + "https://github.com/vladelaina/BongoCat/issues")) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Cannot open URL: %s", SDL_GetError()); + + nk_stroke_line(canvas, logs.x - 10, logs.y + 11, + logs.x - 10, logs.y + logs.h - 11, 1, p.border_subtle); + struct nk_color logs_color = link_color(context, logs, + "support-logs-hover", p); + float content_width = logs_content_width(small_font, logs_label); + float content_x = logs.x + (logs.w - content_width) * .5f; + bongo_cat_preferences_icon_draw(value, canvas, BONGO_CAT_UI_ICON_FOLDER, + nk_rect(content_x, logs.y + (logs.h - SUPPORT_LOGS_ICON_SIZE) * .5f, + SUPPORT_LOGS_ICON_SIZE, SUPPORT_LOGS_ICON_SIZE), logs_color); + centered(canvas, nk_rect(content_x + SUPPORT_LOGS_ICON_SIZE + + SUPPORT_LOGS_GAP, logs.y, width(small_font, logs_label) + 1, + logs.h), logs_label, small_font, logs_color); + link_cursor(context, logs); + if (hit(context, logs)) open_logs(value); +} + +void bongo_cat_preferences_about_footer(BongoCatPreferences *value, + struct nk_context *context, struct nk_command_buffer *canvas, + struct nk_rect bounds, BongoCatUIPalette p) { + const char *version_label = tr(value, + "native.support.version", "App version"); + const char *update = tr(value, + "native.support.checkUpdate", "Check for updates"); + const char *feedback = tr(value, "native.support.feedback", "Feedback"); + const char *logs = tr(value, "native.support.openLogs", "Open logs"); + const struct nk_user_font *small_font = logs_font(value); + char version[32]; + snprintf(version, sizeof(version), "v%s", BONGO_CAT_VERSION); + float label_width = width(value->ui.caption_font, version_label) + 2; + float version_width = width(value->ui.label_font, version) + 2; + float update_text_width = width(value->ui.caption_font, update); + float update_content_width = 24.0f + update_text_width; + float update_width = NK_MAX(110.0f, update_content_width + 28.0f); + float feedback_width = NK_MAX(56.0f, + width(value->ui.caption_font, feedback) + 4.0f); + float logs_width = logs_content_width(small_font, logs) + 4.0f; + float links_width = feedback_width + 20.0f + logs_width; + float actions_width = update_width + 14.0f + links_width; + float info_width = label_width + version_width + 8.0f; + float total = info_width + actions_width + 14.0f; + bool stacked = total > bounds.w; + float info_x = bounds.x + (bounds.w - + (stacked ? info_width : total)) * .5f; + float info_y = bounds.y + (stacked ? -8.0f : 0.0f); + centered(canvas, nk_rect(info_x, info_y, label_width, 36), version_label, + value->ui.caption_font, p.muted); + centered(canvas, nk_rect(info_x + label_width + 8, info_y, + version_width, 36), version, value->ui.label_font, p.text); + + float actions_x = stacked ? bounds.x + + (bounds.w - actions_width) * .5f : info_x + info_width + 14.0f; + float actions_y = bounds.y + (stacked ? 22.0f : 0.0f); + struct nk_rect update_button = nk_rect(actions_x, actions_y, + update_width, 36); + draw_update(value, context, canvas, update_button, update, + update_content_width, p); + struct nk_rect feedback_link = nk_rect(actions_x + update_width + 14, + actions_y, feedback_width, 36); + struct nk_rect logs_link = nk_rect(feedback_link.x + feedback_width + 20, + actions_y, logs_width, 36); + draw_links(value, context, canvas, feedback_link, logs_link, + feedback, logs, p); +} diff --git a/src/ui/preferences/preferences_about_footer.h b/src/ui/preferences/preferences_about_footer.h new file mode 100644 index 00000000..02a53ecb --- /dev/null +++ b/src/ui/preferences/preferences_about_footer.h @@ -0,0 +1,10 @@ +#ifndef BONGO_CAT_PREFERENCES_ABOUT_FOOTER_H +#define BONGO_CAT_PREFERENCES_ABOUT_FOOTER_H + +#include "preferences_internal.h" + +void bongo_cat_preferences_about_footer(BongoCatPreferences *value, + struct nk_context *context, struct nk_command_buffer *canvas, + struct nk_rect bounds, BongoCatUIPalette palette); + +#endif diff --git a/src/ui/preferences/preferences_state.h b/src/ui/preferences/preferences_state.h index 2cb5cd51..608903cf 100644 --- a/src/ui/preferences/preferences_state.h +++ b/src/ui/preferences/preferences_state.h @@ -41,6 +41,7 @@ struct BongoCatPreferences { int catime_width, catime_height; int vlaina_width, vlaina_height; bool support_assets_loaded; + struct nk_user_font support_logs_font; int page; int style_theme; BongoCatLanguage font_language; diff --git a/tests/core/test_config.c b/tests/core/test_config.c index 5faaadd0..039544c8 100644 --- a/tests/core/test_config.c +++ b/tests/core/test_config.c @@ -205,7 +205,15 @@ void test_config(void) { CHECK(loaded_settings.model.max_fps == 240); CHECK(loaded_session.window.scale_percent == 10.0f); + const char *append_path = "bongocat-append.log"; + if (bongo_cat_path_is_file(append_path)) + CHECK(bongo_cat_file_remove(append_path)); + CHECK(bongo_cat_file_append(append_path, "first\n", 6)); + CHECK(bongo_cat_file_append(append_path, "second\n", 7)); + CHECK(contains_text(append_path, "first\nsecond\n")); + CHECK(bongo_cat_file_remove(settings_path)); CHECK(bongo_cat_file_remove(session_path)); CHECK(bongo_cat_file_remove(unsupported)); + CHECK(bongo_cat_file_remove(append_path)); } diff --git a/tests/i18n/test_i18n.c b/tests/i18n/test_i18n.c index 032445aa..51121d0d 100644 --- a/tests/i18n/test_i18n.c +++ b/tests/i18n/test_i18n.c @@ -159,6 +159,7 @@ int main(void) { "native.startup.detail", "native.startup.diagnosticLog", "native.support.website", + "native.support.openLogs", "native.support.starOnGitHub", "native.support.by"}; const uint32_t expected[] = {'A', 0x4e2d, 0x8a2d, 0x00e7, 0x00fc, From 20b8b5a573473c796ab1e0241374d9b00beed059 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 24 Aug 2026 15:20:12 +0800 Subject: [PATCH 096/334] fix: pin Cubism builds to Visual Studio 2022 --- .github/workflows/ci.yml | 5 +++-- .github/workflows/release.yml | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9f9424e..d71e551f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -255,7 +255,7 @@ jobs: github.event_name == 'push' && github.ref == 'refs/heads/main' needs: [publishing-policy, quality] - runs-on: windows-latest + runs-on: windows-2022 env: CUBISM_SDK_ARCHIVE_URL: ${{ secrets.CUBISM_SDK_ARCHIVE_URL }} CUBISM_SDK_ARCHIVE_SHA256: ${{ secrets.CUBISM_SDK_ARCHIVE_SHA256 }} @@ -272,7 +272,8 @@ jobs: - name: Configure Store release shell: pwsh run: | - cmake -S . -B build-store -A x64 ` + cmake -S . -B build-store -G "Visual Studio 17 2022" ` + -A x64 -T v143 ` -DBONGO_CAT_REQUIRE_CUBISM=ON ` -DBONGO_CAT_WARNINGS_AS_ERRORS=ON diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5425b0a6..956c7778 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: release: name: release / windows-x64 if: github.repository == 'vladelaina/BongoCat' - runs-on: windows-latest + runs-on: windows-2022 env: CUBISM_SDK_ARCHIVE_URL: ${{ secrets.CUBISM_SDK_ARCHIVE_URL }} CUBISM_SDK_ARCHIVE_SHA256: ${{ secrets.CUBISM_SDK_ARCHIVE_SHA256 }} @@ -50,7 +50,8 @@ jobs: - name: Configure release shell: pwsh run: | - cmake -S . -B build-release -A x64 ` + cmake -S . -B build-release -G "Visual Studio 17 2022" ` + -A x64 -T v143 ` -DBONGO_CAT_REQUIRE_CUBISM=ON ` -DBONGO_CAT_WARNINGS_AS_ERRORS=ON From ae256ed5994c2eb35edd96ab3a4cbc293c862b29 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 24 Aug 2026 16:23:42 +0800 Subject: [PATCH 097/334] fix: set policies for line check script --- cmake/CheckLines.cmake | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmake/CheckLines.cmake b/cmake/CheckLines.cmake index 21a4fa34..44f0f3ca 100644 --- a/cmake/CheckLines.cmake +++ b/cmake/CheckLines.cmake @@ -1,3 +1,5 @@ +cmake_minimum_required(VERSION 3.24) + if(NOT DEFINED ROOT) message(FATAL_ERROR "ROOT is required") endif() From 89ea1eb9f9f8b1e9a7235d12ae10db39b8ed7362 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 24 Aug 2026 17:36:39 +0800 Subject: [PATCH 098/334] fix: publish Cubism-enabled Windows artifacts --- .github/workflows/ci.yml | 110 +++++++++++++++++++++------------------ 1 file changed, 60 insertions(+), 50 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d71e551f..a50d7f68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,27 +163,6 @@ jobs: if: runner.os != 'Windows' run: ctest --test-dir build --output-on-failure - - name: Install NSIS - if: runner.os == 'Windows' - shell: pwsh - run: | - if (-not (Get-Command makensis.exe -ErrorAction SilentlyContinue)) { - choco install nsis --no-progress --yes - } - $nsisCommand = Get-Command makensis.exe -ErrorAction SilentlyContinue - $nsisRoots = @( - (Join-Path ${env:ProgramFiles(x86)} 'NSIS'), - (Join-Path $env:ProgramFiles 'NSIS') - ) - if ($nsisCommand) { $nsisRoots += Split-Path $nsisCommand.Source } - $nsisRoot = $nsisRoots | Where-Object { - Test-Path -LiteralPath (Join-Path $_ 'makensis.exe') - } | Select-Object -First 1 - if (-not $nsisRoot) { - throw 'NSIS was not installed (makensis.exe was not found)' - } - $nsisRoot | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 - - name: Resolve release package name id: package shell: pwsh @@ -212,21 +191,6 @@ jobs: throw "Release archive checksum is missing" } - - name: Package Windows installer - if: runner.os == 'Windows' - shell: pwsh - run: | - cmake --build build --config Release --target package-installer - $setup = @(Get-ChildItem build/dist -File | Where-Object { - $_.Name -eq '${{ steps.package.outputs.name }}-setup.exe' - }) - if ($setup.Count -ne 1) { - throw "Expected one Windows installer, found $($setup.Count)" - } - if (-not (Test-Path -LiteralPath "$($setup[0].FullName).sha256")) { - throw 'Windows installer checksum is missing' - } - - name: Upload portable release uses: actions/upload-artifact@v7 with: @@ -236,23 +200,11 @@ jobs: retention-days: 30 compression-level: 0 - - name: Upload Windows installer - if: runner.os == 'Windows' - uses: actions/upload-artifact@v7 - with: - name: diagnostic-installer-${{ steps.package.outputs.name }} - path: | - build/dist/${{ steps.package.outputs.name }}-setup.exe - build/dist/${{ steps.package.outputs.name }}-setup.exe.sha256 - if-no-files-found: error - retention-days: 30 - compression-level: 0 - store-package: - name: package / microsoft-store + name: package / windows-production if: >- github.repository == 'vladelaina/BongoCat' && - github.event_name == 'push' && + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' needs: [publishing-policy, quality] runs-on: windows-2022 @@ -299,6 +251,51 @@ jobs: } "version=$($Matches[1])" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + - name: Install NSIS + shell: pwsh + run: | + if (-not (Get-Command makensis.exe -ErrorAction SilentlyContinue)) { + choco install nsis --no-progress --yes + } + $nsisCommand = Get-Command makensis.exe -ErrorAction SilentlyContinue + $nsisRoots = @( + (Join-Path ${env:ProgramFiles(x86)} 'NSIS'), + (Join-Path $env:ProgramFiles 'NSIS') + ) + if ($nsisCommand) { $nsisRoots += Split-Path $nsisCommand.Source } + $nsisRoot = $nsisRoots | Where-Object { + Test-Path -LiteralPath (Join-Path $_ 'makensis.exe') + } | Select-Object -First 1 + if (-not $nsisRoot) { + throw 'NSIS was not installed (makensis.exe was not found)' + } + $nsisRoot | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 + + - name: Package Windows desktop release + id: desktop-package + shell: pwsh + run: | + $name = (Get-Content build-store/bongo-cat-package-name.txt -Raw).Trim() + $expected = "BongoCat-${{ steps.version.outputs.version }}-windows-x64" + if ($name -ne $expected) { + throw "Cubism desktop package is not production-ready: $name" + } + + cmake --build build-store --config Release --target package + cmake --build build-store --config Release --target package-installer + $files = @( + "build-store/dist/$name.zip", + "build-store/dist/$name.zip.sha256", + "build-store/dist/$name-setup.exe", + "build-store/dist/$name-setup.exe.sha256" + ) + foreach ($file in $files) { + if (-not (Test-Path -LiteralPath $file)) { + throw "Production desktop file is missing: $file" + } + } + "name=$name" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + - name: Build Microsoft Store MSIX shell: pwsh run: | @@ -325,3 +322,16 @@ jobs: retention-days: 90 compression-level: 0 archive: false + + - name: Upload Windows desktop release + uses: actions/upload-artifact@v7 + with: + name: production-${{ steps.desktop-package.outputs.name }}-${{ github.run_number }}-${{ github.run_attempt }} + path: | + build-store/dist/${{ steps.desktop-package.outputs.name }}.zip + build-store/dist/${{ steps.desktop-package.outputs.name }}.zip.sha256 + build-store/dist/${{ steps.desktop-package.outputs.name }}-setup.exe + build-store/dist/${{ steps.desktop-package.outputs.name }}-setup.exe.sha256 + if-no-files-found: error + retention-days: 30 + compression-level: 0 From 5985bb6e8b9fd6d0d7345d3a894b9745c964464f Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 24 Aug 2026 18:15:02 +0800 Subject: [PATCH 099/334] fix: prohibit SDK-free workflow artifacts --- .github/scripts/check-publish-guards.ps1 | 23 ++++++++++++++- .github/workflows/ci.yml | 37 ------------------------ 2 files changed, 22 insertions(+), 38 deletions(-) diff --git a/.github/scripts/check-publish-guards.ps1 b/.github/scripts/check-publish-guards.ps1 index 1bdb5972..a0c829c8 100644 --- a/.github/scripts/check-publish-guards.ps1 +++ b/.github/scripts/check-publish-guards.ps1 @@ -11,6 +11,11 @@ $workflowDirectory = Join-Path (Split-Path $PSScriptRoot -Parent) 'workflows' $sensitiveMarkers = @( 'build-store-package.ps1', 'CUBISM_SDK_ARCHIVE_URL', + 'actions/upload-artifact', + 'softprops/action-gh-release' +) +$artifactMarkers = @( + 'actions/upload-artifact', 'softprops/action-gh-release' ) @@ -39,6 +44,14 @@ function Get-PublishingGuardFailures { $failures += "$Label`: job '$jobName' lacks upstream guard; " + "matched $($matched -join ', ')" } + $publishesArtifacts = @($artifactMarkers | Where-Object { + $jobText.Contains($_) + }).Count -gt 0 + if ($publishesArtifacts -and + -not $jobText.Contains('-DBONGO_CAT_REQUIRE_CUBISM=ON')) { + $failures += "$Label`: job '$jobName' publishes artifacts without " + + 'requiring the Cubism SDK' + } } return $failures } @@ -87,6 +100,14 @@ if ($SelfTest) { if ($caught.Count -eq 0) { $failures += 'Self-test did not reject a removed upstream guard.' } + $withoutCubism = $selfTestText.Replace( + '-DBONGO_CAT_REQUIRE_CUBISM=ON', + '-DBONGO_CAT_REQUIRE_CUBISM=OFF') + $cubismFailures = @(Get-PublishingGuardFailures ` + -Text $withoutCubism -Label 'self-test') + if (-not ($cubismFailures -match 'requiring the Cubism SDK')) { + $failures += 'Self-test did not reject an SDK-free artifact job.' + } } } @@ -97,4 +118,4 @@ if ($failures.Count -gt 0) { } Write-Host "Publishing guard policy passed for $checkedJobs sensitive job(s)." -if ($SelfTest) { Write-Host 'Publishing guard negative self-test passed.' } +if ($SelfTest) { Write-Host 'Publishing guard negative self-tests passed.' } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a50d7f68..6149d059 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,43 +163,6 @@ jobs: if: runner.os != 'Windows' run: ctest --test-dir build --output-on-failure - - name: Resolve release package name - id: package - shell: pwsh - run: | - $name = (Get-Content build/bongo-cat-package-name.txt -Raw).Trim() - if ($name -notmatch '^BongoCat-Diagnostic-\d+\.\d+\.\d+-(windows|linux|macos)-(x64|x86|arm64|universal)$') { - throw "Unexpected release package name: $name" - } - "name=$name" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - - - name: Package release - shell: pwsh - run: | - cmake --build build --config Release --target package - $archives = @(Get-ChildItem build/dist -File | Where-Object { - $_.Name -match '\.(zip|tar\.gz)$' - }) - if ($archives.Count -ne 1) { - throw "Expected one release archive, found $($archives.Count)" - } - $expected = '${{ steps.package.outputs.name }}' - if (-not $archives[0].Name.StartsWith("$expected.")) { - throw "Unexpected release archive: $($archives[0].Name)" - } - if (-not (Test-Path -LiteralPath "$($archives[0].FullName).sha256")) { - throw "Release archive checksum is missing" - } - - - name: Upload portable release - uses: actions/upload-artifact@v7 - with: - name: diagnostic-portable-${{ steps.package.outputs.name }} - path: build/dist/${{ steps.package.outputs.name }}.* - if-no-files-found: error - retention-days: 30 - compression-level: 0 - store-package: name: package / windows-production if: >- From efa129a11bb3806f4afc59fb4364fed9d6916c81 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 24 Aug 2026 18:31:06 +0800 Subject: [PATCH 100/334] feat: publish Cubism builds for desktop platforms --- .github/scripts/restore-cubism-sdk.ps1 | 31 +++++---- .github/workflows/ci.yml | 90 +++++++++++++++++++++++++- 2 files changed, 105 insertions(+), 16 deletions(-) diff --git a/.github/scripts/restore-cubism-sdk.ps1 b/.github/scripts/restore-cubism-sdk.ps1 index bda40a4d..148d62ae 100644 --- a/.github/scripts/restore-cubism-sdk.ps1 +++ b/.github/scripts/restore-cubism-sdk.ps1 @@ -26,9 +26,9 @@ param( $ErrorActionPreference = "Stop" Set-StrictMode -Version Latest -$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..')) +$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..')) if (-not $Destination) { - $Destination = Join-Path $repositoryRoot 'vendor\CubismSdkForNative' + $Destination = Join-Path $repositoryRoot 'vendor/CubismSdkForNative' } if ([string]::IsNullOrWhiteSpace($ArchiveUrl) -or @@ -72,9 +72,9 @@ try { Expand-Archive -LiteralPath $archivePath -DestinationPath $extractPath -Force $rootCandidate = Get-Item -LiteralPath $extractPath - if (-not (Test-Path (Join-Path $rootCandidate.FullName "Core\include\Live2DCubismCore.h"))) { + if (-not (Test-Path (Join-Path $rootCandidate.FullName "Core/include/Live2DCubismCore.h"))) { $rootCandidate = Get-ChildItem -LiteralPath $extractPath -Directory -Recurse | - Where-Object { Test-Path (Join-Path $_.FullName "Core\include\Live2DCubismCore.h") } | + Where-Object { Test-Path (Join-Path $_.FullName "Core/include/Live2DCubismCore.h") } | Select-Object -First 1 } if (-not $rootCandidate) { @@ -82,9 +82,12 @@ try { } $required = @( - (Join-Path $rootCandidate.FullName "Core\include\Live2DCubismCore.h"), - (Join-Path $rootCandidate.FullName "Core\lib\windows\x86_64\143\Live2DCubismCore_MT.lib"), - (Join-Path $rootCandidate.FullName "Framework\CMakeLists.txt") + (Join-Path $rootCandidate.FullName "Core/include/Live2DCubismCore.h"), + (Join-Path $rootCandidate.FullName "Core/lib/windows/x86_64/143/Live2DCubismCore_MT.lib"), + (Join-Path $rootCandidate.FullName "Core/lib/linux/x86_64/libLive2DCubismCore.a"), + (Join-Path $rootCandidate.FullName "Core/lib/macos/x86_64/libLive2DCubismCore.a"), + (Join-Path $rootCandidate.FullName "Core/lib/macos/arm64/libLive2DCubismCore.a"), + (Join-Path $rootCandidate.FullName "Framework/CMakeLists.txt") ) foreach ($path in $required) { if (-not (Test-Path -LiteralPath $path)) { @@ -101,9 +104,9 @@ try { Expand-Archive -LiteralPath $glewArchivePath -DestinationPath $extractPath -Force $glewRoot = Get-ChildItem -LiteralPath $extractPath -Directory -Recurse | Where-Object { - (Test-Path (Join-Path $_.FullName 'build\cmake\CMakeLists.txt')) -and - (Test-Path (Join-Path $_.FullName 'include\GL\glew.h')) -and - (Test-Path (Join-Path $_.FullName 'src\glew.c')) + (Test-Path (Join-Path $_.FullName 'build/cmake/CMakeLists.txt')) -and + (Test-Path (Join-Path $_.FullName 'include/GL/glew.h')) -and + (Test-Path (Join-Path $_.FullName 'src/glew.c')) } | Select-Object -First 1 if (-not $glewRoot) { @@ -113,14 +116,14 @@ try { Remove-Item -LiteralPath $Destination -Recurse -Force -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $Destination | Out-Null Get-ChildItem -LiteralPath $rootCandidate.FullName -Force | Copy-Item -Destination $Destination -Recurse -Force - $glewDestination = Join-Path $Destination 'Samples\OpenGL\thirdParty\glew' + $glewDestination = Join-Path $Destination 'Samples/OpenGL/thirdParty/glew' New-Item -ItemType Directory -Force -Path $glewDestination | Out-Null Get-ChildItem -LiteralPath $glewRoot.FullName -Force | Copy-Item -Destination $glewDestination -Recurse -Force $requiredGlew = @( - (Join-Path $glewDestination 'build\cmake\CMakeLists.txt'), - (Join-Path $glewDestination 'include\GL\glew.h'), - (Join-Path $glewDestination 'src\glew.c') + (Join-Path $glewDestination 'build/cmake/CMakeLists.txt'), + (Join-Path $glewDestination 'include/GL/glew.h'), + (Join-Path $glewDestination 'src/glew.c') ) foreach ($path in $requiredGlew) { if (-not (Test-Path -LiteralPath $path)) { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6149d059..c7d622cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,8 +163,94 @@ jobs: if: runner.os != 'Windows' run: ctest --test-dir build --output-on-failure + desktop-package: + name: package / ${{ matrix.platform }} + if: >- + github.repository == 'vladelaina/BongoCat' && + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && + github.ref == 'refs/heads/main' + needs: [publishing-policy, quality] + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + platform: linux-x64 + - os: macos-latest + platform: macos + runs-on: ${{ matrix.os }} + env: + CUBISM_SDK_ARCHIVE_URL: ${{ secrets.CUBISM_SDK_ARCHIVE_URL }} + CUBISM_SDK_ARCHIVE_SHA256: ${{ secrets.CUBISM_SDK_ARCHIVE_SHA256 }} + CUBISM_GLEW_ARCHIVE_URL: ${{ secrets.CUBISM_GLEW_ARCHIVE_URL }} + CUBISM_GLEW_ARCHIVE_SHA256: ${{ secrets.CUBISM_GLEW_ARCHIVE_SHA256 }} + steps: + - name: Checkout source + uses: actions/checkout@v5 + + - name: Restore licensed Cubism SDK + shell: pwsh + run: ./.github/scripts/restore-cubism-sdk.ps1 + + - name: Install Linux dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + cmake ninja-build libgl1-mesa-dev libx11-dev libxi-dev \ + libxfixes-dev + + - name: Configure release + run: | + cmake -S . -B build-package -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_REQUIRE_CUBISM=ON \ + -DBONGO_CAT_WARNINGS_AS_ERRORS=ON + + - name: Build release + run: bash .github/scripts/build-unix.sh cmake --build build-package --parallel 2 + + - name: Test release + run: ctest --test-dir build-package --output-on-failure + + - name: Package desktop release + id: package + shell: pwsh + run: | + $header = Get-Content build-package/generated/bongo_cat/version.h -Raw + if ($header -notmatch '#define\s+BONGO_CAT_VERSION\s+"([^"]+)"') { + throw 'Unable to read the BongoCat version' + } + $version = $Matches[1] + $name = (Get-Content build-package/bongo-cat-package-name.txt -Raw).Trim() + $platform = if ($IsLinux) { 'linux-x64' } else { 'macos-(x64|arm64)' } + if ($name -notmatch "^BongoCat-$([regex]::Escape($version))-$platform$") { + throw "Cubism desktop package is not production-ready: $name" + } + + cmake --build build-package --target package + $archives = @(Get-ChildItem build-package/dist -File | Where-Object { + $_.Name -match '\.(zip|tar\.gz)$' + }) + if ($archives.Count -ne 1) { + throw "Expected one desktop archive, found $($archives.Count)" + } + if (-not (Test-Path -LiteralPath "$($archives[0].FullName).sha256")) { + throw 'Desktop archive checksum is missing' + } + "name=$name" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: Upload desktop release + uses: actions/upload-artifact@v7 + with: + name: ${{ steps.package.outputs.name }}-${{ github.run_number }}-${{ github.run_attempt }} + path: build-package/dist/${{ steps.package.outputs.name }}.* + if-no-files-found: error + retention-days: 30 + compression-level: 0 + store-package: - name: package / windows-production + name: package / windows-x64 if: >- github.repository == 'vladelaina/BongoCat' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && @@ -289,7 +375,7 @@ jobs: - name: Upload Windows desktop release uses: actions/upload-artifact@v7 with: - name: production-${{ steps.desktop-package.outputs.name }}-${{ github.run_number }}-${{ github.run_attempt }} + name: ${{ steps.desktop-package.outputs.name }}-${{ github.run_number }}-${{ github.run_attempt }} path: | build-store/dist/${{ steps.desktop-package.outputs.name }}.zip build-store/dist/${{ steps.desktop-package.outputs.name }}.zip.sha256 From e37dfee703d63c326db07c52cbd271c657cb2626 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 24 Aug 2026 18:46:12 +0800 Subject: [PATCH 101/334] fix: compile Cubism on Unix runners --- cmake/Cubism.cmake | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/cmake/Cubism.cmake b/cmake/Cubism.cmake index 1bb5b444..ef2db625 100644 --- a/cmake/Cubism.cmake +++ b/cmake/Cubism.cmake @@ -47,11 +47,14 @@ include(cmake/CubismShaderOptimize.cmake) bongo_cat_optimize_cubism_shaders(Framework) if(WIN32) - target_compile_definitions(Framework PUBLIC CSM_TARGET_WIN_GL) + target_compile_definitions(Framework PUBLIC CSM_TARGET_WIN_GL GLEW_NO_GLU) elseif(APPLE) - target_compile_definitions(Framework PUBLIC CSM_TARGET_MAC_GL) + target_compile_definitions(Framework PUBLIC CSM_TARGET_MAC_GL GLEW_NO_GLU) else() - target_compile_definitions(Framework PUBLIC CSM_TARGET_LINUX_GL) + target_compile_definitions(Framework PUBLIC CSM_TARGET_LINUX_GL GLEW_NO_GLU) endif() -target_include_directories(Framework PUBLIC "${CUBISM_GLEW_PATH}/include") +target_include_directories(Framework SYSTEM PUBLIC + "${CUBISM_FRAMEWORK_PATH}/src" + "${CUBISM_CORE_PATH}/include" + "${CUBISM_GLEW_PATH}/include") target_link_libraries(Framework PUBLIC Live2DCubismCore glew_s) From 059f851228991821f7cb0e95e1ecba834c00becd Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 24 Aug 2026 19:27:06 +0800 Subject: [PATCH 102/334] fix: complete cross-platform release artifacts --- .github/workflows/ci.yml | 47 +++++++++++++++++--------------- src/live2d/cubism_model_load.cpp | 3 +- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7d622cc..63601a21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -235,19 +235,17 @@ jobs: if ($archives.Count -ne 1) { throw "Expected one desktop archive, found $($archives.Count)" } - if (-not (Test-Path -LiteralPath "$($archives[0].FullName).sha256")) { - throw 'Desktop archive checksum is missing' - } "name=$name" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "file=$($archives[0].FullName)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - name: Upload desktop release uses: actions/upload-artifact@v7 with: - name: ${{ steps.package.outputs.name }}-${{ github.run_number }}-${{ github.run_attempt }} - path: build-package/dist/${{ steps.package.outputs.name }}.* + path: ${{ steps.package.outputs.file }} if-no-files-found: error retention-days: 30 compression-level: 0 + archive: false store-package: name: package / windows-x64 @@ -332,18 +330,18 @@ jobs: cmake --build build-store --config Release --target package cmake --build build-store --config Release --target package-installer - $files = @( - "build-store/dist/$name.zip", - "build-store/dist/$name.zip.sha256", - "build-store/dist/$name-setup.exe", - "build-store/dist/$name-setup.exe.sha256" - ) - foreach ($file in $files) { - if (-not (Test-Path -LiteralPath $file)) { - throw "Production desktop file is missing: $file" - } + $portableSource = "build-store/dist/$name.zip" + $portable = "build-store/dist/$name-portable.zip" + $setup = "build-store/dist/$name-setup.exe" + if (-not (Test-Path -LiteralPath $portableSource)) { + throw "Production portable package is missing: $portableSource" } - "name=$name" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + if (-not (Test-Path -LiteralPath $setup)) { + throw "Production desktop installer is missing: $setup" + } + Move-Item -LiteralPath $portableSource -Destination $portable -Force + "portable=$portable" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "file=$setup" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - name: Build Microsoft Store MSIX shell: pwsh @@ -375,12 +373,17 @@ jobs: - name: Upload Windows desktop release uses: actions/upload-artifact@v7 with: - name: ${{ steps.desktop-package.outputs.name }}-${{ github.run_number }}-${{ github.run_attempt }} - path: | - build-store/dist/${{ steps.desktop-package.outputs.name }}.zip - build-store/dist/${{ steps.desktop-package.outputs.name }}.zip.sha256 - build-store/dist/${{ steps.desktop-package.outputs.name }}-setup.exe - build-store/dist/${{ steps.desktop-package.outputs.name }}-setup.exe.sha256 + path: ${{ steps.desktop-package.outputs.file }} if-no-files-found: error retention-days: 30 compression-level: 0 + archive: false + + - name: Upload Windows portable release + uses: actions/upload-artifact@v7 + with: + path: ${{ steps.desktop-package.outputs.portable }} + if-no-files-found: error + retention-days: 30 + compression-level: 0 + archive: false diff --git a/src/live2d/cubism_model_load.cpp b/src/live2d/cubism_model_load.cpp index 29edf8f8..1d7150b5 100644 --- a/src/live2d/cubism_model_load.cpp +++ b/src/live2d/cubism_model_load.cpp @@ -139,7 +139,8 @@ void NativeModel::load_expressions() { if (bytes.empty()) continue; Csm::ACubismMotion *motion = LoadExpression(bytes.data(), (Csm::csmSizeInt)bytes.size(), name); - if (!motion) continue; expressions_[name] = motion; + if (!motion) continue; + expressions_[name] = motion; expression_names_[(size_t)i] = name; } if (!expressions_.empty()) From 81159242edca6a3d5d02d8507036adfbf816324b Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 24 Aug 2026 19:59:13 +0800 Subject: [PATCH 103/334] feat: add 32-bit and macOS architecture builds --- .github/scripts/restore-cubism-sdk.ps1 | 1 + .github/workflows/ci.yml | 55 ++++++++++++++----- .github/workflows/release.yml | 29 +++++++--- cmake/Cubism.cmake | 7 ++- packaging/microsoft-store/AppxManifest.xml.in | 2 +- packaging/microsoft-store/README.md | 15 +++-- .../microsoft-store/build-store-package.ps1 | 7 ++- .../validate-store-package.ps1 | 11 +++- 8 files changed, 91 insertions(+), 36 deletions(-) diff --git a/.github/scripts/restore-cubism-sdk.ps1 b/.github/scripts/restore-cubism-sdk.ps1 index 148d62ae..91a575c5 100644 --- a/.github/scripts/restore-cubism-sdk.ps1 +++ b/.github/scripts/restore-cubism-sdk.ps1 @@ -83,6 +83,7 @@ try { $required = @( (Join-Path $rootCandidate.FullName "Core/include/Live2DCubismCore.h"), + (Join-Path $rootCandidate.FullName "Core/lib/windows/x86/143/Live2DCubismCore_MT.lib"), (Join-Path $rootCandidate.FullName "Core/lib/windows/x86_64/143/Live2DCubismCore_MT.lib"), (Join-Path $rootCandidate.FullName "Core/lib/linux/x86_64/libLive2DCubismCore.a"), (Join-Path $rootCandidate.FullName "Core/lib/macos/x86_64/libLive2DCubismCore.a"), diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63601a21..eebe879c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,10 +98,19 @@ jobs: include: - os: windows-latest platform: windows-x64 + cmake_arch: x64 + - os: windows-latest + platform: windows-x86 + cmake_arch: Win32 - os: ubuntu-latest platform: linux-x64 - - os: macos-latest - platform: macos + cmake_arch: '' + - os: macos-13 + platform: macos-x64 + cmake_arch: x86_64 + - os: macos-14 + platform: macos-arm64 + cmake_arch: arm64 runs-on: ${{ matrix.os }} steps: - name: Checkout source @@ -119,7 +128,7 @@ jobs: if: runner.os == 'Windows' shell: pwsh run: | - cmake -S . -B build -A x64 ` + cmake -S . -B build -A ${{ matrix.cmake_arch }} ` -DBONGO_CAT_WARNINGS_AS_ERRORS=ON - name: Configure Unix release @@ -127,6 +136,7 @@ jobs: run: | cmake -S . -B build -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_OSX_ARCHITECTURES=${{ matrix.cmake_arch }} \ -DBONGO_CAT_WARNINGS_AS_ERRORS=ON - name: Register GCC diagnostics @@ -176,8 +186,13 @@ jobs: include: - os: ubuntu-latest platform: linux-x64 - - os: macos-latest - platform: macos + cmake_arch: '' + - os: macos-13 + platform: macos-x64 + cmake_arch: x86_64 + - os: macos-14 + platform: macos-arm64 + cmake_arch: arm64 runs-on: ${{ matrix.os }} env: CUBISM_SDK_ARCHIVE_URL: ${{ secrets.CUBISM_SDK_ARCHIVE_URL }} @@ -204,6 +219,7 @@ jobs: run: | cmake -S . -B build-package -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_OSX_ARCHITECTURES=${{ matrix.cmake_arch }} \ -DBONGO_CAT_REQUIRE_CUBISM=ON \ -DBONGO_CAT_WARNINGS_AS_ERRORS=ON @@ -223,7 +239,7 @@ jobs: } $version = $Matches[1] $name = (Get-Content build-package/bongo-cat-package-name.txt -Raw).Trim() - $platform = if ($IsLinux) { 'linux-x64' } else { 'macos-(x64|arm64)' } + $platform = "${{ matrix.platform }}" if ($name -notmatch "^BongoCat-$([regex]::Escape($version))-$platform$") { throw "Cubism desktop package is not production-ready: $name" } @@ -248,12 +264,22 @@ jobs: archive: false store-package: - name: package / windows-x64 + name: package / ${{ matrix.platform }} if: >- github.repository == 'vladelaina/BongoCat' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' needs: [publishing-policy, quality] + strategy: + fail-fast: false + matrix: + include: + - platform: windows-x64 + cmake_arch: x64 + store_arch: x64 + - platform: windows-x86 + cmake_arch: Win32 + store_arch: x86 runs-on: windows-2022 env: CUBISM_SDK_ARCHIVE_URL: ${{ secrets.CUBISM_SDK_ARCHIVE_URL }} @@ -272,7 +298,7 @@ jobs: shell: pwsh run: | cmake -S . -B build-store -G "Visual Studio 17 2022" ` - -A x64 -T v143 ` + -A ${{ matrix.cmake_arch }} -T v143 ` -DBONGO_CAT_REQUIRE_CUBISM=ON ` -DBONGO_CAT_WARNINGS_AS_ERRORS=ON @@ -323,7 +349,7 @@ jobs: shell: pwsh run: | $name = (Get-Content build-store/bongo-cat-package-name.txt -Raw).Trim() - $expected = "BongoCat-${{ steps.version.outputs.version }}-windows-x64" + $expected = "BongoCat-${{ steps.version.outputs.version }}-${{ matrix.platform }}" if ($name -ne $expected) { throw "Cubism desktop package is not production-ready: $name" } @@ -349,22 +375,23 @@ jobs: ./packaging/microsoft-store/build-store-package.ps1 ` -ExecutablePath "./build-store/Release/BongoCat.exe" ` -Version "${{ steps.version.outputs.version }}" ` + -Architecture "${{ matrix.store_arch }}" ` -OutputDirectory "./store-output" - $msix = Get-Item "store-output/bongocat_${{ steps.version.outputs.version }}_x64.msix" + $msix = Get-Item "store-output/bongocat_${{ steps.version.outputs.version }}_${{ matrix.store_arch }}.msix" Write-Host ("Microsoft Store MSIX size: {0} KB" -f [math]::Round($msix.Length / 1KB, 2)) - name: Validate Microsoft Store MSIX shell: pwsh run: | ./packaging/microsoft-store/validate-store-package.ps1 ` - -PackagePath "./store-output/bongocat_${{ steps.version.outputs.version }}_x64.msix" ` - -ExpectedAppVersion "${{ steps.version.outputs.version }}" + -PackagePath "./store-output/bongocat_${{ steps.version.outputs.version }}_${{ matrix.store_arch }}.msix" ` + -ExpectedAppVersion "${{ steps.version.outputs.version }}" ` + -ExpectedArchitecture "${{ matrix.store_arch }}" - name: Upload Microsoft Store MSIX uses: actions/upload-artifact@v7 with: - name: bongocat-store-msix-${{ steps.version.outputs.version }}-${{ github.run_number }}-${{ github.run_attempt }} - path: store-output/bongocat_${{ steps.version.outputs.version }}_x64.msix + path: store-output/bongocat_${{ steps.version.outputs.version }}_${{ matrix.store_arch }}.msix if-no-files-found: error retention-days: 90 compression-level: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 956c7778..af0cdc1c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,9 +14,18 @@ concurrency: jobs: release: - name: release / windows-x64 + name: release / ${{ matrix.platform }} if: github.repository == 'vladelaina/BongoCat' runs-on: windows-2022 + strategy: + fail-fast: false + max-parallel: 1 + matrix: + include: + - platform: windows-x64 + cmake_arch: x64 + - platform: windows-x86 + cmake_arch: Win32 env: CUBISM_SDK_ARCHIVE_URL: ${{ secrets.CUBISM_SDK_ARCHIVE_URL }} CUBISM_SDK_ARCHIVE_SHA256: ${{ secrets.CUBISM_SDK_ARCHIVE_SHA256 }} @@ -51,7 +60,7 @@ jobs: shell: pwsh run: | cmake -S . -B build-release -G "Visual Studio 17 2022" ` - -A x64 -T v143 ` + -A ${{ matrix.cmake_arch }} -T v143 ` -DBONGO_CAT_REQUIRE_CUBISM=ON ` -DBONGO_CAT_WARNINGS_AS_ERRORS=ON @@ -93,7 +102,7 @@ jobs: shell: pwsh run: | $name = (Get-Content build-release/bongo-cat-package-name.txt -Raw).Trim() - $expected = "BongoCat-${{ steps.version.outputs.version }}-windows-x64" + $expected = "BongoCat-${{ steps.version.outputs.version }}-${{ matrix.platform }}" if ($name -ne $expected) { throw "Unexpected release package name: $name" } @@ -101,9 +110,13 @@ jobs: cmake --build build-release --config Release --target package cmake --build build-release --config Release --target package-installer + $portableSource = "build-release/dist/$name.zip" + $portable = "build-release/dist/$name-portable.zip" + $portableChecksumSource = "$portableSource.sha256" + $portableChecksum = "$portable.sha256" $files = @( - "build-release/dist/$name.zip", - "build-release/dist/$name.zip.sha256", + $portableSource, + $portableChecksumSource, "build-release/dist/$name-setup.exe", "build-release/dist/$name-setup.exe.sha256" ) @@ -112,6 +125,8 @@ jobs: throw "Release file is missing: $file" } } + Move-Item -LiteralPath $portableSource -Destination $portable -Force + Move-Item -LiteralPath $portableChecksumSource -Destination $portableChecksum -Force "name=$name" | Out-File -FilePath $env:GITHUB_OUTPUT ` -Append -Encoding utf8 @@ -122,7 +137,7 @@ jobs: generate_release_notes: true fail_on_unmatched_files: true files: | - build-release/dist/${{ steps.package.outputs.name }}.zip - build-release/dist/${{ steps.package.outputs.name }}.zip.sha256 + build-release/dist/${{ steps.package.outputs.name }}-portable.zip + build-release/dist/${{ steps.package.outputs.name }}-portable.zip.sha256 build-release/dist/${{ steps.package.outputs.name }}-setup.exe build-release/dist/${{ steps.package.outputs.name }}-setup.exe.sha256 diff --git a/cmake/Cubism.cmake b/cmake/Cubism.cmake index ef2db625..0a99b1c5 100644 --- a/cmake/Cubism.cmake +++ b/cmake/Cubism.cmake @@ -17,8 +17,13 @@ if(WIN32) if(MSVC_VERSION LESS 1930 OR MSVC_VERSION GREATER_EQUAL 1950) message(FATAL_ERROR "Cubism Windows build requires Visual Studio 2022") endif() + if(CMAKE_SIZEOF_VOID_P EQUAL 8) + set(CUBISM_WINDOWS_ARCH "x86_64") + else() + set(CUBISM_WINDOWS_ARCH "x86") + endif() set(CUBISM_CORE_LIBRARY - "${CUBISM_CORE_PATH}/lib/windows/x86_64/143/Live2DCubismCore_MT.lib") + "${CUBISM_CORE_PATH}/lib/windows/${CUBISM_WINDOWS_ARCH}/143/Live2DCubismCore_MT.lib") elseif(APPLE) if(CMAKE_OSX_ARCHITECTURES) list(GET CMAKE_OSX_ARCHITECTURES 0 CUBISM_ARCH) diff --git a/packaging/microsoft-store/AppxManifest.xml.in b/packaging/microsoft-store/AppxManifest.xml.in index a46da704..d041bbc4 100644 --- a/packaging/microsoft-store/AppxManifest.xml.in +++ b/packaging/microsoft-store/AppxManifest.xml.in @@ -9,7 +9,7 @@ Name="vladelaina.bongocat" Publisher="CN=5503A135-7FA4-466B-815C-DBE627F4065F" Version="@PACKAGE_VERSION@" - ProcessorArchitecture="x64" /> + ProcessorArchitecture="@PROCESSOR_ARCHITECTURE@" /> BongoCat diff --git a/packaging/microsoft-store/README.md b/packaging/microsoft-store/README.md index a4757a8d..694c6ea1 100644 --- a/packaging/microsoft-store/README.md +++ b/packaging/microsoft-store/README.md @@ -1,8 +1,9 @@ # Microsoft Store packaging -The protected `store-package` job in `.github/workflows/ci.yml` creates a raw -`bongocat__x64.msix` artifact on pushes to the upstream `main` -branch of `vladelaina/BongoCat`. The artifact is intentionally unsigned: +The protected `store-package` job in `.github/workflows/ci.yml` creates raw +`bongocat__.msix` artifacts for Windows x64 and x86 +on pushes to the upstream `main` branch of `vladelaina/BongoCat`. The artifacts +are intentionally unsigned: Partner Center signs accepted MSIX packages with the Microsoft Store certificate during submission. @@ -16,8 +17,8 @@ certificate during submission. The package name and publisher are stored in `AppxManifest.xml.in`. The PFN and package SID are derived by Windows and must not be used as signing -secrets. The package uses `x64`, matching the current Cubism Core library and -the Windows build job. +secrets. The package architecture matches the Windows build job; both x64 and +x86 Cubism Core libraries are validated before a package can be published. MSIX versions must have a non-zero major component and a zero revision. The script maps app version `0.1.1` to package version `1.1.1.0` by default; pass @@ -39,9 +40,7 @@ Running the protected workflow is subject to the Live2D Cubism SDK licenses; the repository owner is responsible for satisfying their release terms. The job verifies the digests and required files before configuring CMake with -`BONGO_CAT_REQUIRE_CUBISM=ON`; it never uploads the diagnostic backend used by -the public cross-platform build. The ordinary CI Windows artifacts are named -`diagnostic-*` for this reason and are not suitable for Store submission. +`BONGO_CAT_REQUIRE_CUBISM=ON`; it never uploads the diagnostic backend. ## Local build and install check diff --git a/packaging/microsoft-store/build-store-package.ps1 b/packaging/microsoft-store/build-store-package.ps1 index a7033256..d5ed0b13 100644 --- a/packaging/microsoft-store/build-store-package.ps1 +++ b/packaging/microsoft-store/build-store-package.ps1 @@ -9,6 +9,8 @@ param( [string]$PackageVersion, [string]$OutputDirectory, [string]$CertificateOutputPath, + [ValidateSet('x86', 'x64')] + [string]$Architecture = 'x64', [switch]$SignForLocalTesting ) @@ -62,10 +64,10 @@ if ($packageParts[3] -ne 0) { } $packageBaseName = if ($SignForLocalTesting) { - "bongocat_${Version}_x64-local-test" + "bongocat_${Version}_${Architecture}-local-test" } else { - "bongocat_${Version}_x64" + "bongocat_${Version}_${Architecture}" } $msixPath = Join-Path $OutputDirectory "$packageBaseName.msix" $temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("BongoCatStorePackage_" + [guid]::NewGuid()) @@ -154,6 +156,7 @@ try { $manifestTemplate = Get-Content (Join-Path $scriptDirectory "AppxManifest.xml.in") -Raw $manifest = $manifestTemplate.Replace("@PACKAGE_VERSION@", $PackageVersion) + $manifest = $manifest.Replace("@PROCESSOR_ARCHITECTURE@", $Architecture) [System.IO.File]::WriteAllText( (Join-Path $stagingDirectory "AppxManifest.xml"), $manifest, diff --git a/packaging/microsoft-store/validate-store-package.ps1 b/packaging/microsoft-store/validate-store-package.ps1 index 4587ec87..29cfcae9 100644 --- a/packaging/microsoft-store/validate-store-package.ps1 +++ b/packaging/microsoft-store/validate-store-package.ps1 @@ -6,6 +6,9 @@ param( [Parameter(Mandatory = $true)] [string]$ExpectedAppVersion, + [ValidateSet('x86', 'x64')] + [string]$ExpectedArchitecture = 'x64', + [string]$ExpectedPackageVersion ) @@ -94,7 +97,7 @@ try { Name = 'vladelaina.bongocat' Publisher = 'CN=5503A135-7FA4-466B-815C-DBE627F4065F' Version = $ExpectedPackageVersion - ProcessorArchitecture = 'x64' + ProcessorArchitecture = $ExpectedArchitecture } foreach ($field in $expectedIdentity.Keys) { if ($identity.$field -ne $expectedIdentity[$field]) { @@ -125,8 +128,10 @@ try { } $machine = Get-PeMachine (Join-Path $temporaryRoot 'BongoCat.exe') - if ($machine -ne 0x8664) { - throw ('Packaged executable machine is 0x{0:X4}; expected x64 0x8664.' -f $machine) + $expectedMachine = if ($ExpectedArchitecture -eq 'x64') { 0x8664 } else { 0x014C } + if ($machine -ne $expectedMachine) { + throw ('Packaged executable machine is 0x{0:X4}; expected {1}.' -f + $machine, $ExpectedArchitecture) } $publisherLength = 0 From e702ee5c0507520be2080162fffd39590046fd98 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 24 Aug 2026 20:07:40 +0800 Subject: [PATCH 104/334] Update tray tooltip branding --- include/bongo_cat/common.h | 1 + src/runtime/shell/tray.c | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/include/bongo_cat/common.h b/include/bongo_cat/common.h index 6593c99d..0d33b556 100644 --- a/include/bongo_cat/common.h +++ b/include/bongo_cat/common.h @@ -9,6 +9,7 @@ #define BONGO_CAT_NAME "BongoCat" #define BONGO_CAT_PET_WINDOW_TITLE BONGO_CAT_NAME +#define BONGO_CAT_TRAY_TITLE BONGO_CAT_NAME " v" BONGO_CAT_VERSION " \xC2\xB7 by vladelaina" #define BONGO_CAT_SLUG "bongo-cat" #define BONGO_CAT_EXECUTABLE "BongoCat" #define BONGO_CAT_APP_ID "com.bongocat.desktop" diff --git a/src/runtime/shell/tray.c b/src/runtime/shell/tray.c index caa2c9ed..0b492d61 100644 --- a/src/runtime/shell/tray.c +++ b/src/runtime/shell/tray.c @@ -108,7 +108,7 @@ static void destroy_native_tray(BongoCatTray *tray) { } static bool create_native_tray(BongoCatTray *tray, BongoCatError *error) { - tray->handle = SDL_CreateTray(tray->icon.surface, BONGO_CAT_NAME); + tray->handle = SDL_CreateTray(tray->icon.surface, BONGO_CAT_TRAY_TITLE); if (!tray->handle) { bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, "Tray creation failed: %s", SDL_GetError()); From 414393266128af757681ce86bc7b08a29adbc3ca Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 24 Aug 2026 20:21:59 +0800 Subject: [PATCH 105/334] fix: publish only x64 Store package --- .github/workflows/ci.yml | 5 ++++- packaging/microsoft-store/README.md | 11 +++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eebe879c..98864c7c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -279,7 +279,7 @@ jobs: store_arch: x64 - platform: windows-x86 cmake_arch: Win32 - store_arch: x86 + store_arch: '' runs-on: windows-2022 env: CUBISM_SDK_ARCHIVE_URL: ${{ secrets.CUBISM_SDK_ARCHIVE_URL }} @@ -370,6 +370,7 @@ jobs: "file=$setup" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - name: Build Microsoft Store MSIX + if: matrix.store_arch == 'x64' shell: pwsh run: | ./packaging/microsoft-store/build-store-package.ps1 ` @@ -381,6 +382,7 @@ jobs: Write-Host ("Microsoft Store MSIX size: {0} KB" -f [math]::Round($msix.Length / 1KB, 2)) - name: Validate Microsoft Store MSIX + if: matrix.store_arch == 'x64' shell: pwsh run: | ./packaging/microsoft-store/validate-store-package.ps1 ` @@ -389,6 +391,7 @@ jobs: -ExpectedArchitecture "${{ matrix.store_arch }}" - name: Upload Microsoft Store MSIX + if: matrix.store_arch == 'x64' uses: actions/upload-artifact@v7 with: path: store-output/bongocat_${{ steps.version.outputs.version }}_${{ matrix.store_arch }}.msix diff --git a/packaging/microsoft-store/README.md b/packaging/microsoft-store/README.md index 694c6ea1..623c22ba 100644 --- a/packaging/microsoft-store/README.md +++ b/packaging/microsoft-store/README.md @@ -1,9 +1,8 @@ # Microsoft Store packaging -The protected `store-package` job in `.github/workflows/ci.yml` creates raw -`bongocat__.msix` artifacts for Windows x64 and x86 -on pushes to the upstream `main` branch of `vladelaina/BongoCat`. The artifacts -are intentionally unsigned: +The protected `store-package` job in `.github/workflows/ci.yml` creates a raw +`bongocat__x64.msix` artifact on pushes to the upstream `main` +branch of `vladelaina/BongoCat`. The artifact is intentionally unsigned: Partner Center signs accepted MSIX packages with the Microsoft Store certificate during submission. @@ -17,8 +16,8 @@ certificate during submission. The package name and publisher are stored in `AppxManifest.xml.in`. The PFN and package SID are derived by Windows and must not be used as signing -secrets. The package architecture matches the Windows build job; both x64 and -x86 Cubism Core libraries are validated before a package can be published. +secrets. The Store package is x64; the separate Windows x86 workflow target +only publishes its desktop installer and portable archive. MSIX versions must have a non-zero major component and a zero revision. The script maps app version `0.1.1` to package version `1.1.1.0` by default; pass From 7b3f3b3f27d6a11c4b5639ed0fbf0b3e06fab3d5 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 26 Aug 2026 10:07:24 +0800 Subject: [PATCH 106/334] Update README with OpenGL vs Vulkan discussion Add a section addressing OpenGL vs Vulkan. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 7631ec39..ec29813d 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,9 @@ flowchart TB style Toolchain fill:#fffaf3,stroke:#dda866,stroke-width:1.5px ``` +# Here are a few things you might be curious about: +1. Why OpenGL instead of Vulkan From d765e8fbf3bda6b33c99d61eb9dfd2b63230a282 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 26 Aug 2026 12:04:44 +0800 Subject: [PATCH 107/334] refactor: unify built-in and imported model storage --- README.md | 2 +- cmake/RuntimeSources.cmake | 2 + include/bongo_cat/app.h | 1 + include/bongo_cat/common.h | 1 + src/core/model_catalog.c | 10 ++- src/core/model_package.c | 5 +- src/runtime/lifecycle/storage_paths.c | 12 ++- src/runtime/model/import/model_import.c | 78 +++---------------- src/runtime/model/import/model_import.h | 4 +- .../model/import/model_import_adapter.c | 2 +- src/runtime/model/import/model_import_app.c | 60 ++++++++++++++ .../model/import/model_import_package.c | 5 +- src/runtime/model/model_catalog_builtins.c | 47 +++++++++++ src/runtime/model/model_catalog_refresh.c | 8 +- src/runtime/model/model_catalog_runtime.c | 77 ++---------------- src/runtime/model/model_storage.c | 58 ++++++++++++++ src/runtime/model/model_storage.h | 4 + src/runtime/runtime.h | 2 - src/ui/preferences/preferences_import.c | 6 +- .../preferences/preferences_import_internal.h | 2 +- .../model_import/test_model_import_identity.c | 9 ++- tests/model_import/test_mver_missing_motion.c | 4 +- .../model_import/test_mver_nearby_identity.c | 4 +- tests/model_import/test_slim_package.c | 6 +- 24 files changed, 239 insertions(+), 170 deletions(-) create mode 100644 src/runtime/model/import/model_import_app.c create mode 100644 src/runtime/model/model_catalog_builtins.c diff --git a/README.md b/README.md index ec29813d..31200e8b 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ flowchart TB Discover["Source Discovery
standalone model3 / Mver package / image patch"] Validate["Validation and Identity
manifest references / bounded paths / SHA-256 digest / metadata"] Adapt["Runtime Adapter Generation
preview assets / input overlays / projection and binding metadata"] - Installed[("Installed Packages
data_root/models / preserved source payload / package descriptor")] + Installed[("Installed Packages
models_root / preserved source payload / package descriptor")] Nearby[("Nearby Models
source tree stays authoritative / cache_root/model-adapters")] Catalog["Model and Behavior Catalogs
models / motions / expressions / sounds / effects"] diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index cba09b69..39557818 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -62,6 +62,7 @@ set(BONGO_CAT_RUNTIME_INPUT_SOURCES set(BONGO_CAT_RUNTIME_MODEL_SOURCES src/runtime/model/model_behavior_state.c src/runtime/model/model_catalog_runtime.c + src/runtime/model/model_catalog_builtins.c src/runtime/model/model_catalog_refresh.c src/runtime/model/model_files.c src/runtime/model/model_geometry.c @@ -74,6 +75,7 @@ set(BONGO_CAT_RUNTIME_MODEL_SOURCES set(BONGO_CAT_RUNTIME_IMPORT_SOURCES src/runtime/model/import/model_import.c + src/runtime/model/import/model_import_app.c src/runtime/model/import/model_import_adapter.c src/runtime/model/import/model_import_digest.c src/runtime/model/import/model_import_digest_api.c diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index c4b7877e..84e6adf4 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -49,6 +49,7 @@ typedef struct BongoCatApp { char session_path[BONGO_CAT_PATH_CAP]; char config_root[BONGO_CAT_PATH_CAP]; char data_root[BONGO_CAT_PATH_CAP]; + char models_root[BONGO_CAT_PATH_CAP]; char cache_root[BONGO_CAT_PATH_CAP]; char state_root[BONGO_CAT_PATH_CAP]; char log_root[BONGO_CAT_PATH_CAP]; diff --git a/include/bongo_cat/common.h b/include/bongo_cat/common.h index 0d33b556..d5422c0b 100644 --- a/include/bongo_cat/common.h +++ b/include/bongo_cat/common.h @@ -28,6 +28,7 @@ #define BONGO_CAT_SCHEDULED_RELEASE_CAP 64 #define BONGO_CAT_MODEL_PACKAGE_FILE ".bongo-cat-package.json" #define BONGO_CAT_MODEL_ADAPTER_FILE ".bongo-cat-adapter.json" +#define BONGO_CAT_MODEL_BUILTIN_MARKER ".bongo-cat-builtin" #define BONGO_CAT_MODEL_PACKAGE_SCHEMA 2 #define BONGO_CAT_MODEL_ADAPTER_SCHEMA 1 #define BONGO_CAT_MODEL_ADAPTER_GENERATOR 1 diff --git a/src/core/model_catalog.c b/src/core/model_catalog.c index 2dff46bd..c530771f 100644 --- a/src/core/model_catalog.c +++ b/src/core/model_catalog.c @@ -40,13 +40,19 @@ static BongoCatModelMode infer_mode(const char *directory) { return bongo_cat_path_is_dir(path) ? BONGO_CAT_MODE_KEYBOARD : BONGO_CAT_MODE_STANDARD; } +static bool builtin_marker(const char *directory) { + char marker[BONGO_CAT_PATH_CAP]; + return bongo_cat_path_join(marker, sizeof(marker), directory, + BONGO_CAT_MODEL_BUILTIN_MARKER) && bongo_cat_path_is_file(marker); +} + static bool add_model(BongoCatModelCatalog *catalog, const char *directory, bool preset) { bool handled = false; bool package_ok = bongo_cat_model_package_add(catalog, directory, preset, &handled); if (handled) return package_ok; if (!package_ok) return false; - if (!preset) return true; + if (!preset && !builtin_marker(directory)) return true; if (catalog->count >= BONGO_CAT_MODEL_CAP) return false; char setting[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_find_suffix(directory, ".model3.json", setting, sizeof(setting))) return true; @@ -62,7 +68,7 @@ static bool add_model(BongoCatModelCatalog *catalog, const char *directory, bool entry->source_format = BONGO_CAT_MODEL_SOURCE_BUILTIN; entry->capabilities = BONGO_CAT_MODEL_CAPABILITY_LIVE2D | BONGO_CAT_MODEL_CAPABILITY_PREVIEW; - entry->preset = preset; + entry->preset = preset || builtin_marker(directory); return true; } diff --git a/src/core/model_package.c b/src/core/model_package.c index d11c8a41..4f46474f 100644 --- a/src/core/model_package.c +++ b/src/core/model_package.c @@ -232,7 +232,10 @@ bool bongo_cat_model_package_add(BongoCatModelCatalog *catalog, entry->package_schema = schema; entry->adapter_schema = adapter_schema; entry->adapter_generator = adapter_generator; - entry->preset = preset; + char marker[BONGO_CAT_PATH_CAP]; + entry->preset = preset || (bongo_cat_path_join(marker, sizeof(marker), + directory, BONGO_CAT_MODEL_BUILTIN_MARKER) && + bongo_cat_path_is_file(marker)); catalog->count++; } yyjson_doc_free(document); diff --git a/src/runtime/lifecycle/storage_paths.c b/src/runtime/lifecycle/storage_paths.c index 9dbfe04c..dfea5006 100644 --- a/src/runtime/lifecycle/storage_paths.c +++ b/src/runtime/lifecycle/storage_paths.c @@ -31,6 +31,8 @@ static bool isolated_roots(BongoCatApp *app) { app->storage_root, "config") && child_path(app->data_root, sizeof(app->data_root), app->storage_root, "data") && + child_path(app->models_root, sizeof(app->models_root), + app->storage_root, "models") && child_path(app->cache_root, sizeof(app->cache_root), app->storage_root, "cache") && child_path(app->state_root, sizeof(app->state_root), @@ -48,6 +50,8 @@ static bool platform_roots(BongoCatApp *app) { local_app, "config") && child_path(app->data_root, sizeof(app->data_root), local_app, "data") && + child_path(app->models_root, sizeof(app->models_root), + local_app, "models") && child_path(app->cache_root, sizeof(app->cache_root), local_app, "cache") && child_path(app->state_root, sizeof(app->state_root), @@ -66,6 +70,8 @@ static bool platform_roots(BongoCatApp *app) { child_path(app->config_root, sizeof(app->config_root), app_support, "config") && child_path(app->data_root, sizeof(app->data_root), app_support, "data") && + child_path(app->models_root, sizeof(app->models_root), + app_support, "models") && child_path(app->state_root, sizeof(app->state_root), app_support, "state") && child_path(app->cache_root, sizeof(app->cache_root), library, "Caches/BongoCat") && @@ -93,6 +99,8 @@ static bool platform_roots(BongoCatApp *app) { "XDG_STATE_HOME", ".local/state") || !xdg_root(app->cache_root, sizeof(app->cache_root), "XDG_CACHE_HOME", ".cache")) return false; + if (!child_path(app->models_root, sizeof(app->models_root), + app->data_root, "models")) return false; return child_path(app->log_root, sizeof(app->log_root), app->state_root, "logs"); } @@ -112,8 +120,8 @@ bool bongo_cat_storage_paths_prepare(BongoCatApp *app, sizeof(app->state_root), app->primary_state_root, app->secondary_model_id); } - const char *roots[] = {app->config_root, app->data_root, app->cache_root, - app->state_root, app->log_root}; + const char *roots[] = {app->config_root, app->data_root, app->models_root, + app->cache_root, app->state_root, app->log_root}; for (size_t i = 0; resolved && i < sizeof(roots) / sizeof(roots[0]); ++i) resolved = writable_directory(roots[i]); resolved = resolved && child_path(app->settings_path, diff --git a/src/runtime/model/import/model_import.c b/src/runtime/model/import/model_import.c index a0a50c29..88993452 100644 --- a/src/runtime/model/import/model_import.c +++ b/src/runtime/model/import/model_import.c @@ -21,19 +21,12 @@ struct BongoCatImportSession { BongoCatImportDigestCache *digests; }; -static bool custom_root(const char *data_root, char *path, size_t capacity) { - return data_root && data_root[0] && - bongo_cat_path_join(path, capacity, data_root, "models") && - bongo_cat_path_create_directory(path); -} - static void cleanup(ImportInstall *installs, size_t count, bool committed) { for (size_t i = 0; i < count; ++i) if (!installs[i].existing) bongo_cat_model_remove_tree(committed && installs[i].committed ? installs[i].target : installs[i].temporary, NULL); } - static bool prepare_install(const BongoCatImportCandidate *candidate, const BongoCatPackageMetadata *metadata, const BongoCatModelCatalog *existing, ImportInstall *install, @@ -93,9 +86,9 @@ static void remember_installs(BongoCatImportSession *session, } } -BongoCatImportSession *bongo_cat_import_session_create(const char *data_root, +BongoCatImportSession *bongo_cat_import_session_create(const char *models_root, BongoCatError *error) { - if (!data_root) return NULL; + if (!models_root) return NULL; BongoCatImportSession *session = calloc(1, sizeof(*session)); if (session) session->existing = calloc(1, sizeof(*session->existing)); if (!session || !session->existing) { @@ -106,7 +99,10 @@ BongoCatImportSession *bongo_cat_import_session_create(const char *data_root, return NULL; } session->digests = bongo_cat_import_digest_cache_create(); - if (!custom_root(data_root, session->root, sizeof(session->root)) || + int root_length = snprintf(session->root, sizeof(session->root), "%s", + models_root); + if (root_length < 0 || (size_t)root_length >= sizeof(session->root) || + !bongo_cat_path_create_directory(session->root) || !bongo_cat_model_cleanup_imports(session->root, error)) { bongo_cat_import_session_destroy(session); return NULL; @@ -191,70 +187,16 @@ BongoCatResult bongo_cat_import_session_install( return BONGO_CAT_OK; } -BongoCatResult bongo_cat_import_install(const char *source, const char *data_root, +BongoCatResult bongo_cat_import_install(const char *source, const char *models_root, BongoCatImportReceipt *receipt, BongoCatError *error) { if (receipt) memset(receipt, 0, sizeof(*receipt)); - if (!source || !data_root) return BONGO_CAT_ERROR_ARGUMENT; + if (!source || !models_root) return BONGO_CAT_ERROR_ARGUMENT; BongoCatImportSession *session = bongo_cat_import_session_create( - data_root, error); + models_root, error); if (!session) return error && error->code ? error->code : BONGO_CAT_ERROR_IO; BongoCatResult result = bongo_cat_import_session_install(session, source, receipt, error); bongo_cat_import_session_destroy(session); return result; -} - -#ifdef BONGO_CAT_HAS_CUBISM -static void remove_receipt(const char *data_root, - const BongoCatImportReceipt *receipt) { - char root[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; - if (!receipt || !custom_root(data_root, root, sizeof(root))) return; - for (size_t i = 0; i < receipt->count; ++i) - if (receipt->installed[i] && - bongo_cat_path_join(path, sizeof(path), root, receipt->ids[i])) - bongo_cat_model_remove_tree(path, NULL); -} -#endif - -BongoCatResult bongo_cat_app_import_model(BongoCatApp *app, const char *source, - BongoCatError *error) { - if (!app || !source) return BONGO_CAT_ERROR_ARGUMENT; - BongoCatImportReceipt receipt; - BongoCatResult result = bongo_cat_import_install(source, app->data_root, - &receipt, error); - if (result != BONGO_CAT_OK) { - if (error && !error->message[0]) bongo_cat_error_set(error, result, - "Model import failed while installing: %s", source); - return result; - } - char previous[BONGO_CAT_PATH_CAP]; - snprintf(previous, sizeof(previous), "%s", app->session.active_model_id); - size_t preferred = 0; - for (size_t i = 0; i < receipt.count; ++i) - if (receipt.installed[i]) { preferred = i; break; } - const char *imported_id = receipt.count ? receipt.ids[preferred] : NULL; - bongo_cat_app_refresh_installed_models(app); - for (size_t i = 0; i < receipt.count; ++i) - if (receipt.installed[i]) - bongo_cat_app_forget_behavior_state(app, receipt.ids[i]); - if (imported_id && bongo_cat_app_select_model(app, imported_id)) - return BONGO_CAT_OK; -#ifndef BONGO_CAT_HAS_CUBISM - const BongoCatModelEntry *entry = imported_id ? - bongo_cat_models_find(&app->models, imported_id) : NULL; - if (entry) { - snprintf(app->session.active_model_id, sizeof(app->session.active_model_id), - "%s", imported_id); - app->loaded_mode = entry->mode; - } - return BONGO_CAT_OK; -#else - remove_receipt(app->data_root, &receipt); - bongo_cat_app_refresh_installed_models(app); - if (previous[0]) bongo_cat_app_select_model(app, previous); - bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, - "Model import was rolled back because the Live2D model could not be loaded"); - return BONGO_CAT_ERROR_CUBISM; -#endif -} +} \ No newline at end of file diff --git a/src/runtime/model/import/model_import.h b/src/runtime/model/import/model_import.h index 74ae6eee..58f07fa3 100644 --- a/src/runtime/model/import/model_import.h +++ b/src/runtime/model/import/model_import.h @@ -131,9 +131,9 @@ BongoCatResult bongo_cat_import_nearby_scan(BongoCatApp *app, BongoCatResult bongo_cat_import_nearby(BongoCatApp *app, const char *root, BongoCatError *error); BongoCatResult bongo_cat_import_install(const char *source, - const char *data_root, BongoCatImportReceipt *receipt, + const char *models_root, BongoCatImportReceipt *receipt, BongoCatError *error); -BongoCatImportSession *bongo_cat_import_session_create(const char *data_root, +BongoCatImportSession *bongo_cat_import_session_create(const char *models_root, BongoCatError *error); void bongo_cat_import_session_destroy(BongoCatImportSession *session); BongoCatResult bongo_cat_import_session_install( diff --git a/src/runtime/model/import/model_import_adapter.c b/src/runtime/model/import/model_import_adapter.c index 1fa577b0..78921e44 100644 --- a/src/runtime/model/import/model_import_adapter.c +++ b/src/runtime/model/import/model_import_adapter.c @@ -62,7 +62,7 @@ static bool copy_preview(const BongoCatImportCandidate *candidate, "resources")) return false; bool target_exists = bongo_cat_path_is_dir(target_resources); if (bongo_cat_path_is_dir(source_resources) && !target_exists) { - if (bongo_cat_copy_directory(source_resources, target_resources, + if (bongo_cat_model_copy_directory(source_resources, target_resources, error) != BONGO_CAT_OK) return false; target_exists = true; } diff --git a/src/runtime/model/import/model_import_app.c b/src/runtime/model/import/model_import_app.c new file mode 100644 index 00000000..42523251 --- /dev/null +++ b/src/runtime/model/import/model_import_app.c @@ -0,0 +1,60 @@ +#include "model_import.h" +#include "model_storage.h" +#include "runtime.h" +#include "bongo_cat/path.h" + +#include + +#ifdef BONGO_CAT_HAS_CUBISM +static void remove_receipt(const char *models_root, + const BongoCatImportReceipt *receipt) { + char path[BONGO_CAT_PATH_CAP]; + if (!receipt || !models_root) return; + for (size_t i = 0; i < receipt->count; ++i) + if (receipt->installed[i] && + bongo_cat_path_join(path, sizeof(path), models_root, receipt->ids[i])) + bongo_cat_model_remove_tree(path, NULL); +} +#endif + +BongoCatResult bongo_cat_app_import_model(BongoCatApp *app, const char *source, + BongoCatError *error) { + if (!app || !source) return BONGO_CAT_ERROR_ARGUMENT; + BongoCatImportReceipt receipt; + BongoCatResult result = bongo_cat_import_install(source, app->models_root, + &receipt, error); + if (result != BONGO_CAT_OK) { + if (error && !error->message[0]) bongo_cat_error_set(error, result, + "Model import failed while installing: %s", source); + return result; + } + char previous[BONGO_CAT_PATH_CAP]; + snprintf(previous, sizeof(previous), "%s", app->session.active_model_id); + size_t preferred = 0; + for (size_t i = 0; i < receipt.count; ++i) + if (receipt.installed[i]) { preferred = i; break; } + const char *imported_id = receipt.count ? receipt.ids[preferred] : NULL; + bongo_cat_app_refresh_installed_models(app); + for (size_t i = 0; i < receipt.count; ++i) + if (receipt.installed[i]) + bongo_cat_app_forget_behavior_state(app, receipt.ids[i]); + if (imported_id && bongo_cat_app_select_model(app, imported_id)) + return BONGO_CAT_OK; +#ifndef BONGO_CAT_HAS_CUBISM + const BongoCatModelEntry *entry = imported_id ? + bongo_cat_models_find(&app->models, imported_id) : NULL; + if (entry) { + snprintf(app->session.active_model_id, sizeof(app->session.active_model_id), + "%s", imported_id); + app->loaded_mode = entry->mode; + } + return BONGO_CAT_OK; +#else + remove_receipt(app->models_root, &receipt); + bongo_cat_app_refresh_installed_models(app); + if (previous[0]) bongo_cat_app_select_model(app, previous); + bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, + "Model import was rolled back because the Live2D model could not be loaded"); + return BONGO_CAT_ERROR_CUBISM; +#endif +} diff --git a/src/runtime/model/import/model_import_package.c b/src/runtime/model/import/model_import_package.c index c161bb49..46bff3f1 100644 --- a/src/runtime/model/import/model_import_package.c +++ b/src/runtime/model/import/model_import_package.c @@ -1,4 +1,5 @@ #include "model_import.h" +#include "model_storage.h" #include "runtime.h" #include "bongo_cat/json.h" #include "bongo_cat/path.h" @@ -53,7 +54,7 @@ static bool copy_relative_directory(const char *source_root, if (!relative_path(source_root, source, relative, sizeof(relative)) || !relative[0] || !bongo_cat_path_join(target, sizeof(target), target_root, relative)) return false; - return bongo_cat_copy_directory(source, target, error) == BONGO_CAT_OK; + return bongo_cat_model_copy_directory(source, target, error) == BONGO_CAT_OK; } static bool copy_relative_file(const char *source_root, const char *source, @@ -99,7 +100,7 @@ static bool copy_tauri_payload(const BongoCatImportCandidate *candidate, if (!relative_path(candidate->package_root, candidate->directory, relative, sizeof(relative))) return false; if (!relative[0]) - return bongo_cat_copy_directory(candidate->directory, target, error) == + return bongo_cat_model_copy_directory(candidate->directory, target, error) == BONGO_CAT_OK; return bongo_cat_path_create_directory(target) && copy_relative_directory(candidate->package_root, candidate->directory, diff --git a/src/runtime/model/model_catalog_builtins.c b/src/runtime/model/model_catalog_builtins.c new file mode 100644 index 00000000..e8a355a8 --- /dev/null +++ b/src/runtime/model/model_catalog_builtins.c @@ -0,0 +1,47 @@ +#include "model_storage.h" +#include "bongo_cat/file.h" +#include "bongo_cat/path.h" + +#include +#include + +static bool mark_builtin(const char *directory, BongoCatError *error) { + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), directory, + BONGO_CAT_MODEL_BUILTIN_MARKER)) return false; + if (bongo_cat_path_is_file(path)) return true; + FILE *file = bongo_cat_file_open(path, "wb"); + bool ok = file && fputs("BongoCat built-in model\n", file) >= 0; + if (file && fclose(file) != 0) ok = false; + if (!ok) bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot mark built-in model: %s", directory); + return ok; +} + +BongoCatResult bongo_cat_model_install_builtins(const char *asset_root, + const char *models_root, BongoCatError *error) { + static const char *const names[] = {"standard", "keyboard", "gamepad"}; + char source_root[BONGO_CAT_PATH_CAP]; + if (!asset_root || !models_root || + !bongo_cat_path_join(source_root, sizeof(source_root), asset_root, + "models") || !bongo_cat_path_create_directory(models_root)) + return BONGO_CAT_ERROR_ARGUMENT; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) { + char source[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(source, sizeof(source), source_root, names[i]) || + !bongo_cat_path_join(target, sizeof(target), models_root, names[i]) || + !bongo_cat_path_is_dir(source)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Built-in model assets are missing: %s", names[i]); + return BONGO_CAT_ERROR_IO; + } + if (!bongo_cat_path_is_dir(target)) { + if (bongo_cat_path_is_file(target) || + bongo_cat_model_copy_directory(source, target, error) != BONGO_CAT_OK) + return error && error->code ? error->code : BONGO_CAT_ERROR_IO; + } + if (!mark_builtin(target, error)) + return error && error->code ? error->code : BONGO_CAT_ERROR_IO; + } + return BONGO_CAT_OK; +} diff --git a/src/runtime/model/model_catalog_refresh.c b/src/runtime/model/model_catalog_refresh.c index ce9a3309..d90dd282 100644 --- a/src/runtime/model/model_catalog_refresh.c +++ b/src/runtime/model/model_catalog_refresh.c @@ -10,7 +10,7 @@ typedef struct BongoCatModelRefreshJob { struct BongoCatModelRefresh *owner; BongoCatModelCatalog models; char asset_root[BONGO_CAT_PATH_CAP]; - char data_root[BONGO_CAT_PATH_CAP]; + char models_root[BONGO_CAT_PATH_CAP]; char cache_root[BONGO_CAT_PATH_CAP]; char nearby_root[BONGO_CAT_PATH_CAP]; char active_model_id[BONGO_CAT_ID_CAP]; @@ -48,8 +48,8 @@ static int SDLCALL refresh_worker(void *userdata) { if (scan) { snprintf(scan->asset_root, sizeof(scan->asset_root), "%s", job->asset_root); - snprintf(scan->data_root, sizeof(scan->data_root), "%s", - job->data_root); + snprintf(scan->models_root, sizeof(scan->models_root), "%s", + job->models_root); snprintf(scan->cache_root, sizeof(scan->cache_root), "%s", job->cache_root); snprintf(scan->session.active_model_id, @@ -81,7 +81,7 @@ static bool start_refresh(BongoCatApp *app) { job->started_ns = SDL_GetTicksNS(); job->revision = refresh->revision; snprintf(job->asset_root, sizeof(job->asset_root), "%s", app->asset_root); - snprintf(job->data_root, sizeof(job->data_root), "%s", app->data_root); + snprintf(job->models_root, sizeof(job->models_root), "%s", app->models_root); snprintf(job->cache_root, sizeof(job->cache_root), "%s", app->cache_root); snprintf(job->nearby_root, sizeof(job->nearby_root), "%s", app->nearby_root); diff --git a/src/runtime/model/model_catalog_runtime.c b/src/runtime/model/model_catalog_runtime.c index 241b06c4..27cc51c8 100644 --- a/src/runtime/model/model_catalog_runtime.c +++ b/src/runtime/model/model_catalog_runtime.c @@ -8,20 +8,11 @@ #include #include -typedef struct TreeContext { - const char *source; - const char *target; - BongoCatError *error; - unsigned depth; -} TreeContext; - typedef struct ModelSelection { char additional[BONGO_CAT_ADDITIONAL_MODEL_CAP][BONGO_CAT_ID_CAP]; size_t count; } ModelSelection; -enum { MODEL_TREE_DEPTH_CAP = 32 }; - static ModelSelection capture_model_selection(const BongoCatApp *app) { ModelSelection selection = {0}; selection.count = app->session.additional_model_count; @@ -42,62 +33,6 @@ static void restore_model_selection(BongoCatApp *app, bongo_cat_multi_pet_primary_update(app, SDL_GetTicksNS()); } -static bool copy_tree(const char *source, const char *target, unsigned depth, - BongoCatError *error); - -static BongoCatPathVisit copy_item(void *userdata, - const char *dirname, const char *name) { - (void)dirname; - TreeContext *context = userdata; - char source[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(source, sizeof(source), context->source, name) || - !bongo_cat_path_join(target, sizeof(target), context->target, name)) { - bongo_cat_error_set(context->error, BONGO_CAT_ERROR_IO, - "Model path is too long"); - return BONGO_CAT_PATH_FAILURE; - } - bool directory = bongo_cat_path_is_dir(source); - bool file = !directory && bongo_cat_path_is_file(source); - bool ok = directory ? copy_tree(source, target, context->depth + 1, - context->error) : file && bongo_cat_path_copy_file(source, target); - if (!ok && context->error && !context->error->message[0]) - bongo_cat_error_set(context->error, BONGO_CAT_ERROR_IO, - "Cannot copy %s: %s", source, SDL_GetError()); - return ok ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; -} - -static bool copy_tree(const char *source, const char *target, unsigned depth, - BongoCatError *error) { - if (depth > MODEL_TREE_DEPTH_CAP) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Model directory nesting exceeds %u levels", - MODEL_TREE_DEPTH_CAP); - return false; - } - if (!bongo_cat_path_create_directory(target)) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot create %s: %s", target, SDL_GetError()); - return false; - } - TreeContext context = {source, target, error, depth}; - return bongo_cat_path_enumerate(source, copy_item, &context); -} - -BongoCatResult bongo_cat_copy_directory(const char *source, - const char *target, BongoCatError *error) { - if (!source || !target || !bongo_cat_path_is_dir(source)) - return BONGO_CAT_ERROR_ARGUMENT; - return copy_tree(source, target, 0, error) ? BONGO_CAT_OK : - error && error->code == BONGO_CAT_ERROR_FORMAT ? - BONGO_CAT_ERROR_FORMAT : BONGO_CAT_ERROR_IO; -} - -static bool custom_root(BongoCatApp *app, char *path, size_t capacity) { - return app->data_root[0] && bongo_cat_path_join(path, capacity, - app->data_root, "models") && - bongo_cat_path_create_directory(path); -} - static bool shortcut_model_exists(const BongoCatModelCatalog *models, const char *shortcut_id) { const char *separator = shortcut_id ? strchr(shortcut_id, ':') : NULL; @@ -137,14 +72,12 @@ static void scan_nearby_root(BongoCatApp *app, const char *root) { static void scan_owned_models(BongoCatApp *app, bool cleanup) { BongoCatError error = {0}; - char root[BONGO_CAT_PATH_CAP]; bongo_cat_models_init(&app->models); - bongo_cat_path_join(root, sizeof(root), app->asset_root, "models"); - bongo_cat_models_scan(&app->models, root, true, &error); - if (custom_root(app, root, sizeof(root))) { - if (cleanup) bongo_cat_model_cleanup_imports(root, &error); - bongo_cat_models_scan(&app->models, root, false, &error); - } + if (bongo_cat_model_install_builtins(app->asset_root, app->models_root, + &error) != BONGO_CAT_OK && error.message[0]) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", error.message); + if (cleanup) bongo_cat_model_cleanup_imports(app->models_root, &error); + bongo_cat_models_scan(&app->models, app->models_root, false, &error); } void bongo_cat_model_catalog_finish(BongoCatApp *app) { diff --git a/src/runtime/model/model_storage.c b/src/runtime/model/model_storage.c index 0fa3a1ed..ca86856e 100644 --- a/src/runtime/model/model_storage.c +++ b/src/runtime/model/model_storage.c @@ -3,9 +3,11 @@ #include #include +#include #include #define MODEL_REMOVE_DEPTH_CAP 32 +#define MODEL_COPY_DEPTH_CAP 32 typedef struct RemoveContext { BongoCatError *error; unsigned depth; } RemoveContext; static bool remove_tree(const char *path, unsigned depth, BongoCatError *error); @@ -72,3 +74,59 @@ bool bongo_cat_model_cleanup_imports(const char *root, BongoCatError *error) { return root && bongo_cat_path_is_dir(root) && bongo_cat_path_enumerate(root, cleanup_item, error); } + +typedef struct CopyContext { + const char *source; + const char *target; + BongoCatError *error; + unsigned depth; +} CopyContext; + +static bool copy_tree(const char *source, const char *target, unsigned depth, + BongoCatError *error); + +static BongoCatPathVisit copy_item(void *userdata, + const char *dirname, const char *name) { + (void)dirname; + CopyContext *context = userdata; + char source[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(source, sizeof(source), context->source, name) || + !bongo_cat_path_join(target, sizeof(target), context->target, name)) { + bongo_cat_error_set(context->error, BONGO_CAT_ERROR_IO, + "Model path is too long"); + return BONGO_CAT_PATH_FAILURE; + } + bool directory = bongo_cat_path_is_dir(source); + bool file = !directory && bongo_cat_path_is_file(source); + bool ok = directory ? copy_tree(source, target, context->depth + 1, + context->error) : file && bongo_cat_path_copy_file(source, target); + if (!ok && context->error && !context->error->message[0]) + bongo_cat_error_set(context->error, BONGO_CAT_ERROR_IO, + "Cannot copy %s: %s", source, SDL_GetError()); + return ok ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; +} + +static bool copy_tree(const char *source, const char *target, unsigned depth, + BongoCatError *error) { + if (depth > MODEL_COPY_DEPTH_CAP) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Model directory nesting exceeds %u levels", MODEL_COPY_DEPTH_CAP); + return false; + } + if (!bongo_cat_path_create_directory(target)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot create %s: %s", target, SDL_GetError()); + return false; + } + CopyContext context = {source, target, error, depth}; + return bongo_cat_path_enumerate(source, copy_item, &context); +} + +BongoCatResult bongo_cat_model_copy_directory(const char *source, + const char *target, BongoCatError *error) { + if (!source || !target || !bongo_cat_path_is_dir(source)) + return BONGO_CAT_ERROR_ARGUMENT; + return copy_tree(source, target, 0, error) ? BONGO_CAT_OK : + error && error->code == BONGO_CAT_ERROR_FORMAT ? + BONGO_CAT_ERROR_FORMAT : BONGO_CAT_ERROR_IO; +} diff --git a/src/runtime/model/model_storage.h b/src/runtime/model/model_storage.h index 0ae79853..ce721bf2 100644 --- a/src/runtime/model/model_storage.h +++ b/src/runtime/model/model_storage.h @@ -5,5 +5,9 @@ bool bongo_cat_model_remove_tree(const char *path, BongoCatError *error); bool bongo_cat_model_cleanup_imports(const char *root, BongoCatError *error); +BongoCatResult bongo_cat_model_copy_directory(const char *source, + const char *target, BongoCatError *error); +BongoCatResult bongo_cat_model_install_builtins(const char *asset_root, + const char *models_root, BongoCatError *error); #endif diff --git a/src/runtime/runtime.h b/src/runtime/runtime.h index fb46e9a4..a5ca84ff 100644 --- a/src/runtime/runtime.h +++ b/src/runtime/runtime.h @@ -95,8 +95,6 @@ bool bongo_cat_app_audit_screen_pointer(BongoCatApp *app); bool bongo_cat_app_audit_display_pointer(BongoCatApp *app); void bongo_cat_app_track_hover(BongoCatApp *app, double x, double y); void bongo_cat_app_update_hover(BongoCatApp *app, uint64_t now); -BongoCatResult bongo_cat_copy_directory(const char *source, const char *target, - BongoCatError *error); bool bongo_cat_app_shortcuts_self_test(BongoCatApp *app); void bongo_cat_window_menu_action(BongoCatApp *app, BongoCatMenuAction action); bool bongo_cat_window_menu_self_test(BongoCatApp *app); diff --git a/src/ui/preferences/preferences_import.c b/src/ui/preferences/preferences_import.c index e564c397..6d2590ac 100644 --- a/src/ui/preferences/preferences_import.c +++ b/src/ui/preferences/preferences_import.c @@ -87,7 +87,7 @@ static int SDLCALL import_worker(void *userdata) { } job->result = BONGO_CAT_ERROR_FORMAT; BongoCatImportSession *session = bongo_cat_import_session_create( - job->data_root, &job->error); + job->models_root, &job->error); if (!session) job->result = job->error.code ? job->error.code : BONGO_CAT_ERROR_IO; for (size_t i = 0; session && i < job->count; ++i) { @@ -150,8 +150,8 @@ static int SDLCALL import_worker(void *userdata) { static bool start_job(BongoCatImportDialog *dialog, BongoCatApp *app, SDL_WindowID window_id, BongoCatImportJob *job) { - if (!dialog || !app || !job || !app->data_root[0]) return false; - snprintf(job->data_root, sizeof(job->data_root), "%s", app->data_root); + if (!dialog || !app || !job || !app->models_root[0]) return false; + snprintf(job->models_root, sizeof(job->models_root), "%s", app->models_root); job->dialog = dialog; SDL_LockMutex(dialog->mutex); if (!dialog->active || dialog->busy) { diff --git a/src/ui/preferences/preferences_import_internal.h b/src/ui/preferences/preferences_import_internal.h index 220e4e2e..da3f7a0a 100644 --- a/src/ui/preferences/preferences_import_internal.h +++ b/src/ui/preferences/preferences_import_internal.h @@ -6,7 +6,7 @@ typedef struct BongoCatImportJob { size_t count; char **paths; - char data_root[BONGO_CAT_PATH_CAP]; + char models_root[BONGO_CAT_PATH_CAP]; char first_id[BONGO_CAT_ID_CAP]; bool first_id_installed; size_t resolved_count; diff --git a/tests/model_import/test_model_import_identity.c b/tests/model_import/test_model_import_identity.c index 5b9eeed3..346026fe 100644 --- a/tests/model_import/test_model_import_identity.c +++ b/tests/model_import/test_model_import_identity.c @@ -47,6 +47,8 @@ int test_model_import_identity(void) { temporary, nonce); CHECK(SDL_CreateDirectory(root)); CHECK(SDL_CreateDirectory(data)); + char models_root[BONGO_CAT_PATH_CAP]; + CHECK(child(models_root, sizeof(models_root), data, "models", true)); CHECK(child(source, sizeof(source), root, "source", false)); CHECK(mver_fixture(source)); CHECK(child(config, sizeof(config), source, "config.json", false)); @@ -61,7 +63,7 @@ int test_model_import_identity(void) { CHECK(bongo_cat_import_candidate_inspect(&discovered.candidates[0], digest, &placeholder, &error) && !placeholder); BongoCatImportReceipt first = {0}, repeated = {0}, wrapped = {0}; - BongoCatImportSession *session = bongo_cat_import_session_create(data, + BongoCatImportSession *session = bongo_cat_import_session_create(models_root, &error); CHECK(session != NULL); CHECK(session && bongo_cat_import_session_install(session, config, &first, @@ -75,12 +77,11 @@ int test_model_import_identity(void) { CHECK(repeated.count == 1 && repeated.installed_count == 0 && !repeated.installed[0] && strcmp(first.ids[0], repeated.ids[0]) == 0); bongo_cat_import_session_destroy(session); - CHECK(bongo_cat_import_install(manifest, data, &wrapped, &error) == + CHECK(bongo_cat_import_install(manifest, models_root, &wrapped, &error) == BONGO_CAT_OK); CHECK(wrapped.count == 1 && wrapped.installed_count == 0 && strcmp(first.ids[0], wrapped.ids[0]) == 0); - char models_root[BONGO_CAT_PATH_CAP], adapter_metadata[BONGO_CAT_PATH_CAP]; - CHECK(child(models_root, sizeof(models_root), data, "models", false)); + char adapter_metadata[BONGO_CAT_PATH_CAP]; BongoCatModelCatalog *installed = calloc(1, sizeof(*installed)); CHECK(installed != NULL); if (installed) { diff --git a/tests/model_import/test_mver_missing_motion.c b/tests/model_import/test_mver_missing_motion.c index b974743c..b7857245 100644 --- a/tests/model_import/test_mver_missing_motion.c +++ b/tests/model_import/test_mver_missing_motion.c @@ -32,7 +32,9 @@ int test_mver_missing_motion_groups(void) { "\"l2d_motion_lockhand\":[[68]]}}")); BongoCatImportReceipt receipt = {0}; BongoCatError error = {0}; - CHECK(bongo_cat_import_install(source, data, &receipt, &error) == + char models_root[BONGO_CAT_PATH_CAP]; + CHECK(child(models_root, sizeof(models_root), data, "models", true)); + CHECK(bongo_cat_import_install(source, models_root, &receipt, &error) == BONGO_CAT_OK); CHECK(receipt.count == 1 && receipt.installed_count == 1); CHECK(bongo_cat_model_remove_tree(source, NULL)); diff --git a/tests/model_import/test_mver_nearby_identity.c b/tests/model_import/test_mver_nearby_identity.c index 88401936..56cd5630 100644 --- a/tests/model_import/test_mver_nearby_identity.c +++ b/tests/model_import/test_mver_nearby_identity.c @@ -49,7 +49,7 @@ int test_mver_nearby_identity(void) { bongo_cat_settings_defaults(&app->settings); bongo_cat_session_defaults(&app->session); bongo_cat_models_init(&app->models); - snprintf(app->data_root, sizeof(app->data_root), "%s", data); + snprintf(app->models_root, sizeof(app->models_root), "%s", data); snprintf(app->cache_root, sizeof(app->cache_root), "%s", data); BongoCatError error = {0}; CHECK(bongo_cat_import_nearby_root(app, source, &error) == @@ -150,7 +150,7 @@ int test_mver_nearby_refresh(void) { bongo_cat_models_init(&app->models); snprintf(app->asset_root, sizeof(app->asset_root), "%s/resources/assets", BONGO_CAT_NATIVE_SOURCE_DIR); - snprintf(app->data_root, sizeof(app->data_root), "%s", data); + snprintf(app->models_root, sizeof(app->models_root), "%s", data); snprintf(app->cache_root, sizeof(app->cache_root), "%s", data); snprintf(app->nearby_root, sizeof(app->nearby_root), "%s", root); diff --git a/tests/model_import/test_slim_package.c b/tests/model_import/test_slim_package.c index efe480c8..052327ce 100644 --- a/tests/model_import/test_slim_package.c +++ b/tests/model_import/test_slim_package.c @@ -39,12 +39,14 @@ int test_slim_package(void) { BongoCatImportReceipt receipt = {0}; BongoCatError error = {0}; - CHECK_SLIM(bongo_cat_import_install(source, data, &receipt, &error) == + char models_root[BONGO_CAT_PATH_CAP]; + CHECK_SLIM(child(models_root, sizeof(models_root), data, "models", true)); + CHECK_SLIM(bongo_cat_import_install(source, models_root, &receipt, &error) == BONGO_CAT_OK); CHECK_SLIM(receipt.count == 1 && receipt.installed_count == 1); char installed[BONGO_CAT_PATH_CAP], models[BONGO_CAT_PATH_CAP]; char package[BONGO_CAT_PATH_CAP]; - CHECK_SLIM(bongo_cat_path_join(models, sizeof(models), data, "models")); + snprintf(models, sizeof(models), "%s", models_root); CHECK_SLIM(bongo_cat_path_join(package, sizeof(package), models, receipt.ids[0])); CHECK_SLIM(child(installed, sizeof(installed), package, From 158306bead1c6a72f6897121ae136e847742d6c9 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 26 Aug 2026 17:46:05 +0800 Subject: [PATCH 108/334] refactor: use portable Mver model storage --- CMakeLists.txt | 2 +- README.md | 4 +- cmake/RuntimeSources.cmake | 2 + include/bongo_cat/common.h | 2 - include/bongo_cat/config.h | 12 + include/bongo_cat/model.h | 1 - src/core/config_defaults.c | 100 +++++ src/core/config_io.c | 28 +- src/core/config_settings_save.c | 12 + src/core/model_catalog.c | 13 +- src/core/model_package.c | 243 ---------- src/core/model_package.h | 9 - src/runtime/lifecycle/config_store.c | 6 + src/runtime/model/import/model_import.c | 300 ++++++++----- src/runtime/model/import/model_import.h | 21 +- src/runtime/model/import/model_import_app.c | 3 + .../model/import/model_import_discovery.c | 123 +++++- .../model/import/model_import_format.c | 4 + .../model/import/model_import_identity.c | 174 +++++++- .../model/import/model_import_installed.c | 193 ++++++++ .../model/import/model_import_package.c | 363 ++++++++------- src/runtime/model/import/model_import_tauri.c | 2 + .../model/import/model_import_tauri_mver.c | 414 ++++++++++++++++++ .../import/mver/model_import_mver_metadata.c | 13 +- .../import/mver/model_import_mver_patch.c | 6 +- .../model/import/nearby/model_import_nearby.c | 20 +- .../nearby/model_import_nearby_internal.h | 4 + src/runtime/model/model_catalog_runtime.c | 56 ++- src/runtime/model/model_files.c | 4 +- src/ui/preferences/preferences_import.c | 31 +- .../preferences/preferences_import_internal.h | 2 + tests/core/test_config.c | 9 + tests/core/test_models.c | 16 - .../model-packages/v2/.bongo-cat-package.json | 32 -- .../v2/adapter/.bongo-cat-adapter.json | 7 - .../v2/payload/dummy.model3.json | 1 - .../model_import/test_model_import_identity.c | 125 +++++- tests/model_import/test_mver_container.c | 102 ++++- tests/model_import/test_mver_import.c | 85 +++- tests/model_import/test_slim_package.c | 2 +- 40 files changed, 1851 insertions(+), 695 deletions(-) delete mode 100644 src/core/model_package.c delete mode 100644 src/core/model_package.h create mode 100644 src/runtime/model/import/model_import_installed.c create mode 100644 src/runtime/model/import/model_import_tauri_mver.c delete mode 100644 tests/fixtures/model-packages/v2/.bongo-cat-package.json delete mode 100644 tests/fixtures/model-packages/v2/adapter/.bongo-cat-adapter.json delete mode 100644 tests/fixtures/model-packages/v2/payload/dummy.model3.json diff --git a/CMakeLists.txt b/CMakeLists.txt index b2da39c9..2e736673 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -69,7 +69,7 @@ add_library(bongo_cat_core STATIC src/core/file.c src/core/i18n.c src/core/input_state.c src/core/mver_pointer.c src/core/mouse_tracking.c src/core/sha256.c src/core/utf8.c - src/core/model_catalog.c src/core/model_package.c + src/core/model_catalog.c src/core/model_behavior.c src/core/path.c src/core/path_io.c src/core/json.c src/core/shortcut.c) target_include_directories(bongo_cat_core PUBLIC "${BONGO_CAT_GENERATED_INCLUDE_DIR}" include) diff --git a/README.md b/README.md index 31200e8b..0b3655e3 100644 --- a/README.md +++ b/README.md @@ -134,8 +134,8 @@ flowchart TB Discover["Source Discovery
standalone model3 / Mver package / image patch"] Validate["Validation and Identity
manifest references / bounded paths / SHA-256 digest / metadata"] Adapt["Runtime Adapter Generation
preview assets / input overlays / projection and binding metadata"] - Installed[("Installed Packages
models_root / preserved source payload / package descriptor")] - Nearby[("Nearby Models
source tree stays authoritative / cache_root/model-adapters")] + Installed[("Installed Packages
models_root / directly copyable Mver trees")] + Nearby[("Runtime Adapters
generated outside models_root / cache_root/model-adapters")] Catalog["Model and Behavior Catalogs
models / motions / expressions / sounds / effects"] Discover --> Validate --> Adapt diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 39557818..b450940a 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -83,12 +83,14 @@ set(BONGO_CAT_RUNTIME_IMPORT_SOURCES src/runtime/model/import/model_import_discovery.c src/runtime/model/import/model_import_format.c src/runtime/model/import/model_import_identity.c + src/runtime/model/import/model_import_installed.c src/runtime/model/import/model_import_metadata.c src/runtime/model/import/model_import_package.c src/runtime/model/import/model_import_report.c src/runtime/model/import/model_import_scan.c src/runtime/model/import/model_import_source.c src/runtime/model/import/model_import_tauri.c + src/runtime/model/import/model_import_tauri_mver.c src/runtime/model/import/mver/model_import_mver.c src/runtime/model/import/mver/model_import_mver_effect.c src/runtime/model/import/mver/model_import_mver_image.c diff --git a/include/bongo_cat/common.h b/include/bongo_cat/common.h index d5422c0b..6d02347c 100644 --- a/include/bongo_cat/common.h +++ b/include/bongo_cat/common.h @@ -26,10 +26,8 @@ #define BONGO_CAT_BEHAVIOR_BINDING_CAP 256 #define BONGO_CAT_MENU_LABEL_CAP (BONGO_CAT_ID_CAP + BONGO_CAT_SHORTCUT_CAP + 4) #define BONGO_CAT_SCHEDULED_RELEASE_CAP 64 -#define BONGO_CAT_MODEL_PACKAGE_FILE ".bongo-cat-package.json" #define BONGO_CAT_MODEL_ADAPTER_FILE ".bongo-cat-adapter.json" #define BONGO_CAT_MODEL_BUILTIN_MARKER ".bongo-cat-builtin" -#define BONGO_CAT_MODEL_PACKAGE_SCHEMA 2 #define BONGO_CAT_MODEL_ADAPTER_SCHEMA 1 #define BONGO_CAT_MODEL_ADAPTER_GENERATOR 1 diff --git a/include/bongo_cat/config.h b/include/bongo_cat/config.h index c6548889..873ea70a 100644 --- a/include/bongo_cat/config.h +++ b/include/bongo_cat/config.h @@ -97,6 +97,10 @@ typedef struct BongoCatModelLabel { char label[BONGO_CAT_ID_CAP]; } BongoCatModelLabel; +typedef struct BongoCatRemovedModel { + char id[BONGO_CAT_ID_CAP]; +} BongoCatRemovedModel; + typedef struct BongoCatActiveBehavior { char model_id[BONGO_CAT_ID_CAP]; char behavior_id[BONGO_CAT_BEHAVIOR_ID_CAP]; @@ -113,6 +117,8 @@ typedef struct BongoCatSettings { size_t behavior_shortcut_count; BongoCatModelLabel model_labels[BONGO_CAT_MODEL_CAP]; size_t model_label_count; + BongoCatRemovedModel removed_models[BONGO_CAT_MODEL_CAP]; + size_t removed_model_count; char extensions_json[BONGO_CAT_SETTINGS_EXTENSIONS_CAP]; } BongoCatSettings; @@ -146,6 +152,12 @@ const char *bongo_cat_settings_model_label(const BongoCatSettings *settings, const char *id); bool bongo_cat_settings_set_model_label(BongoCatSettings *settings, const char *id, const char *label); +bool bongo_cat_settings_model_removed(const BongoCatSettings *settings, + const char *id); +bool bongo_cat_settings_set_model_removed(BongoCatSettings *settings, + const char *id, bool removed); +bool bongo_cat_settings_restore_model_package(BongoCatSettings *settings, + const char *package_id); BongoCatResult bongo_cat_settings_load(const char *path, BongoCatSettings *settings, BongoCatError *error); BongoCatResult bongo_cat_settings_save(const char *path, diff --git a/include/bongo_cat/model.h b/include/bongo_cat/model.h index 18b0f933..4dac002d 100644 --- a/include/bongo_cat/model.h +++ b/include/bongo_cat/model.h @@ -39,7 +39,6 @@ typedef struct BongoCatModelEntry { BongoCatModelMode mode; BongoCatModelSourceFormat source_format; uint32_t capabilities; - int package_schema; int adapter_schema; int adapter_generator; bool preset; diff --git a/src/core/config_defaults.c b/src/core/config_defaults.c index fae1d93a..9219cef9 100644 --- a/src/core/config_defaults.c +++ b/src/core/config_defaults.c @@ -102,6 +102,79 @@ bool bongo_cat_settings_set_model_label(BongoCatSettings *config, return true; } +bool bongo_cat_settings_model_removed(const BongoCatSettings *config, + const char *id) { + if (!config || !id) return false; + size_t count = config->removed_model_count; + if (count > BONGO_CAT_MODEL_CAP) count = BONGO_CAT_MODEL_CAP; + for (size_t i = 0; i < count; ++i) + if (!strcmp(config->removed_models[i].id, id)) return true; + return false; +} + +bool bongo_cat_settings_set_model_removed(BongoCatSettings *config, + const char *id, bool removed) { + if (!config || !id || !id[0] || strlen(id) >= BONGO_CAT_ID_CAP || + !bongo_cat_utf8_valid(id)) return false; + bongo_cat_settings_validate(config); + size_t index = config->removed_model_count; + for (size_t i = 0; i < config->removed_model_count; ++i) + if (!strcmp(config->removed_models[i].id, id)) { + index = i; + break; + } + if (removed) { + if (index < config->removed_model_count) return false; + if (config->removed_model_count >= BONGO_CAT_MODEL_CAP) return false; + BongoCatRemovedModel *entry = + &config->removed_models[config->removed_model_count++]; + memset(entry, 0, sizeof(*entry)); + snprintf(entry->id, sizeof(entry->id), "%s", id); + return true; + } + if (index == config->removed_model_count) return false; + if (index + 1 < config->removed_model_count) + memmove(&config->removed_models[index], + &config->removed_models[index + 1], + (config->removed_model_count - index - 1) * + sizeof(config->removed_models[0])); + config->removed_model_count--; + memset(&config->removed_models[config->removed_model_count], 0, + sizeof(config->removed_models[0])); + return true; +} + +static bool package_model_id(const char *id, const char *package_id) { + size_t length = package_id ? strlen(package_id) : 0; + if (!id || !length || strncmp(id, package_id, length) != 0) return false; + const char *suffix = id + length; + if (!suffix[0]) return true; + if (*suffix++ != '~' || !*suffix) return false; + while (*suffix) + if (!isdigit((unsigned char)*suffix++)) return false; + return true; +} + +bool bongo_cat_settings_restore_model_package(BongoCatSettings *config, + const char *package_id) { + if (!config || !package_id || !package_id[0]) return false; + bongo_cat_settings_validate(config); + size_t output = 0; + bool changed = false; + for (size_t i = 0; i < config->removed_model_count; ++i) { + BongoCatRemovedModel entry = config->removed_models[i]; + if (package_model_id(entry.id, package_id)) { + changed = true; + continue; + } + config->removed_models[output++] = entry; + } + memset(&config->removed_models[output], 0, + (BONGO_CAT_MODEL_CAP - output) * sizeof(config->removed_models[0])); + config->removed_model_count = output; + return changed; +} + static void validate_shortcuts(BongoCatSettings *config) { char *global[] = {config->shortcuts.toggle_pet_visibility, config->shortcuts.visible_preferences, config->shortcuts.mirror, @@ -212,6 +285,32 @@ static void compact_model_overrides(BongoCatSettings *config) { config->model_label_count = output_count; } +static void compact_removed_models(BongoCatSettings *config) { + size_t input_count = config->removed_model_count; + if (input_count > BONGO_CAT_MODEL_CAP) input_count = BONGO_CAT_MODEL_CAP; + size_t output_count = 0; + for (size_t i = 0; i < input_count; ++i) { + BongoCatRemovedModel entry = config->removed_models[i]; + entry.id[sizeof(entry.id) - 1] = '\0'; + if (!entry.id[0] || !bongo_cat_utf8_valid(entry.id)) continue; + bool duplicate = false; + for (size_t j = 0; j < output_count; ++j) + if (!strcmp(config->removed_models[j].id, entry.id)) { + duplicate = true; + break; + } + if (!duplicate) { + BongoCatRemovedModel canonical = {0}; + snprintf(canonical.id, sizeof(canonical.id), "%s", entry.id); + config->removed_models[output_count++] = canonical; + } + } + memset(&config->removed_models[output_count], 0, + (BONGO_CAT_MODEL_CAP - output_count) * + sizeof(config->removed_models[0])); + config->removed_model_count = output_count; +} + void bongo_cat_settings_defaults(BongoCatSettings *config) { if (!config) return; memset(config, 0, sizeof(*config)); @@ -246,6 +345,7 @@ void bongo_cat_settings_validate(BongoCatSettings *config) { config->app.language = BONGO_CAT_LANG_EN_US; compact_behavior_overrides(config); compact_model_overrides(config); + compact_removed_models(config); validate_shortcuts(config); compact_behavior_overrides(config); } diff --git a/src/core/config_io.c b/src/core/config_io.c index 21c7db52..b8c502f9 100644 --- a/src/core/config_io.c +++ b/src/core/config_io.c @@ -179,6 +179,29 @@ static bool read_model_labels(yyjson_val *array, BongoCatSettings *settings, return true; } +static bool read_removed_models(yyjson_val *array, BongoCatSettings *settings, + BongoCatError *error) { + if (!array) return true; + if (yyjson_arr_size(array) > BONGO_CAT_MODEL_CAP) + return type_error(error, "removedModels", "a smaller array"); + settings->removed_model_count = 0; + size_t index, count; + yyjson_val *item; + yyjson_arr_foreach(array, index, count, item) { + const char *id = yyjson_get_str(item); + size_t length = yyjson_get_len(item); + if (!yyjson_is_str(item) || !id || !length || strlen(id) != length) + return type_error(error, "removedModels[]", + "a non-empty string without embedded nulls"); + BongoCatRemovedModel *entry = + &settings->removed_models[settings->removed_model_count++]; + memset(entry, 0, sizeof(*entry)); + if (!copy_text(entry->id, sizeof(entry->id), id, length, + "removedModels[]", error)) return false; + } + return true; +} + static BongoCatResult read_extensions(yyjson_val *value, BongoCatSettings *settings, BongoCatError *error) { if (!value) return BONGO_CAT_OK; @@ -222,6 +245,7 @@ BongoCatResult bongo_cat_settings_load(const char *path, yyjson_val *shortcuts = NULL; yyjson_val *behaviors = NULL; yyjson_val *models = NULL; + yyjson_val *removed_models = NULL; yyjson_val *extensions_value = NULL; bool valid = read_object(root, "rendering", &model, error) && read_object(root, "window", &window, error) && @@ -229,13 +253,15 @@ BongoCatResult bongo_cat_settings_load(const char *path, read_object(root, "shortcuts", &shortcuts, error) && read_array(root, "behaviorOverrides", &behaviors, error) && read_array(root, "modelOverrides", &models, error) && + read_array(root, "removedModels", &removed_models, error) && read_value(root, "extensions", &extensions_value, error) && (!model || read_model(model, &loaded.model, error)) && (!window || read_window(window, &loaded.window, error)) && (!application || read_app(application, &loaded.app, error)) && (!shortcuts || read_shortcuts(shortcuts, &loaded.shortcuts, error)) && read_behaviors(behaviors, &loaded, error) && - read_model_labels(models, &loaded, error); + read_model_labels(models, &loaded, error) && + read_removed_models(removed_models, &loaded, error); if (!valid) result = BONGO_CAT_ERROR_FORMAT; if (valid) result = read_extensions(extensions_value, &loaded, error); yyjson_doc_free(document); diff --git a/src/core/config_settings_save.c b/src/core/config_settings_save.c index 6ae61a8e..08147c0b 100644 --- a/src/core/config_settings_save.c +++ b/src/core/config_settings_save.c @@ -109,6 +109,17 @@ static bool write_model_labels(yyjson_mut_doc *doc, yyjson_mut_val *root, return true; } +static bool write_removed_models(yyjson_mut_doc *doc, yyjson_mut_val *root, + const BongoCatSettings *settings) { + yyjson_mut_val *array = yyjson_mut_arr(doc); + if (!array || !yyjson_mut_obj_add_val( + doc, root, "removedModels", array)) return false; + for (size_t i = 0; i < settings->removed_model_count; ++i) + if (!yyjson_mut_arr_add_strcpy(doc, array, + settings->removed_models[i].id)) return false; + return true; +} + static yyjson_mut_val *write_extensions(yyjson_mut_doc *target, const BongoCatSettings *settings, BongoCatResult *failure, BongoCatError *error) { @@ -190,6 +201,7 @@ BongoCatResult bongo_cat_settings_save(const char *path, &canonical.shortcuts) && write_behaviors(doc, root, &canonical) && write_model_labels(doc, root, &canonical) && + write_removed_models(doc, root, &canonical) && yyjson_mut_obj_add_val(doc, root, "extensions", extensions); if (!built) { yyjson_mut_doc_free(doc); diff --git a/src/core/model_catalog.c b/src/core/model_catalog.c index c530771f..03dbec95 100644 --- a/src/core/model_catalog.c +++ b/src/core/model_catalog.c @@ -1,7 +1,6 @@ #include "bongo_cat/file.h" #include "bongo_cat/model.h" #include "bongo_cat/path.h" -#include "model_package.h" #include #include @@ -18,6 +17,13 @@ void bongo_cat_models_init(BongoCatModelCatalog *catalog) { if (catalog) memset(catalog, 0, sizeof(*catalog)); } +bool bongo_cat_model_adapter_metadata_path(const char *directory, + char *path, size_t capacity) { + return directory && path && capacity && + bongo_cat_path_join(path, capacity, directory, + BONGO_CAT_MODEL_ADAPTER_FILE); +} + static BongoCatModelMode infer_mode(const char *directory) { char path[BONGO_CAT_PATH_CAP], stored[16] = {0}; bongo_cat_path_join(path, sizeof(path), directory, ".bongo-cat-mode"); @@ -47,11 +53,6 @@ static bool builtin_marker(const char *directory) { } static bool add_model(BongoCatModelCatalog *catalog, const char *directory, bool preset) { - bool handled = false; - bool package_ok = bongo_cat_model_package_add(catalog, directory, - preset, &handled); - if (handled) return package_ok; - if (!package_ok) return false; if (!preset && !builtin_marker(directory)) return true; if (catalog->count >= BONGO_CAT_MODEL_CAP) return false; char setting[BONGO_CAT_PATH_CAP]; diff --git a/src/core/model_package.c b/src/core/model_package.c deleted file mode 100644 index 4f46474f..00000000 --- a/src/core/model_package.c +++ /dev/null @@ -1,243 +0,0 @@ -#include "model_package.h" -#include "bongo_cat/json.h" -#include "bongo_cat/path.h" - -#include -#include -#include - -static bool safe_relative(const char *value) { - if (!value || !value[0] || value[0] == '/' || value[0] == '\\' || - strchr(value, ':')) return false; - const char *cursor = value; - while (*cursor) { - while (*cursor == '/' || *cursor == '\\') cursor++; - if (cursor[0] == '.' && cursor[1] == '.' && - (!cursor[2] || cursor[2] == '/' || cursor[2] == '\\')) return false; - cursor = strpbrk(cursor, "/\\"); - if (!cursor) break; - } - return true; -} - -static bool mode_valid(const char *value) { - return value && (strcmp(value, "standard") == 0 || - strcmp(value, "keyboard") == 0 || strcmp(value, "gamepad") == 0); -} - -static BongoCatModelMode stored_mode(const char *value) { - if (value && strcmp(value, "keyboard") == 0) return BONGO_CAT_MODE_KEYBOARD; - if (value && strcmp(value, "gamepad") == 0) return BONGO_CAT_MODE_GAMEPAD; - return BONGO_CAT_MODE_STANDARD; -} - -static BongoCatModelSourceFormat stored_format(const char *value) { - if (value && strcmp(value, "tauri-live2d") == 0) - return BONGO_CAT_MODEL_SOURCE_TAURI; - if (value && strcmp(value, "bongo-cat-mver") == 0) - return BONGO_CAT_MODEL_SOURCE_MVER; - if (value && strcmp(value, "bongo-cat-mver-patch") == 0) - return BONGO_CAT_MODEL_SOURCE_MVER_PATCH; - return BONGO_CAT_MODEL_SOURCE_UNKNOWN; -} - -static bool safe_id(const char *value) { - if (!value || !value[0] || strlen(value) >= BONGO_CAT_ID_CAP || - value[0] == '.') return false; - for (const unsigned char *cursor = (const unsigned char *)value; - *cursor; ++cursor) - if (!((*cursor >= 'a' && *cursor <= 'z') || - (*cursor >= 'A' && *cursor <= 'Z') || - (*cursor >= '0' && *cursor <= '9') || *cursor == '-' || - *cursor == '_' || *cursor == '.')) return false; - return true; -} - -static bool digest_valid(const char *value) { - if (!value || strlen(value) != 64) return false; - for (const unsigned char *cursor = (const unsigned char *)value; - *cursor; ++cursor) - if (!((*cursor >= '0' && *cursor <= '9') || - (*cursor >= 'a' && *cursor <= 'f'))) return false; - return true; -} - -static bool text_valid(const char *value) { - return value && value[0] && strlen(value) < BONGO_CAT_ID_CAP; -} - -static bool same_reference(const char *left, const char *right) { - while (left && right && *left && *right) { - char a = *left == '\\' ? '/' : *left; - char b = *right == '\\' ? '/' : *right; - if (a != b) return false; - left++; right++; - } - return left && right && !*left && !*right; -} - -static bool metadata_reference(const char *adapter, const char *metadata) { - char expected[BONGO_CAT_PATH_CAP]; - return bongo_cat_path_join(expected, sizeof(expected), adapter, - BONGO_CAT_MODEL_ADAPTER_FILE) && same_reference(expected, metadata); -} - -static bool adapter_valid(const char *path, const char *source_format) { - yyjson_doc *document = bongo_cat_json_read_file(path, 0, NULL); - yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; - const char *kind = yyjson_get_str(yyjson_obj_get(root, "kind")); - const char *format = yyjson_get_str(yyjson_obj_get(root, "sourceFormat")); - yyjson_val *render = yyjson_obj_get(root, "render"); - bool valid = yyjson_is_obj(root) && - yyjson_get_int(yyjson_obj_get(root, "schemaVersion")) == - BONGO_CAT_MODEL_ADAPTER_SCHEMA && kind && - strcmp(kind, "bongo-cat-runtime-adapter") == 0 && format && - strcmp(format, source_format) == 0 && yyjson_is_obj(render) && - text_valid(yyjson_get_str(yyjson_obj_get(render, "profile"))) && - yyjson_is_arr(yyjson_obj_get(root, "bindings")); - yyjson_doc_free(document); - return valid; -} - -typedef struct CapabilityName { const char *name; uint32_t flag; } CapabilityName; - -static uint32_t stored_capabilities(yyjson_val *values) { - static const CapabilityName names[] = { - {"live2d", BONGO_CAT_MODEL_CAPABILITY_LIVE2D}, - {"preview", BONGO_CAT_MODEL_CAPABILITY_PREVIEW}, - {"runtime-adapter", BONGO_CAT_MODEL_CAPABILITY_RUNTIME_ADAPTER}, - {"input-images", BONGO_CAT_MODEL_CAPABILITY_INPUT_IMAGES}, - {"keyboard-input", BONGO_CAT_MODEL_CAPABILITY_KEYBOARD_INPUT}, - {"gamepad-input", BONGO_CAT_MODEL_CAPABILITY_GAMEPAD_INPUT}, - {"behaviors", BONGO_CAT_MODEL_CAPABILITY_BEHAVIORS}, - {"audio", BONGO_CAT_MODEL_CAPABILITY_AUDIO}, - {"effects", BONGO_CAT_MODEL_CAPABILITY_EFFECTS}, - {"mver-projection", BONGO_CAT_MODEL_CAPABILITY_MVER_PROJECTION}, - {"pointer-overlay", BONGO_CAT_MODEL_CAPABILITY_POINTER_OVERLAY}, - {"image-patch", BONGO_CAT_MODEL_CAPABILITY_IMAGE_PATCH} - }; - uint32_t result = 0; - if (!yyjson_is_arr(values)) return result; - size_t index, count; yyjson_val *item; - yyjson_arr_foreach(values, index, count, item) { - const char *value = yyjson_get_str(item); - for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) - if (value && strcmp(value, names[i].name) == 0) { - result |= names[i].flag; - break; - } - } - return result; -} - -static bool catalog_has_id(const BongoCatModelCatalog *catalog, - const char *id) { - for (size_t i = 0; i < catalog->count; ++i) - if (strcmp(catalog->entries[i].id, id) == 0) return true; - return false; -} - -bool bongo_cat_model_adapter_metadata_path(const char *directory, - char *path, size_t capacity) { - return directory && path && capacity && - bongo_cat_path_join(path, capacity, directory, - BONGO_CAT_MODEL_ADAPTER_FILE); -} - -static bool resolve_entry(BongoCatModelEntry *entry, const char *package, - const char *model, const char *adapter, const char *setting, - const char *source_format, int schema) { - char setting_path[BONGO_CAT_PATH_CAP], metadata_path[BONGO_CAT_PATH_CAP]; - memset(entry, 0, sizeof(*entry)); - return bongo_cat_path_join(entry->directory, sizeof(entry->directory), - package, model) && - bongo_cat_path_join(entry->adapter_directory, - sizeof(entry->adapter_directory), package, adapter) && - bongo_cat_path_join(setting_path, sizeof(setting_path), entry->directory, - setting) && bongo_cat_path_is_file(setting_path) && - bongo_cat_path_is_dir(entry->adapter_directory) && - schema == BONGO_CAT_MODEL_PACKAGE_SCHEMA && - bongo_cat_path_join(metadata_path, sizeof(metadata_path), - entry->adapter_directory, BONGO_CAT_MODEL_ADAPTER_FILE) && - adapter_valid(metadata_path, source_format); -} - -bool bongo_cat_model_package_add(BongoCatModelCatalog *catalog, - const char *directory, bool preset, bool *handled) { - char descriptor[BONGO_CAT_PATH_CAP]; - *handled = bongo_cat_path_join(descriptor, sizeof(descriptor), directory, - BONGO_CAT_MODEL_PACKAGE_FILE) && bongo_cat_path_is_file(descriptor); - if (!*handled) return true; - yyjson_doc *document = bongo_cat_json_read_file(descriptor, 0, NULL); - yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; - int schema = (int)yyjson_get_int(yyjson_obj_get(root, "schemaVersion")); - yyjson_val *model_object = yyjson_obj_get(root, "model"); - yyjson_val *runtime_object = yyjson_obj_get(root, "runtime"); - yyjson_val *source_object = yyjson_obj_get(root, "source"); - const char *model = yyjson_get_str(yyjson_obj_get(model_object, "directory")); - const char *adapter = yyjson_get_str(yyjson_obj_get(runtime_object, "adapter")); - const char *setting = yyjson_get_str(yyjson_obj_get(model_object, "setting")); - const char *mode = yyjson_get_str(yyjson_obj_get(root, "mode")); - const char *source_format = yyjson_get_str(yyjson_obj_get(source_object, "format")); - const char *source_name = yyjson_get_str(yyjson_obj_get(source_object, "name")); - const char *source_layout = yyjson_get_str(yyjson_obj_get(source_object, "layout")); - const char *display = yyjson_get_str(yyjson_obj_get(root, "displayName")); - const char *package_id = yyjson_get_str(yyjson_obj_get(root, "packageId")); - const char *digest = yyjson_get_str(yyjson_obj_get(root, "contentDigest")); - const char *family = yyjson_get_str(yyjson_obj_get(root, "familyId")); - const char *metadata = yyjson_get_str(yyjson_obj_get(runtime_object, "metadata")); - int adapter_schema = (int)yyjson_get_int(yyjson_obj_get(runtime_object, - "adapterSchemaVersion")); - int adapter_generator = (int)yyjson_get_int(yyjson_obj_get(runtime_object, - "generatorVersion")); - uint32_t capabilities = stored_capabilities( - yyjson_obj_get(root, "capabilities")); - uint32_t required = BONGO_CAT_MODEL_CAPABILITY_LIVE2D | - BONGO_CAT_MODEL_CAPABILITY_RUNTIME_ADAPTER; - bool valid = yyjson_is_obj(root) && - schema == BONGO_CAT_MODEL_PACKAGE_SCHEMA && - safe_relative(model) && safe_relative(adapter) && safe_relative(setting) && - safe_id(package_id) && digest_valid(digest) && - (!family || safe_id(family)) && - yyjson_is_obj(model_object) && yyjson_is_obj(runtime_object) && - yyjson_is_obj(source_object) && safe_id(source_format) && - text_valid(source_name) && safe_id(source_layout) && - text_valid(display) && mode_valid(mode) && - yyjson_is_bool(yyjson_obj_get(source_object, "preserved")) && - safe_relative(metadata) && metadata_reference(adapter, metadata) && - adapter_schema == BONGO_CAT_MODEL_ADAPTER_SCHEMA && - adapter_generator > 0 && - yyjson_is_arr(yyjson_obj_get(root, "capabilities")) && - (capabilities & required) == required && - yyjson_is_obj(yyjson_obj_get(root, "extensions")) && - !catalog_has_id(catalog, package_id) && - catalog->count < BONGO_CAT_MODEL_CAP; - BongoCatModelEntry *entry = valid ? &catalog->entries[catalog->count] : NULL; - if (entry) valid = resolve_entry(entry, directory, model, adapter, setting, - source_format, schema); - if (valid) { - snprintf(entry->id, sizeof(entry->id), "%s", package_id); - snprintf(entry->package_id, sizeof(entry->package_id), "%s", package_id); - if (digest) snprintf(entry->content_digest, - sizeof(entry->content_digest), "%s", digest); - if (family) snprintf(entry->family_id, sizeof(entry->family_id), "%s", family); - if (display) snprintf(entry->display_name, - sizeof(entry->display_name), "%s", display); - snprintf(entry->storage_directory, sizeof(entry->storage_directory), - "%s", directory); - snprintf(entry->setting_file, sizeof(entry->setting_file), "%s", setting); - entry->mode = stored_mode(mode); - entry->source_format = stored_format(source_format); - entry->capabilities = capabilities; - entry->package_schema = schema; - entry->adapter_schema = adapter_schema; - entry->adapter_generator = adapter_generator; - char marker[BONGO_CAT_PATH_CAP]; - entry->preset = preset || (bongo_cat_path_join(marker, sizeof(marker), - directory, BONGO_CAT_MODEL_BUILTIN_MARKER) && - bongo_cat_path_is_file(marker)); - catalog->count++; - } - yyjson_doc_free(document); - return valid; -} diff --git a/src/core/model_package.h b/src/core/model_package.h deleted file mode 100644 index 8295e20f..00000000 --- a/src/core/model_package.h +++ /dev/null @@ -1,9 +0,0 @@ -#ifndef BONGO_CAT_MODEL_PACKAGE_H -#define BONGO_CAT_MODEL_PACKAGE_H - -#include "bongo_cat/model.h" - -bool bongo_cat_model_package_add(BongoCatModelCatalog *catalog, - const char *directory, bool preset, bool *handled); - -#endif diff --git a/src/runtime/lifecycle/config_store.c b/src/runtime/lifecycle/config_store.c index 516079f8..4972d635 100644 --- a/src/runtime/lifecycle/config_store.c +++ b/src/runtime/lifecycle/config_store.c @@ -34,6 +34,12 @@ static uint64_t settings_hash(const BongoCatSettings *settings) { if (model_count > BONGO_CAT_MODEL_CAP) model_count = BONGO_CAT_MODEL_CAP; hash = hash_bytes(hash, settings->model_labels, model_count * sizeof(settings->model_labels[0])); + HASH_FIELD(settings->removed_model_count); + size_t removed_count = settings->removed_model_count; + if (removed_count > BONGO_CAT_MODEL_CAP) + removed_count = BONGO_CAT_MODEL_CAP; + hash = hash_bytes(hash, settings->removed_models, + removed_count * sizeof(settings->removed_models[0])); HASH_FIELD(settings->extensions_json); #undef HASH_FIELD return hash; diff --git a/src/runtime/model/import/model_import.c b/src/runtime/model/import/model_import.c index 88993452..fbd77436 100644 --- a/src/runtime/model/import/model_import.c +++ b/src/runtime/model/import/model_import.c @@ -1,8 +1,8 @@ #include "model_import.h" #include "model_storage.h" -#include "runtime.h" #include "bongo_cat/path.h" +#include #include #include #include @@ -11,89 +11,187 @@ typedef struct ImportInstall { char id[BONGO_CAT_ID_CAP]; char temporary[BONGO_CAT_PATH_CAP]; char target[BONGO_CAT_PATH_CAP]; - bool existing; bool committed; } ImportInstall; struct BongoCatImportSession { char root[BONGO_CAT_PATH_CAP]; - BongoCatModelCatalog *existing; BongoCatImportDigestCache *digests; }; -static void cleanup(ImportInstall *installs, size_t count, bool committed) { - for (size_t i = 0; i < count; ++i) - if (!installs[i].existing) - bongo_cat_model_remove_tree(committed && installs[i].committed - ? installs[i].target : installs[i].temporary, NULL); +static void cleanup(ImportInstall *install) { + if (!install) return; + bongo_cat_model_remove_tree(install->committed + ? install->target : install->temporary, NULL); } -static bool prepare_install(const BongoCatImportCandidate *candidate, - const BongoCatPackageMetadata *metadata, - const BongoCatModelCatalog *existing, ImportInstall *install, - const char *root, size_t index, BongoCatError *error) { - const BongoCatModelEntry *duplicate = - bongo_cat_import_find_existing_package(existing, metadata, - candidate->mode); - if (duplicate) { - snprintf(install->id, sizeof(install->id), "%s", duplicate->id); - install->existing = true; - return true; - } - snprintf(install->id, sizeof(install->id), "%s", metadata->package_id); - char temporary_name[BONGO_CAT_ID_CAP + 8]; - snprintf(temporary_name, sizeof(temporary_name), ".import-%.16s-%zu.tmp", - metadata->content_digest, index + 1); - BongoCatImportCandidate installed; - if (!bongo_cat_path_join(install->temporary, sizeof(install->temporary), - root, temporary_name) || - !bongo_cat_path_join(install->target, sizeof(install->target), root, - install->id)) return false; - if (bongo_cat_path_is_dir(install->target) || - bongo_cat_path_is_file(install->target)) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Model package id collides with an existing directory: %s", - install->id); - return false; + +static bool same_package(const BongoCatImportDiscovery *left, + const BongoCatPackageMetadata *left_metadata, + const BongoCatImportDiscovery *right, + const BongoCatPackageMetadata *right_metadata) { + if (!left || !right || left->count != right->count) return false; + for (size_t i = 0; i < left->count; ++i) + if (left->candidates[i].mode != right->candidates[i].mode || + strcmp(left_metadata[i].content_digest, + right_metadata[i].content_digest) != 0) return false; + return true; +} + +typedef struct ExistingPackageSearch { + const BongoCatImportDiscovery *expected; + const BongoCatPackageMetadata *metadata; + BongoCatImportDigestCache *digests; + char id[BONGO_CAT_ID_CAP]; +} ExistingPackageSearch; + +static BongoCatPathVisit find_existing_package(void *userdata, + const char *dirname, const char *name) { + ExistingPackageSearch *search = userdata; + if (!name || name[0] == '.') return BONGO_CAT_PATH_CONTINUE; + char directory[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(directory, sizeof(directory), dirname, name) || + !bongo_cat_import_authored_package(directory)) + return BONGO_CAT_PATH_CONTINUE; + BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); + BongoCatPackageMetadata *metadata = calloc(BONGO_CAT_IMPORT_CANDIDATE_CAP, + sizeof(*metadata)); + if (!discovery || !metadata) { + free(discovery); + free(metadata); + return BONGO_CAT_PATH_CONTINUE; } - if (!bongo_cat_import_prepare_package(candidate, install->temporary, - &installed, error)) return false; - char adapter[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(adapter, sizeof(adapter), install->temporary, "adapter") || - !bongo_cat_import_prepare_adapter(candidate, adapter, error) || - !bongo_cat_import_write_package(&installed, metadata, - install->temporary, error)) return false; - return bongo_cat_import_manifest_valid(installed.directory, - installed.setting, error); + BongoCatError ignored = {0}; + bool matches = bongo_cat_import_discover(directory, discovery, &ignored) && + bongo_cat_import_prepare_package_metadata_cached(discovery, metadata, + search->digests, &ignored) && + same_package(search->expected, search->metadata, discovery, metadata); + free(discovery); + free(metadata); + if (!matches) return BONGO_CAT_PATH_CONTINUE; + return bongo_cat_import_package_id(search->id, sizeof(search->id), name) + ? BONGO_CAT_PATH_SUCCESS : BONGO_CAT_PATH_CONTINUE; } -static void remember_installs(BongoCatImportSession *session, +static bool existing_package(BongoCatImportSession *session, const BongoCatImportDiscovery *discovery, - const BongoCatPackageMetadata *metadata, const ImportInstall *installs) { - if (!session || !session->existing) return; - for (size_t i = 0; i < discovery->count; ++i) { - if (installs[i].existing || session->existing->count >= - BONGO_CAT_MODEL_CAP) continue; - BongoCatModelEntry *entry = - &session->existing->entries[session->existing->count++]; - memset(entry, 0, sizeof(*entry)); - snprintf(entry->id, sizeof(entry->id), "%s", installs[i].id); - snprintf(entry->package_id, sizeof(entry->package_id), "%s", - installs[i].id); - snprintf(entry->content_digest, sizeof(entry->content_digest), "%s", - metadata[i].content_digest); - entry->mode = discovery->candidates[i].mode; - entry->package_schema = BONGO_CAT_MODEL_PACKAGE_SCHEMA; + const BongoCatPackageMetadata *metadata, char id[BONGO_CAT_ID_CAP]) { + ExistingPackageSearch search = {discovery, metadata, session->digests, {0}}; + if (!bongo_cat_path_enumerate(session->root, find_existing_package, + &search) || !search.id[0]) return false; + snprintf(id, BONGO_CAT_ID_CAP, "%s", search.id); + return true; +} + +static bool resolve_install_id(const char *base, const char *root, + char output[BONGO_CAT_ID_CAP], BongoCatError *error) { + for (size_t suffix = 1; suffix < 10000; ++suffix) { + char id[BONGO_CAT_ID_CAP]; + int written = suffix == 1 + ? snprintf(id, sizeof(id), "%s", base) + : snprintf(id, sizeof(id), "%s-%zu", base, suffix); + if (written < 0 || (size_t)written >= sizeof(id)) continue; + char path[BONGO_CAT_PATH_CAP]; + bool exists = bongo_cat_path_join(path, sizeof(path), root, id) && + (bongo_cat_path_is_dir(path) || bongo_cat_path_is_file(path)); + if (!exists) { + snprintf(output, BONGO_CAT_ID_CAP, "%s", id); + return true; + } } + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Cannot allocate a unique model directory for %s", base); + return false; +} + +static void fill_receipt(BongoCatImportReceipt *receipt, const char *id, + size_t count, bool installed) { + if (!receipt) return; + receipt->count = count; + receipt->installed_count = installed ? count : 0; + for (size_t i = 0; i < count; ++i) { + bongo_cat_import_variant_id(receipt->ids[i], + sizeof(receipt->ids[i]), id, i); + receipt->installed[i] = installed; + } +} + +static BongoCatResult install_discovery(BongoCatImportSession *session, + BongoCatImportDiscovery *source_discovery, + BongoCatPackageMetadata *source_metadata, + BongoCatImportReceipt *receipt, BongoCatError *error) { + ImportInstall install = {0}; + char temporary_name[BONGO_CAT_ID_CAP + 24]; + snprintf(temporary_name, sizeof(temporary_name), ".import-%.16s-1.tmp", + source_metadata[0].content_digest); + if (!bongo_cat_path_join(install.temporary, sizeof(install.temporary), + session->root, temporary_name)) return BONGO_CAT_ERROR_IO; + bongo_cat_model_remove_tree(install.temporary, NULL); + if (!bongo_cat_import_prepare_storage(source_discovery, + install.temporary, error)) { + cleanup(&install); + return error && error->code == BONGO_CAT_ERROR_FORMAT + ? BONGO_CAT_ERROR_FORMAT : BONGO_CAT_ERROR_IO; + } + + BongoCatImportDiscovery *normalized = calloc(1, sizeof(*normalized)); + BongoCatPackageMetadata *metadata = calloc(BONGO_CAT_IMPORT_CANDIDATE_CAP, + sizeof(*metadata)); + if (!normalized || !metadata) { + free(normalized); + free(metadata); + cleanup(&install); + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Cannot validate normalized model package"); + return BONGO_CAT_ERROR_MEMORY; + } + bool valid = bongo_cat_import_discover(install.temporary, normalized, + error) && + bongo_cat_import_prepare_package_metadata_cached(normalized, metadata, + session->digests, error); + if (!valid) { + free(normalized); + free(metadata); + cleanup(&install); + return error && error->code ? error->code : BONGO_CAT_ERROR_FORMAT; + } + + char existing[BONGO_CAT_ID_CAP]; + if (existing_package(session, normalized, metadata, existing)) { + fill_receipt(receipt, existing, normalized->count, false); + free(normalized); + free(metadata); + cleanup(&install); + return BONGO_CAT_OK; + } + if (!resolve_install_id(source_metadata[0].package_id, session->root, + install.id, error) || + !bongo_cat_path_join(install.target, sizeof(install.target), + session->root, install.id)) { + free(normalized); + free(metadata); + cleanup(&install); + return error && error->code ? error->code : BONGO_CAT_ERROR_FORMAT; + } + if (!bongo_cat_path_rename(install.temporary, install.target)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot finish model import: %s", SDL_GetError()); + free(normalized); + free(metadata); + cleanup(&install); + return BONGO_CAT_ERROR_IO; + } + install.committed = true; + fill_receipt(receipt, install.id, normalized->count, true); + free(normalized); + free(metadata); + return BONGO_CAT_OK; } BongoCatImportSession *bongo_cat_import_session_create(const char *models_root, BongoCatError *error) { if (!models_root) return NULL; BongoCatImportSession *session = calloc(1, sizeof(*session)); - if (session) session->existing = calloc(1, sizeof(*session->existing)); - if (!session || !session->existing) { - if (session) free(session->existing); - free(session); + if (!session) { bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, "Cannot allocate model import session"); return NULL; @@ -101,25 +199,19 @@ BongoCatImportSession *bongo_cat_import_session_create(const char *models_root, session->digests = bongo_cat_import_digest_cache_create(); int root_length = snprintf(session->root, sizeof(session->root), "%s", models_root); - if (root_length < 0 || (size_t)root_length >= sizeof(session->root) || + if (!session->digests || root_length < 0 || + (size_t)root_length >= sizeof(session->root) || !bongo_cat_path_create_directory(session->root) || !bongo_cat_model_cleanup_imports(session->root, error)) { bongo_cat_import_session_destroy(session); return NULL; } - bongo_cat_models_init(session->existing); - if (bongo_cat_models_scan(session->existing, session->root, false, - error) != BONGO_CAT_OK) { - bongo_cat_import_session_destroy(session); - return NULL; - } return session; } void bongo_cat_import_session_destroy(BongoCatImportSession *session) { if (!session) return; bongo_cat_import_digest_cache_destroy(session->digests); - free(session->existing); free(session); } @@ -133,62 +225,34 @@ BongoCatResult bongo_cat_import_session_install( source_directory, sizeof(source_directory), error); if (source_result != BONGO_CAT_OK) return source_result; BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); - ImportInstall *installs = calloc(BONGO_CAT_IMPORT_CANDIDATE_CAP, sizeof(*installs)); BongoCatPackageMetadata *metadata = calloc(BONGO_CAT_IMPORT_CANDIDATE_CAP, sizeof(*metadata)); - if (!discovery || !installs || !metadata) { - free(discovery); free(installs); free(metadata); + if (!discovery || !metadata) { + free(discovery); + free(metadata); bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, "Cannot allocate model import workspace"); return BONGO_CAT_ERROR_MEMORY; } - if (!bongo_cat_import_discover(source_directory, discovery, error)) { - free(discovery); free(installs); free(metadata); - return BONGO_CAT_ERROR_FORMAT; - } - if (!bongo_cat_import_prepare_package_metadata_cached(discovery, metadata, - session->digests, error)) { + if (!bongo_cat_import_discover(source_directory, discovery, error) || + !bongo_cat_import_prepare_package_metadata_cached(discovery, metadata, + session->digests, error)) { BongoCatResult result = error && error->code ? error->code : - BONGO_CAT_ERROR_IO; - free(discovery); free(installs); free(metadata); + BONGO_CAT_ERROR_FORMAT; + free(discovery); + free(metadata); return result; } - for (size_t i = 0; i < discovery->count; ++i) { - if (prepare_install(&discovery->candidates[i], &metadata[i], - session->existing, &installs[i], session->root, i, error)) - continue; - cleanup(installs, i + 1, false); - BongoCatResult result = error && error->code == BONGO_CAT_ERROR_FORMAT - ? BONGO_CAT_ERROR_FORMAT : BONGO_CAT_ERROR_IO; - free(discovery); free(installs); free(metadata); - return result; - } - for (size_t i = 0; i < discovery->count; ++i) { - if (installs[i].existing) continue; - if (!bongo_cat_path_rename(installs[i].temporary, installs[i].target)) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot finish model import: %s", SDL_GetError()); - cleanup(installs, discovery->count, true); - free(discovery); free(installs); free(metadata); - return BONGO_CAT_ERROR_IO; - } - installs[i].committed = true; - } - if (receipt) { - receipt->count = discovery->count; - for (size_t i = 0; i < discovery->count; ++i) { - snprintf(receipt->ids[i], sizeof(receipt->ids[i]), "%s", installs[i].id); - receipt->installed[i] = !installs[i].existing; - if (receipt->installed[i]) receipt->installed_count++; - } - } - remember_installs(session, discovery, metadata, installs); - free(discovery); free(installs); free(metadata); - return BONGO_CAT_OK; + BongoCatResult result = install_discovery(session, discovery, metadata, + receipt, error); + free(discovery); + free(metadata); + return result; } -BongoCatResult bongo_cat_import_install(const char *source, const char *models_root, - BongoCatImportReceipt *receipt, BongoCatError *error) { +BongoCatResult bongo_cat_import_install(const char *source, + const char *models_root, BongoCatImportReceipt *receipt, + BongoCatError *error) { if (receipt) memset(receipt, 0, sizeof(*receipt)); if (!source || !models_root) return BONGO_CAT_ERROR_ARGUMENT; BongoCatImportSession *session = bongo_cat_import_session_create( @@ -199,4 +263,4 @@ BongoCatResult bongo_cat_import_install(const char *source, const char *models_r source, receipt, error); bongo_cat_import_session_destroy(session); return result; -} \ No newline at end of file +} diff --git a/src/runtime/model/import/model_import.h b/src/runtime/model/import/model_import.h index 58f07fa3..28927726 100644 --- a/src/runtime/model/import/model_import.h +++ b/src/runtime/model/import/model_import.h @@ -28,6 +28,8 @@ typedef struct BongoCatImportCandidate { typedef struct BongoCatImportDiscovery { BongoCatImportCandidate candidates[BONGO_CAT_IMPORT_CANDIDATE_CAP]; + /* Basename selected by the user. This becomes the visible model id. */ + char source_name[BONGO_CAT_ID_CAP]; size_t count; int depth; bool ambiguous; @@ -75,6 +77,9 @@ bool bongo_cat_import_manifest_valid(const char *root, const char *setting, BongoCatError *error); bool bongo_cat_import_tauri_add_candidate(BongoCatImportDiscovery *discovery, const char *directory, const char *setting); +bool bongo_cat_import_tauri_convert_to_mver( + const BongoCatImportCandidate *candidate, const char *target, + BongoCatImportCandidate *installed, BongoCatError *error); bool bongo_cat_import_mver_assets(const BongoCatImportCandidate *candidate, const char *target, BongoCatError *error); bool bongo_cat_import_adapter_metadata(const BongoCatImportCandidate *candidate, @@ -85,9 +90,10 @@ bool bongo_cat_import_prepare_adapter(const BongoCatImportCandidate *candidate, const char *target, BongoCatError *error); bool bongo_cat_import_prepare_package(const BongoCatImportCandidate *candidate, const char *target, BongoCatImportCandidate *installed, BongoCatError *error); -bool bongo_cat_import_write_package(const BongoCatImportCandidate *candidate, - const BongoCatPackageMetadata *metadata, const char *target, +bool bongo_cat_import_prepare_storage( + const BongoCatImportDiscovery *discovery, const char *target, BongoCatError *error); +bool bongo_cat_import_authored_package(const char *directory); bool bongo_cat_import_candidate_digest(const BongoCatImportCandidate *candidate, char output[65], BongoCatError *error); bool bongo_cat_import_candidate_inspect(const BongoCatImportCandidate *candidate, @@ -106,9 +112,12 @@ bool bongo_cat_import_prepare_package_metadata( bool bongo_cat_import_prepare_package_metadata_cached( BongoCatImportDiscovery *discovery, BongoCatPackageMetadata *metadata, BongoCatImportDigestCache *cache, BongoCatError *error); -const BongoCatModelEntry *bongo_cat_import_find_existing_package( - const BongoCatModelCatalog *catalog, - const BongoCatPackageMetadata *metadata, BongoCatModelMode mode); +bool bongo_cat_import_package_id(char *output, size_t capacity, + const char *name); +bool bongo_cat_import_variant_id(char *output, size_t capacity, + const char *package_id, size_t variant_index); +uint32_t bongo_cat_import_candidate_capabilities( + const BongoCatImportCandidate *candidate); void bongo_cat_import_describe_nearby_entry(BongoCatModelEntry *entry, const BongoCatImportCandidate *candidate, const char *id, const char *identity, const char *source_hash, const char *source, @@ -130,6 +139,8 @@ BongoCatResult bongo_cat_import_nearby_scan(BongoCatApp *app, const char *root, BongoCatError *error); BongoCatResult bongo_cat_import_nearby(BongoCatApp *app, const char *root, BongoCatError *error); +BongoCatResult bongo_cat_import_installed_models(BongoCatApp *app, + const char *root, BongoCatError *error); BongoCatResult bongo_cat_import_install(const char *source, const char *models_root, BongoCatImportReceipt *receipt, BongoCatError *error); diff --git a/src/runtime/model/import/model_import_app.c b/src/runtime/model/import/model_import_app.c index 42523251..7811320a 100644 --- a/src/runtime/model/import/model_import_app.c +++ b/src/runtime/model/import/model_import_app.c @@ -34,6 +34,9 @@ BongoCatResult bongo_cat_app_import_model(BongoCatApp *app, const char *source, for (size_t i = 0; i < receipt.count; ++i) if (receipt.installed[i]) { preferred = i; break; } const char *imported_id = receipt.count ? receipt.ids[preferred] : NULL; + if (receipt.count) + bongo_cat_settings_restore_model_package(&app->settings, + receipt.ids[0]); bongo_cat_app_refresh_installed_models(app); for (size_t i = 0; i < receipt.count; ++i) if (receipt.installed[i]) diff --git a/src/runtime/model/import/model_import_discovery.c b/src/runtime/model/import/model_import_discovery.c index 7ed1df2b..812ae640 100644 --- a/src/runtime/model/import/model_import_discovery.c +++ b/src/runtime/model/import/model_import_discovery.c @@ -23,6 +23,19 @@ static bool safe_reference(const char *value) { return true; } +static void seed_source_name(const char *source, + BongoCatImportDiscovery *discovery) { + char path[BONGO_CAT_PATH_CAP]; + int written = snprintf(path, sizeof(path), "%s", source ? source : ""); + if (written < 0 || (size_t)written >= sizeof(path)) return; + size_t length = strlen(path); + while (length && (path[length - 1] == '/' || path[length - 1] == '\\')) + path[--length] = '\0'; + const char *name = bongo_cat_path_name(path); + if (name && name[0]) snprintf(discovery->source_name, + sizeof(discovery->source_name), "%s", name); +} + static bool referenced_file(const char *root, const char *relative) { char path[BONGO_CAT_PATH_CAP]; return safe_reference(relative) && @@ -104,6 +117,26 @@ static bool path_parent(const char *path, char *parent, size_t capacity) { return true; } +static bool ascii_contains_ci(const char *value, const char *needle) { + if (!value || !needle || !needle[0]) return false; + size_t length = strlen(needle); + for (const char *cursor = value; *cursor; ++cursor) + if (SDL_strncasecmp(cursor, needle, length) == 0) return true; + return false; +} + +static bool utf8_contains(const char *value, const unsigned char *needle, + size_t length) { + if (!value || !needle || !length) return false; + for (const unsigned char *cursor = (const unsigned char *)value; *cursor; + ++cursor) { + size_t remaining = strlen((const char *)cursor); + if (remaining >= length && memcmp(cursor, needle, length) == 0) + return true; + } + return false; +} + static BongoCatModelMode import_mode(const char *path) { const char *cursor = path; BongoCatModelMode mode = BONGO_CAT_MODE_STANDARD; @@ -120,6 +153,19 @@ static BongoCatModelMode import_mode(const char *path) { if (!end) break; cursor = end + 1; } + const char *name = bongo_cat_path_name(path); + static const unsigned char keyboard_cn[] = {0xe9,0x94,0xae,0xe7,0x9b,0x98}; + static const unsigned char gamepad_cn[] = {0xe6,0x89,0x8b,0xe6,0x9f,0x84}; + static const unsigned char standard_cn[] = {0xe6,0xa0,0x87,0xe5,0x87,0x86}; + if (name && (ascii_contains_ci(name, "keyboard") || + utf8_contains(name, keyboard_cn, sizeof(keyboard_cn)))) + return BONGO_CAT_MODE_KEYBOARD; + if (name && (ascii_contains_ci(name, "gamepad") || + utf8_contains(name, gamepad_cn, sizeof(gamepad_cn)))) + return BONGO_CAT_MODE_GAMEPAD; + if (name && (ascii_contains_ci(name, "standard") || + utf8_contains(name, standard_cn, sizeof(standard_cn)))) + return BONGO_CAT_MODE_STANDARD; return mode; } @@ -155,6 +201,64 @@ static bool has_background_asset(const char *directory) { return false; } +typedef struct TauriRightKeys { + bool gamepad; +} TauriRightKeys; + +static bool gamepad_key_name(const char *name) { + static const char *const names[] = { + "South", "East", "West", "North", "LeftTrigger", "RightTrigger", + "LeftTrigger2", "RightTrigger2", "LeftThumb", "RightThumb", + "DPadLeft", "DPadRight", "DPadUp", "DPadDown", "Start", "Select" + }; + size_t length = name ? strlen(name) : 0; + if (length <= 4 || SDL_strcasecmp(name + length - 4, ".png") != 0) + return false; + length -= 4; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) + if (strlen(names[i]) == length && + SDL_strncasecmp(name, names[i], length) == 0) return true; + return false; +} + +static BongoCatPathVisit inspect_right_key(void *userdata, + const char *dirname, const char *name) { + (void)dirname; + TauriRightKeys *keys = userdata; + if (gamepad_key_name(name)) keys->gamepad = true; + return BONGO_CAT_PATH_CONTINUE; +} + +static bool tauri_resource_mode(const char *directory, const char *assets, + BongoCatModelMode *mode, bool *gamepad_buttons) { + const char *roots[] = {directory, assets}; + bool right_keys = false; + TauriRightKeys keys = {0}; + for (size_t i = 0; i < sizeof(roots) / sizeof(roots[0]); ++i) { + if (!roots[i] || !roots[i][0] || (i && strcmp(roots[0], roots[i]) == 0)) + continue; + const char *prefixes[] = {"resources", NULL}; + for (size_t j = 0; j < sizeof(prefixes) / sizeof(prefixes[0]); ++j) { + char parent[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; + const char *root = roots[i]; + if (prefixes[j]) { + if (!bongo_cat_path_join(parent, sizeof(parent), root, + prefixes[j])) continue; + root = parent; + } + if (!bongo_cat_path_join(path, sizeof(path), root, "right-keys") || + !bongo_cat_path_is_dir(path)) continue; + right_keys = true; + if (!bongo_cat_path_enumerate(path, inspect_right_key, &keys)) + return false; + } + } + if (!right_keys) return false; + *mode = keys.gamepad ? BONGO_CAT_MODE_GAMEPAD : BONGO_CAT_MODE_KEYBOARD; + *gamepad_buttons = keys.gamepad; + return true; +} + bool bongo_cat_import_tauri_add_candidate(BongoCatImportDiscovery *discovery, const char *directory, const char *setting) { if (discovery->count >= BONGO_CAT_IMPORT_CANDIDATE_CAP) return false; @@ -174,7 +278,11 @@ bool bongo_cat_import_tauri_add_candidate(BongoCatImportDiscovery *discovery, if ((!has_cover_asset(directory) || !has_background_asset(directory)) && path_parent(directory, parent, sizeof(parent)) && has_preview_assets(parent)) snprintf(candidate->assets, sizeof(candidate->assets), "%s", parent); - candidate->mode = import_mode(candidate->assets); + if (!tauri_resource_mode(candidate->directory, candidate->assets, + &candidate->mode, &candidate->gamepad_buttons)) { + candidate->mode = import_mode(candidate->directory); + candidate->gamepad_buttons = candidate->mode == BONGO_CAT_MODE_GAMEPAD; + } return true; } @@ -237,6 +345,7 @@ static BongoCatResult collect_container(void *userdata, const char *source, bool bongo_cat_import_discover(const char *source, BongoCatImportDiscovery *discovery, BongoCatError *error) { memset(discovery, 0, sizeof(*discovery)); + seed_source_name(source, discovery); int mver = bongo_cat_import_mver_discover(source, discovery, error); if (mver > 0) return true; if (mver < 0) return false; @@ -244,15 +353,24 @@ bool bongo_cat_import_discover(const char *source, BongoCatImportDiscovery *disc int patch = bongo_cat_import_mver_patch_discover(source, discovery, &patch_error); if (patch > 0) return true; memset(discovery, 0, sizeof(*discovery)); + seed_source_name(source, discovery); int tauri = bongo_cat_import_tauri_discover_exact(source, discovery, error); if (tauri > 0) return true; if (tauri < 0) return false; memset(discovery, 0, sizeof(*discovery)); + seed_source_name(source, discovery); ContainerDiscovery container = {discovery}; BongoCatResult container_result = bongo_cat_import_scan(source, collect_container, &container, error); if (container_result != BONGO_CAT_OK) return false; if (discovery->count) { + char config[BONGO_CAT_PATH_CAP]; + bool is_container = !bongo_cat_import_mver_config_path(source, + config, sizeof(config)); + if (is_container) for (size_t i = 0; i < discovery->count; ++i) + if (discovery->candidates[i].format != BONGO_CAT_IMPORT_TAURI) + snprintf(discovery->candidates[i].package_root, + sizeof(discovery->candidates[i].package_root), "%s", source); qsort(discovery->candidates, discovery->count, sizeof(discovery->candidates[0]), compare_candidates); return true; @@ -274,7 +392,8 @@ bool bongo_cat_import_discover(const char *source, BongoCatImportDiscovery *disc return false; } for (size_t i = 0; i < discovery->count; ++i) - if (discovery->candidates[i].format == BONGO_CAT_IMPORT_TAURI) + if (discovery->candidates[i].format == BONGO_CAT_IMPORT_TAURI && + !discovery->candidates[i].package_root[0]) snprintf(discovery->candidates[i].package_root, sizeof(discovery->candidates[i].package_root), "%s", source); qsort(discovery->candidates, discovery->count, diff --git a/src/runtime/model/import/model_import_format.c b/src/runtime/model/import/model_import_format.c index 2822d673..138f105e 100644 --- a/src/runtime/model/import/model_import_format.c +++ b/src/runtime/model/import/model_import_format.c @@ -176,6 +176,8 @@ int bongo_cat_import_mver_discover_exact(const char *source, BongoCatImportDiscovery *discovery, BongoCatError *error) { char config[BONGO_CAT_PATH_CAP], image_root[BONGO_CAT_PATH_CAP]; if (!package_at(source, config, image_root)) return 0; + if (!discovery->source_name[0]) snprintf(discovery->source_name, + sizeof(discovery->source_name), "%s", bongo_cat_path_name(source)); return discover_package(source, config, image_root, discovery, error); } @@ -184,5 +186,7 @@ int bongo_cat_import_mver_discover(const char *source, char package[BONGO_CAT_PATH_CAP], config[BONGO_CAT_PATH_CAP]; char image_root[BONGO_CAT_PATH_CAP]; if (!find_package(source, package, sizeof(package), config, image_root)) return 0; + if (!discovery->source_name[0]) snprintf(discovery->source_name, + sizeof(discovery->source_name), "%s", bongo_cat_path_name(package)); return discover_package(package, config, image_root, discovery, error); } diff --git a/src/runtime/model/import/model_import_identity.c b/src/runtime/model/import/model_import_identity.c index 07d17790..942414b5 100644 --- a/src/runtime/model/import/model_import_identity.c +++ b/src/runtime/model/import/model_import_identity.c @@ -1,11 +1,15 @@ #include "model_import.h" #include "bongo_cat/path.h" #include "bongo_cat/sha256.h" +#include "bongo_cat/utf8.h" #include #include -static uint32_t candidate_capabilities(const BongoCatImportCandidate *candidate) { +#define IMPORT_COLLISION_SUFFIX_RESERVE 5 + +uint32_t bongo_cat_import_candidate_capabilities( + const BongoCatImportCandidate *candidate) { uint32_t result = BONGO_CAT_MODEL_CAPABILITY_LIVE2D | BONGO_CAT_MODEL_CAPABILITY_PREVIEW | BONGO_CAT_MODEL_CAPABILITY_RUNTIME_ADAPTER | @@ -50,6 +54,120 @@ static const char *display_source(const BongoCatImportCandidate *candidate, return source; } +static void truncate_utf8(char *value, size_t maximum) { + size_t length = value ? strlen(value) : 0; + if (length > maximum) value[maximum] = '\0'; + length = value ? strlen(value) : 0; + while (length && !bongo_cat_utf8_valid(value)) value[--length] = '\0'; +} + +static void trim_id_end(char *value) { + size_t length = value ? strlen(value) : 0; + while (length && (value[length - 1] == ' ' || value[length - 1] == '.')) + value[--length] = '\0'; +} + +static bool ascii_equal(const char *value, size_t length, const char *expected) { + if (strlen(expected) != length) return false; + for (size_t i = 0; i < length; ++i) { + unsigned char left = (unsigned char)value[i]; + unsigned char right = (unsigned char)expected[i]; + if (left >= 'a' && left <= 'z') left = (unsigned char)(left - 'a' + 'A'); + if (right >= 'a' && right <= 'z') right = (unsigned char)(right - 'a' + 'A'); + if (left != right) return false; + } + return true; +} + +static bool reserved_device_name(const char *value) { + size_t length = value ? strcspn(value, ".") : 0; + while (length && value[length - 1] == ' ') length--; + if (ascii_equal(value, length, "CON") || ascii_equal(value, length, "PRN") || + ascii_equal(value, length, "AUX") || ascii_equal(value, length, "NUL")) + return true; + return length == 4 && value[3] >= '1' && value[3] <= '9' && + (ascii_equal(value, 3, "COM") || ascii_equal(value, 3, "LPT")); +} + +static void copy_display_text(char *output, size_t capacity, const char *value) { + if (!output || !capacity) return; + output[0] = '\0'; + if (!value) return; + snprintf(output, capacity, "%s", value); + output[capacity - 1] = '\0'; + truncate_utf8(output, capacity - 1); + for (unsigned char *cursor = (unsigned char *)output; *cursor; ++cursor) + if (*cursor < 0x20 || *cursor == 0x7f) *cursor = ' '; +} + +static bool forbidden_id_byte(unsigned char value) { + return value < 0x20 || value == 0x7f || value == '/' || value == '\\' || + value == ':' || value == '*' || value == '?' || value == '"' || + value == '<' || value == '>' || value == '|' || value == '~'; +} + +static void normalize_id_base(char *output, size_t capacity, + const char *value) { + if (!output || !capacity) return; + output[0] = '\0'; + if (!value) return; + size_t used = 0; + for (const unsigned char *cursor = (const unsigned char *)value; + *cursor && used + 1 < capacity; ++cursor) { + unsigned char byte = *cursor; + if (byte < 0x80 && (forbidden_id_byte(byte) || (!used && byte == '.'))) + byte = '-'; + output[used++] = (char)byte; + } + output[used] = '\0'; + truncate_utf8(output, capacity - 1); + trim_id_end(output); + if (reserved_device_name(output)) { + truncate_utf8(output, capacity > 1 ? capacity - 2 : 0); + trim_id_end(output); + size_t length = strlen(output); + if (length + 1 < capacity) { + memmove(output + 1, output, length + 1); + output[0] = '_'; + } + } +} + +bool bongo_cat_import_package_id(char *output, size_t capacity, + const char *name) { + if (!output || capacity < 2) return false; + char normalized[BONGO_CAT_ID_CAP]; + normalize_id_base(normalized, sizeof(normalized), name); + if (!normalized[0]) snprintf(normalized, sizeof(normalized), "Imported model"); + size_t maximum = capacity > IMPORT_COLLISION_SUFFIX_RESERVE + 1 + ? capacity - IMPORT_COLLISION_SUFFIX_RESERVE - 1 : 0; + truncate_utf8(normalized, maximum); + trim_id_end(normalized); + if (!normalized[0]) snprintf(normalized, sizeof(normalized), "model"); + int written = snprintf(output, capacity, "%s", normalized); + return written >= 0 && (size_t)written < capacity; +} + +bool bongo_cat_import_variant_id(char *output, size_t capacity, + const char *package_id, size_t variant_index) { + if (!output || !capacity || !package_id || !package_id[0]) return false; + if (!variant_index) { + int written = snprintf(output, capacity, "%s", package_id); + return written >= 0 && (size_t)written < capacity; + } + char suffix[32]; + int suffix_length = snprintf(suffix, sizeof(suffix), "~%zu", + variant_index + 1); + if (suffix_length < 0 || (size_t)suffix_length >= sizeof(suffix) || + capacity <= (size_t)suffix_length + 1) return false; + char base[BONGO_CAT_ID_CAP]; + snprintf(base, sizeof(base), "%s", package_id); + truncate_utf8(base, capacity - (size_t)suffix_length - 1); + trim_id_end(base); + int written = snprintf(output, capacity, "%s%s", base, suffix); + return written >= 0 && (size_t)written < capacity; +} + bool bongo_cat_import_prepare_package_metadata_cached( BongoCatImportDiscovery *discovery, BongoCatPackageMetadata *metadata, BongoCatImportDigestCache *cache, @@ -63,29 +181,54 @@ bool bongo_cat_import_prepare_package_metadata_cached( if (!bongo_cat_import_candidate_inspect_cached(&candidate, item->content_digest, &placeholder, cache, error)) return false; if (placeholder) continue; + bool duplicate = false; + for (size_t j = 0; j < output; ++j) + if (discovery->candidates[j].mode == candidate.mode && + strcmp(metadata[j].content_digest, item->content_digest) == 0) { + duplicate = true; + break; + } + if (duplicate) continue; discovery->candidates[output] = candidate; int written = snprintf(family_material + used, sizeof(family_material) - used, "%s|", item->content_digest); if (written < 0 || (size_t)written >= sizeof(family_material) - used) return false; used += (size_t)written; - snprintf(item->package_id, sizeof(item->package_id), "model-%s-%s", - item->content_digest, bongo_cat_mode_name(candidate.mode)); char parent[BONGO_CAT_PATH_CAP]; - const char *name = bongo_cat_path_name(display_source(&candidate, parent)); - snprintf(item->source_name, sizeof(item->source_name), "%s", + const char *name = discovery->source_name[0] ? discovery->source_name : + bongo_cat_path_name(display_source(&candidate, parent)); + copy_display_text(item->source_name, sizeof(item->source_name), name && name[0] ? name : "Imported model"); - snprintf(item->display_name, sizeof(item->display_name), "%s", + if (!item->source_name[0]) copy_display_text(item->source_name, + sizeof(item->source_name), "Imported model"); + copy_display_text(item->display_name, sizeof(item->display_name), item->source_name); - item->capabilities = candidate_capabilities(&candidate); + item->capabilities = bongo_cat_import_candidate_capabilities(&candidate); + /* Tauri candidates are normalized to Mver before they are stored. */ + if (candidate.format == BONGO_CAT_IMPORT_TAURI) + item->capabilities |= BONGO_CAT_MODEL_CAPABILITY_INPUT_IMAGES | + BONGO_CAT_MODEL_CAPABILITY_KEYBOARD_INPUT | + BONGO_CAT_MODEL_CAPABILITY_AUDIO | + BONGO_CAT_MODEL_CAPABILITY_EFFECTS | + BONGO_CAT_MODEL_CAPABILITY_MVER_PROJECTION | + (candidate.mode == BONGO_CAT_MODE_GAMEPAD + ? BONGO_CAT_MODEL_CAPABILITY_GAMEPAD_INPUT : 0); output++; } discovery->count = output; if (!output) { bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Selected Mver package contains only bundled placeholder modes"); + "Selected model package contains only bundled placeholder modes"); return false; } + for (size_t i = 0; i < output; ++i) { + char package_id[BONGO_CAT_ID_CAP]; + if (!bongo_cat_import_package_id(package_id, sizeof(package_id), + metadata[i].source_name) || + !bongo_cat_import_variant_id(metadata[i].package_id, + sizeof(metadata[i].package_id), package_id, i)) return false; + } if (output > 1) { char digest[65]; bongo_cat_sha256_bytes(family_material, used, digest); @@ -103,19 +246,6 @@ bool bongo_cat_import_prepare_package_metadata( metadata, NULL, error); } -const BongoCatModelEntry *bongo_cat_import_find_existing_package( - const BongoCatModelCatalog *catalog, - const BongoCatPackageMetadata *metadata, BongoCatModelMode mode) { - for (size_t i = 0; i < catalog->count; ++i) { - const BongoCatModelEntry *entry = &catalog->entries[i]; - if (entry->package_schema == BONGO_CAT_MODEL_PACKAGE_SCHEMA && - entry->mode == mode && - strcmp(entry->content_digest, metadata->content_digest) == 0) - return entry; - } - return NULL; -} - void bongo_cat_import_describe_nearby_entry(BongoCatModelEntry *entry, const BongoCatImportCandidate *candidate, const char *id, const char *identity, const char *source_hash, const char *source, @@ -134,7 +264,7 @@ void bongo_cat_import_describe_nearby_entry(BongoCatModelEntry *entry, ? BONGO_CAT_MODEL_SOURCE_TAURI : candidate->format == BONGO_CAT_IMPORT_MVER_PATCH ? BONGO_CAT_MODEL_SOURCE_MVER_PATCH : BONGO_CAT_MODEL_SOURCE_MVER; - entry->capabilities = candidate_capabilities(candidate); + entry->capabilities = bongo_cat_import_candidate_capabilities(candidate); entry->adapter_schema = BONGO_CAT_MODEL_ADAPTER_SCHEMA; entry->adapter_generator = BONGO_CAT_MODEL_ADAPTER_GENERATOR; entry->managed = true; diff --git a/src/runtime/model/import/model_import_installed.c b/src/runtime/model/import/model_import_installed.c new file mode 100644 index 00000000..03dcb2fa --- /dev/null +++ b/src/runtime/model/import/model_import_installed.c @@ -0,0 +1,193 @@ +#include "model_import.h" +#include "model_import_nearby_internal.h" +#include "runtime.h" +#include "bongo_cat/path.h" +#include "bongo_cat/sha256.h" + +#include +#include +#include +#include + +typedef struct InstalledModelScan { + BongoCatApp *app; + const char *cache_root; + BongoCatError *error; + BongoCatResult result; +} InstalledModelScan; + +static bool existing_identity(const BongoCatModelCatalog *models, + const char *identity, BongoCatModelMode mode) { + for (size_t i = 0; models && i < models->count; ++i) { + const BongoCatModelEntry *entry = &models->entries[i]; + if (entry->mode == mode && entry->content_digest[0] && + strcmp(entry->content_digest, identity) == 0) return true; + } + return false; +} + +static BongoCatModelSourceFormat source_format( + const BongoCatImportCandidate *candidate) { + if (candidate->format == BONGO_CAT_IMPORT_MVER_PATCH) + return BONGO_CAT_MODEL_SOURCE_MVER_PATCH; + return candidate->format == BONGO_CAT_IMPORT_TAURI + ? BONGO_CAT_MODEL_SOURCE_TAURI : BONGO_CAT_MODEL_SOURCE_MVER; +} + +static const char *variant_name(const BongoCatImportCandidate *candidate) { + if (candidate->patch_root[0]) { + const char *name = bongo_cat_path_name(candidate->patch_root); + if (name && name[0]) return name; + } + return bongo_cat_mode_name(candidate->mode); +} + +static void describe_entry(BongoCatModelEntry *entry, + const BongoCatImportCandidate *candidate, const char *id, + const char *identity, const char *family, const char *storage, + const char *adapter, const char *display) { + memset(entry, 0, sizeof(*entry)); + snprintf(entry->id, sizeof(entry->id), "%s", id); + snprintf(entry->package_id, sizeof(entry->package_id), "%s", id); + snprintf(entry->content_digest, sizeof(entry->content_digest), "%s", + identity); + snprintf(entry->family_id, sizeof(entry->family_id), "%s", family); + snprintf(entry->display_name, sizeof(entry->display_name), "%s", display); + snprintf(entry->directory, sizeof(entry->directory), "%s", + candidate->directory); + snprintf(entry->adapter_directory, sizeof(entry->adapter_directory), "%s", + adapter); + snprintf(entry->storage_directory, sizeof(entry->storage_directory), "%s", + storage); + snprintf(entry->setting_file, sizeof(entry->setting_file), "%s", + candidate->setting); + entry->mode = candidate->mode; + entry->source_format = source_format(candidate); + entry->capabilities = bongo_cat_import_candidate_capabilities(candidate); + entry->adapter_schema = BONGO_CAT_MODEL_ADAPTER_SCHEMA; + entry->adapter_generator = BONGO_CAT_MODEL_ADAPTER_GENERATOR; +} + +static bool add_package(InstalledModelScan *scan, const char *directory, + BongoCatImportDiscovery *discovery) { + const char *folder = bongo_cat_path_name(directory); + char package_id[BONGO_CAT_ID_CAP], path_hash[65], family[BONGO_CAT_ID_CAP]; + if (!bongo_cat_import_package_id(package_id, sizeof(package_id), folder) || + !package_id[0]) return false; + bongo_cat_sha256_bytes(directory, strlen(directory), path_hash); + snprintf(family, sizeof(family), "family-installed-%.16s", path_hash); + size_t emitted = 0; + for (size_t i = 0; i < discovery->count; ++i) { + BongoCatImportCandidate *candidate = &discovery->candidates[i]; + char cache_id[BONGO_CAT_ID_CAP], adapter[BONGO_CAT_PATH_CAP]; + char signature[65], identity[65]; + snprintf(cache_id, sizeof(cache_id), "installed-%.16s-%zu", + path_hash, i + 1); + if (!bongo_cat_path_join(adapter, sizeof(adapter), scan->cache_root, + cache_id) || + !bongo_cat_nearby_signature(candidate, signature, scan->error)) + return false; + bool placeholder = false; + bool cached = bongo_cat_nearby_cached_inspection(adapter, directory, + signature, identity, &placeholder); + if (!cached && !bongo_cat_import_candidate_inspect(candidate, identity, + &placeholder, scan->error)) return false; + if (placeholder) { + if (!cached) bongo_cat_nearby_remember_inspection(adapter, + directory, signature, identity, true, candidate->mode); + continue; + } + char id[BONGO_CAT_ID_CAP]; + if (!bongo_cat_import_variant_id(id, sizeof(id), package_id, emitted)) { + bongo_cat_error_set(scan->error, BONGO_CAT_ERROR_FORMAT, + "Installed model id is invalid: %s", package_id); + return false; + } + emitted++; + if (bongo_cat_settings_model_removed(&scan->app->settings, id)) { + if (!cached) bongo_cat_nearby_remember_inspection(adapter, + directory, signature, identity, false, candidate->mode); + continue; + } + if (existing_identity(&scan->app->models, identity, candidate->mode)) { + if (!cached) bongo_cat_nearby_remember_inspection(adapter, + directory, signature, identity, false, candidate->mode); + continue; + } + if (scan->app->models.count >= BONGO_CAT_MODEL_CAP) { + bongo_cat_error_set(scan->error, BONGO_CAT_ERROR_FORMAT, + "Too many installed model variants"); + return false; + } + bool created = false; + if (!bongo_cat_nearby_refresh_cache(candidate, scan->cache_root, + cache_id, directory, signature, identity, false, adapter, + &created, scan->error)) return false; + (void)created; + if (bongo_cat_models_find(&scan->app->models, id)) { + bongo_cat_error_set(scan->error, BONGO_CAT_ERROR_FORMAT, + "Installed model id is not unique: %s", package_id); + return false; + } + char display[BONGO_CAT_ID_CAP]; + if (discovery->count > 1) + snprintf(display, sizeof(display), "%s - %s", package_id, + variant_name(candidate)); + else snprintf(display, sizeof(display), "%s", package_id); + BongoCatModelEntry *entry = + &scan->app->models.entries[scan->app->models.count++]; + describe_entry(entry, candidate, id, identity, family, directory, + adapter, display); + } + return true; +} + +static BongoCatPathVisit scan_package(void *userdata, + const char *dirname, const char *name) { + InstalledModelScan *scan = userdata; + if (!name || name[0] == '.') return BONGO_CAT_PATH_CONTINUE; + char directory[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(directory, sizeof(directory), dirname, name)) { + scan->result = BONGO_CAT_ERROR_FORMAT; + return BONGO_CAT_PATH_FAILURE; + } + if (!bongo_cat_import_authored_package(directory)) + return BONGO_CAT_PATH_CONTINUE; + BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); + if (!discovery) { + scan->result = BONGO_CAT_ERROR_MEMORY; + return BONGO_CAT_PATH_FAILURE; + } + BongoCatError local = {0}; + bool found = bongo_cat_import_discover(directory, discovery, &local); + if (!found) { + free(discovery); + return BONGO_CAT_PATH_CONTINUE; + } + bool added = add_package(scan, directory, discovery); + free(discovery); + if (added) return BONGO_CAT_PATH_CONTINUE; + scan->result = scan->error && scan->error->code + ? scan->error->code : BONGO_CAT_ERROR_IO; + return BONGO_CAT_PATH_FAILURE; +} + +BongoCatResult bongo_cat_import_installed_models(BongoCatApp *app, + const char *root, BongoCatError *error) { + if (!app || !root || !app->cache_root[0]) return BONGO_CAT_ERROR_ARGUMENT; + if (!bongo_cat_path_is_dir(root)) return BONGO_CAT_OK; + char cache_root[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(cache_root, sizeof(cache_root), app->cache_root, + BONGO_CAT_ADAPTER_CACHE_DIRECTORY) || + !bongo_cat_path_create_directory(cache_root)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot create installed model adapter cache"); + return BONGO_CAT_ERROR_IO; + } + InstalledModelScan scan = {app, cache_root, error, BONGO_CAT_OK}; + bongo_cat_import_model_scan_lock(); + bool ok = bongo_cat_path_enumerate(root, scan_package, &scan); + bongo_cat_import_model_scan_unlock(); + if (!ok && scan.result == BONGO_CAT_OK) scan.result = BONGO_CAT_ERROR_IO; + return scan.result; +} diff --git a/src/runtime/model/import/model_import_package.c b/src/runtime/model/import/model_import_package.c index 46bff3f1..89af050e 100644 --- a/src/runtime/model/import/model_import_package.c +++ b/src/runtime/model/import/model_import_package.c @@ -1,62 +1,50 @@ #include "model_import.h" -#include "model_storage.h" -#include "runtime.h" -#include "bongo_cat/json.h" #include "bongo_cat/path.h" #include #include #include -#include -static bool separator(char value) { return value == '/' || value == '\\'; } +static bool separator(char value) { + return value == '/' || value == '\\'; +} -static bool path_prefix(const char *root, const char *path, size_t length) { +static bool character_equal(char left, char right) { + if (separator(left) && separator(right)) return true; #ifdef _WIN32 - return SDL_strncasecmp(root, path, length) == 0; -#else - return strncmp(root, path, length) == 0; + if (left >= 'A' && left <= 'Z') left = (char)(left - 'A' + 'a'); + if (right >= 'A' && right <= 'Z') right = (char)(right - 'A' + 'a'); #endif + return left == right; } static bool relative_path(const char *root, const char *path, char *relative, size_t capacity) { - if (!root || !path || !relative || !capacity) return false; - size_t root_length = strlen(root); - while (root_length && separator(root[root_length - 1])) root_length--; - if (!root_length || !path_prefix(root, path, root_length) || - (path[root_length] && !separator(path[root_length]))) return false; - const char *cursor = path + root_length; - while (separator(*cursor)) cursor++; - size_t length = strlen(cursor); - while (length && separator(cursor[length - 1])) length--; - if (length >= capacity) return false; - memcpy(relative, cursor, length); - relative[length] = '\0'; - return true; + size_t root_length = root ? strlen(root) : 0; + while (root_length > 1 && separator(root[root_length - 1])) root_length--; + size_t path_length = path ? strlen(path) : 0; + if (!root_length || path_length < root_length) return false; + for (size_t i = 0; i < root_length; ++i) + if (!character_equal(root[i], path[i])) return false; + if (path_length > root_length && !separator(path[root_length])) return false; + const char *value = path + root_length; + while (separator(*value)) value++; + int written = snprintf(relative, capacity, "%s", value); + return written >= 0 && (size_t)written < capacity; } static bool rebase(const char *source_root, const char *installed_root, const char *source, char *target, size_t capacity) { - if (!source[0]) { target[0] = '\0'; return true; } char relative[BONGO_CAT_PATH_CAP]; - if (!relative_path(source_root, source, relative, sizeof(relative))) return false; + if (!relative_path(source_root, source, relative, sizeof(relative))) + return false; if (!relative[0]) { - snprintf(target, capacity, "%s", installed_root); - return true; + int written = snprintf(target, capacity, "%s", installed_root); + return written >= 0 && (size_t)written < capacity; } return bongo_cat_path_join(target, capacity, installed_root, relative); } -static bool copy_relative_directory(const char *source_root, - const char *source, const char *target_root, BongoCatError *error) { - char relative[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; - if (!relative_path(source_root, source, relative, sizeof(relative)) || - !relative[0] || !bongo_cat_path_join(target, sizeof(target), - target_root, relative)) return false; - return bongo_cat_model_copy_directory(source, target, error) == BONGO_CAT_OK; -} - static bool copy_relative_file(const char *source_root, const char *source, const char *target_root, BongoCatError *error) { char relative[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; @@ -64,195 +52,186 @@ static bool copy_relative_file(const char *source_root, const char *source, !relative[0] || !bongo_cat_path_join(target, sizeof(target), target_root, relative)) return false; if (bongo_cat_path_is_file(target)) return true; - size_t length = strlen(target); - while (length && target[length - 1] != '/' && target[length - 1] != '\\') - length--; - if (!length) return false; - char parent[BONGO_CAT_PATH_CAP]; - memcpy(parent, target, length - 1); - parent[length - 1] = '\0'; - if (!bongo_cat_path_create_directory(parent) || - !bongo_cat_path_copy_file(source, target)) { + if (bongo_cat_path_copy_file(source, target)) return true; + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot copy imported model file: %s", source); + return false; +} + +typedef struct MverDirectoryCopy { + const char *target_root; + BongoCatError *error; + unsigned depth; +} MverDirectoryCopy; + +static bool distribution_file(const char *name) { + if (!name || name[0] == '.') return true; + size_t length = strlen(name); + if (length >= 4 && SDL_strcasecmp(name + length - 4, "_bak") == 0) + return true; + const char *dot = strrchr(name, '.'); + if (!dot) return false; + static const char *const extensions[] = { + ".exe", ".dll", ".pdf", ".bak", ".lock", ".pdb", ".zip" + }; + for (size_t i = 0; i < sizeof(extensions) / sizeof(extensions[0]); ++i) + if (SDL_strcasecmp(dot, extensions[i]) == 0) return true; + return false; +} + +static bool copy_mver_tree(const char *source, const char *target, + unsigned depth, BongoCatError *error); + +static BongoCatPathVisit copy_mver_child(void *userdata, + const char *dirname, const char *name) { + MverDirectoryCopy *context = userdata; + char source[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(source, sizeof(source), dirname, name)) + return BONGO_CAT_PATH_FAILURE; + if (distribution_file(name)) return BONGO_CAT_PATH_CONTINUE; + char target[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(target, sizeof(target), context->target_root, + name)) return BONGO_CAT_PATH_FAILURE; + if (!bongo_cat_path_is_dir(source)) { + if (context->depth == 0) return BONGO_CAT_PATH_CONTINUE; + return bongo_cat_path_copy_file(source, target) + ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; + } + return copy_mver_tree(source, target, context->depth + 1, context->error) + ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; +} + +static bool copy_mver_tree(const char *source, const char *target, + unsigned depth, BongoCatError *error) { + if (depth > 32 || !bongo_cat_path_create_directory(target)) { bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot preserve imported model file: %s", source); + "Cannot create imported model directory: %s", target); return false; } - return true; + MverDirectoryCopy context = {target, error, depth}; + return bongo_cat_path_enumerate(source, copy_mver_child, &context); } -static bool copy_mver_payload(const BongoCatImportCandidate *candidate, - const char *target, BongoCatError *error) { - if (!bongo_cat_path_create_directory(target) || - !copy_relative_directory(candidate->package_root, candidate->assets, - target, error)) return false; - char inside[BONGO_CAT_PATH_CAP]; - if (!relative_path(candidate->assets, candidate->directory, - inside, sizeof(inside)) && - !copy_relative_directory(candidate->package_root, candidate->directory, - target, error)) return false; - return copy_relative_file(candidate->package_root, candidate->config, - target, error); +static bool copy_mver_relative_directory(const char *source_root, + const char *source, const char *target_root, BongoCatError *error) { + char relative[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; + if (!relative_path(source_root, source, relative, sizeof(relative)) || + !relative[0] || !bongo_cat_path_join(target, sizeof(target), + target_root, relative)) return false; + return copy_mver_tree(source, target, 1, error); } -static bool copy_tauri_payload(const BongoCatImportCandidate *candidate, +static bool copy_mver_package(const BongoCatImportCandidate *candidate, const char *target, BongoCatError *error) { - char relative[BONGO_CAT_PATH_CAP]; - if (!relative_path(candidate->package_root, candidate->directory, - relative, sizeof(relative))) return false; - if (!relative[0]) - return bongo_cat_model_copy_directory(candidate->directory, target, error) == - BONGO_CAT_OK; - return bongo_cat_path_create_directory(target) && - copy_relative_directory(candidate->package_root, candidate->directory, - target, error); + if (!bongo_cat_path_create_directory(target)) return false; + if (!copy_mver_tree(candidate->package_root, target, 0, error)) return false; + return copy_relative_file(candidate->package_root, candidate->config, + target, error); } -static bool copy_payload(const BongoCatImportCandidate *candidate, +static bool copy_package_files(const BongoCatImportCandidate *candidate, const char *target, BongoCatImportCandidate *installed, BongoCatError *error) { - char payload[BONGO_CAT_PATH_CAP], base[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(payload, sizeof(payload), target, "payload")) return false; + char base[BONGO_CAT_PATH_CAP]; + if (candidate->format == BONGO_CAT_IMPORT_TAURI) + return bongo_cat_import_tauri_convert_to_mver(candidate, target, + installed, error); bool patch = candidate->format == BONGO_CAT_IMPORT_MVER_PATCH; if (patch) { - char patch_path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_create_directory(payload) || - !bongo_cat_path_join(base, sizeof(base), payload, "base") || - !bongo_cat_path_join(patch_path, sizeof(patch_path), payload, "patch") || - !copy_mver_payload(candidate, base, error) || - !bongo_cat_path_create_directory(patch_path) || - (candidate->overrides[0] && !copy_relative_directory( - candidate->patch_root, candidate->overrides, patch_path, error)) || - !rebase(candidate->patch_root, patch_path, candidate->overrides, - installed->overrides, sizeof(installed->overrides))) return false; - snprintf(installed->patch_root, sizeof(installed->patch_root), "%s", patch_path); + char patch_relative[BONGO_CAT_PATH_CAP]; + bool patch_inside_package = relative_path(candidate->package_root, + candidate->patch_root, patch_relative, sizeof(patch_relative)); + const char *patch_source_root = patch_inside_package + ? candidate->package_root : candidate->patch_root; + snprintf(base, sizeof(base), "%s", target); + if (!copy_mver_package(candidate, base, error) || + (!patch_inside_package && candidate->overrides[0] && + !copy_mver_relative_directory(patch_source_root, + candidate->overrides, target, error)) || + (candidate->overrides[0] && !rebase(patch_source_root, target, + candidate->overrides, installed->overrides, + sizeof(installed->overrides))) || + !rebase(patch_source_root, target, candidate->patch_root, + installed->patch_root, sizeof(installed->patch_root))) + return false; } else if (candidate->format == BONGO_CAT_IMPORT_MVER) { - snprintf(base, sizeof(base), "%s", payload); - if (!copy_mver_payload(candidate, base, error)) return false; - } else { - snprintf(base, sizeof(base), "%s", payload); - if (!copy_tauri_payload(candidate, base, error)) return false; - } + snprintf(base, sizeof(base), "%s", target); + if (!copy_mver_package(candidate, base, error)) return false; + } else return false; if (!rebase(candidate->package_root, base, candidate->directory, installed->directory, sizeof(installed->directory)) || !rebase(candidate->package_root, base, candidate->assets, installed->assets, sizeof(installed->assets)) || !rebase(candidate->package_root, base, candidate->config, installed->config, sizeof(installed->config))) return false; - snprintf(installed->package_root, sizeof(installed->package_root), "%s", base); + installed->format = candidate->format; return true; } bool bongo_cat_import_prepare_package(const BongoCatImportCandidate *candidate, - const char *target, BongoCatImportCandidate *installed, BongoCatError *error) { - if (!candidate || !target || !installed || !bongo_cat_path_create_directory(target)) return false; + const char *target, BongoCatImportCandidate *installed, + BongoCatError *error) { + if (!candidate || !target || !installed || + !bongo_cat_path_create_directory(target)) return false; *installed = *candidate; - if (!copy_payload(candidate, target, installed, error)) { - if (error && !error->message[0]) bongo_cat_error_set(error, - BONGO_CAT_ERROR_IO, "Cannot preserve imported model payload"); - return false; - } - char adapter[BONGO_CAT_PATH_CAP]; - return bongo_cat_path_join(adapter, sizeof(adapter), target, "adapter") && - bongo_cat_path_create_directory(adapter); + if (copy_package_files(candidate, target, installed, error)) return true; + if (error && !error->message[0]) bongo_cat_error_set(error, + BONGO_CAT_ERROR_IO, "Cannot preserve imported model files"); + return false; } -static const char *format_name(BongoCatImportFormat format) { - if (format == BONGO_CAT_IMPORT_MVER) return "bongo-cat-mver"; - if (format == BONGO_CAT_IMPORT_MVER_PATCH) return "bongo-cat-mver-patch"; - return "tauri-live2d"; +static bool tauri_variant_directory(char *output, size_t capacity, + const char *target, const BongoCatImportDiscovery *discovery, + size_t index) { + const char *mode = bongo_cat_mode_name(discovery->candidates[index].mode); + size_t occurrence = 1; + for (size_t i = 0; i < index; ++i) + if (discovery->candidates[i].mode == discovery->candidates[index].mode) + occurrence++; + char name[48]; + int written = occurrence == 1 + ? snprintf(name, sizeof(name), "%s", mode) + : snprintf(name, sizeof(name), "%s-%zu", mode, occurrence); + return written >= 0 && (size_t)written < sizeof(name) && + bongo_cat_path_join(output, capacity, target, name); } -static const char *layout_name(BongoCatImportFormat format) { - if (format == BONGO_CAT_IMPORT_MVER) return "full-package"; - if (format == BONGO_CAT_IMPORT_MVER_PATCH) return "image-patch"; - return "single-model"; +bool bongo_cat_import_prepare_storage( + const BongoCatImportDiscovery *discovery, const char *target, + BongoCatError *error) { + if (!discovery || !discovery->count || !target) return false; + bool tauri = true; + for (size_t i = 0; i < discovery->count; ++i) + tauri = tauri && discovery->candidates[i].format == + BONGO_CAT_IMPORT_TAURI; + if (!tauri) { + BongoCatImportCandidate installed; + return bongo_cat_import_prepare_package(&discovery->candidates[0], + target, &installed, error); + } + for (size_t i = 0; i < discovery->count; ++i) { + char package[BONGO_CAT_PATH_CAP]; + if (discovery->count == 1) + snprintf(package, sizeof(package), "%s", target); + else if (!tauri_variant_directory(package, sizeof(package), target, + discovery, i)) return false; + BongoCatImportCandidate installed; + if (!bongo_cat_import_prepare_package(&discovery->candidates[i], + package, &installed, error)) return false; + } + return true; } -typedef struct CapabilityName { uint32_t flag; const char *name; } CapabilityName; - -static bool add_capabilities(yyjson_mut_doc *document, yyjson_mut_val *root, - uint32_t capabilities) { - static const CapabilityName names[] = { - {BONGO_CAT_MODEL_CAPABILITY_LIVE2D, "live2d"}, - {BONGO_CAT_MODEL_CAPABILITY_PREVIEW, "preview"}, - {BONGO_CAT_MODEL_CAPABILITY_RUNTIME_ADAPTER, "runtime-adapter"}, - {BONGO_CAT_MODEL_CAPABILITY_INPUT_IMAGES, "input-images"}, - {BONGO_CAT_MODEL_CAPABILITY_KEYBOARD_INPUT, "keyboard-input"}, - {BONGO_CAT_MODEL_CAPABILITY_GAMEPAD_INPUT, "gamepad-input"}, - {BONGO_CAT_MODEL_CAPABILITY_BEHAVIORS, "behaviors"}, - {BONGO_CAT_MODEL_CAPABILITY_AUDIO, "audio"}, - {BONGO_CAT_MODEL_CAPABILITY_EFFECTS, "effects"}, - {BONGO_CAT_MODEL_CAPABILITY_MVER_PROJECTION, "mver-projection"}, - {BONGO_CAT_MODEL_CAPABILITY_POINTER_OVERLAY, "pointer-overlay"}, - {BONGO_CAT_MODEL_CAPABILITY_IMAGE_PATCH, "image-patch"} +bool bongo_cat_import_authored_package(const char *directory) { + char path[BONGO_CAT_PATH_CAP]; + if (!directory || !bongo_cat_path_is_dir(directory)) return false; + static const char *const internal[] = { + ".bongo-cat-package.json", ".bongo-cat-adapter", + BONGO_CAT_MODEL_BUILTIN_MARKER }; - yyjson_mut_val *array = yyjson_mut_obj_add_arr(document, root, "capabilities"); - if (!array) return false; - for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) - if ((capabilities & names[i].flag) && - !yyjson_mut_arr_add_str(document, array, names[i].name)) return false; + for (size_t i = 0; i < sizeof(internal) / sizeof(internal[0]); ++i) + if (bongo_cat_path_join(path, sizeof(path), directory, internal[i]) && + (bongo_cat_path_is_file(path) || bongo_cat_path_is_dir(path))) + return false; return true; } - -static bool add_extensions(yyjson_mut_doc *document, yyjson_mut_val *root, - const BongoCatImportCandidate *candidate, const char *target) { - yyjson_mut_val *extensions = yyjson_mut_obj_add_obj(document, root, "extensions"); - if (!extensions) return false; - if (candidate->format == BONGO_CAT_IMPORT_TAURI) return true; - char config[BONGO_CAT_PATH_CAP]; - if (!relative_path(target, candidate->config, config, sizeof(config))) return false; - yyjson_mut_val *mver = yyjson_mut_obj_add_obj(document, extensions, "mver"); - return mver && yyjson_mut_obj_add_strcpy(document, mver, - "configuration", config); -} - -bool bongo_cat_import_write_package(const BongoCatImportCandidate *candidate, - const BongoCatPackageMetadata *metadata, const char *target, - BongoCatError *error) { - char directory[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; - bool relative = relative_path(target, candidate->directory, - directory, sizeof(directory)); - if (relative && !directory[0]) snprintf(directory, sizeof(directory), "."); - yyjson_mut_doc *document = yyjson_mut_doc_new(NULL); - yyjson_mut_val *root = document ? yyjson_mut_obj(document) : NULL; - yyjson_mut_val *source = root ? yyjson_mut_obj_add_obj(document, root, "source") : NULL; - yyjson_mut_val *model = root ? yyjson_mut_obj_add_obj(document, root, "model") : NULL; - yyjson_mut_val *runtime = root ? yyjson_mut_obj_add_obj(document, root, "runtime") : NULL; - if (document) yyjson_mut_doc_set_root(document, root); - bool ok = candidate && metadata && relative && root && source && model && runtime && - yyjson_mut_obj_add_int(document, root, "schemaVersion", - BONGO_CAT_MODEL_PACKAGE_SCHEMA) && - yyjson_mut_obj_add_strcpy(document, root, "packageId", metadata->package_id) && - yyjson_mut_obj_add_strcpy(document, root, "contentDigest", - metadata->content_digest) && - (!metadata->family_id[0] || yyjson_mut_obj_add_strcpy(document, root, - "familyId", metadata->family_id)) && - yyjson_mut_obj_add_strcpy(document, root, "displayName", - metadata->display_name) && - yyjson_mut_obj_add_strcpy(document, root, "mode", - bongo_cat_mode_name(candidate->mode)) && - yyjson_mut_obj_add_strcpy(document, source, "format", - format_name(candidate->format)) && - yyjson_mut_obj_add_strcpy(document, source, "name", metadata->source_name) && - yyjson_mut_obj_add_strcpy(document, source, "layout", - layout_name(candidate->format)) && - yyjson_mut_obj_add_bool(document, source, "preserved", true) && - yyjson_mut_obj_add_strcpy(document, model, "directory", directory) && - yyjson_mut_obj_add_strcpy(document, model, "setting", candidate->setting) && - yyjson_mut_obj_add_str(document, runtime, "adapter", "adapter") && - yyjson_mut_obj_add_str(document, runtime, "metadata", - "adapter/" BONGO_CAT_MODEL_ADAPTER_FILE) && - yyjson_mut_obj_add_int(document, runtime, "adapterSchemaVersion", - BONGO_CAT_MODEL_ADAPTER_SCHEMA) && - yyjson_mut_obj_add_int(document, runtime, "generatorVersion", - BONGO_CAT_MODEL_ADAPTER_GENERATOR) && - add_capabilities(document, root, metadata->capabilities) && - add_extensions(document, root, candidate, target) && - bongo_cat_path_join(path, sizeof(path), target, - BONGO_CAT_MODEL_PACKAGE_FILE) && - bongo_cat_json_write_file(path, document, YYJSON_WRITE_PRETTY, NULL); - yyjson_mut_doc_free(document); - if (!ok) bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot write model package description"); - return ok; -} diff --git a/src/runtime/model/import/model_import_tauri.c b/src/runtime/model/import/model_import_tauri.c index bef05ab3..6c81f837 100644 --- a/src/runtime/model/import/model_import_tauri.c +++ b/src/runtime/model/import/model_import_tauri.c @@ -28,6 +28,8 @@ static BongoCatPathVisit discover_item(void *userdata, int bongo_cat_import_tauri_discover_exact(const char *source, BongoCatImportDiscovery *discovery, BongoCatError *error) { if (!source || !discovery || !bongo_cat_path_is_dir(source)) return 0; + if (!discovery->source_name[0]) snprintf(discovery->source_name, + sizeof(discovery->source_name), "%s", bongo_cat_path_name(source)); size_t before = discovery->count; if (!bongo_cat_path_enumerate(source, discover_item, discovery)) { bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, diff --git a/src/runtime/model/import/model_import_tauri_mver.c b/src/runtime/model/import/model_import_tauri_mver.c new file mode 100644 index 00000000..16ef60bd --- /dev/null +++ b/src/runtime/model/import/model_import_tauri_mver.c @@ -0,0 +1,414 @@ +#include "model_import.h" +#include "bongo_cat/file.h" +#include "bongo_cat/json.h" +#include "bongo_cat/path.h" + +#include +#include +#include +#include +#include + +#define TAURI_KEY_CAP 256 +#define TAURI_MODEL_COPY_DEPTH_CAP 32 + +typedef struct TauriKeyFile { + char name[BONGO_CAT_ID_CAP]; + char path[BONGO_CAT_PATH_CAP]; + int code; +} TauriKeyFile; + +typedef struct TauriKeyFiles { + TauriKeyFile values[TAURI_KEY_CAP]; + size_t count; +} TauriKeyFiles; + +static bool suffix(const char *name, const char *ending) { + size_t a = name ? strlen(name) : 0, b = ending ? strlen(ending) : 0; + return a >= b && SDL_strcasecmp(name + a - b, ending) == 0; +} + +static int key_code(const char *filename) { + char name[BONGO_CAT_ID_CAP]; + snprintf(name, sizeof(name), "%s", filename ? filename : ""); + char *dot = strrchr(name, '.'); + if (!dot || SDL_strcasecmp(dot, ".png") != 0) return -1; + *dot = '\0'; + if (strlen(name) == 4 && SDL_strncasecmp(name, "Key", 3) == 0 && + name[3] >= 'A' && name[3] <= 'Z') return name[3]; + if (strlen(name) == 4 && SDL_strncasecmp(name, "Num", 3) == 0 && + name[3] >= '0' && name[3] <= '9') return name[3]; + if ((name[0] == 'F' || name[0] == 'f') && strlen(name) <= 3) { + int value = atoi(name + 1); + if (value >= 1 && value <= 12) return 111 + value; + } + static const struct { const char *name; int code; } map[] = { + {"Backspace", 8}, {"BackSpace", 8}, {"Tab", 9}, {"Return", 13}, + {"Pause", 19}, {"CapsLock", 20}, {"Escape", 27}, {"Space", 32}, + {"PageUp", 33}, {"PageDown", 34}, {"End", 35}, {"Home", 36}, + {"LeftArrow", 37}, {"UpArrow", 38}, {"RightArrow", 39}, + {"DownArrow", 40}, {"PrintScreen", 44}, {"Insert", 45}, + {"Delete", 46}, {"Meta", 91}, {"MetaLeft", 91}, {"MetaRight", 92}, + {"Apps", 93}, {"Kp0", 96}, {"Kp1", 97}, {"Kp2", 98}, + {"Kp3", 99}, {"Kp4", 100}, {"Kp5", 101}, {"Kp6", 102}, + {"Kp7", 103}, {"Kp8", 104}, {"Kp9", 105}, + {"KpMultiply", 106}, {"KpPlus", 107}, {"KpMinus", 109}, + {"KpDecimal", 110}, {"KpDivide", 111}, {"NumLock", 144}, + {"ScrollLock", 145}, {"Semicolon", 186}, {"SemiColon", 186}, + {"Equal", 187}, {"Comma", 188}, {"Minus", 189}, + {"Period", 190}, {"Dot", 190}, {"Slash", 191}, + {"BackQuote", 192}, {"BracketLeft", 219}, {"LeftBracket", 219}, + {"Backslash", 220}, {"BracketRight", 221}, {"RightBracket", 221}, + {"Quote", 222}, {"Shift", 16}, {"ShiftLeft", 16}, + {"ShiftRight", 16}, {"Control", 17}, {"ControlLeft", 17}, + {"ControlRight", 17}, {"Alt", 18}, {"AltGr", 18} + }; + for (size_t i = 0; i < sizeof(map) / sizeof(map[0]); ++i) + if (SDL_strcasecmp(name, map[i].name) == 0) return map[i].code; + static const struct { const char *name; int code; } gamepad[] = { + {"South", 0}, {"East", 1}, {"West", 2}, {"North", 3}, + {"LeftTrigger", 4}, {"RightTrigger", 5}, {"LeftTrigger2", 6}, + {"RightTrigger2", 7}, {"LeftThumb", 8}, {"RightThumb", 9}, + {"DPadLeft", 10}, {"DPadRight", 11}, {"DPadUp", 12}, + {"DPadDown", 13}, {"Start", 14}, {"Select", 15} + }; + for (size_t i = 0; i < sizeof(gamepad) / sizeof(gamepad[0]); ++i) + if (SDL_strcasecmp(name, gamepad[i].name) == 0) return gamepad[i].code; + return -1; +} + +static BongoCatPathVisit collect_keys(void *userdata, const char *dirname, + const char *name) { + TauriKeyFiles *files = userdata; + if (files->count >= TAURI_KEY_CAP || !suffix(name, ".png")) + return BONGO_CAT_PATH_CONTINUE; + int code = key_code(name); + if (code < 0) return BONGO_CAT_PATH_CONTINUE; + TauriKeyFile *item = &files->values[files->count]; + if (!bongo_cat_path_join(item->path, sizeof(item->path), dirname, name)) + return BONGO_CAT_PATH_FAILURE; + if (!bongo_cat_path_is_file(item->path)) return BONGO_CAT_PATH_CONTINUE; + snprintf(item->name, sizeof(item->name), "%s", name); + item->code = code; + files->count++; + return BONGO_CAT_PATH_CONTINUE; +} + +static int compare_keys(const void *left, const void *right) { + const TauriKeyFile *a = left, *b = right; + return SDL_strcasecmp(a->name, b->name); +} + +static bool resource_file(const BongoCatImportCandidate *candidate, + const char *name, char *path, size_t capacity) { + const char *roots[3] = {candidate->assets, candidate->directory, + candidate->package_root}; + for (size_t i = 0; i < sizeof(roots) / sizeof(roots[0]); ++i) { + if (!roots[i][0]) continue; + char resources[BONGO_CAT_PATH_CAP]; + if (bongo_cat_path_join(resources, sizeof(resources), roots[i], + "resources") && bongo_cat_path_join(path, capacity, + resources, name) && bongo_cat_path_is_file(path)) return true; + if (bongo_cat_path_join(path, capacity, roots[i], name) && + bongo_cat_path_is_file(path)) return true; + } + return false; +} + +static bool write_placeholder(const char *path) { + static const unsigned char png[] = { + 0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a,0x00,0x00,0x00,0x0d, + 0x49,0x48,0x44,0x52,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x01, + 0x08,0x06,0x00,0x00,0x00,0x1f,0x15,0xc4,0x89,0x00,0x00,0x00, + 0x0d,0x49,0x44,0x41,0x54,0x08,0xd7,0x63,0xf8,0xcf,0xc0,0xf0, + 0x1f,0x00,0x05,0x00,0x01,0xff,0x89,0x99,0x3d,0x1d,0x00,0x00, + 0x00,0x00,0x49,0x45,0x4e,0x44,0xae,0x42,0x60,0x82 + }; + FILE *file = bongo_cat_file_open(path, "wb"); + if (!file) return false; + bool ok = fwrite(png, 1, sizeof(png), file) == sizeof(png); + if (fclose(file) != 0) ok = false; + return ok; +} + +static bool copy_image_or_placeholder(const char *source, const char *target, + BongoCatError *error) { + bool ok = source && bongo_cat_path_is_file(source) + ? bongo_cat_path_copy_file(source, target) : write_placeholder(target); + if (!ok && error) bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot create normalized Mver image: %s", target); + return ok; +} + +typedef struct TauriModelCopy { + const char *target_root; + BongoCatError *error; + unsigned depth; +} TauriModelCopy; + +static bool copy_tauri_model_tree(const char *source, const char *target, + unsigned depth, BongoCatError *error); +static bool copy_tauri_resource_tree(const char *source, const char *target, + unsigned depth, BongoCatError *error); + +static bool tauri_interface_resource(const char *name) { + static const char *const files[] = { + "cover.png", "background.png", "cat.png", "bg.png", + "mousebg.png", "tabletbg.png" + }; + if (SDL_strcasecmp(name, "left-keys") == 0 || + SDL_strcasecmp(name, "right-keys") == 0) return true; + for (size_t i = 0; i < sizeof(files) / sizeof(files[0]); ++i) + if (SDL_strcasecmp(name, files[i]) == 0) return true; + return false; +} + +static BongoCatPathVisit copy_tauri_resource_child(void *userdata, + const char *dirname, const char *name) { + TauriModelCopy *context = userdata; + if (!name || name[0] == '.' || tauri_interface_resource(name)) + return BONGO_CAT_PATH_CONTINUE; + char source[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(source, sizeof(source), dirname, name) || + !bongo_cat_path_join(target, sizeof(target), context->target_root, + name)) return BONGO_CAT_PATH_FAILURE; + if (bongo_cat_path_is_dir(source)) + return copy_tauri_model_tree(source, target, context->depth + 1, + context->error) ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; + if (!bongo_cat_path_is_file(source)) return BONGO_CAT_PATH_CONTINUE; + if (!bongo_cat_path_create_directory(context->target_root) || + !bongo_cat_path_copy_file(source, target)) { + bongo_cat_error_set(context->error, BONGO_CAT_ERROR_IO, + "Cannot preserve Tauri Live2D resource: %s", source); + return BONGO_CAT_PATH_FAILURE; + } + return BONGO_CAT_PATH_CONTINUE; +} + +static BongoCatPathVisit copy_tauri_model_child(void *userdata, + const char *dirname, const char *name) { + TauriModelCopy *context = userdata; + if (!name || name[0] == '.') return BONGO_CAT_PATH_CONTINUE; + char source[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(source, sizeof(source), dirname, name) || + !bongo_cat_path_join(target, sizeof(target), context->target_root, + name)) return BONGO_CAT_PATH_FAILURE; + /* Normalize Tauri interface assets, but retain model-owned resources. */ + if (context->depth == 0 && bongo_cat_path_is_dir(source) && + SDL_strcasecmp(name, "resources") == 0) + return copy_tauri_resource_tree(source, target, context->depth + 1, + context->error) ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; + if (bongo_cat_path_is_dir(source)) + return copy_tauri_model_tree(source, target, context->depth + 1, + context->error) ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; + if (!bongo_cat_path_is_file(source) || + !bongo_cat_path_copy_file(source, target)) { + bongo_cat_error_set(context->error, BONGO_CAT_ERROR_IO, + "Cannot copy Tauri Live2D asset: %s", source); + return BONGO_CAT_PATH_FAILURE; + } + return BONGO_CAT_PATH_CONTINUE; +} + +static bool copy_tauri_model_tree(const char *source, const char *target, + unsigned depth, BongoCatError *error) { + if (depth > TAURI_MODEL_COPY_DEPTH_CAP || + !bongo_cat_path_create_directory(target) || + !bongo_cat_path_enumerate(source, copy_tauri_model_child, + &(TauriModelCopy){target, error, depth})) { + if (error && !error->message[0]) bongo_cat_error_set(error, + BONGO_CAT_ERROR_IO, "Cannot copy Tauri Live2D directory: %s", + source); + return false; + } + return true; +} + +static bool copy_tauri_resource_tree(const char *source, const char *target, + unsigned depth, BongoCatError *error) { + if (depth > TAURI_MODEL_COPY_DEPTH_CAP || + !bongo_cat_path_enumerate(source, copy_tauri_resource_child, + &(TauriModelCopy){target, error, depth})) { + if (error && !error->message[0]) bongo_cat_error_set(error, + BONGO_CAT_ERROR_IO, "Cannot inspect Tauri resource directory: %s", + source); + return false; + } + return true; +} + +static bool add_matrix(yyjson_mut_doc *document, yyjson_mut_val *mode, + const char *name, const TauriKeyFiles *files) { + yyjson_mut_val *matrix = yyjson_mut_obj_add_arr(document, mode, name); + if (!matrix) return false; + for (size_t i = 0; i < files->count; ++i) { + yyjson_mut_val *row = yyjson_mut_arr_add_arr(document, matrix); + if (!row || !yyjson_mut_arr_add_int(document, row, files->values[i].code)) + return false; + } + return true; +} + +static bool config_write(const char *path, BongoCatModelMode mode, + const TauriKeyFiles *left, const TauriKeyFiles *right, + BongoCatError *error) { + yyjson_mut_doc *document = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root = document ? yyjson_mut_obj(document) : NULL; + if (document) yyjson_mut_doc_set_root(document, root); + yyjson_mut_val *decoration = root ? yyjson_mut_obj_add_obj(document, root, + "decoration") : NULL; + yyjson_mut_val *mode_object = root ? yyjson_mut_obj_add_obj(document, root, + bongo_cat_mode_name(mode)) : NULL; + bool ok = root && decoration && mode_object && + yyjson_mut_obj_add_int(document, root, "mode", + mode == BONGO_CAT_MODE_STANDARD ? 1 : + mode == BONGO_CAT_MODE_KEYBOARD ? 2 : 3) && + yyjson_mut_obj_add_bool(document, mode_object, "l2d", true); + if (ok && mode == BONGO_CAT_MODE_STANDARD) + ok = add_matrix(document, mode_object, "hand", left) && + yyjson_mut_obj_add_bool(document, mode_object, "mouse", false) && + yyjson_mut_obj_add_arr(document, mode_object, "keyboard"); + if (ok && mode != BONGO_CAT_MODE_STANDARD) + ok = add_matrix(document, mode_object, "lefthand", left) && + add_matrix(document, mode_object, "righthand", right) && + yyjson_mut_obj_add_arr(document, mode_object, "keyboard"); + if (ok && mode == BONGO_CAT_MODE_GAMEPAD) + ok = yyjson_mut_obj_add_int(document, mode_object, "input_mode", 1); + if (ok) ok = yyjson_mut_obj_add_arr(document, mode_object, "face") && + yyjson_mut_obj_add_arr(document, mode_object, "sounds") && + yyjson_mut_obj_add_arr(document, mode_object, "l2d_expression") && + yyjson_mut_obj_add_arr(document, mode_object, "l2d_motion") && + yyjson_mut_obj_add_arr(document, mode_object, "l2d_motion_lockhand"); + if (ok) ok = bongo_cat_json_write_file(path, document, YYJSON_WRITE_PRETTY, + NULL); + yyjson_mut_doc_free(document); + if (!ok && error) bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot write normalized Mver configuration: %s", path); + return ok; +} + +static bool copy_preview(const BongoCatImportCandidate *candidate, + const char *mode_root, BongoCatError *error) { + char source[BONGO_CAT_PATH_CAP], cat[BONGO_CAT_PATH_CAP]; + char target[BONGO_CAT_PATH_CAP]; + bool have_cover = resource_file(candidate, "cover.png", source, + sizeof(source)); + if (!bongo_cat_path_join(cat, sizeof(cat), mode_root, "cat.png") || + !copy_image_or_placeholder(have_cover ? source : NULL, cat, error)) + return false; + bool have_background = resource_file(candidate, "background.png", source, + sizeof(source)); + if (!have_background && have_cover) { + /* A cover is a better fallback than an absent preview. */ + snprintf(source, sizeof(source), "%s", cat); + have_background = true; + } + if (!bongo_cat_path_join(target, sizeof(target), mode_root, "bg.png")) + return false; + return copy_image_or_placeholder(have_background ? source : NULL, target, + error); +} + +static bool copy_keys(const BongoCatImportCandidate *candidate, + const char *resource_directory, const char *mode_root, const char *name, + const char *mver_group, TauriKeyFiles *files, BongoCatError *error) { + char source[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(source, sizeof(source), resource_directory, name)) + return false; + if (!bongo_cat_path_is_dir(source)) { + char parent[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(parent, sizeof(parent), candidate->directory, + "resources") || !bongo_cat_path_join(source, sizeof(source), + parent, name) || !bongo_cat_path_is_dir(source)) return true; + } + if (!bongo_cat_path_enumerate(source, collect_keys, files)) return false; + qsort(files->values, files->count, sizeof(files->values[0]), compare_keys); + char destination[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(destination, sizeof(destination), mode_root, + mver_group) || !bongo_cat_path_create_directory(destination)) + return false; + for (size_t i = 0; i < files->count; ++i) { + char filename[32], path[BONGO_CAT_PATH_CAP]; + snprintf(filename, sizeof(filename), "%zu.png", i); + if (!bongo_cat_path_join(path, sizeof(path), destination, filename) || + !bongo_cat_path_copy_file(files->values[i].path, path)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot copy tauri input image: %s", files->values[i].path); + return false; + } + } + return true; +} + +bool bongo_cat_import_tauri_convert_to_mver( + const BongoCatImportCandidate *candidate, const char *target, + BongoCatImportCandidate *installed, BongoCatError *error) { + if (!candidate || !target || !installed || + candidate->format != BONGO_CAT_IMPORT_TAURI) return false; + char img[BONGO_CAT_PATH_CAP], mode_root[BONGO_CAT_PATH_CAP], model_root[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_create_directory(target) || + !bongo_cat_path_join(img, sizeof(img), target, "img") || + !bongo_cat_path_join(mode_root, sizeof(mode_root), img, + bongo_cat_mode_name(candidate->mode)) || + !bongo_cat_path_join(model_root, sizeof(model_root), mode_root, + "cat_model") || !bongo_cat_path_create_directory(mode_root) || + !bongo_cat_path_create_directory(model_root)) return false; + if (!copy_tauri_model_tree(candidate->directory, model_root, 0, error)) + return false; + char resource_root[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(resource_root, sizeof(resource_root), + candidate->assets, "resources") || !bongo_cat_path_is_dir(resource_root)) { + if (!bongo_cat_path_join(resource_root, sizeof(resource_root), + candidate->directory, "resources") || + !bongo_cat_path_is_dir(resource_root)) + snprintf(resource_root, sizeof(resource_root), "%s", + candidate->assets); + } + TauriKeyFiles left = {0}, right = {0}; + if (candidate->mode == BONGO_CAT_MODE_STANDARD) { + if (!copy_keys(candidate, resource_root, mode_root, "left-keys", "hand", + &left, error)) return false; + } else if (!copy_keys(candidate, resource_root, mode_root, "left-keys", + "lefthand", &left, error) || + !copy_keys(candidate, resource_root, mode_root, "right-keys", "righthand", + &right, error)) return false; + char source[BONGO_CAT_PATH_CAP], fallback[BONGO_CAT_PATH_CAP]; + bool have_fallback = resource_file(candidate, "cover.png", source, + sizeof(source)) || resource_file(candidate, "background.png", source, + sizeof(source)); + if (!left.count) { + char hand[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(hand, sizeof(hand), mode_root, + candidate->mode == BONGO_CAT_MODE_STANDARD ? "hand" : "lefthand") || + !bongo_cat_path_create_directory(hand) || + !bongo_cat_path_join(fallback, sizeof(fallback), hand, "0.png") || + !copy_image_or_placeholder(have_fallback ? source : NULL, fallback, + error)) return false; + left.values[0].code = candidate->mode == BONGO_CAT_MODE_GAMEPAD ? 0 : 65; + left.count = 1; + } + if (candidate->mode != BONGO_CAT_MODE_STANDARD && !right.count) { + char hand[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(hand, sizeof(hand), mode_root, "righthand") || + !bongo_cat_path_create_directory(hand) || + !bongo_cat_path_join(fallback, sizeof(fallback), hand, "0.png") || + !copy_image_or_placeholder(have_fallback ? source : NULL, fallback, + error)) return false; + right.values[0].code = candidate->mode == BONGO_CAT_MODE_GAMEPAD ? 1 : 65; + right.count = 1; + } + char config[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(config, sizeof(config), target, "config.json") || + !config_write(config, candidate->mode, &left, &right, error) || + !copy_preview(candidate, mode_root, error)) return false; + *installed = *candidate; + snprintf(installed->directory, sizeof(installed->directory), "%s", model_root); + snprintf(installed->assets, sizeof(installed->assets), "%s", mode_root); + snprintf(installed->package_root, sizeof(installed->package_root), "%s", target); + snprintf(installed->config, sizeof(installed->config), "%s", config); + installed->overrides[0] = '\0'; + installed->patch_root[0] = '\0'; + installed->format = BONGO_CAT_IMPORT_MVER; + installed->gamepad_buttons = candidate->mode == BONGO_CAT_MODE_GAMEPAD; + return true; +} diff --git a/src/runtime/model/import/mver/model_import_mver_metadata.c b/src/runtime/model/import/mver/model_import_mver_metadata.c index 80f5788a..a80b142f 100644 --- a/src/runtime/model/import/mver/model_import_mver_metadata.c +++ b/src/runtime/model/import/mver/model_import_mver_metadata.c @@ -19,6 +19,15 @@ static double number_or(yyjson_val *value, double fallback) { return yyjson_is_num(value) ? yyjson_get_num(value) : fallback; } +static bool pointer_asset(const BongoCatImportCandidate *candidate, + const char *name) { + char path[BONGO_CAT_PATH_CAP]; + if (candidate->overrides[0] && bongo_cat_path_join(path, sizeof(path), + candidate->overrides, name) && bongo_cat_path_is_file(path)) return true; + return bongo_cat_path_join(path, sizeof(path), candidate->assets, name) && + bongo_cat_path_is_file(path); +} + static bool add_standard_pointer(yyjson_mut_doc *output, yyjson_mut_val *root, yyjson_val *config, const BongoCatImportCandidate *candidate) { if (candidate->mode != BONGO_CAT_MODE_STANDARD) return true; @@ -43,12 +52,14 @@ static bool add_standard_pointer(yyjson_mut_doc *output, yyjson_mut_val *root, const char *right = mouse ? "resources/mver-pointer/mouse_right.png" : "resources/mver-pointer/tablet_right.png"; const char *side = mouse ? "resources/mver-pointer/mouse_side.png" : ""; + bool enabled = pointer_asset(candidate, "arm.png") && + pointer_asset(candidate, mouse ? "mouse.png" : "tablet.png"); yyjson_mut_val *pointer = yyjson_mut_obj_add_obj(output, root, "standardPointer"); return pointer && /* Mver draws the pointer layer after the Live2D model in both standard and Live2D-standard modes. Keep it enabled so the authored hand, device, and button overlays continue to follow the pointer. */ - yyjson_mut_obj_add_bool(output, pointer, "enabled", true) && + yyjson_mut_obj_add_bool(output, pointer, "enabled", enabled) && yyjson_mut_obj_add_bool(output, pointer, "mouse", mouse) && yyjson_mut_obj_add_bool(output, pointer, "leftHanded", yyjson_is_bool(left_handed) && yyjson_get_bool(left_handed)) && diff --git a/src/runtime/model/import/mver/model_import_mver_patch.c b/src/runtime/model/import/mver/model_import_mver_patch.c index 66e68266..cfda9bef 100644 --- a/src/runtime/model/import/mver/model_import_mver_patch.c +++ b/src/runtime/model/import/mver/model_import_mver_patch.c @@ -97,13 +97,17 @@ static int finish_patch(const char *source, const char *image_root, } int result = bongo_cat_import_mver_discover(base, discovery, error); if (result <= 0) return result < 0 ? -1 : 0; + const char *source_name = bongo_cat_path_name(source); + if (source_name && source_name[0]) snprintf(discovery->source_name, + sizeof(discovery->source_name), "%s", source_name); for (size_t i = 0; i < discovery->count; ++i) { BongoCatImportCandidate *candidate = &discovery->candidates[i]; char mode[BONGO_CAT_PATH_CAP]; if (bongo_cat_path_join(mode, sizeof(mode), image_root, bongo_cat_mode_name(candidate->mode)) && bongo_cat_path_is_dir(mode)) snprintf(candidate->overrides, sizeof(candidate->overrides), "%s", mode); - snprintf(candidate->patch_root, sizeof(candidate->patch_root), "%s", source); + snprintf(candidate->patch_root, sizeof(candidate->patch_root), "%s", + source); candidate->format = BONGO_CAT_IMPORT_MVER_PATCH; } return 1; diff --git a/src/runtime/model/import/nearby/model_import_nearby.c b/src/runtime/model/import/nearby/model_import_nearby.c index b74ddbd2..70838290 100644 --- a/src/runtime/model/import/nearby/model_import_nearby.c +++ b/src/runtime/model/import/nearby/model_import_nearby.c @@ -8,13 +8,12 @@ #include #include -#define NEARBY_ADAPTER_DIRECTORY "model-adapters" #define NEARBY_SCAN_BUDGET_NS 500000000ull static SDL_InitState nearby_scan_lock_init; static SDL_Mutex *nearby_scan_lock; -static SDL_Mutex *scan_lock(void) { +static SDL_Mutex *scan_mutex(void) { if (SDL_ShouldInit(&nearby_scan_lock_init)) { nearby_scan_lock = SDL_CreateMutex(); SDL_SetInitialized(&nearby_scan_lock_init, @@ -23,6 +22,16 @@ static SDL_Mutex *scan_lock(void) { return nearby_scan_lock; } +void bongo_cat_import_model_scan_lock(void) { + SDL_Mutex *mutex = scan_mutex(); + if (mutex) SDL_LockMutex(mutex); +} + +void bongo_cat_import_model_scan_unlock(void) { + SDL_Mutex *mutex = nearby_scan_lock; + if (mutex) SDL_UnlockMutex(mutex); +} + void bongo_cat_import_nearby_shutdown(void) { if (!SDL_ShouldQuit(&nearby_scan_lock_init)) return; SDL_Mutex *mutex = nearby_scan_lock; @@ -188,7 +197,7 @@ static BongoCatResult import_root_unlocked(BongoCatApp *app, const char *root, char cache_root[BONGO_CAT_PATH_CAP]; char first_created[BONGO_CAT_ID_CAP] = {0}; if (!bongo_cat_path_join(cache_root, sizeof(cache_root), app->cache_root, - NEARBY_ADAPTER_DIRECTORY) || + BONGO_CAT_ADAPTER_CACHE_DIRECTORY) || !bongo_cat_path_create_directory(cache_root)) return BONGO_CAT_ERROR_IO; BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); @@ -215,10 +224,9 @@ static BongoCatResult import_root_unlocked(BongoCatApp *app, const char *root, static BongoCatResult import_root(BongoCatApp *app, const char *root, bool bounded, BongoCatError *error) { - SDL_Mutex *mutex = scan_lock(); - if (mutex) SDL_LockMutex(mutex); + bongo_cat_import_model_scan_lock(); BongoCatResult result = import_root_unlocked(app, root, bounded, error); - if (mutex) SDL_UnlockMutex(mutex); + bongo_cat_import_model_scan_unlock(); return result; } diff --git a/src/runtime/model/import/nearby/model_import_nearby_internal.h b/src/runtime/model/import/nearby/model_import_nearby_internal.h index 00440154..fb308394 100644 --- a/src/runtime/model/import/nearby/model_import_nearby_internal.h +++ b/src/runtime/model/import/nearby/model_import_nearby_internal.h @@ -5,6 +5,10 @@ #define BONGO_CAT_NEARBY_CACHE_MARKER ".bongo-cat-cache.json" #define BONGO_CAT_NEARBY_CACHE_SCHEMA 4 +#define BONGO_CAT_ADAPTER_CACHE_DIRECTORY "model-adapters" + +void bongo_cat_import_model_scan_lock(void); +void bongo_cat_import_model_scan_unlock(void); bool bongo_cat_nearby_signature(const BongoCatImportCandidate *candidate, char output[65], BongoCatError *error); diff --git a/src/runtime/model/model_catalog_runtime.c b/src/runtime/model/model_catalog_runtime.c index 27cc51c8..b396fca5 100644 --- a/src/runtime/model/model_catalog_runtime.c +++ b/src/runtime/model/model_catalog_runtime.c @@ -33,6 +33,22 @@ static void restore_model_selection(BongoCatApp *app, bongo_cat_multi_pet_primary_update(app, SDL_GetTicksNS()); } +static size_t models_using_storage(const BongoCatModelCatalog *models, + const char *directory) { + size_t count = 0; + if (!models || !directory || !directory[0]) return 0; + for (size_t i = 0; i < models->count; ++i) + if (!strcmp(models->entries[i].storage_directory, directory)) count++; + return count; +} + +static bool storage_package_id(const char *directory, + char output[BONGO_CAT_ID_CAP]) { + const char *name = directory ? bongo_cat_path_name(directory) : NULL; + return name && name[0] && bongo_cat_import_package_id(output, + BONGO_CAT_ID_CAP, name); +} + static bool shortcut_model_exists(const BongoCatModelCatalog *models, const char *shortcut_id) { const char *separator = shortcut_id ? strchr(shortcut_id, ':') : NULL; @@ -78,6 +94,10 @@ static void scan_owned_models(BongoCatApp *app, bool cleanup) { SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", error.message); if (cleanup) bongo_cat_model_cleanup_imports(app->models_root, &error); bongo_cat_models_scan(&app->models, app->models_root, false, &error); + error = (BongoCatError){0}; + if (bongo_cat_import_installed_models(app, app->models_root, &error) != + BONGO_CAT_OK && error.message[0]) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", error.message); } void bongo_cat_model_catalog_finish(BongoCatApp *app) { @@ -161,6 +181,22 @@ BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, ModelSelection previous_selection = capture_model_selection(app); char directory[BONGO_CAT_PATH_CAP]; snprintf(directory, sizeof(directory), "%s", entry->storage_directory); + char package_id[BONGO_CAT_ID_CAP]; + if (!directory[0] || !storage_package_id(directory, package_id)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Model storage directory is invalid: %s", id); + return BONGO_CAT_ERROR_FORMAT; + } + bool shared_storage = models_using_storage(&app->models, directory) > 1; + bongo_cat_settings_validate(&app->settings); + bool already_removed = bongo_cat_settings_model_removed( + &app->settings, id); + if (shared_storage && !already_removed && + app->settings.removed_model_count >= BONGO_CAT_MODEL_CAP) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Too many removed model versions are recorded"); + return BONGO_CAT_ERROR_FORMAT; + } if (additional) { BongoCatError deactivate_error = {0}; if (!bongo_cat_app_set_model_active(app, id, false, @@ -188,7 +224,23 @@ BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, return BONGO_CAT_ERROR_CUBISM; } } - if (!bongo_cat_model_remove_tree(directory, error)) { + if (shared_storage && !already_removed && + !bongo_cat_settings_set_model_removed(&app->settings, id, true)) { + if (primary) { + BongoCatError restore_error = {0}; + if (!bongo_cat_app_select_model_with_error(app, id, + &restore_error)) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Unable to restore model after recording deletion failed: %s", + restore_error.message[0] ? restore_error.message : + "unknown error"); + } + if (additional) restore_model_selection(app, &previous_selection); + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Cannot record removed model version: %s", id); + return BONGO_CAT_ERROR_FORMAT; + } + if (!shared_storage && !bongo_cat_model_remove_tree(directory, error)) { bongo_cat_app_rescan_models(app); bool restore_selection = additional; if (primary && bongo_cat_models_find(&app->models, id)) { @@ -205,6 +257,8 @@ BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, restore_model_selection(app, &previous_selection); return BONGO_CAT_ERROR_IO; } + if (!shared_storage) + bongo_cat_settings_restore_model_package(&app->settings, package_id); bongo_cat_app_forget_behavior_state(app, id); bongo_cat_settings_set_model_label(&app->settings, id, ""); bongo_cat_app_rescan_models(app); diff --git a/src/runtime/model/model_files.c b/src/runtime/model/model_files.c index 09f50e13..3cb51908 100644 --- a/src/runtime/model/model_files.c +++ b/src/runtime/model/model_files.c @@ -43,8 +43,8 @@ static void model_runtime_stage(BongoCatApp *app, const char *stage, } static bool behavior_cacheable(const BongoCatModelEntry *entry) { - /* Installed packages are content-addressed and immutable. Nearby models - are managed by their source directory, so do not retain stale data. */ + /* Installed Mver trees keep a stable content digest. Nearby models are + managed by their source directory, so do not retain stale data. */ return entry && (entry->preset || (!entry->managed && entry->content_digest[0])); } diff --git a/src/ui/preferences/preferences_import.c b/src/ui/preferences/preferences_import.c index 6d2590ac..1620dca0 100644 --- a/src/ui/preferences/preferences_import.c +++ b/src/ui/preferences/preferences_import.c @@ -78,6 +78,15 @@ static BongoCatImportJob *copy_path(const char *path) { return path && path[0] ? copy_job(files) : NULL; } +static void remember_package(BongoCatImportJob *job, const char *id) { + if (!job || !id || !id[0]) return; + for (size_t i = 0; i < job->package_id_count; ++i) + if (!strcmp(job->package_ids[i], id)) return; + if (job->package_id_count >= BONGO_CAT_MODEL_CAP) return; + snprintf(job->package_ids[job->package_id_count++], BONGO_CAT_ID_CAP, + "%s", id); +} + static int SDLCALL import_worker(void *userdata) { BongoCatImportJob *job = userdata; if (!SDL_SetCurrentThreadPriority(SDL_THREAD_PRIORITY_LOW)) { @@ -96,6 +105,7 @@ static int SDLCALL import_worker(void *userdata) { BongoCatResult result = bongo_cat_import_session_install(session, job->paths[i], &receipt, &error); if (result == BONGO_CAT_OK) { + if (receipt.count) remember_package(job, receipt.ids[0]); for (size_t j = 0; j < receipt.count; ++j) { if (!job->first_id[0] || (receipt.installed[j] && !job->first_id_installed)) { @@ -232,18 +242,31 @@ static void complete_job(BongoCatImportDialog *dialog, BongoCatApp *app, } SDL_UnlockMutex(dialog->mutex); if (worker) SDL_WaitThread(worker, NULL); - if (job->result == BONGO_CAT_OK && job->first_id[0] && - job->first_id_installed) { + char restored_id[BONGO_CAT_ID_CAP] = {0}; + size_t restored_count = 0; + if (job->result == BONGO_CAT_OK) + for (size_t i = 0; i < job->package_id_count; ++i) + if (bongo_cat_settings_restore_model_package(&app->settings, + job->package_ids[i])) { + restored_count++; + if (!restored_id[0]) + snprintf(restored_id, sizeof(restored_id), "%s", + job->package_ids[i]); + } + bool catalog_changed = job->installed_count > 0 || restored_count > 0; + if (job->result == BONGO_CAT_OK && job->first_id[0] && catalog_changed) { SDL_GL_MakeCurrent(app->window, app->gl_context); bongo_cat_app_refresh_installed_models(app); - if (!bongo_cat_app_select_model(app, job->first_id)) { + const char *selected = job->first_id_installed || !restored_id[0] + ? job->first_id : restored_id; + if (!bongo_cat_app_select_model(app, selected)) { job->result = BONGO_CAT_ERROR_CUBISM; bongo_cat_error_set(&job->error, job->result, "Model imported but could not be loaded"); } } bongo_cat_preferences_import_complete(app, job->result, &job->error, - job->resolved_count, job->installed_count); + job->resolved_count, job->installed_count + restored_count); free_job(job); release_dialog(dialog); } diff --git a/src/ui/preferences/preferences_import_internal.h b/src/ui/preferences/preferences_import_internal.h index da3f7a0a..4d4408fa 100644 --- a/src/ui/preferences/preferences_import_internal.h +++ b/src/ui/preferences/preferences_import_internal.h @@ -9,6 +9,8 @@ typedef struct BongoCatImportJob { char models_root[BONGO_CAT_PATH_CAP]; char first_id[BONGO_CAT_ID_CAP]; bool first_id_installed; + char package_ids[BONGO_CAT_MODEL_CAP][BONGO_CAT_ID_CAP]; + size_t package_id_count; size_t resolved_count; size_t installed_count; BongoCatResult result; diff --git a/tests/core/test_config.c b/tests/core/test_config.c index 039544c8..2fd88564 100644 --- a/tests/core/test_config.c +++ b/tests/core/test_config.c @@ -62,6 +62,8 @@ void test_config(void) { memcpy(settings.behavior_shortcuts[0].label, "Happy tap", sizeof("Happy tap")); CHECK(bongo_cat_settings_set_model_label(&settings, "model", "Display")); + CHECK(bongo_cat_settings_set_model_removed(&settings, "model~2", true)); + CHECK(bongo_cat_settings_set_model_removed(&settings, "model-extra", true)); session.window.x = -321; session.window.position_known = true; session.window.opacity_percent = 75.0f; @@ -95,6 +97,8 @@ void test_config(void) { CHECK(contains_text(settings_path, "\"randomExpressionIntervalSeconds\": 12.0")); CHECK(contains_text(settings_path, "\"multiplePets\": true") && !contains_text(settings_path, "inputReleaseDelaySeconds")); + CHECK(contains_text(settings_path, "\"removedModels\"") && + contains_text(settings_path, "\"model~2\"")); CHECK(contains_text(settings_path, "\"example\"")); CHECK(!contains_text(settings_path, "activeModelId")); CHECK(contains_text(session_path, "\"format\": \"bongocat/session\"")); @@ -127,6 +131,11 @@ void test_config(void) { CHECK(strcmp(bongo_cat_model_name(&loaded_settings, &(BongoCatModelEntry){.id = "model", .display_name = "Imported"}), "Display") == 0); + CHECK(bongo_cat_settings_model_removed(&loaded_settings, "model~2") && + bongo_cat_settings_model_removed(&loaded_settings, "model-extra")); + CHECK(bongo_cat_settings_restore_model_package(&loaded_settings, "model")); + CHECK(!bongo_cat_settings_model_removed(&loaded_settings, "model~2") && + bongo_cat_settings_model_removed(&loaded_settings, "model-extra")); CHECK(loaded_session.window.x == -321 && loaded_session.window.opacity_percent == 75.0f && loaded_session.window.width == 700 && diff --git a/tests/core/test_models.c b/tests/core/test_models.c index f6e5a050..d4f225e5 100644 --- a/tests/core/test_models.c +++ b/tests/core/test_models.c @@ -153,22 +153,6 @@ void test_models(void) { CHECK(strstr(behaviors->entries[0].sound, "live2d_motion1.flac") != NULL); CHECK(behaviors->entries[6].kind == BONGO_CAT_BEHAVIOR_EXPRESSION); - bongo_cat_models_init(catalog); - CHECK(bongo_cat_models_scan(catalog, - BONGO_CAT_NATIVE_SOURCE_DIR "/tests/fixtures/model-packages", - false, &error) == BONGO_CAT_OK); - CHECK(catalog->count == 1); - const BongoCatModelEntry *v2 = bongo_cat_models_find(catalog, - "test-package-v2"); - CHECK(v2 && v2->package_schema == 2 && - strcmp(v2->content_digest, - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") == 0 && - strcmp(v2->family_id, "test-family") == 0 && - strcmp(v2->display_name, "Test Package v2") == 0 && - v2->mode == BONGO_CAT_MODE_KEYBOARD && - v2->source_format == BONGO_CAT_MODEL_SOURCE_TAURI && - v2->adapter_schema == 1 && v2->adapter_generator == 1 && - (v2->capabilities & BONGO_CAT_MODEL_CAPABILITY_KEYBOARD_INPUT)); free(behaviors); free(catalog); } diff --git a/tests/fixtures/model-packages/v2/.bongo-cat-package.json b/tests/fixtures/model-packages/v2/.bongo-cat-package.json deleted file mode 100644 index baeb4f9a..00000000 --- a/tests/fixtures/model-packages/v2/.bongo-cat-package.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "schemaVersion": 2, - "packageId": "test-package-v2", - "contentDigest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "familyId": "test-family", - "displayName": "Test Package v2", - "mode": "keyboard", - "source": { - "format": "tauri-live2d", - "name": "Fixture", - "layout": "single-model", - "preserved": true - }, - "model": { - "directory": "payload", - "setting": "dummy.model3.json" - }, - "runtime": { - "adapter": "adapter", - "metadata": "adapter/.bongo-cat-adapter.json", - "adapterSchemaVersion": 1, - "generatorVersion": 1 - }, - "capabilities": [ - "live2d", - "preview", - "runtime-adapter", - "keyboard-input", - "future-capability" - ], - "extensions": {} -} diff --git a/tests/fixtures/model-packages/v2/adapter/.bongo-cat-adapter.json b/tests/fixtures/model-packages/v2/adapter/.bongo-cat-adapter.json deleted file mode 100644 index 0687d338..00000000 --- a/tests/fixtures/model-packages/v2/adapter/.bongo-cat-adapter.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "schemaVersion": 1, - "kind": "bongo-cat-runtime-adapter", - "sourceFormat": "tauri-live2d", - "render": { "profile": "native" }, - "bindings": [] -} diff --git a/tests/fixtures/model-packages/v2/payload/dummy.model3.json b/tests/fixtures/model-packages/v2/payload/dummy.model3.json deleted file mode 100644 index 0967ef42..00000000 --- a/tests/fixtures/model-packages/v2/payload/dummy.model3.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/tests/model_import/test_model_import_identity.c b/tests/model_import/test_model_import_identity.c index 346026fe..3a630eb8 100644 --- a/tests/model_import/test_model_import_identity.c +++ b/tests/model_import/test_model_import_identity.c @@ -2,6 +2,7 @@ #include "model_import.h" #include "model_storage.h" +#include "runtime.h" #include "bongo_cat/path.h" #include @@ -31,6 +32,69 @@ static void disabled_mver_live2d_is_not_imported(const char *temporary) { CHECK(bongo_cat_model_remove_tree(root, NULL)); } +static bool ends_with(const char *value, const char *suffix) { + size_t value_length = strlen(value), suffix_length = strlen(suffix); + return value_length >= suffix_length && + strcmp(value + value_length - suffix_length, suffix) == 0; +} + +static void package_identity_rules(const BongoCatImportDiscovery *fixture) { + static const char unicode_name[] = + "\xE9\x9C\xB2\xE8\xA5\xBF\xE4\xBA\x9A-" + "\xE8\xAA\x93\xE7\x84\xB0\xE7\x89\x88"; + BongoCatImportDiscovery discovery = *fixture; + BongoCatPackageMetadata metadata[BONGO_CAT_IMPORT_CANDIDATE_CAP] = {0}; + BongoCatError error = {0}; + + snprintf(discovery.source_name, sizeof(discovery.source_name), "%s", + unicode_name); + CHECK(bongo_cat_import_prepare_package_metadata(&discovery, metadata, + &error)); + CHECK(discovery.count == 1 && + strcmp(metadata[0].package_id, unicode_name) == 0); + + discovery = *fixture; + memset(metadata, 0, sizeof(metadata)); + snprintf(discovery.source_name, sizeof(discovery.source_name), + "bad:name. "); + CHECK(bongo_cat_import_prepare_package_metadata(&discovery, metadata, + &error)); + CHECK(strcmp(metadata[0].package_id, "bad-name") == 0 && + strcmp(metadata[0].display_name, "bad:name. ") == 0); + + discovery = *fixture; + memset(metadata, 0, sizeof(metadata)); + snprintf(discovery.source_name, sizeof(discovery.source_name), "CON.txt"); + CHECK(bongo_cat_import_prepare_package_metadata(&discovery, metadata, + &error)); + CHECK(strcmp(metadata[0].package_id, "_CON.txt") == 0); + + discovery = *fixture; + discovery.count = 2; + discovery.candidates[1] = discovery.candidates[0]; + discovery.candidates[1].mode = BONGO_CAT_MODE_GAMEPAD; + discovery.candidates[1].gamepad_buttons = true; + memset(discovery.source_name, 'x', sizeof(discovery.source_name) - 1); + discovery.source_name[sizeof(discovery.source_name) - 1] = '\0'; + memset(metadata, 0, sizeof(metadata)); + CHECK(bongo_cat_import_prepare_package_metadata(&discovery, metadata, + &error)); + CHECK(discovery.count == 2 && + strlen(metadata[0].package_id) < BONGO_CAT_ID_CAP && + strlen(metadata[1].package_id) < BONGO_CAT_ID_CAP && + ends_with(metadata[1].package_id, "~2")); + + discovery = *fixture; + discovery.count = 2; + discovery.candidates[1] = discovery.candidates[0]; + snprintf(discovery.source_name, sizeof(discovery.source_name), "duplicate"); + memset(metadata, 0, sizeof(metadata)); + CHECK(bongo_cat_import_prepare_package_metadata(&discovery, metadata, + &error)); + CHECK(discovery.count == 1 && strcmp(metadata[0].package_id, + "duplicate") == 0); +} + int test_model_import_identity(void) { failures = 0; char *temporary = SDL_GetCurrentDirectory(); @@ -62,13 +126,17 @@ int test_model_import_identity(void) { bool placeholder = true; CHECK(bongo_cat_import_candidate_inspect(&discovered.candidates[0], digest, &placeholder, &error) && !placeholder); + package_identity_rules(&discovered); BongoCatImportReceipt first = {0}, repeated = {0}, wrapped = {0}; + BongoCatImportReceipt changed = {0}; BongoCatImportSession *session = bongo_cat_import_session_create(models_root, &error); CHECK(session != NULL); CHECK(session && bongo_cat_import_session_install(session, config, &first, &error) == BONGO_CAT_OK); CHECK(first.count == 1 && first.installed_count == 1 && first.installed[0]); + CHECK(strcmp(first.ids[0], "source") == 0); + CHECK(strncmp(first.ids[0], "model-", 6) != 0); CHECK(session && bongo_cat_import_session_install(session, missing, NULL, &error) != BONGO_CAT_OK); error = (BongoCatError){0}; @@ -81,22 +149,53 @@ int test_model_import_identity(void) { BONGO_CAT_OK); CHECK(wrapped.count == 1 && wrapped.installed_count == 0 && strcmp(first.ids[0], wrapped.ids[0]) == 0); + char extra[BONGO_CAT_PATH_CAP]; + CHECK(child(extra, sizeof(extra), source, + "img/standard/cat_model/changed.txt", false)); + CHECK(write_text(extra, "changed")); + CHECK(bongo_cat_import_install(source, models_root, &changed, &error) == + BONGO_CAT_OK); + CHECK(changed.count == 1 && changed.installed_count == 1 && + changed.installed[0] && strcmp(changed.ids[0], "source-2") == 0); char adapter_metadata[BONGO_CAT_PATH_CAP]; - BongoCatModelCatalog *installed = calloc(1, sizeof(*installed)); - CHECK(installed != NULL); - if (installed) { - CHECK(bongo_cat_models_scan(installed, models_root, false, &error) == + BongoCatApp *app = calloc(1, sizeof(*app)); + CHECK(app != NULL); + if (app) { + snprintf(app->models_root, sizeof(app->models_root), "%s", models_root); + CHECK(child(app->cache_root, sizeof(app->cache_root), data, "cache", + true)); + bongo_cat_models_init(&app->models); + CHECK(bongo_cat_import_installed_models(app, models_root, &error) == BONGO_CAT_OK); - CHECK(installed->count == 1 && installed->entries[0].package_schema == 2 && - installed->entries[0].source_format == BONGO_CAT_MODEL_SOURCE_MVER && - installed->entries[0].content_digest[0] != '\0' && - (installed->entries[0].capabilities & + const BongoCatModelEntry *original = bongo_cat_models_find(&app->models, + "source"); + const BongoCatModelEntry *modified = bongo_cat_models_find(&app->models, + "source-2"); + CHECK(app->models.count == 2 && original && modified && + original->source_format == BONGO_CAT_MODEL_SOURCE_MVER && + original->content_digest[0] != '\0' && + strcmp(original->content_digest, modified->content_digest) != 0 && + (original->capabilities & BONGO_CAT_MODEL_CAPABILITY_MVER_PROJECTION)); - CHECK(child(adapter_metadata, sizeof(adapter_metadata), - installed->entries[0].adapter_directory, - ".bongo-cat-adapter.json", false)); - CHECK(bongo_cat_path_is_file(adapter_metadata)); - free(installed); + if (original) { + CHECK(child(adapter_metadata, sizeof(adapter_metadata), + original->adapter_directory, + ".bongo-cat-adapter.json", false)); + CHECK(bongo_cat_path_is_file(adapter_metadata)); + char imported_config[BONGO_CAT_PATH_CAP]; + CHECK(child(imported_config, sizeof(imported_config), + original->storage_directory, "config.json", false)); + CHECK(bongo_cat_path_is_file(imported_config)); + char internal[BONGO_CAT_PATH_CAP]; + CHECK(child(internal, sizeof(internal), + original->storage_directory, ".bongo-cat-adapter", false)); + CHECK(!bongo_cat_path_is_dir(internal)); + CHECK(child(internal, sizeof(internal), + original->storage_directory, ".bongo-cat-package.json", + false)); + CHECK(!bongo_cat_path_is_file(internal)); + } + free(app); } CHECK(bongo_cat_model_remove_tree(root, NULL)); CHECK(bongo_cat_model_remove_tree(data, NULL)); diff --git a/tests/model_import/test_mver_container.c b/tests/model_import/test_mver_container.c index 26fc798a..d6d10b1c 100644 --- a/tests/model_import/test_mver_container.c +++ b/tests/model_import/test_mver_container.c @@ -2,14 +2,17 @@ #include "test_mver_support.h" #include "model_import_mver_internal.h" #include "model_storage.h" +#include "runtime.h" #include "bongo_cat/path.h" #include #include +#include #include void test_mver_container_discovery(void) { char root[BONGO_CAT_PATH_CAP], package[BONGO_CAT_PATH_CAP]; + char backup[BONGO_CAT_PATH_CAP]; char mode[BONGO_CAT_PATH_CAP]; char bundle[BONGO_CAT_PATH_CAP], nested_package[BONGO_CAT_PATH_CAP]; char variant[BONGO_CAT_PATH_CAP], variant_model[BONGO_CAT_PATH_CAP]; @@ -25,6 +28,9 @@ void test_mver_container_discovery(void) { CHECK(SDL_CreateDirectory(root)); CHECK(child(package, sizeof(package), root, "app", true)); CHECK(mver_fixture(package)); + CHECK(child(backup, sizeof(backup), package, + "config.json.before_console_bak", false)); + CHECK(write_text(backup, "backup")); CHECK(child(bundle, sizeof(bundle), root, "bundle", true)); CHECK(child(nested_package, sizeof(nested_package), bundle, "model", true)); CHECK(mver_fixture(nested_package)); @@ -45,6 +51,9 @@ void test_mver_container_discovery(void) { "img/standard/hand/0.png", false)); CHECK(child(mode, sizeof(mode), variant_hand, "0.png", false) && SDL_CopyFile(source_hand, mode)); + CHECK(child(backup, sizeof(backup), variant_standard, + "override_bak", false)); + CHECK(write_text(backup, "backup")); char normalized[BONGO_CAT_PATH_CAP]; BongoCatError source_error = {0}; CHECK(bongo_cat_import_source_directory(mode, normalized, @@ -62,10 +71,101 @@ void test_mver_container_discovery(void) { CHECK(bongo_cat_import_discover(root, &selected_container, &error)); CHECK(selected_container.count == 3); size_t patch_count = 0; + const BongoCatImportCandidate *contained_patch = NULL; for (size_t i = 0; i < selected_container.count; ++i) if (selected_container.candidates[i].format == - BONGO_CAT_IMPORT_MVER_PATCH) patch_count++; + BONGO_CAT_IMPORT_MVER_PATCH) { + patch_count++; + contained_patch = &selected_container.candidates[i]; + } CHECK(patch_count == 1); + char installed_root[BONGO_CAT_PATH_CAP]; + snprintf(installed_root, sizeof(installed_root), + "%s/bongo-cat-container-installed-%llu", temporary, stamp); + BongoCatImportCandidate installed = {0}; + CHECK(contained_patch && bongo_cat_import_prepare_package(contained_patch, + installed_root, &installed, &error)); + CHECK(strncmp(installed.overrides, installed_root, + strlen(installed_root)) == 0); + CHECK(child(mode, sizeof(mode), installed.overrides, "hand/0.png", false) && + bongo_cat_path_is_file(mode)); + CHECK(child(backup, sizeof(backup), installed_root, + "app/config.json.before_console_bak", false) && + !bongo_cat_path_is_file(backup)); + + BongoCatImportDiscovery external_patch = {0}; + CHECK(bongo_cat_import_mver_patch_discover(variant, &external_patch, + &error) == 1); + char external_root[BONGO_CAT_PATH_CAP]; + snprintf(external_root, sizeof(external_root), + "%s/bongo-cat-container-external-%llu", temporary, stamp); + installed = (BongoCatImportCandidate){0}; + CHECK(external_patch.count == 1 && + bongo_cat_import_prepare_package(&external_patch.candidates[0], + external_root, &installed, &error)); + CHECK(strncmp(installed.overrides, external_root, + strlen(external_root)) == 0); + CHECK(child(mode, sizeof(mode), installed.overrides, "hand/0.png", false) && + bongo_cat_path_is_file(mode)); + CHECK(child(backup, sizeof(backup), installed.overrides, + "override_bak", false) && !bongo_cat_path_is_file(backup)); + + char models_root[BONGO_CAT_PATH_CAP], cache_root[BONGO_CAT_PATH_CAP]; + snprintf(models_root, sizeof(models_root), + "%s/bongo-cat-container-models-%llu", temporary, stamp); + snprintf(cache_root, sizeof(cache_root), + "%s/bongo-cat-container-cache-%llu", temporary, stamp); + CHECK(SDL_CreateDirectory(models_root)); + CHECK(SDL_CreateDirectory(cache_root)); + BongoCatImportReceipt receipt = {0}; + CHECK(bongo_cat_import_install(root, models_root, &receipt, &error) == + BONGO_CAT_OK); + CHECK(receipt.count > 1 && receipt.installed_count == receipt.count); + char stored[BONGO_CAT_PATH_CAP], duplicate_directory[BONGO_CAT_PATH_CAP]; + CHECK(child(stored, sizeof(stored), models_root, receipt.ids[0], false) && + bongo_cat_path_is_dir(stored)); + CHECK(child(duplicate_directory, sizeof(duplicate_directory), models_root, + receipt.ids[1], false) && !bongo_cat_path_is_dir(duplicate_directory)); + CHECK(child(backup, sizeof(backup), stored, + ".bongo-cat-package.json", false) && !bongo_cat_path_is_file(backup)); + CHECK(child(backup, sizeof(backup), stored, + ".bongo-cat-adapter", false) && !bongo_cat_path_is_dir(backup)); + CHECK(child(backup, sizeof(backup), stored, + BONGO_CAT_MODEL_ADAPTER_FILE, false) && + !bongo_cat_path_is_file(backup)); + BongoCatApp *app = calloc(1, sizeof(*app)); + CHECK(app != NULL); + if (app) { + snprintf(app->models_root, sizeof(app->models_root), "%s", models_root); + snprintf(app->cache_root, sizeof(app->cache_root), "%s", cache_root); + bongo_cat_models_init(&app->models); + CHECK(bongo_cat_import_installed_models(app, models_root, &error) == + BONGO_CAT_OK); + CHECK(app->models.count == receipt.count); + for (size_t i = 0; i < app->models.count; ++i) + CHECK(strcmp(app->models.entries[i].storage_directory, stored) == 0); + CHECK(bongo_cat_settings_set_model_removed(&app->settings, + receipt.ids[0], true)); + bongo_cat_models_init(&app->models); + CHECK(bongo_cat_import_installed_models(app, models_root, &error) == + BONGO_CAT_OK); + CHECK(app->models.count + 1 == receipt.count && + !bongo_cat_models_find(&app->models, receipt.ids[0]) && + bongo_cat_models_find(&app->models, receipt.ids[1]) && + bongo_cat_path_is_dir(stored)); + CHECK(bongo_cat_settings_restore_model_package(&app->settings, + receipt.ids[0])); + bongo_cat_models_init(&app->models); + CHECK(bongo_cat_import_installed_models(app, models_root, &error) == + BONGO_CAT_OK); + CHECK(app->models.count == receipt.count && + bongo_cat_models_find(&app->models, receipt.ids[0])); + free(app); + } + CHECK(bongo_cat_model_remove_tree(models_root, NULL)); + CHECK(bongo_cat_model_remove_tree(cache_root, NULL)); + CHECK(bongo_cat_model_remove_tree(installed_root, NULL)); + CHECK(bongo_cat_model_remove_tree(external_root, NULL)); CHECK(bongo_cat_model_remove_tree(root, NULL)); SDL_free(temporary); } diff --git a/tests/model_import/test_mver_import.c b/tests/model_import/test_mver_import.c index 5f54481f..65889a59 100644 --- a/tests/model_import/test_mver_import.c +++ b/tests/model_import/test_mver_import.c @@ -6,6 +6,7 @@ #include "runtime.h" #include "test_mver_support.h" #include "test_mver_import_internal.h" +#include "bongo_cat/json.h" #include "bongo_cat/path.h" #include #include @@ -193,22 +194,96 @@ static void tauri_exact_discovery(void) { snprintf(root, sizeof(root), "%s/bongo-cat-tauri-%llu", temporary, (unsigned long long)SDL_GetTicksNS()); CHECK(SDL_CreateDirectory(root)); - CHECK(child(model, sizeof(model), root, "model", true)); + CHECK(child(model, sizeof(model), root, "keyboard", true)); CHECK(child(path, sizeof(path), model, "cat.model3.json", false)); CHECK(write_text(path, "{\"Version\":3,\"FileReferences\":{" - "\"Moc\":\"cat.moc3\",\"Textures\":[\"texture.png\"]}}")); + "\"Moc\":\"cat.moc3\",\"Textures\":[" + "\"resources/model-texture.png\"]}}")); CHECK(child(path, sizeof(path), model, "cat.moc3", false)); CHECK(write_text(path, "MOC3")); - CHECK(child(path, sizeof(path), model, "texture.png", false)); char tray[BONGO_CAT_PATH_CAP]; snprintf(tray, sizeof(tray), "%s/resources/assets/tray.png", BONGO_CAT_NATIVE_SOURCE_DIR); - CHECK(SDL_CopyFile(tray, path)); + char resources[BONGO_CAT_PATH_CAP], right_keys[BONGO_CAT_PATH_CAP]; + char east[BONGO_CAT_PATH_CAP], model_texture[BONGO_CAT_PATH_CAP]; + CHECK(child(resources, sizeof(resources), model, "resources", true)); + CHECK(child(model_texture, sizeof(model_texture), resources, + "model-texture.png", false)); + CHECK(SDL_CopyFile(tray, model_texture)); + CHECK(child(right_keys, sizeof(right_keys), resources, "right-keys", true)); + CHECK(child(east, sizeof(east), right_keys, "East.png", false)); + CHECK(SDL_CopyFile(tray, east)); + char nested[BONGO_CAT_PATH_CAP], nested_resources[BONGO_CAT_PATH_CAP]; + CHECK(child(nested, sizeof(nested), model, "nested", true)); + CHECK(child(nested_resources, sizeof(nested_resources), nested, + "resources", true)); + CHECK(child(path, sizeof(path), nested_resources, "keep.bin", false)); + CHECK(write_text(path, "keep")); BongoCatImportDiscovery discovery = {0}; BongoCatError error = {0}; CHECK(bongo_cat_import_tauri_discover_exact(model, &discovery, &error) == 1); CHECK(discovery.count == 1 && - discovery.candidates[0].format == BONGO_CAT_IMPORT_TAURI); + discovery.candidates[0].format == BONGO_CAT_IMPORT_TAURI && + discovery.candidates[0].mode == BONGO_CAT_MODE_GAMEPAD && + discovery.candidates[0].gamepad_buttons); + CHECK(bongo_cat_path_remove(east)); + CHECK(child(path, sizeof(path), right_keys, "KeyA.png", false)); + CHECK(SDL_CopyFile(tray, path)); + BongoCatImportDiscovery keyboard = {0}; + CHECK(bongo_cat_import_tauri_discover_exact(model, &keyboard, &error) == 1); + CHECK(keyboard.count == 1 && + keyboard.candidates[0].mode == BONGO_CAT_MODE_KEYBOARD && + !keyboard.candidates[0].gamepad_buttons); + char models_root[BONGO_CAT_PATH_CAP]; + CHECK(child(models_root, sizeof(models_root), root, "models", true)); + BongoCatImportReceipt receipt = {0}; + CHECK(bongo_cat_import_install(model, models_root, &receipt, &error) == + BONGO_CAT_OK); + CHECK(receipt.count == 1 && receipt.installed_count == 1 && + strcmp(receipt.ids[0], "keyboard") == 0); + char package[BONGO_CAT_PATH_CAP]; + CHECK(child(package, sizeof(package), models_root, receipt.ids[0], false)); + CHECK(child(path, sizeof(path), package, "config.json", false) && + bongo_cat_path_is_file(path)); + yyjson_doc *normalized = bongo_cat_json_read_file(path, 0, NULL); + yyjson_val *normalized_root = normalized + ? yyjson_doc_get_root(normalized) : NULL; + yyjson_val *normalized_mode = yyjson_obj_get(normalized_root, "keyboard"); + CHECK(yyjson_get_int(yyjson_obj_get(normalized_root, "mode")) == 2 && + yyjson_is_arr(yyjson_obj_get(normalized_mode, "lefthand")) && + yyjson_is_arr(yyjson_obj_get(normalized_mode, "righthand"))); + yyjson_doc_free(normalized); + CHECK(child(path, sizeof(path), package, + "img/keyboard/cat_model/cat.model3.json", false) && + bongo_cat_path_is_file(path)); + CHECK(child(path, sizeof(path), package, + "img/keyboard/cat_model/resources/model-texture.png", false) && + bongo_cat_path_is_file(path)); + CHECK(child(path, sizeof(path), package, + "img/keyboard/cat_model/resources/right-keys", false) && + !bongo_cat_path_is_dir(path)); + CHECK(child(path, sizeof(path), package, + "img/keyboard/cat_model/nested/resources/keep.bin", false) && + bongo_cat_path_is_file(path)); + CHECK(child(path, sizeof(path), package, ".bongo-cat-adapter", false) && + !bongo_cat_path_is_dir(path)); + CHECK(child(path, sizeof(path), package, ".bongo-cat-package.json", false) && + !bongo_cat_path_is_file(path)); + BongoCatApp *app = calloc(1, sizeof(*app)); + CHECK(app != NULL); + if (app) { + snprintf(app->models_root, sizeof(app->models_root), "%s", models_root); + CHECK(child(app->cache_root, sizeof(app->cache_root), root, "cache", + true)); + bongo_cat_models_init(&app->models); + CHECK(bongo_cat_import_installed_models(app, models_root, &error) == + BONGO_CAT_OK); + CHECK(app->models.count == 1 && + app->models.entries[0].source_format == BONGO_CAT_MODEL_SOURCE_MVER && + strcmp(app->models.entries[0].storage_directory, package) == 0 && + strcmp(app->models.entries[0].adapter_directory, package) != 0); + free(app); + } CHECK(bongo_cat_model_remove_tree(root, NULL)); SDL_free(temporary); } diff --git a/tests/model_import/test_slim_package.c b/tests/model_import/test_slim_package.c index 052327ce..6161d22a 100644 --- a/tests/model_import/test_slim_package.c +++ b/tests/model_import/test_slim_package.c @@ -50,7 +50,7 @@ int test_slim_package(void) { CHECK_SLIM(bongo_cat_path_join(package, sizeof(package), models, receipt.ids[0])); CHECK_SLIM(child(installed, sizeof(installed), package, - "payload/" BONGO_CAT_SKIN_CONFIG_FILE, false)); + BONGO_CAT_SKIN_CONFIG_FILE, false)); CHECK_SLIM(bongo_cat_path_is_file(installed)); CHECK_SLIM(bongo_cat_model_remove_tree(source, NULL)); CHECK_SLIM(bongo_cat_model_remove_tree(data, NULL)); From dd6e7c9b8315d98938b818e2696daabe038b47c9 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 26 Aug 2026 19:08:36 +0800 Subject: [PATCH 109/334] fix: complete portable Mver model assets --- src/platform/windows/windows_keys.c | 4 +- .../model/import/model_import_tauri_mver.c | 96 ++++++++++++++++- .../model/import/mver/model_import_mver.c | 7 ++ .../import/mver/model_import_mver_metadata.c | 13 ++- tests/model_import/test_mver_import.c | 100 +++++++++++++++++- 5 files changed, 208 insertions(+), 12 deletions(-) diff --git a/src/platform/windows/windows_keys.c b/src/platform/windows/windows_keys.c index 41fbbf53..bdc6dd9b 100644 --- a/src/platform/windows/windows_keys.c +++ b/src/platform/windows/windows_keys.c @@ -77,7 +77,9 @@ const char *bongo_cat_windows_key_name(const KBDLLHOOKSTRUCT *key, char output[1 case VK_OEM_COMMA: return "Comma"; case VK_OEM_PERIOD: return "Period"; case VK_OEM_2: return "Slash"; - default: return NULL; + default: + snprintf(output, 16, "%lu", code); + return output; } } diff --git a/src/runtime/model/import/model_import_tauri_mver.c b/src/runtime/model/import/model_import_tauri_mver.c index 16ef60bd..0e7fb156 100644 --- a/src/runtime/model/import/model_import_tauri_mver.c +++ b/src/runtime/model/import/model_import_tauri_mver.c @@ -1,5 +1,6 @@ #include "model_import.h" #include "bongo_cat/file.h" +#include "bongo_cat/image.h" #include "bongo_cat/json.h" #include "bongo_cat/path.h" @@ -34,6 +35,10 @@ static int key_code(const char *filename) { char *dot = strrchr(name, '.'); if (!dot || SDL_strcasecmp(dot, ".png") != 0) return -1; *dot = '\0'; + char *end = NULL; + long numeric = strtol(name, &end, 10); + if (name[0] && end && !end[0] && numeric >= 0 && numeric <= 255) + return (int)numeric; if (strlen(name) == 4 && SDL_strncasecmp(name, "Key", 3) == 0 && name[3] >= 'A' && name[3] <= 'Z') return name[3]; if (strlen(name) == 4 && SDL_strncasecmp(name, "Num", 3) == 0 && @@ -140,6 +145,76 @@ static bool copy_image_or_placeholder(const char *source, const char *target, return ok; } +static bool copy_mver_image(const BongoCatImportCandidate *candidate, + const char *mode_root, const char *source_name, const char *target_name, + const char *fallback, BongoCatError *error) { + char source[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; + const char *selected = fallback; + if (source_name && resource_file(candidate, source_name, source, + sizeof(source))) selected = source; + if (!bongo_cat_path_join(target, sizeof(target), mode_root, target_name)) + return false; + return copy_image_or_placeholder(selected, target, error); +} + +static bool copy_mver_runtime_images( + const BongoCatImportCandidate *candidate, const char *mode_root, + BongoCatError *error) { + char background[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(background, sizeof(background), mode_root, + candidate->mode == BONGO_CAT_MODE_STANDARD ? "mousebg.png" : + "bg.png")) return false; + /* Tauri omits these Mver-only layers. Preserve real files when supplied; + otherwise use transparent PNGs so the portable package still loads. */ + if (candidate->mode == BONGO_CAT_MODE_STANDARD) { + static const char *const backgrounds[] = { + "tabletbg.png", "l2dmousebg.png", "l2dtabletbg.png" + }; + static const char *const layers[] = { + "arm.png", "up.png", "mouse.png", "mouse_left.png", + "mouse_right.png", "mouse_side.png", "tablet.png", + "tablet_left.png", "tablet_right.png" + }; + for (size_t i = 0; i < sizeof(backgrounds) / sizeof(backgrounds[0]); + ++i) + if (!copy_mver_image(candidate, mode_root, backgrounds[i], + backgrounds[i], background, error)) return false; + for (size_t i = 0; i < sizeof(layers) / sizeof(layers[0]); ++i) + if (!copy_mver_image(candidate, mode_root, layers[i], layers[i], + NULL, error)) return false; + return true; + } + static const char *const idle_hands[] = { + "lefthand/leftup.png", "righthand/rightup.png" + }; + for (size_t i = 0; i < sizeof(idle_hands) / sizeof(idle_hands[0]); ++i) + if (!copy_mver_image(candidate, mode_root, idle_hands[i], + idle_hands[i], NULL, error)) return false; + if (candidate->mode != BONGO_CAT_MODE_GAMEPAD) return true; + static const char *const gamepad[] = { + "arm_L.png", "arm_R.png", "left_stick.png", "left_stick_down.png", + "right_stick.png", "right_stick_down.png" + }; + for (size_t i = 0; i < sizeof(gamepad) / sizeof(gamepad[0]); ++i) + if (!copy_mver_image(candidate, mode_root, gamepad[i], gamepad[i], + NULL, error)) return false; + return true; +} + +static void mver_window_size(const BongoCatImportCandidate *candidate, + int *width, int *height) { + *width = 612; + *height = 352; + char source[BONGO_CAT_PATH_CAP]; + if ((resource_file(candidate, "background.png", source, + sizeof(source)) || + resource_file(candidate, "cover.png", source, sizeof(source))) && + !bongo_cat_image_info(source, width, height)) { + *width = 612; + *height = 352; + } +} + typedef struct TauriModelCopy { const char *target_root; BongoCatError *error; @@ -251,7 +326,7 @@ static bool add_matrix(yyjson_mut_doc *document, yyjson_mut_val *mode, static bool config_write(const char *path, BongoCatModelMode mode, const TauriKeyFiles *left, const TauriKeyFiles *right, - BongoCatError *error) { + int width, int height, BongoCatError *error) { yyjson_mut_doc *document = yyjson_mut_doc_new(NULL); yyjson_mut_val *root = document ? yyjson_mut_obj(document) : NULL; if (document) yyjson_mut_doc_set_root(document, root); @@ -259,7 +334,11 @@ static bool config_write(const char *path, BongoCatModelMode mode, "decoration") : NULL; yyjson_mut_val *mode_object = root ? yyjson_mut_obj_add_obj(document, root, bongo_cat_mode_name(mode)) : NULL; - bool ok = root && decoration && mode_object && + yyjson_mut_val *window_size = decoration ? yyjson_mut_obj_add_arr(document, + decoration, "window_size") : NULL; + bool ok = root && decoration && mode_object && window_size && + yyjson_mut_arr_add_int(document, window_size, width) && + yyjson_mut_arr_add_int(document, window_size, height) && yyjson_mut_obj_add_int(document, root, "mode", mode == BONGO_CAT_MODE_STANDARD ? 1 : mode == BONGO_CAT_MODE_KEYBOARD ? 2 : 3) && @@ -303,7 +382,10 @@ static bool copy_preview(const BongoCatImportCandidate *candidate, snprintf(source, sizeof(source), "%s", cat); have_background = true; } - if (!bongo_cat_path_join(target, sizeof(target), mode_root, "bg.png")) + const char *background_name = candidate->mode == BONGO_CAT_MODE_STANDARD + ? "mousebg.png" : "bg.png"; + if (!bongo_cat_path_join(target, sizeof(target), mode_root, + background_name)) return false; return copy_image_or_placeholder(have_background ? source : NULL, target, error); @@ -397,10 +479,14 @@ bool bongo_cat_import_tauri_convert_to_mver( right.values[0].code = candidate->mode == BONGO_CAT_MODE_GAMEPAD ? 1 : 65; right.count = 1; } + int width, height; + mver_window_size(candidate, &width, &height); char config[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(config, sizeof(config), target, "config.json") || - !config_write(config, candidate->mode, &left, &right, error) || - !copy_preview(candidate, mode_root, error)) return false; + !config_write(config, candidate->mode, &left, &right, width, height, + error) || + !copy_preview(candidate, mode_root, error) || + !copy_mver_runtime_images(candidate, mode_root, error)) return false; *installed = *candidate; snprintf(installed->directory, sizeof(installed->directory), "%s", model_root); snprintf(installed->assets, sizeof(installed->assets), "%s", mode_root); diff --git a/src/runtime/model/import/mver/model_import_mver.c b/src/runtime/model/import/mver/model_import_mver.c index 47f3b11a..d98a4b85 100644 --- a/src/runtime/model/import/mver/model_import_mver.c +++ b/src/runtime/model/import/mver/model_import_mver.c @@ -50,6 +50,9 @@ static bool copy_standard_pointer_assets(const BongoCatImportCandidate *candidat for (size_t index = 0; index < sizeof(names) / sizeof(names[0]); ++index) { char source[BONGO_CAT_PATH_CAP], destination[BONGO_CAT_PATH_CAP]; if (!root_asset_file(candidate, names[index], source, sizeof(source))) continue; + int width = 0, height = 0; + if (!bongo_cat_image_info(source, &width, &height) || + (width <= 1 && height <= 1)) continue; if (!bongo_cat_path_join(destination, sizeof(destination), output, names[index]) || !bongo_cat_path_copy_file(source, destination)) return false; } @@ -112,6 +115,10 @@ KeyNames bongo_cat_mver_device_names(int code, size_t occurrence, size_t total) }; for (size_t i = 0; i < sizeof(map) / sizeof(map[0]); ++i) if (map[i].code == code) { names.items[0] = map[i].name; names.count = 1; break; } + if (!names.count && code > 0 && code <= 255) { + snprintf(names.generated, sizeof(names.generated), "%d", code); + names.count = 1; + } return names; } names.count = 1; diff --git a/src/runtime/model/import/mver/model_import_mver_metadata.c b/src/runtime/model/import/mver/model_import_mver_metadata.c index a80b142f..1c0b2e12 100644 --- a/src/runtime/model/import/mver/model_import_mver_metadata.c +++ b/src/runtime/model/import/mver/model_import_mver_metadata.c @@ -2,6 +2,7 @@ #include "model_import_mver_internal.h" #include "runtime.h" #include "bongo_cat/file.h" +#include "bongo_cat/image.h" #include "bongo_cat/json.h" #include "bongo_cat/path.h" @@ -19,13 +20,21 @@ static double number_or(yyjson_val *value, double fallback) { return yyjson_is_num(value) ? yyjson_get_num(value) : fallback; } +static bool usable_pointer_image(const char *path) { + int width = 0, height = 0; + return bongo_cat_path_is_file(path) && + bongo_cat_image_info(path, &width, &height) && + (width > 1 || height > 1); +} + static bool pointer_asset(const BongoCatImportCandidate *candidate, const char *name) { char path[BONGO_CAT_PATH_CAP]; if (candidate->overrides[0] && bongo_cat_path_join(path, sizeof(path), - candidate->overrides, name) && bongo_cat_path_is_file(path)) return true; + candidate->overrides, name) && bongo_cat_path_is_file(path)) + return usable_pointer_image(path); return bongo_cat_path_join(path, sizeof(path), candidate->assets, name) && - bongo_cat_path_is_file(path); + usable_pointer_image(path); } static bool add_standard_pointer(yyjson_mut_doc *output, yyjson_mut_val *root, diff --git a/tests/model_import/test_mver_import.c b/tests/model_import/test_mver_import.c index 65889a59..18d145ed 100644 --- a/tests/model_import/test_mver_import.c +++ b/tests/model_import/test_mver_import.c @@ -6,6 +6,7 @@ #include "runtime.h" #include "test_mver_support.h" #include "test_mver_import_internal.h" +#include "bongo_cat/image.h" #include "bongo_cat/json.h" #include "bongo_cat/path.h" #include @@ -186,6 +187,16 @@ static void model_visual_curve(void) { CHECK(complete == 1.0f && !value.model_load_visual_active); } +static void check_portable_images(const char *root, + const char *const *relative, size_t count) { + char path[BONGO_CAT_PATH_CAP]; + for (size_t i = 0; i < count; ++i) { + CHECK(child(path, sizeof(path), root, relative[i], false)); + CHECK(bongo_cat_path_is_file(path)); + CHECK(bongo_cat_image_info(path, NULL, NULL)); + } +} + static void tauri_exact_discovery(void) { char *temporary = SDL_GetCurrentDirectory(); CHECK(temporary != NULL); @@ -204,12 +215,20 @@ static void tauri_exact_discovery(void) { char tray[BONGO_CAT_PATH_CAP]; snprintf(tray, sizeof(tray), "%s/resources/assets/tray.png", BONGO_CAT_NATIVE_SOURCE_DIR); - char resources[BONGO_CAT_PATH_CAP], right_keys[BONGO_CAT_PATH_CAP]; - char east[BONGO_CAT_PATH_CAP], model_texture[BONGO_CAT_PATH_CAP]; + char resources[BONGO_CAT_PATH_CAP], left_keys[BONGO_CAT_PATH_CAP]; + char right_keys[BONGO_CAT_PATH_CAP], east[BONGO_CAT_PATH_CAP]; + char numeric[BONGO_CAT_PATH_CAP], model_texture[BONGO_CAT_PATH_CAP]; CHECK(child(resources, sizeof(resources), model, "resources", true)); CHECK(child(model_texture, sizeof(model_texture), resources, "model-texture.png", false)); CHECK(SDL_CopyFile(tray, model_texture)); + CHECK(child(path, sizeof(path), resources, "background.png", false)); + CHECK(SDL_CopyFile(tray, path)); + CHECK(child(path, sizeof(path), resources, "cover.png", false)); + CHECK(SDL_CopyFile(tray, path)); + CHECK(child(left_keys, sizeof(left_keys), resources, "left-keys", true)); + CHECK(child(numeric, sizeof(numeric), left_keys, "24.png", false)); + CHECK(SDL_CopyFile(tray, numeric)); CHECK(child(right_keys, sizeof(right_keys), resources, "right-keys", true)); CHECK(child(east, sizeof(east), right_keys, "East.png", false)); CHECK(SDL_CopyFile(tray, east)); @@ -249,9 +268,18 @@ static void tauri_exact_discovery(void) { yyjson_val *normalized_root = normalized ? yyjson_doc_get_root(normalized) : NULL; yyjson_val *normalized_mode = yyjson_obj_get(normalized_root, "keyboard"); + yyjson_val *normalized_left = yyjson_obj_get(normalized_mode, "lefthand"); + yyjson_val *window_size = yyjson_obj_get( + yyjson_obj_get(normalized_root, "decoration"), "window_size"); + int expected_width = 0, expected_height = 0; + CHECK(bongo_cat_image_info(tray, &expected_width, &expected_height)); CHECK(yyjson_get_int(yyjson_obj_get(normalized_root, "mode")) == 2 && - yyjson_is_arr(yyjson_obj_get(normalized_mode, "lefthand")) && - yyjson_is_arr(yyjson_obj_get(normalized_mode, "righthand"))); + yyjson_is_arr(normalized_left) && + yyjson_is_arr(yyjson_obj_get(normalized_mode, "righthand")) && + yyjson_get_int(yyjson_arr_get(yyjson_arr_get(normalized_left, 0), 0)) == + 24 && + yyjson_get_int(yyjson_arr_get(window_size, 0)) == expected_width && + yyjson_get_int(yyjson_arr_get(window_size, 1)) == expected_height); yyjson_doc_free(normalized); CHECK(child(path, sizeof(path), package, "img/keyboard/cat_model/cat.model3.json", false) && @@ -265,6 +293,12 @@ static void tauri_exact_discovery(void) { CHECK(child(path, sizeof(path), package, "img/keyboard/cat_model/nested/resources/keep.bin", false) && bongo_cat_path_is_file(path)); + static const char *const keyboard_runtime[] = { + "img/keyboard/lefthand/leftup.png", + "img/keyboard/righthand/rightup.png" + }; + check_portable_images(package, keyboard_runtime, + sizeof(keyboard_runtime) / sizeof(keyboard_runtime[0])); CHECK(child(path, sizeof(path), package, ".bongo-cat-adapter", false) && !bongo_cat_path_is_dir(path)); CHECK(child(path, sizeof(path), package, ".bongo-cat-package.json", false) && @@ -284,6 +318,61 @@ static void tauri_exact_discovery(void) { strcmp(app->models.entries[0].adapter_directory, package) != 0); free(app); } + BongoCatImportCandidate portable = keyboard.candidates[0], installed = {0}; + char portable_root[BONGO_CAT_PATH_CAP]; + portable.mode = BONGO_CAT_MODE_STANDARD; + portable.gamepad_buttons = false; + CHECK(child(portable_root, sizeof(portable_root), root, + "portable-standard", false)); + error = (BongoCatError){0}; + CHECK(bongo_cat_import_tauri_convert_to_mver(&portable, portable_root, + &installed, &error)); + static const char *const standard_runtime[] = { + "img/standard/arm.png", "img/standard/up.png", + "img/standard/mousebg.png", "img/standard/l2dmousebg.png", + "img/standard/mouse.png", "img/standard/mouse_left.png", + "img/standard/mouse_right.png", "img/standard/mouse_side.png", + "img/standard/tabletbg.png", "img/standard/l2dtabletbg.png", + "img/standard/tablet.png", "img/standard/tablet_left.png", + "img/standard/tablet_right.png" + }; + check_portable_images(portable_root, standard_runtime, + sizeof(standard_runtime) / sizeof(standard_runtime[0])); + CHECK(child(path, sizeof(path), portable_root, "img/standard/bg.png", + false) && !bongo_cat_path_is_file(path)); + char adapter_root[BONGO_CAT_PATH_CAP]; + CHECK(child(adapter_root, sizeof(adapter_root), root, + "portable-standard-adapter", true)); + error = (BongoCatError){0}; + CHECK(bongo_cat_import_prepare_adapter(&installed, adapter_root, &error)); + CHECK(child(path, sizeof(path), adapter_root, + BONGO_CAT_MODEL_ADAPTER_FILE, false)); + yyjson_doc *adapter = bongo_cat_json_read_file(path, 0, NULL); + yyjson_val *pointer = adapter ? yyjson_obj_get( + yyjson_doc_get_root(adapter), "standardPointer") : NULL; + CHECK(yyjson_is_obj(pointer) && + !yyjson_get_bool(yyjson_obj_get(pointer, "enabled"))); + yyjson_doc_free(adapter); + CHECK(child(path, sizeof(path), adapter_root, + "resources/mver-pointer/arm.png", false) && + !bongo_cat_path_is_file(path)); + portable.mode = BONGO_CAT_MODE_GAMEPAD; + portable.gamepad_buttons = true; + installed = (BongoCatImportCandidate){0}; + CHECK(child(portable_root, sizeof(portable_root), root, + "portable-gamepad", false)); + error = (BongoCatError){0}; + CHECK(bongo_cat_import_tauri_convert_to_mver(&portable, portable_root, + &installed, &error)); + static const char *const gamepad_runtime[] = { + "img/gamepad/arm_L.png", "img/gamepad/arm_R.png", + "img/gamepad/left_stick.png", "img/gamepad/left_stick_down.png", + "img/gamepad/right_stick.png", "img/gamepad/right_stick_down.png", + "img/gamepad/lefthand/leftup.png", + "img/gamepad/righthand/rightup.png" + }; + check_portable_images(portable_root, gamepad_runtime, + sizeof(gamepad_runtime) / sizeof(gamepad_runtime[0])); CHECK(bongo_cat_model_remove_tree(root, NULL)); SDL_free(temporary); } @@ -294,6 +383,7 @@ int main(void) { CHECK(chord("[0]", true, "Gamepad:South")); CHECK(chord("[15]", true, "Gamepad:Select")); CHECK(chord("[8]", false, "Backspace")); + CHECK(chord("[24]", false, "24")); CHECK(chord("[16,65]", false, "Shift+A")); CHECK(chord("[17,18,90]", false, "Control+Alt+Z")); CHECK(chord("[0]", false, NULL)); @@ -303,6 +393,8 @@ int main(void) { CHECK(modifier.count == 1 && strcmp(modifier.items[0], "ShiftRight") == 0); BongoCatMverKeyNames dpad = bongo_cat_mver_gamepad_names(12); CHECK(dpad.count == 1 && strcmp(dpad.items[0], "DPadUp") == 0); + BongoCatMverKeyNames numeric = bongo_cat_mver_device_names(24, 0, 1); + CHECK(numeric.count == 1 && strcmp(numeric.generated, "24") == 0); labels_from_shortcut_rows(); metadata_backfills_labels(); failures += test_mver_missing_motion_groups(); From 8d8fe306d92937467e7869a4728ef43f4cc33a2b Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 26 Aug 2026 19:35:35 +0800 Subject: [PATCH 110/334] fix: restore clean model import builds --- CMakeLists.txt | 2 +- cmake/RuntimeSources.cmake | 3 + cmake/Tests.cmake | 1 + src/core/config_defaults.c | 125 ---------- src/core/config_model_state.c | 132 ++++++++++ .../model/import/model_import_discover.c | 136 +++++++++++ .../model/import/model_import_discovery.c | 130 ---------- .../model/import/model_import_installed.c | 22 +- .../model/import/model_import_tauri_mver.c | 231 ++---------------- .../import/model_import_tauri_mver_internal.h | 26 ++ .../import/model_import_tauri_mver_keys.c | 86 +++++++ .../import/model_import_tauri_mver_tree.c | 101 ++++++++ .../model/import/mver/model_import_mver.c | 1 + src/ui/preferences/preferences_import.c | 6 - tests/model_import/test_mver_container.c | 8 +- tests/model_import/test_mver_import.c | 192 +-------------- .../model_import/test_mver_import_internal.h | 1 + tests/model_import/test_tauri_portable.c | 206 ++++++++++++++++ 18 files changed, 735 insertions(+), 674 deletions(-) create mode 100644 src/core/config_model_state.c create mode 100644 src/runtime/model/import/model_import_discover.c create mode 100644 src/runtime/model/import/model_import_tauri_mver_internal.h create mode 100644 src/runtime/model/import/model_import_tauri_mver_keys.c create mode 100644 src/runtime/model/import/model_import_tauri_mver_tree.c create mode 100644 tests/model_import/test_tauri_portable.c diff --git a/CMakeLists.txt b/CMakeLists.txt index 2e736673..3a75976e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -63,7 +63,7 @@ else() message(WARNING "Cubism SDK missing; building the diagnostic backend") endif() add_library(bongo_cat_core STATIC - src/core/config_defaults.c src/core/config_session_defaults.c src/core/config_names.c src/core/config_fields.c src/core/config_io.c src/core/config_settings_save.c + src/core/config_defaults.c src/core/config_model_state.c src/core/config_session_defaults.c src/core/config_names.c src/core/config_fields.c src/core/config_io.c src/core/config_settings_save.c src/core/config_file.c src/core/config_session_io.c src/core/error.c src/core/file.c src/core/i18n.c diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index b450940a..21ac3407 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -80,6 +80,7 @@ set(BONGO_CAT_RUNTIME_IMPORT_SOURCES src/runtime/model/import/model_import_digest.c src/runtime/model/import/model_import_digest_api.c src/runtime/model/import/model_import_digest_cache.c + src/runtime/model/import/model_import_discover.c src/runtime/model/import/model_import_discovery.c src/runtime/model/import/model_import_format.c src/runtime/model/import/model_import_identity.c @@ -91,6 +92,8 @@ set(BONGO_CAT_RUNTIME_IMPORT_SOURCES src/runtime/model/import/model_import_source.c src/runtime/model/import/model_import_tauri.c src/runtime/model/import/model_import_tauri_mver.c + src/runtime/model/import/model_import_tauri_mver_keys.c + src/runtime/model/import/model_import_tauri_mver_tree.c src/runtime/model/import/mver/model_import_mver.c src/runtime/model/import/mver/model_import_mver_effect.c src/runtime/model/import/mver/model_import_mver_image.c diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index 65548a29..5a8f153a 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -46,6 +46,7 @@ if(BUILD_TESTING) set(BONGO_CAT_MVER_IMPORT_TEST_SOURCES tests/model_import/test_mver_import.c + tests/model_import/test_tauri_portable.c tests/model_import/test_mver_container.c tests/model_import/test_mver_missing_motion.c tests/model_import/test_mver_pointer_import.c diff --git a/src/core/config_defaults.c b/src/core/config_defaults.c index 9219cef9..b7491aae 100644 --- a/src/core/config_defaults.c +++ b/src/core/config_defaults.c @@ -50,131 +50,6 @@ bool bongo_cat_settings_shortcut_conflicts(const BongoCatSettings *config, return false; } -const char *bongo_cat_settings_model_label(const BongoCatSettings *config, - const char *id) { - if (!config || !id) return NULL; - size_t count = config->model_label_count; - if (count > BONGO_CAT_MODEL_CAP) count = BONGO_CAT_MODEL_CAP; - for (size_t i = 0; i < count; ++i) - if (!strcmp(config->model_labels[i].id, id)) - return config->model_labels[i].label; - return NULL; -} - -bool bongo_cat_settings_set_model_label(BongoCatSettings *config, - const char *id, const char *label) { - if (!config || !id || !id[0] || strlen(id) >= BONGO_CAT_ID_CAP || - !bongo_cat_utf8_valid(id)) return false; - bongo_cat_settings_validate(config); - size_t index = config->model_label_count; - for (size_t i = 0; i < config->model_label_count; ++i) - if (!strcmp(config->model_labels[i].id, id)) { - index = i; break; - } - if (!label || !label[0]) { - if (index == config->model_label_count) return false; - if (index + 1 < config->model_label_count) - memmove(&config->model_labels[index], - &config->model_labels[index + 1], - (config->model_label_count - index - 1) * - sizeof(config->model_labels[0])); - config->model_label_count--; - memset(&config->model_labels[config->model_label_count], 0, - sizeof(config->model_labels[0])); - return true; - } - if (strlen(label) >= BONGO_CAT_ID_CAP || !bongo_cat_utf8_valid(label)) - return false; - if (index < config->model_label_count) { - if (!strcmp(config->model_labels[index].label, label)) return false; - memset(config->model_labels[index].label, 0, - sizeof(config->model_labels[index].label)); - snprintf(config->model_labels[index].label, - sizeof(config->model_labels[index].label), "%s", label); - return true; - } - if (config->model_label_count >= BONGO_CAT_MODEL_CAP) return false; - BongoCatModelLabel *value = - &config->model_labels[config->model_label_count++]; - memset(value, 0, sizeof(*value)); - snprintf(value->id, sizeof(value->id), "%s", id); - snprintf(value->label, sizeof(value->label), "%s", label); - return true; -} - -bool bongo_cat_settings_model_removed(const BongoCatSettings *config, - const char *id) { - if (!config || !id) return false; - size_t count = config->removed_model_count; - if (count > BONGO_CAT_MODEL_CAP) count = BONGO_CAT_MODEL_CAP; - for (size_t i = 0; i < count; ++i) - if (!strcmp(config->removed_models[i].id, id)) return true; - return false; -} - -bool bongo_cat_settings_set_model_removed(BongoCatSettings *config, - const char *id, bool removed) { - if (!config || !id || !id[0] || strlen(id) >= BONGO_CAT_ID_CAP || - !bongo_cat_utf8_valid(id)) return false; - bongo_cat_settings_validate(config); - size_t index = config->removed_model_count; - for (size_t i = 0; i < config->removed_model_count; ++i) - if (!strcmp(config->removed_models[i].id, id)) { - index = i; - break; - } - if (removed) { - if (index < config->removed_model_count) return false; - if (config->removed_model_count >= BONGO_CAT_MODEL_CAP) return false; - BongoCatRemovedModel *entry = - &config->removed_models[config->removed_model_count++]; - memset(entry, 0, sizeof(*entry)); - snprintf(entry->id, sizeof(entry->id), "%s", id); - return true; - } - if (index == config->removed_model_count) return false; - if (index + 1 < config->removed_model_count) - memmove(&config->removed_models[index], - &config->removed_models[index + 1], - (config->removed_model_count - index - 1) * - sizeof(config->removed_models[0])); - config->removed_model_count--; - memset(&config->removed_models[config->removed_model_count], 0, - sizeof(config->removed_models[0])); - return true; -} - -static bool package_model_id(const char *id, const char *package_id) { - size_t length = package_id ? strlen(package_id) : 0; - if (!id || !length || strncmp(id, package_id, length) != 0) return false; - const char *suffix = id + length; - if (!suffix[0]) return true; - if (*suffix++ != '~' || !*suffix) return false; - while (*suffix) - if (!isdigit((unsigned char)*suffix++)) return false; - return true; -} - -bool bongo_cat_settings_restore_model_package(BongoCatSettings *config, - const char *package_id) { - if (!config || !package_id || !package_id[0]) return false; - bongo_cat_settings_validate(config); - size_t output = 0; - bool changed = false; - for (size_t i = 0; i < config->removed_model_count; ++i) { - BongoCatRemovedModel entry = config->removed_models[i]; - if (package_model_id(entry.id, package_id)) { - changed = true; - continue; - } - config->removed_models[output++] = entry; - } - memset(&config->removed_models[output], 0, - (BONGO_CAT_MODEL_CAP - output) * sizeof(config->removed_models[0])); - config->removed_model_count = output; - return changed; -} - static void validate_shortcuts(BongoCatSettings *config) { char *global[] = {config->shortcuts.toggle_pet_visibility, config->shortcuts.visible_preferences, config->shortcuts.mirror, diff --git a/src/core/config_model_state.c b/src/core/config_model_state.c new file mode 100644 index 00000000..db92414e --- /dev/null +++ b/src/core/config_model_state.c @@ -0,0 +1,132 @@ +#include "bongo_cat/config.h" +#include "bongo_cat/utf8.h" + +#include +#include +#include + +const char *bongo_cat_settings_model_label(const BongoCatSettings *config, + const char *id) { + if (!config || !id) return NULL; + size_t count = config->model_label_count; + if (count > BONGO_CAT_MODEL_CAP) count = BONGO_CAT_MODEL_CAP; + for (size_t i = 0; i < count; ++i) + if (!strcmp(config->model_labels[i].id, id)) + return config->model_labels[i].label; + return NULL; +} + +bool bongo_cat_settings_set_model_label(BongoCatSettings *config, + const char *id, const char *label) { + if (!config || !id || !id[0] || strlen(id) >= BONGO_CAT_ID_CAP || + !bongo_cat_utf8_valid(id)) return false; + bongo_cat_settings_validate(config); + size_t index = config->model_label_count; + for (size_t i = 0; i < config->model_label_count; ++i) + if (!strcmp(config->model_labels[i].id, id)) { + index = i; + break; + } + if (!label || !label[0]) { + if (index == config->model_label_count) return false; + if (index + 1 < config->model_label_count) + memmove(&config->model_labels[index], + &config->model_labels[index + 1], + (config->model_label_count - index - 1) * + sizeof(config->model_labels[0])); + config->model_label_count--; + memset(&config->model_labels[config->model_label_count], 0, + sizeof(config->model_labels[0])); + return true; + } + if (strlen(label) >= BONGO_CAT_ID_CAP || !bongo_cat_utf8_valid(label)) + return false; + if (index < config->model_label_count) { + if (!strcmp(config->model_labels[index].label, label)) return false; + memset(config->model_labels[index].label, 0, + sizeof(config->model_labels[index].label)); + snprintf(config->model_labels[index].label, + sizeof(config->model_labels[index].label), "%s", label); + return true; + } + if (config->model_label_count >= BONGO_CAT_MODEL_CAP) return false; + BongoCatModelLabel *value = + &config->model_labels[config->model_label_count++]; + memset(value, 0, sizeof(*value)); + snprintf(value->id, sizeof(value->id), "%s", id); + snprintf(value->label, sizeof(value->label), "%s", label); + return true; +} + +bool bongo_cat_settings_model_removed(const BongoCatSettings *config, + const char *id) { + if (!config || !id) return false; + size_t count = config->removed_model_count; + if (count > BONGO_CAT_MODEL_CAP) count = BONGO_CAT_MODEL_CAP; + for (size_t i = 0; i < count; ++i) + if (!strcmp(config->removed_models[i].id, id)) return true; + return false; +} + +bool bongo_cat_settings_set_model_removed(BongoCatSettings *config, + const char *id, bool removed) { + if (!config || !id || !id[0] || strlen(id) >= BONGO_CAT_ID_CAP || + !bongo_cat_utf8_valid(id)) return false; + bongo_cat_settings_validate(config); + size_t index = config->removed_model_count; + for (size_t i = 0; i < config->removed_model_count; ++i) + if (!strcmp(config->removed_models[i].id, id)) { + index = i; + break; + } + if (removed) { + if (index < config->removed_model_count) return false; + if (config->removed_model_count >= BONGO_CAT_MODEL_CAP) return false; + BongoCatRemovedModel *entry = + &config->removed_models[config->removed_model_count++]; + memset(entry, 0, sizeof(*entry)); + snprintf(entry->id, sizeof(entry->id), "%s", id); + return true; + } + if (index == config->removed_model_count) return false; + if (index + 1 < config->removed_model_count) + memmove(&config->removed_models[index], + &config->removed_models[index + 1], + (config->removed_model_count - index - 1) * + sizeof(config->removed_models[0])); + config->removed_model_count--; + memset(&config->removed_models[config->removed_model_count], 0, + sizeof(config->removed_models[0])); + return true; +} + +static bool package_model_id(const char *id, const char *package_id) { + size_t length = package_id ? strlen(package_id) : 0; + if (!id || !length || strncmp(id, package_id, length) != 0) return false; + const char *suffix = id + length; + if (!suffix[0]) return true; + if (*suffix++ != '~' || !*suffix) return false; + while (*suffix) + if (!isdigit((unsigned char)*suffix++)) return false; + return true; +} + +bool bongo_cat_settings_restore_model_package(BongoCatSettings *config, + const char *package_id) { + if (!config || !package_id || !package_id[0]) return false; + bongo_cat_settings_validate(config); + size_t output = 0; + bool changed = false; + for (size_t i = 0; i < config->removed_model_count; ++i) { + BongoCatRemovedModel entry = config->removed_models[i]; + if (package_model_id(entry.id, package_id)) { + changed = true; + continue; + } + config->removed_models[output++] = entry; + } + memset(&config->removed_models[output], 0, + (BONGO_CAT_MODEL_CAP - output) * sizeof(config->removed_models[0])); + config->removed_model_count = output; + return changed; +} diff --git a/src/runtime/model/import/model_import_discover.c b/src/runtime/model/import/model_import_discover.c new file mode 100644 index 00000000..2c31734f --- /dev/null +++ b/src/runtime/model/import/model_import_discover.c @@ -0,0 +1,136 @@ +#include "model_import.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +static void seed_source_name(const char *source, + BongoCatImportDiscovery *discovery) { + char path[BONGO_CAT_PATH_CAP]; + int written = snprintf(path, sizeof(path), "%s", source ? source : ""); + if (written < 0 || (size_t)written >= sizeof(path)) return; + size_t length = strlen(path); + while (length && (path[length - 1] == '/' || path[length - 1] == '\\')) + path[--length] = '\0'; + const char *name = bongo_cat_path_name(path); + if (name && name[0]) snprintf(discovery->source_name, + sizeof(discovery->source_name), "%s", name); +} + +static bool suffix(const char *name, const char *ending) { + size_t a = name ? strlen(name) : 0, b = ending ? strlen(ending) : 0; + return a >= b && strcmp(name + a - b, ending) == 0; +} + +static BongoCatPathVisit discover_item(void *userdata, + const char *dirname, const char *name) { + BongoCatImportDiscovery *discovery = userdata; + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), dirname, name)) + return BONGO_CAT_PATH_FAILURE; + if (bongo_cat_path_is_file(path) && suffix(name, ".model3.json")) { + if (bongo_cat_import_manifest_valid(dirname, name, NULL) && + !bongo_cat_import_tauri_add_candidate(discovery, dirname, name)) + return BONGO_CAT_PATH_FAILURE; + return BONGO_CAT_PATH_CONTINUE; + } + if (!bongo_cat_path_is_dir(path) || discovery->depth >= 8 || name[0] == '.') + return BONGO_CAT_PATH_CONTINUE; + discovery->depth++; + bool ok = bongo_cat_path_enumerate(path, discover_item, discovery); + discovery->depth--; + return ok ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; +} + +static int rank(const BongoCatImportCandidate *candidate) { + return candidate->mode == BONGO_CAT_MODE_STANDARD ? 0 : + candidate->mode == BONGO_CAT_MODE_KEYBOARD ? 1 : 2; +} + +static int compare_candidates(const void *left, const void *right) { + const BongoCatImportCandidate *a = left, *b = right; + int difference = rank(a) - rank(b); + return difference ? difference : strcmp(a->directory, b->directory); +} + +typedef struct ContainerDiscovery { + BongoCatImportDiscovery *output; +} ContainerDiscovery; + +static BongoCatResult collect_container(void *userdata, const char *source, + BongoCatImportDiscovery *found, BongoCatError *error) { + (void)source; + ContainerDiscovery *container = userdata; + for (size_t i = 0; i < found->count; ++i) { + if (container->output->count >= BONGO_CAT_IMPORT_CANDIDATE_CAP) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Selected folder contains too many model variants"); + return BONGO_CAT_ERROR_FORMAT; + } + container->output->candidates[container->output->count++] = + found->candidates[i]; + } + return BONGO_CAT_OK; +} + +bool bongo_cat_import_discover(const char *source, + BongoCatImportDiscovery *discovery, BongoCatError *error) { + memset(discovery, 0, sizeof(*discovery)); + seed_source_name(source, discovery); + int mver = bongo_cat_import_mver_discover(source, discovery, error); + if (mver > 0) return true; + if (mver < 0) return false; + BongoCatError patch_error = {0}; + int patch = bongo_cat_import_mver_patch_discover(source, discovery, + &patch_error); + if (patch > 0) return true; + memset(discovery, 0, sizeof(*discovery)); + seed_source_name(source, discovery); + int tauri = bongo_cat_import_tauri_discover_exact(source, discovery, error); + if (tauri > 0) return true; + if (tauri < 0) return false; + memset(discovery, 0, sizeof(*discovery)); + seed_source_name(source, discovery); + ContainerDiscovery container = {discovery}; + BongoCatResult result = bongo_cat_import_scan(source, collect_container, + &container, error); + if (result != BONGO_CAT_OK) return false; + if (discovery->count) { + char config[BONGO_CAT_PATH_CAP]; + bool is_container = !bongo_cat_import_mver_config_path(source, + config, sizeof(config)); + if (is_container) for (size_t i = 0; i < discovery->count; ++i) + if (discovery->candidates[i].format != BONGO_CAT_IMPORT_TAURI) + snprintf(discovery->candidates[i].package_root, + sizeof(discovery->candidates[i].package_root), "%s", source); + qsort(discovery->candidates, discovery->count, + sizeof(discovery->candidates[0]), compare_candidates); + return true; + } + if (patch < 0) { + if (error) *error = patch_error; + return false; + } + if (error) *error = (BongoCatError){0}; + if (!bongo_cat_path_enumerate(source, discover_item, discovery)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + discovery->ambiguous + ? "A model directory contains multiple model3 manifests" + : "Cannot scan model directory or it contains too many models"); + return false; + } + if (!discovery->count) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Selected directory contains no valid Live2D model3 JSON"); + return false; + } + for (size_t i = 0; i < discovery->count; ++i) + if (discovery->candidates[i].format == BONGO_CAT_IMPORT_TAURI && + !discovery->candidates[i].package_root[0]) + snprintf(discovery->candidates[i].package_root, + sizeof(discovery->candidates[i].package_root), "%s", source); + qsort(discovery->candidates, discovery->count, + sizeof(discovery->candidates[0]), compare_candidates); + return true; +} diff --git a/src/runtime/model/import/model_import_discovery.c b/src/runtime/model/import/model_import_discovery.c index 812ae640..dc44bd1c 100644 --- a/src/runtime/model/import/model_import_discovery.c +++ b/src/runtime/model/import/model_import_discovery.c @@ -22,20 +22,6 @@ static bool safe_reference(const char *value) { } return true; } - -static void seed_source_name(const char *source, - BongoCatImportDiscovery *discovery) { - char path[BONGO_CAT_PATH_CAP]; - int written = snprintf(path, sizeof(path), "%s", source ? source : ""); - if (written < 0 || (size_t)written >= sizeof(path)) return; - size_t length = strlen(path); - while (length && (path[length - 1] == '/' || path[length - 1] == '\\')) - path[--length] = '\0'; - const char *name = bongo_cat_path_name(path); - if (name && name[0]) snprintf(discovery->source_name, - sizeof(discovery->source_name), "%s", name); -} - static bool referenced_file(const char *root, const char *relative) { char path[BONGO_CAT_PATH_CAP]; return safe_reference(relative) && @@ -78,7 +64,6 @@ static bool behavior_references(const char *root, yyjson_val *refs) { } return true; } - bool bongo_cat_import_manifest_valid(const char *root, const char *setting, BongoCatError *error) { char path[BONGO_CAT_PATH_CAP]; @@ -285,118 +270,3 @@ bool bongo_cat_import_tauri_add_candidate(BongoCatImportDiscovery *discovery, } return true; } - -static bool suffix(const char *name, const char *ending) { - size_t a = name ? strlen(name) : 0, b = ending ? strlen(ending) : 0; - return a >= b && strcmp(name + a - b, ending) == 0; -} - -static BongoCatPathVisit discover_item(void *userdata, - const char *dirname, const char *name) { - BongoCatImportDiscovery *discovery = userdata; - char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), dirname, name)) - return BONGO_CAT_PATH_FAILURE; - if (bongo_cat_path_is_file(path) && suffix(name, ".model3.json")) { - if (bongo_cat_import_manifest_valid(dirname, name, NULL) && - !bongo_cat_import_tauri_add_candidate(discovery, dirname, name)) - return BONGO_CAT_PATH_FAILURE; - return BONGO_CAT_PATH_CONTINUE; - } - if (!bongo_cat_path_is_dir(path) || discovery->depth >= 8 || name[0] == '.') - return BONGO_CAT_PATH_CONTINUE; - discovery->depth++; - bool ok = bongo_cat_path_enumerate(path, discover_item, discovery); - discovery->depth--; - return ok ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; -} - -static int rank(const BongoCatImportCandidate *candidate) { - return candidate->mode == BONGO_CAT_MODE_STANDARD ? 0 : - candidate->mode == BONGO_CAT_MODE_KEYBOARD ? 1 : 2; -} - -static int compare_candidates(const void *left, const void *right) { - const BongoCatImportCandidate *a = left, *b = right; - int difference = rank(a) - rank(b); - return difference ? difference : strcmp(a->directory, b->directory); -} - -typedef struct ContainerDiscovery { - BongoCatImportDiscovery *output; -} ContainerDiscovery; - -static BongoCatResult collect_container(void *userdata, const char *source, - BongoCatImportDiscovery *found, BongoCatError *error) { - (void)source; - ContainerDiscovery *container = userdata; - for (size_t i = 0; i < found->count; ++i) { - if (container->output->count >= BONGO_CAT_IMPORT_CANDIDATE_CAP) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Selected folder contains too many model variants"); - return BONGO_CAT_ERROR_FORMAT; - } - container->output->candidates[container->output->count++] = - found->candidates[i]; - } - return BONGO_CAT_OK; -} - -bool bongo_cat_import_discover(const char *source, BongoCatImportDiscovery *discovery, - BongoCatError *error) { - memset(discovery, 0, sizeof(*discovery)); - seed_source_name(source, discovery); - int mver = bongo_cat_import_mver_discover(source, discovery, error); - if (mver > 0) return true; - if (mver < 0) return false; - BongoCatError patch_error = {0}; - int patch = bongo_cat_import_mver_patch_discover(source, discovery, &patch_error); - if (patch > 0) return true; - memset(discovery, 0, sizeof(*discovery)); - seed_source_name(source, discovery); - int tauri = bongo_cat_import_tauri_discover_exact(source, discovery, error); - if (tauri > 0) return true; - if (tauri < 0) return false; - memset(discovery, 0, sizeof(*discovery)); - seed_source_name(source, discovery); - ContainerDiscovery container = {discovery}; - BongoCatResult container_result = bongo_cat_import_scan(source, - collect_container, &container, error); - if (container_result != BONGO_CAT_OK) return false; - if (discovery->count) { - char config[BONGO_CAT_PATH_CAP]; - bool is_container = !bongo_cat_import_mver_config_path(source, - config, sizeof(config)); - if (is_container) for (size_t i = 0; i < discovery->count; ++i) - if (discovery->candidates[i].format != BONGO_CAT_IMPORT_TAURI) - snprintf(discovery->candidates[i].package_root, - sizeof(discovery->candidates[i].package_root), "%s", source); - qsort(discovery->candidates, discovery->count, - sizeof(discovery->candidates[0]), compare_candidates); - return true; - } - if (patch < 0) { - if (error) *error = patch_error; - return false; - } - if (error) *error = (BongoCatError){0}; - if (!bongo_cat_path_enumerate(source, discover_item, discovery)) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - discovery->ambiguous ? "A model directory contains multiple model3 manifests" : - "Cannot scan model directory or it contains too many models"); - return false; - } - if (!discovery->count) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Selected directory contains no valid Live2D model3 JSON"); - return false; - } - for (size_t i = 0; i < discovery->count; ++i) - if (discovery->candidates[i].format == BONGO_CAT_IMPORT_TAURI && - !discovery->candidates[i].package_root[0]) - snprintf(discovery->candidates[i].package_root, - sizeof(discovery->candidates[i].package_root), "%s", source); - qsort(discovery->candidates, discovery->count, - sizeof(discovery->candidates[0]), compare_candidates); - return true; -} diff --git a/src/runtime/model/import/model_import_installed.c b/src/runtime/model/import/model_import_installed.c index 03dcb2fa..ce698bea 100644 --- a/src/runtime/model/import/model_import_installed.c +++ b/src/runtime/model/import/model_import_installed.c @@ -26,6 +26,16 @@ static bool existing_identity(const BongoCatModelCatalog *models, return false; } +static bool package_identity_seen( + const char identities[BONGO_CAT_IMPORT_CANDIDATE_CAP][65], + const BongoCatModelMode modes[BONGO_CAT_IMPORT_CANDIDATE_CAP], + size_t count, const char *identity, BongoCatModelMode mode) { + for (size_t i = 0; i < count; ++i) + if (modes[i] == mode && strcmp(identities[i], identity) == 0) + return true; + return false; +} + static BongoCatModelSourceFormat source_format( const BongoCatImportCandidate *candidate) { if (candidate->format == BONGO_CAT_IMPORT_MVER_PATCH) @@ -77,6 +87,9 @@ static bool add_package(InstalledModelScan *scan, const char *directory, bongo_cat_sha256_bytes(directory, strlen(directory), path_hash); snprintf(family, sizeof(family), "family-installed-%.16s", path_hash); size_t emitted = 0; + char identities[BONGO_CAT_IMPORT_CANDIDATE_CAP][65] = {{0}}; + BongoCatModelMode modes[BONGO_CAT_IMPORT_CANDIDATE_CAP] = {0}; + size_t identity_count = 0; for (size_t i = 0; i < discovery->count; ++i) { BongoCatImportCandidate *candidate = &discovery->candidates[i]; char cache_id[BONGO_CAT_ID_CAP], adapter[BONGO_CAT_PATH_CAP]; @@ -97,6 +110,13 @@ static bool add_package(InstalledModelScan *scan, const char *directory, directory, signature, identity, true, candidate->mode); continue; } + bool duplicate = existing_identity(&scan->app->models, identity, + candidate->mode) || package_identity_seen(identities, modes, + identity_count, identity, candidate->mode); + if (identity_count < BONGO_CAT_IMPORT_CANDIDATE_CAP) { + snprintf(identities[identity_count], 65, "%s", identity); + modes[identity_count++] = candidate->mode; + } char id[BONGO_CAT_ID_CAP]; if (!bongo_cat_import_variant_id(id, sizeof(id), package_id, emitted)) { bongo_cat_error_set(scan->error, BONGO_CAT_ERROR_FORMAT, @@ -109,7 +129,7 @@ static bool add_package(InstalledModelScan *scan, const char *directory, directory, signature, identity, false, candidate->mode); continue; } - if (existing_identity(&scan->app->models, identity, candidate->mode)) { + if (duplicate) { if (!cached) bongo_cat_nearby_remember_inspection(adapter, directory, signature, identity, false, candidate->mode); continue; diff --git a/src/runtime/model/import/model_import_tauri_mver.c b/src/runtime/model/import/model_import_tauri_mver.c index 0e7fb156..c2b10900 100644 --- a/src/runtime/model/import/model_import_tauri_mver.c +++ b/src/runtime/model/import/model_import_tauri_mver.c @@ -1,111 +1,16 @@ #include "model_import.h" +#include "model_import_tauri_mver_internal.h" #include "bongo_cat/file.h" #include "bongo_cat/image.h" #include "bongo_cat/json.h" #include "bongo_cat/path.h" - #include #include #include #include #include - -#define TAURI_KEY_CAP 256 -#define TAURI_MODEL_COPY_DEPTH_CAP 32 - -typedef struct TauriKeyFile { - char name[BONGO_CAT_ID_CAP]; - char path[BONGO_CAT_PATH_CAP]; - int code; -} TauriKeyFile; - -typedef struct TauriKeyFiles { - TauriKeyFile values[TAURI_KEY_CAP]; - size_t count; -} TauriKeyFiles; - -static bool suffix(const char *name, const char *ending) { - size_t a = name ? strlen(name) : 0, b = ending ? strlen(ending) : 0; - return a >= b && SDL_strcasecmp(name + a - b, ending) == 0; -} - -static int key_code(const char *filename) { - char name[BONGO_CAT_ID_CAP]; - snprintf(name, sizeof(name), "%s", filename ? filename : ""); - char *dot = strrchr(name, '.'); - if (!dot || SDL_strcasecmp(dot, ".png") != 0) return -1; - *dot = '\0'; - char *end = NULL; - long numeric = strtol(name, &end, 10); - if (name[0] && end && !end[0] && numeric >= 0 && numeric <= 255) - return (int)numeric; - if (strlen(name) == 4 && SDL_strncasecmp(name, "Key", 3) == 0 && - name[3] >= 'A' && name[3] <= 'Z') return name[3]; - if (strlen(name) == 4 && SDL_strncasecmp(name, "Num", 3) == 0 && - name[3] >= '0' && name[3] <= '9') return name[3]; - if ((name[0] == 'F' || name[0] == 'f') && strlen(name) <= 3) { - int value = atoi(name + 1); - if (value >= 1 && value <= 12) return 111 + value; - } - static const struct { const char *name; int code; } map[] = { - {"Backspace", 8}, {"BackSpace", 8}, {"Tab", 9}, {"Return", 13}, - {"Pause", 19}, {"CapsLock", 20}, {"Escape", 27}, {"Space", 32}, - {"PageUp", 33}, {"PageDown", 34}, {"End", 35}, {"Home", 36}, - {"LeftArrow", 37}, {"UpArrow", 38}, {"RightArrow", 39}, - {"DownArrow", 40}, {"PrintScreen", 44}, {"Insert", 45}, - {"Delete", 46}, {"Meta", 91}, {"MetaLeft", 91}, {"MetaRight", 92}, - {"Apps", 93}, {"Kp0", 96}, {"Kp1", 97}, {"Kp2", 98}, - {"Kp3", 99}, {"Kp4", 100}, {"Kp5", 101}, {"Kp6", 102}, - {"Kp7", 103}, {"Kp8", 104}, {"Kp9", 105}, - {"KpMultiply", 106}, {"KpPlus", 107}, {"KpMinus", 109}, - {"KpDecimal", 110}, {"KpDivide", 111}, {"NumLock", 144}, - {"ScrollLock", 145}, {"Semicolon", 186}, {"SemiColon", 186}, - {"Equal", 187}, {"Comma", 188}, {"Minus", 189}, - {"Period", 190}, {"Dot", 190}, {"Slash", 191}, - {"BackQuote", 192}, {"BracketLeft", 219}, {"LeftBracket", 219}, - {"Backslash", 220}, {"BracketRight", 221}, {"RightBracket", 221}, - {"Quote", 222}, {"Shift", 16}, {"ShiftLeft", 16}, - {"ShiftRight", 16}, {"Control", 17}, {"ControlLeft", 17}, - {"ControlRight", 17}, {"Alt", 18}, {"AltGr", 18} - }; - for (size_t i = 0; i < sizeof(map) / sizeof(map[0]); ++i) - if (SDL_strcasecmp(name, map[i].name) == 0) return map[i].code; - static const struct { const char *name; int code; } gamepad[] = { - {"South", 0}, {"East", 1}, {"West", 2}, {"North", 3}, - {"LeftTrigger", 4}, {"RightTrigger", 5}, {"LeftTrigger2", 6}, - {"RightTrigger2", 7}, {"LeftThumb", 8}, {"RightThumb", 9}, - {"DPadLeft", 10}, {"DPadRight", 11}, {"DPadUp", 12}, - {"DPadDown", 13}, {"Start", 14}, {"Select", 15} - }; - for (size_t i = 0; i < sizeof(gamepad) / sizeof(gamepad[0]); ++i) - if (SDL_strcasecmp(name, gamepad[i].name) == 0) return gamepad[i].code; - return -1; -} - -static BongoCatPathVisit collect_keys(void *userdata, const char *dirname, - const char *name) { - TauriKeyFiles *files = userdata; - if (files->count >= TAURI_KEY_CAP || !suffix(name, ".png")) - return BONGO_CAT_PATH_CONTINUE; - int code = key_code(name); - if (code < 0) return BONGO_CAT_PATH_CONTINUE; - TauriKeyFile *item = &files->values[files->count]; - if (!bongo_cat_path_join(item->path, sizeof(item->path), dirname, name)) - return BONGO_CAT_PATH_FAILURE; - if (!bongo_cat_path_is_file(item->path)) return BONGO_CAT_PATH_CONTINUE; - snprintf(item->name, sizeof(item->name), "%s", name); - item->code = code; - files->count++; - return BONGO_CAT_PATH_CONTINUE; -} - -static int compare_keys(const void *left, const void *right) { - const TauriKeyFile *a = left, *b = right; - return SDL_strcasecmp(a->name, b->name); -} - -static bool resource_file(const BongoCatImportCandidate *candidate, - const char *name, char *path, size_t capacity) { +static bool resource_file(const BongoCatImportCandidate *candidate, const char *name, + char *path, size_t capacity) { const char *roots[3] = {candidate->assets, candidate->directory, candidate->package_root}; for (size_t i = 0; i < sizeof(roots) / sizeof(roots[0]); ++i) { @@ -119,7 +24,6 @@ static bool resource_file(const BongoCatImportCandidate *candidate, } return false; } - static bool write_placeholder(const char *path) { static const unsigned char png[] = { 0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a,0x00,0x00,0x00,0x0d, @@ -135,7 +39,6 @@ static bool write_placeholder(const char *path) { if (fclose(file) != 0) ok = false; return ok; } - static bool copy_image_or_placeholder(const char *source, const char *target, BongoCatError *error) { bool ok = source && bongo_cat_path_is_file(source) @@ -144,7 +47,6 @@ static bool copy_image_or_placeholder(const char *source, const char *target, "Cannot create normalized Mver image: %s", target); return ok; } - static bool copy_mver_image(const BongoCatImportCandidate *candidate, const char *mode_root, const char *source_name, const char *target_name, const char *fallback, BongoCatError *error) { @@ -156,7 +58,6 @@ static bool copy_mver_image(const BongoCatImportCandidate *candidate, return false; return copy_image_or_placeholder(selected, target, error); } - static bool copy_mver_runtime_images( const BongoCatImportCandidate *candidate, const char *mode_root, BongoCatError *error) { @@ -200,9 +101,8 @@ static bool copy_mver_runtime_images( NULL, error)) return false; return true; } - -static void mver_window_size(const BongoCatImportCandidate *candidate, - int *width, int *height) { +static void mver_window_size(const BongoCatImportCandidate *candidate, int *width, + int *height) { *width = 612; *height = 352; char source[BONGO_CAT_PATH_CAP]; @@ -214,104 +114,6 @@ static void mver_window_size(const BongoCatImportCandidate *candidate, *height = 352; } } - -typedef struct TauriModelCopy { - const char *target_root; - BongoCatError *error; - unsigned depth; -} TauriModelCopy; - -static bool copy_tauri_model_tree(const char *source, const char *target, - unsigned depth, BongoCatError *error); -static bool copy_tauri_resource_tree(const char *source, const char *target, - unsigned depth, BongoCatError *error); - -static bool tauri_interface_resource(const char *name) { - static const char *const files[] = { - "cover.png", "background.png", "cat.png", "bg.png", - "mousebg.png", "tabletbg.png" - }; - if (SDL_strcasecmp(name, "left-keys") == 0 || - SDL_strcasecmp(name, "right-keys") == 0) return true; - for (size_t i = 0; i < sizeof(files) / sizeof(files[0]); ++i) - if (SDL_strcasecmp(name, files[i]) == 0) return true; - return false; -} - -static BongoCatPathVisit copy_tauri_resource_child(void *userdata, - const char *dirname, const char *name) { - TauriModelCopy *context = userdata; - if (!name || name[0] == '.' || tauri_interface_resource(name)) - return BONGO_CAT_PATH_CONTINUE; - char source[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(source, sizeof(source), dirname, name) || - !bongo_cat_path_join(target, sizeof(target), context->target_root, - name)) return BONGO_CAT_PATH_FAILURE; - if (bongo_cat_path_is_dir(source)) - return copy_tauri_model_tree(source, target, context->depth + 1, - context->error) ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; - if (!bongo_cat_path_is_file(source)) return BONGO_CAT_PATH_CONTINUE; - if (!bongo_cat_path_create_directory(context->target_root) || - !bongo_cat_path_copy_file(source, target)) { - bongo_cat_error_set(context->error, BONGO_CAT_ERROR_IO, - "Cannot preserve Tauri Live2D resource: %s", source); - return BONGO_CAT_PATH_FAILURE; - } - return BONGO_CAT_PATH_CONTINUE; -} - -static BongoCatPathVisit copy_tauri_model_child(void *userdata, - const char *dirname, const char *name) { - TauriModelCopy *context = userdata; - if (!name || name[0] == '.') return BONGO_CAT_PATH_CONTINUE; - char source[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(source, sizeof(source), dirname, name) || - !bongo_cat_path_join(target, sizeof(target), context->target_root, - name)) return BONGO_CAT_PATH_FAILURE; - /* Normalize Tauri interface assets, but retain model-owned resources. */ - if (context->depth == 0 && bongo_cat_path_is_dir(source) && - SDL_strcasecmp(name, "resources") == 0) - return copy_tauri_resource_tree(source, target, context->depth + 1, - context->error) ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; - if (bongo_cat_path_is_dir(source)) - return copy_tauri_model_tree(source, target, context->depth + 1, - context->error) ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; - if (!bongo_cat_path_is_file(source) || - !bongo_cat_path_copy_file(source, target)) { - bongo_cat_error_set(context->error, BONGO_CAT_ERROR_IO, - "Cannot copy Tauri Live2D asset: %s", source); - return BONGO_CAT_PATH_FAILURE; - } - return BONGO_CAT_PATH_CONTINUE; -} - -static bool copy_tauri_model_tree(const char *source, const char *target, - unsigned depth, BongoCatError *error) { - if (depth > TAURI_MODEL_COPY_DEPTH_CAP || - !bongo_cat_path_create_directory(target) || - !bongo_cat_path_enumerate(source, copy_tauri_model_child, - &(TauriModelCopy){target, error, depth})) { - if (error && !error->message[0]) bongo_cat_error_set(error, - BONGO_CAT_ERROR_IO, "Cannot copy Tauri Live2D directory: %s", - source); - return false; - } - return true; -} - -static bool copy_tauri_resource_tree(const char *source, const char *target, - unsigned depth, BongoCatError *error) { - if (depth > TAURI_MODEL_COPY_DEPTH_CAP || - !bongo_cat_path_enumerate(source, copy_tauri_resource_child, - &(TauriModelCopy){target, error, depth})) { - if (error && !error->message[0]) bongo_cat_error_set(error, - BONGO_CAT_ERROR_IO, "Cannot inspect Tauri resource directory: %s", - source); - return false; - } - return true; -} - static bool add_matrix(yyjson_mut_doc *document, yyjson_mut_val *mode, const char *name, const TauriKeyFiles *files) { yyjson_mut_val *matrix = yyjson_mut_obj_add_arr(document, mode, name); @@ -323,10 +125,8 @@ static bool add_matrix(yyjson_mut_doc *document, yyjson_mut_val *mode, } return true; } - static bool config_write(const char *path, BongoCatModelMode mode, - const TauriKeyFiles *left, const TauriKeyFiles *right, - int width, int height, BongoCatError *error) { + const TauriKeyFiles *left, const TauriKeyFiles *right, int width, int height, BongoCatError *error) { yyjson_mut_doc *document = yyjson_mut_doc_new(NULL); yyjson_mut_val *root = document ? yyjson_mut_obj(document) : NULL; if (document) yyjson_mut_doc_set_root(document, root); @@ -365,9 +165,7 @@ static bool config_write(const char *path, BongoCatModelMode mode, "Cannot write normalized Mver configuration: %s", path); return ok; } - -static bool copy_preview(const BongoCatImportCandidate *candidate, - const char *mode_root, BongoCatError *error) { +static bool copy_preview(const BongoCatImportCandidate *candidate, const char *mode_root, BongoCatError *error) { char source[BONGO_CAT_PATH_CAP], cat[BONGO_CAT_PATH_CAP]; char target[BONGO_CAT_PATH_CAP]; bool have_cover = resource_file(candidate, "cover.png", source, @@ -390,7 +188,6 @@ static bool copy_preview(const BongoCatImportCandidate *candidate, return copy_image_or_placeholder(have_background ? source : NULL, target, error); } - static bool copy_keys(const BongoCatImportCandidate *candidate, const char *resource_directory, const char *mode_root, const char *name, const char *mver_group, TauriKeyFiles *files, BongoCatError *error) { @@ -403,8 +200,10 @@ static bool copy_keys(const BongoCatImportCandidate *candidate, "resources") || !bongo_cat_path_join(source, sizeof(source), parent, name) || !bongo_cat_path_is_dir(source)) return true; } - if (!bongo_cat_path_enumerate(source, collect_keys, files)) return false; - qsort(files->values, files->count, sizeof(files->values[0]), compare_keys); + if (!bongo_cat_path_enumerate(source, bongo_cat_tauri_collect_keys, files)) + return false; + qsort(files->values, files->count, sizeof(files->values[0]), + bongo_cat_tauri_compare_keys); char destination[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(destination, sizeof(destination), mode_root, mver_group) || !bongo_cat_path_create_directory(destination)) @@ -421,9 +220,8 @@ static bool copy_keys(const BongoCatImportCandidate *candidate, } return true; } - -bool bongo_cat_import_tauri_convert_to_mver( - const BongoCatImportCandidate *candidate, const char *target, +bool bongo_cat_import_tauri_convert_to_mver(const BongoCatImportCandidate *candidate, + const char *target, BongoCatImportCandidate *installed, BongoCatError *error) { if (!candidate || !target || !installed || candidate->format != BONGO_CAT_IMPORT_TAURI) return false; @@ -435,7 +233,8 @@ bool bongo_cat_import_tauri_convert_to_mver( !bongo_cat_path_join(model_root, sizeof(model_root), mode_root, "cat_model") || !bongo_cat_path_create_directory(mode_root) || !bongo_cat_path_create_directory(model_root)) return false; - if (!copy_tauri_model_tree(candidate->directory, model_root, 0, error)) + if (!bongo_cat_tauri_copy_model_tree(candidate->directory, model_root, 0, + error)) return false; char resource_root[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(resource_root, sizeof(resource_root), diff --git a/src/runtime/model/import/model_import_tauri_mver_internal.h b/src/runtime/model/import/model_import_tauri_mver_internal.h new file mode 100644 index 00000000..e6270a54 --- /dev/null +++ b/src/runtime/model/import/model_import_tauri_mver_internal.h @@ -0,0 +1,26 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_TAURI_MVER_INTERNAL_H +#define BONGO_CAT_MODEL_IMPORT_TAURI_MVER_INTERNAL_H + +#include "model_import.h" +#include "bongo_cat/path.h" + +#define TAURI_KEY_CAP 256 + +typedef struct TauriKeyFile { + char name[BONGO_CAT_ID_CAP]; + char path[BONGO_CAT_PATH_CAP]; + int code; +} TauriKeyFile; + +typedef struct TauriKeyFiles { + TauriKeyFile values[TAURI_KEY_CAP]; + size_t count; +} TauriKeyFiles; + +BongoCatPathVisit bongo_cat_tauri_collect_keys(void *userdata, + const char *dirname, const char *name); +int bongo_cat_tauri_compare_keys(const void *left, const void *right); +bool bongo_cat_tauri_copy_model_tree(const char *source, const char *target, + unsigned depth, BongoCatError *error); + +#endif diff --git a/src/runtime/model/import/model_import_tauri_mver_keys.c b/src/runtime/model/import/model_import_tauri_mver_keys.c new file mode 100644 index 00000000..627ea9b0 --- /dev/null +++ b/src/runtime/model/import/model_import_tauri_mver_keys.c @@ -0,0 +1,86 @@ +#include "model_import_tauri_mver_internal.h" + +#include +#include +#include +#include + +static bool suffix(const char *name, const char *ending) { + size_t a = name ? strlen(name) : 0, b = ending ? strlen(ending) : 0; + return a >= b && SDL_strcasecmp(name + a - b, ending) == 0; +} + +static int key_code(const char *filename) { + char name[BONGO_CAT_ID_CAP]; + snprintf(name, sizeof(name), "%s", filename ? filename : ""); + char *dot = strrchr(name, '.'); + if (!dot || SDL_strcasecmp(dot, ".png") != 0) return -1; + *dot = '\0'; + char *end = NULL; + long numeric = strtol(name, &end, 10); + if (name[0] && end && !end[0] && numeric >= 0 && numeric <= 255) + return (int)numeric; + if (strlen(name) == 4 && SDL_strncasecmp(name, "Key", 3) == 0 && + name[3] >= 'A' && name[3] <= 'Z') return name[3]; + if (strlen(name) == 4 && SDL_strncasecmp(name, "Num", 3) == 0 && + name[3] >= '0' && name[3] <= '9') return name[3]; + if ((name[0] == 'F' || name[0] == 'f') && strlen(name) <= 3) { + int value = atoi(name + 1); + if (value >= 1 && value <= 12) return 111 + value; + } + static const struct { const char *name; int code; } map[] = { + {"Backspace", 8}, {"BackSpace", 8}, {"Tab", 9}, {"Return", 13}, + {"Pause", 19}, {"CapsLock", 20}, {"Escape", 27}, {"Space", 32}, + {"PageUp", 33}, {"PageDown", 34}, {"End", 35}, {"Home", 36}, + {"LeftArrow", 37}, {"UpArrow", 38}, {"RightArrow", 39}, + {"DownArrow", 40}, {"PrintScreen", 44}, {"Insert", 45}, + {"Delete", 46}, {"Meta", 91}, {"MetaLeft", 91}, {"MetaRight", 92}, + {"Apps", 93}, {"Kp0", 96}, {"Kp1", 97}, {"Kp2", 98}, + {"Kp3", 99}, {"Kp4", 100}, {"Kp5", 101}, {"Kp6", 102}, + {"Kp7", 103}, {"Kp8", 104}, {"Kp9", 105}, + {"KpMultiply", 106}, {"KpPlus", 107}, {"KpMinus", 109}, + {"KpDecimal", 110}, {"KpDivide", 111}, {"NumLock", 144}, + {"ScrollLock", 145}, {"Semicolon", 186}, {"SemiColon", 186}, + {"Equal", 187}, {"Comma", 188}, {"Minus", 189}, + {"Period", 190}, {"Dot", 190}, {"Slash", 191}, + {"BackQuote", 192}, {"BracketLeft", 219}, {"LeftBracket", 219}, + {"Backslash", 220}, {"BracketRight", 221}, {"RightBracket", 221}, + {"Quote", 222}, {"Shift", 16}, {"ShiftLeft", 16}, + {"ShiftRight", 16}, {"Control", 17}, {"ControlLeft", 17}, + {"ControlRight", 17}, {"Alt", 18}, {"AltGr", 18} + }; + for (size_t i = 0; i < sizeof(map) / sizeof(map[0]); ++i) + if (SDL_strcasecmp(name, map[i].name) == 0) return map[i].code; + static const struct { const char *name; int code; } gamepad[] = { + {"South", 0}, {"East", 1}, {"West", 2}, {"North", 3}, + {"LeftTrigger", 4}, {"RightTrigger", 5}, {"LeftTrigger2", 6}, + {"RightTrigger2", 7}, {"LeftThumb", 8}, {"RightThumb", 9}, + {"DPadLeft", 10}, {"DPadRight", 11}, {"DPadUp", 12}, + {"DPadDown", 13}, {"Start", 14}, {"Select", 15} + }; + for (size_t i = 0; i < sizeof(gamepad) / sizeof(gamepad[0]); ++i) + if (SDL_strcasecmp(name, gamepad[i].name) == 0) return gamepad[i].code; + return -1; +} + +BongoCatPathVisit bongo_cat_tauri_collect_keys(void *userdata, + const char *dirname, const char *name) { + TauriKeyFiles *files = userdata; + if (files->count >= TAURI_KEY_CAP || !suffix(name, ".png")) + return BONGO_CAT_PATH_CONTINUE; + int code = key_code(name); + if (code < 0) return BONGO_CAT_PATH_CONTINUE; + TauriKeyFile *item = &files->values[files->count]; + if (!bongo_cat_path_join(item->path, sizeof(item->path), dirname, name)) + return BONGO_CAT_PATH_FAILURE; + if (!bongo_cat_path_is_file(item->path)) return BONGO_CAT_PATH_CONTINUE; + snprintf(item->name, sizeof(item->name), "%s", name); + item->code = code; + files->count++; + return BONGO_CAT_PATH_CONTINUE; +} + +int bongo_cat_tauri_compare_keys(const void *left, const void *right) { + const TauriKeyFile *a = left, *b = right; + return SDL_strcasecmp(a->name, b->name); +} diff --git a/src/runtime/model/import/model_import_tauri_mver_tree.c b/src/runtime/model/import/model_import_tauri_mver_tree.c new file mode 100644 index 00000000..07e8435c --- /dev/null +++ b/src/runtime/model/import/model_import_tauri_mver_tree.c @@ -0,0 +1,101 @@ +#include "model_import_tauri_mver_internal.h" + +#include + +#define TAURI_MODEL_COPY_DEPTH_CAP 32 + +typedef struct TauriModelCopy { + const char *target_root; + BongoCatError *error; + unsigned depth; +} TauriModelCopy; + +static bool copy_resource_tree(const char *source, const char *target, + unsigned depth, BongoCatError *error); + +static bool interface_resource(const char *name) { + static const char *const files[] = { + "cover.png", "background.png", "cat.png", "bg.png", + "mousebg.png", "tabletbg.png" + }; + if (SDL_strcasecmp(name, "left-keys") == 0 || + SDL_strcasecmp(name, "right-keys") == 0) return true; + for (size_t i = 0; i < sizeof(files) / sizeof(files[0]); ++i) + if (SDL_strcasecmp(name, files[i]) == 0) return true; + return false; +} + +static BongoCatPathVisit copy_resource_child(void *userdata, + const char *dirname, const char *name) { + TauriModelCopy *context = userdata; + if (!name || name[0] == '.' || interface_resource(name)) + return BONGO_CAT_PATH_CONTINUE; + char source[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(source, sizeof(source), dirname, name) || + !bongo_cat_path_join(target, sizeof(target), context->target_root, + name)) return BONGO_CAT_PATH_FAILURE; + if (bongo_cat_path_is_dir(source)) + return bongo_cat_tauri_copy_model_tree(source, target, + context->depth + 1, context->error) + ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; + if (!bongo_cat_path_is_file(source)) return BONGO_CAT_PATH_CONTINUE; + if (!bongo_cat_path_create_directory(context->target_root) || + !bongo_cat_path_copy_file(source, target)) { + bongo_cat_error_set(context->error, BONGO_CAT_ERROR_IO, + "Cannot preserve Tauri Live2D resource: %s", source); + return BONGO_CAT_PATH_FAILURE; + } + return BONGO_CAT_PATH_CONTINUE; +} + +static BongoCatPathVisit copy_model_child(void *userdata, + const char *dirname, const char *name) { + TauriModelCopy *context = userdata; + if (!name || name[0] == '.') return BONGO_CAT_PATH_CONTINUE; + char source[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(source, sizeof(source), dirname, name) || + !bongo_cat_path_join(target, sizeof(target), context->target_root, + name)) return BONGO_CAT_PATH_FAILURE; + if (context->depth == 0 && bongo_cat_path_is_dir(source) && + SDL_strcasecmp(name, "resources") == 0) + return copy_resource_tree(source, target, context->depth + 1, + context->error) ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; + if (bongo_cat_path_is_dir(source)) + return bongo_cat_tauri_copy_model_tree(source, target, + context->depth + 1, context->error) + ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; + if (!bongo_cat_path_is_file(source) || + !bongo_cat_path_copy_file(source, target)) { + bongo_cat_error_set(context->error, BONGO_CAT_ERROR_IO, + "Cannot copy Tauri Live2D asset: %s", source); + return BONGO_CAT_PATH_FAILURE; + } + return BONGO_CAT_PATH_CONTINUE; +} + +bool bongo_cat_tauri_copy_model_tree(const char *source, const char *target, + unsigned depth, BongoCatError *error) { + if (depth > TAURI_MODEL_COPY_DEPTH_CAP || + !bongo_cat_path_create_directory(target) || + !bongo_cat_path_enumerate(source, copy_model_child, + &(TauriModelCopy){target, error, depth})) { + if (error && !error->message[0]) bongo_cat_error_set(error, + BONGO_CAT_ERROR_IO, "Cannot copy Tauri Live2D directory: %s", + source); + return false; + } + return true; +} + +static bool copy_resource_tree(const char *source, const char *target, + unsigned depth, BongoCatError *error) { + if (depth > TAURI_MODEL_COPY_DEPTH_CAP || + !bongo_cat_path_enumerate(source, copy_resource_child, + &(TauriModelCopy){target, error, depth})) { + if (error && !error->message[0]) bongo_cat_error_set(error, + BONGO_CAT_ERROR_IO, "Cannot inspect Tauri resource directory: %s", + source); + return false; + } + return true; +} diff --git a/src/runtime/model/import/mver/model_import_mver.c b/src/runtime/model/import/mver/model_import_mver.c index d98a4b85..4e51a0c4 100644 --- a/src/runtime/model/import/mver/model_import_mver.c +++ b/src/runtime/model/import/mver/model_import_mver.c @@ -2,6 +2,7 @@ #include "model_import_mver_internal.h" #include "runtime.h" #include "bongo_cat/file.h" +#include "bongo_cat/image.h" #include "bongo_cat/path.h" #include diff --git a/src/ui/preferences/preferences_import.c b/src/ui/preferences/preferences_import.c index 1620dca0..49cc7971 100644 --- a/src/ui/preferences/preferences_import.c +++ b/src/ui/preferences/preferences_import.c @@ -1,23 +1,19 @@ #include "preferences_import_internal.h" #include "preferences_state.h" #include "model_import.h" - #ifdef _WIN32 #include "windows_dialog.h" #endif - #include #include #include #include - static void free_job(BongoCatImportJob *job) { if (!job) return; for (size_t i = 0; i < job->count; ++i) SDL_free(job->paths[i]); SDL_free(job->paths); SDL_free(job); } - static void release_dialog(BongoCatImportDialog *dialog) { bool destroy = false; SDL_LockMutex(dialog->mutex); @@ -28,7 +24,6 @@ static void release_dialog(BongoCatImportDialog *dialog) { SDL_free(dialog); } } - void bongo_cat_preferences_import_destroy(BongoCatImportDialog *dialog) { if (!dialog) return; SDL_LockMutex(dialog->mutex); @@ -56,7 +51,6 @@ void bongo_cat_preferences_import_destroy(BongoCatImportDialog *dialog) { if (release_worker) release_dialog(dialog); release_dialog(dialog); } - static BongoCatImportJob *copy_job(const char *const *files) { if (!files || !files[0]) return NULL; size_t count = 0; diff --git a/tests/model_import/test_mver_container.c b/tests/model_import/test_mver_container.c index d6d10b1c..66858dcd 100644 --- a/tests/model_import/test_mver_container.c +++ b/tests/model_import/test_mver_container.c @@ -149,10 +149,10 @@ void test_mver_container_discovery(void) { bongo_cat_models_init(&app->models); CHECK(bongo_cat_import_installed_models(app, models_root, &error) == BONGO_CAT_OK); - CHECK(app->models.count + 1 == receipt.count && - !bongo_cat_models_find(&app->models, receipt.ids[0]) && - bongo_cat_models_find(&app->models, receipt.ids[1]) && - bongo_cat_path_is_dir(stored)); + CHECK(app->models.count + 1 == receipt.count); + CHECK(!bongo_cat_models_find(&app->models, receipt.ids[0])); + CHECK(bongo_cat_models_find(&app->models, receipt.ids[1])); + CHECK(bongo_cat_path_is_dir(stored)); CHECK(bongo_cat_settings_restore_model_package(&app->settings, receipt.ids[0])); bongo_cat_models_init(&app->models); diff --git a/tests/model_import/test_mver_import.c b/tests/model_import/test_mver_import.c index 18d145ed..0abccfe0 100644 --- a/tests/model_import/test_mver_import.c +++ b/tests/model_import/test_mver_import.c @@ -187,196 +187,6 @@ static void model_visual_curve(void) { CHECK(complete == 1.0f && !value.model_load_visual_active); } -static void check_portable_images(const char *root, - const char *const *relative, size_t count) { - char path[BONGO_CAT_PATH_CAP]; - for (size_t i = 0; i < count; ++i) { - CHECK(child(path, sizeof(path), root, relative[i], false)); - CHECK(bongo_cat_path_is_file(path)); - CHECK(bongo_cat_image_info(path, NULL, NULL)); - } -} - -static void tauri_exact_discovery(void) { - char *temporary = SDL_GetCurrentDirectory(); - CHECK(temporary != NULL); - if (!temporary) return; - char root[BONGO_CAT_PATH_CAP], model[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; - snprintf(root, sizeof(root), "%s/bongo-cat-tauri-%llu", temporary, - (unsigned long long)SDL_GetTicksNS()); - CHECK(SDL_CreateDirectory(root)); - CHECK(child(model, sizeof(model), root, "keyboard", true)); - CHECK(child(path, sizeof(path), model, "cat.model3.json", false)); - CHECK(write_text(path, "{\"Version\":3,\"FileReferences\":{" - "\"Moc\":\"cat.moc3\",\"Textures\":[" - "\"resources/model-texture.png\"]}}")); - CHECK(child(path, sizeof(path), model, "cat.moc3", false)); - CHECK(write_text(path, "MOC3")); - char tray[BONGO_CAT_PATH_CAP]; - snprintf(tray, sizeof(tray), "%s/resources/assets/tray.png", - BONGO_CAT_NATIVE_SOURCE_DIR); - char resources[BONGO_CAT_PATH_CAP], left_keys[BONGO_CAT_PATH_CAP]; - char right_keys[BONGO_CAT_PATH_CAP], east[BONGO_CAT_PATH_CAP]; - char numeric[BONGO_CAT_PATH_CAP], model_texture[BONGO_CAT_PATH_CAP]; - CHECK(child(resources, sizeof(resources), model, "resources", true)); - CHECK(child(model_texture, sizeof(model_texture), resources, - "model-texture.png", false)); - CHECK(SDL_CopyFile(tray, model_texture)); - CHECK(child(path, sizeof(path), resources, "background.png", false)); - CHECK(SDL_CopyFile(tray, path)); - CHECK(child(path, sizeof(path), resources, "cover.png", false)); - CHECK(SDL_CopyFile(tray, path)); - CHECK(child(left_keys, sizeof(left_keys), resources, "left-keys", true)); - CHECK(child(numeric, sizeof(numeric), left_keys, "24.png", false)); - CHECK(SDL_CopyFile(tray, numeric)); - CHECK(child(right_keys, sizeof(right_keys), resources, "right-keys", true)); - CHECK(child(east, sizeof(east), right_keys, "East.png", false)); - CHECK(SDL_CopyFile(tray, east)); - char nested[BONGO_CAT_PATH_CAP], nested_resources[BONGO_CAT_PATH_CAP]; - CHECK(child(nested, sizeof(nested), model, "nested", true)); - CHECK(child(nested_resources, sizeof(nested_resources), nested, - "resources", true)); - CHECK(child(path, sizeof(path), nested_resources, "keep.bin", false)); - CHECK(write_text(path, "keep")); - BongoCatImportDiscovery discovery = {0}; - BongoCatError error = {0}; - CHECK(bongo_cat_import_tauri_discover_exact(model, &discovery, &error) == 1); - CHECK(discovery.count == 1 && - discovery.candidates[0].format == BONGO_CAT_IMPORT_TAURI && - discovery.candidates[0].mode == BONGO_CAT_MODE_GAMEPAD && - discovery.candidates[0].gamepad_buttons); - CHECK(bongo_cat_path_remove(east)); - CHECK(child(path, sizeof(path), right_keys, "KeyA.png", false)); - CHECK(SDL_CopyFile(tray, path)); - BongoCatImportDiscovery keyboard = {0}; - CHECK(bongo_cat_import_tauri_discover_exact(model, &keyboard, &error) == 1); - CHECK(keyboard.count == 1 && - keyboard.candidates[0].mode == BONGO_CAT_MODE_KEYBOARD && - !keyboard.candidates[0].gamepad_buttons); - char models_root[BONGO_CAT_PATH_CAP]; - CHECK(child(models_root, sizeof(models_root), root, "models", true)); - BongoCatImportReceipt receipt = {0}; - CHECK(bongo_cat_import_install(model, models_root, &receipt, &error) == - BONGO_CAT_OK); - CHECK(receipt.count == 1 && receipt.installed_count == 1 && - strcmp(receipt.ids[0], "keyboard") == 0); - char package[BONGO_CAT_PATH_CAP]; - CHECK(child(package, sizeof(package), models_root, receipt.ids[0], false)); - CHECK(child(path, sizeof(path), package, "config.json", false) && - bongo_cat_path_is_file(path)); - yyjson_doc *normalized = bongo_cat_json_read_file(path, 0, NULL); - yyjson_val *normalized_root = normalized - ? yyjson_doc_get_root(normalized) : NULL; - yyjson_val *normalized_mode = yyjson_obj_get(normalized_root, "keyboard"); - yyjson_val *normalized_left = yyjson_obj_get(normalized_mode, "lefthand"); - yyjson_val *window_size = yyjson_obj_get( - yyjson_obj_get(normalized_root, "decoration"), "window_size"); - int expected_width = 0, expected_height = 0; - CHECK(bongo_cat_image_info(tray, &expected_width, &expected_height)); - CHECK(yyjson_get_int(yyjson_obj_get(normalized_root, "mode")) == 2 && - yyjson_is_arr(normalized_left) && - yyjson_is_arr(yyjson_obj_get(normalized_mode, "righthand")) && - yyjson_get_int(yyjson_arr_get(yyjson_arr_get(normalized_left, 0), 0)) == - 24 && - yyjson_get_int(yyjson_arr_get(window_size, 0)) == expected_width && - yyjson_get_int(yyjson_arr_get(window_size, 1)) == expected_height); - yyjson_doc_free(normalized); - CHECK(child(path, sizeof(path), package, - "img/keyboard/cat_model/cat.model3.json", false) && - bongo_cat_path_is_file(path)); - CHECK(child(path, sizeof(path), package, - "img/keyboard/cat_model/resources/model-texture.png", false) && - bongo_cat_path_is_file(path)); - CHECK(child(path, sizeof(path), package, - "img/keyboard/cat_model/resources/right-keys", false) && - !bongo_cat_path_is_dir(path)); - CHECK(child(path, sizeof(path), package, - "img/keyboard/cat_model/nested/resources/keep.bin", false) && - bongo_cat_path_is_file(path)); - static const char *const keyboard_runtime[] = { - "img/keyboard/lefthand/leftup.png", - "img/keyboard/righthand/rightup.png" - }; - check_portable_images(package, keyboard_runtime, - sizeof(keyboard_runtime) / sizeof(keyboard_runtime[0])); - CHECK(child(path, sizeof(path), package, ".bongo-cat-adapter", false) && - !bongo_cat_path_is_dir(path)); - CHECK(child(path, sizeof(path), package, ".bongo-cat-package.json", false) && - !bongo_cat_path_is_file(path)); - BongoCatApp *app = calloc(1, sizeof(*app)); - CHECK(app != NULL); - if (app) { - snprintf(app->models_root, sizeof(app->models_root), "%s", models_root); - CHECK(child(app->cache_root, sizeof(app->cache_root), root, "cache", - true)); - bongo_cat_models_init(&app->models); - CHECK(bongo_cat_import_installed_models(app, models_root, &error) == - BONGO_CAT_OK); - CHECK(app->models.count == 1 && - app->models.entries[0].source_format == BONGO_CAT_MODEL_SOURCE_MVER && - strcmp(app->models.entries[0].storage_directory, package) == 0 && - strcmp(app->models.entries[0].adapter_directory, package) != 0); - free(app); - } - BongoCatImportCandidate portable = keyboard.candidates[0], installed = {0}; - char portable_root[BONGO_CAT_PATH_CAP]; - portable.mode = BONGO_CAT_MODE_STANDARD; - portable.gamepad_buttons = false; - CHECK(child(portable_root, sizeof(portable_root), root, - "portable-standard", false)); - error = (BongoCatError){0}; - CHECK(bongo_cat_import_tauri_convert_to_mver(&portable, portable_root, - &installed, &error)); - static const char *const standard_runtime[] = { - "img/standard/arm.png", "img/standard/up.png", - "img/standard/mousebg.png", "img/standard/l2dmousebg.png", - "img/standard/mouse.png", "img/standard/mouse_left.png", - "img/standard/mouse_right.png", "img/standard/mouse_side.png", - "img/standard/tabletbg.png", "img/standard/l2dtabletbg.png", - "img/standard/tablet.png", "img/standard/tablet_left.png", - "img/standard/tablet_right.png" - }; - check_portable_images(portable_root, standard_runtime, - sizeof(standard_runtime) / sizeof(standard_runtime[0])); - CHECK(child(path, sizeof(path), portable_root, "img/standard/bg.png", - false) && !bongo_cat_path_is_file(path)); - char adapter_root[BONGO_CAT_PATH_CAP]; - CHECK(child(adapter_root, sizeof(adapter_root), root, - "portable-standard-adapter", true)); - error = (BongoCatError){0}; - CHECK(bongo_cat_import_prepare_adapter(&installed, adapter_root, &error)); - CHECK(child(path, sizeof(path), adapter_root, - BONGO_CAT_MODEL_ADAPTER_FILE, false)); - yyjson_doc *adapter = bongo_cat_json_read_file(path, 0, NULL); - yyjson_val *pointer = adapter ? yyjson_obj_get( - yyjson_doc_get_root(adapter), "standardPointer") : NULL; - CHECK(yyjson_is_obj(pointer) && - !yyjson_get_bool(yyjson_obj_get(pointer, "enabled"))); - yyjson_doc_free(adapter); - CHECK(child(path, sizeof(path), adapter_root, - "resources/mver-pointer/arm.png", false) && - !bongo_cat_path_is_file(path)); - portable.mode = BONGO_CAT_MODE_GAMEPAD; - portable.gamepad_buttons = true; - installed = (BongoCatImportCandidate){0}; - CHECK(child(portable_root, sizeof(portable_root), root, - "portable-gamepad", false)); - error = (BongoCatError){0}; - CHECK(bongo_cat_import_tauri_convert_to_mver(&portable, portable_root, - &installed, &error)); - static const char *const gamepad_runtime[] = { - "img/gamepad/arm_L.png", "img/gamepad/arm_R.png", - "img/gamepad/left_stick.png", "img/gamepad/left_stick_down.png", - "img/gamepad/right_stick.png", "img/gamepad/right_stick_down.png", - "img/gamepad/lefthand/leftup.png", - "img/gamepad/righthand/rightup.png" - }; - check_portable_images(portable_root, gamepad_runtime, - sizeof(gamepad_runtime) / sizeof(gamepad_runtime[0])); - CHECK(bongo_cat_model_remove_tree(root, NULL)); - SDL_free(temporary); -} - int main(void) { failures += test_preferences_text(); CHECK(chord("[17,65]", true, "Control+A")); @@ -406,7 +216,7 @@ int main(void) { failures += test_mver_nearby_refresh(); failures += test_model_import_identity(); test_mver_container_discovery(); - tauri_exact_discovery(); + test_tauri_portable(); failures += test_slim_package(); return failures ? 1 : 0; } diff --git a/tests/model_import/test_mver_import_internal.h b/tests/model_import/test_mver_import_internal.h index e090e9bf..2a63d14e 100644 --- a/tests/model_import/test_mver_import_internal.h +++ b/tests/model_import/test_mver_import_internal.h @@ -12,5 +12,6 @@ extern int failures; } } while (0) void test_mver_container_discovery(void); +void test_tauri_portable(void); #endif diff --git a/tests/model_import/test_tauri_portable.c b/tests/model_import/test_tauri_portable.c new file mode 100644 index 00000000..911da094 --- /dev/null +++ b/tests/model_import/test_tauri_portable.c @@ -0,0 +1,206 @@ +#include "model_import.h" +#include "model_import_mver_internal.h" +#include "model_storage.h" +#include "runtime.h" +#include "test_mver_import_internal.h" +#include "test_mver_support.h" +#include "bongo_cat/image.h" +#include "bongo_cat/json.h" +#include "bongo_cat/path.h" + +#include +#include +#include +#include +#include + +static void check_portable_images(const char *root, + const char *const *relative, size_t count) { + char path[BONGO_CAT_PATH_CAP]; + for (size_t i = 0; i < count; ++i) { + CHECK(child(path, sizeof(path), root, relative[i], false)); + CHECK(bongo_cat_path_is_file(path)); + CHECK(bongo_cat_image_info(path, NULL, NULL)); + } +} + +void test_tauri_portable(void) { + char *temporary = SDL_GetCurrentDirectory(); + CHECK(temporary != NULL); + if (!temporary) return; + char root[BONGO_CAT_PATH_CAP], model[BONGO_CAT_PATH_CAP]; + char path[BONGO_CAT_PATH_CAP]; + snprintf(root, sizeof(root), "%s/bongo-cat-tauri-%llu", temporary, + (unsigned long long)SDL_GetTicksNS()); + CHECK(SDL_CreateDirectory(root)); + CHECK(child(model, sizeof(model), root, "keyboard", true)); + CHECK(child(path, sizeof(path), model, "cat.model3.json", false)); + CHECK(write_text(path, "{\"Version\":3,\"FileReferences\":{" + "\"Moc\":\"cat.moc3\",\"Textures\":[" + "\"resources/model-texture.png\"]}}")); + CHECK(child(path, sizeof(path), model, "cat.moc3", false)); + CHECK(write_text(path, "MOC3")); + char tray[BONGO_CAT_PATH_CAP]; + snprintf(tray, sizeof(tray), "%s/resources/assets/tray.png", + BONGO_CAT_NATIVE_SOURCE_DIR); + char resources[BONGO_CAT_PATH_CAP], left_keys[BONGO_CAT_PATH_CAP]; + char right_keys[BONGO_CAT_PATH_CAP], east[BONGO_CAT_PATH_CAP]; + char numeric[BONGO_CAT_PATH_CAP], model_texture[BONGO_CAT_PATH_CAP]; + CHECK(child(resources, sizeof(resources), model, "resources", true)); + CHECK(child(model_texture, sizeof(model_texture), resources, + "model-texture.png", false)); + CHECK(SDL_CopyFile(tray, model_texture)); + CHECK(child(path, sizeof(path), resources, "background.png", false)); + CHECK(SDL_CopyFile(tray, path)); + CHECK(child(path, sizeof(path), resources, "cover.png", false)); + CHECK(SDL_CopyFile(tray, path)); + CHECK(child(left_keys, sizeof(left_keys), resources, "left-keys", true)); + CHECK(child(numeric, sizeof(numeric), left_keys, "24.png", false)); + CHECK(SDL_CopyFile(tray, numeric)); + CHECK(child(right_keys, sizeof(right_keys), resources, "right-keys", true)); + CHECK(child(east, sizeof(east), right_keys, "East.png", false)); + CHECK(SDL_CopyFile(tray, east)); + char nested[BONGO_CAT_PATH_CAP], nested_resources[BONGO_CAT_PATH_CAP]; + CHECK(child(nested, sizeof(nested), model, "nested", true)); + CHECK(child(nested_resources, sizeof(nested_resources), nested, + "resources", true)); + CHECK(child(path, sizeof(path), nested_resources, "keep.bin", false)); + CHECK(write_text(path, "keep")); + BongoCatImportDiscovery discovery = {0}; + BongoCatError error = {0}; + CHECK(bongo_cat_import_tauri_discover_exact(model, &discovery, &error) == 1); + CHECK(discovery.count == 1 && + discovery.candidates[0].format == BONGO_CAT_IMPORT_TAURI && + discovery.candidates[0].mode == BONGO_CAT_MODE_GAMEPAD && + discovery.candidates[0].gamepad_buttons); + CHECK(bongo_cat_path_remove(east)); + CHECK(child(path, sizeof(path), right_keys, "KeyA.png", false)); + CHECK(SDL_CopyFile(tray, path)); + BongoCatImportDiscovery keyboard = {0}; + CHECK(bongo_cat_import_tauri_discover_exact(model, &keyboard, &error) == 1); + CHECK(keyboard.count == 1 && + keyboard.candidates[0].mode == BONGO_CAT_MODE_KEYBOARD && + !keyboard.candidates[0].gamepad_buttons); + char models_root[BONGO_CAT_PATH_CAP]; + CHECK(child(models_root, sizeof(models_root), root, "models", true)); + BongoCatImportReceipt receipt = {0}; + CHECK(bongo_cat_import_install(model, models_root, &receipt, &error) == + BONGO_CAT_OK); + CHECK(receipt.count == 1 && receipt.installed_count == 1 && + strcmp(receipt.ids[0], "keyboard") == 0); + char package[BONGO_CAT_PATH_CAP]; + CHECK(child(package, sizeof(package), models_root, receipt.ids[0], false)); + CHECK(child(path, sizeof(path), package, "config.json", false) && + bongo_cat_path_is_file(path)); + yyjson_doc *normalized = bongo_cat_json_read_file(path, 0, NULL); + yyjson_val *normalized_root = normalized + ? yyjson_doc_get_root(normalized) : NULL; + yyjson_val *normalized_mode = yyjson_obj_get(normalized_root, "keyboard"); + yyjson_val *normalized_left = yyjson_obj_get(normalized_mode, "lefthand"); + yyjson_val *window_size = yyjson_obj_get( + yyjson_obj_get(normalized_root, "decoration"), "window_size"); + int expected_width = 0, expected_height = 0; + CHECK(bongo_cat_image_info(tray, &expected_width, &expected_height)); + CHECK(yyjson_get_int(yyjson_obj_get(normalized_root, "mode")) == 2 && + yyjson_is_arr(normalized_left) && + yyjson_is_arr(yyjson_obj_get(normalized_mode, "righthand")) && + yyjson_get_int(yyjson_arr_get(yyjson_arr_get(normalized_left, 0), 0)) == + 24 && + yyjson_get_int(yyjson_arr_get(window_size, 0)) == expected_width && + yyjson_get_int(yyjson_arr_get(window_size, 1)) == expected_height); + yyjson_doc_free(normalized); + CHECK(child(path, sizeof(path), package, + "img/keyboard/cat_model/cat.model3.json", false) && + bongo_cat_path_is_file(path)); + CHECK(child(path, sizeof(path), package, + "img/keyboard/cat_model/resources/model-texture.png", false) && + bongo_cat_path_is_file(path)); + CHECK(child(path, sizeof(path), package, + "img/keyboard/cat_model/resources/right-keys", false) && + !bongo_cat_path_is_dir(path)); + CHECK(child(path, sizeof(path), package, + "img/keyboard/cat_model/nested/resources/keep.bin", false) && + bongo_cat_path_is_file(path)); + static const char *const keyboard_runtime[] = { + "img/keyboard/lefthand/leftup.png", + "img/keyboard/righthand/rightup.png" + }; + check_portable_images(package, keyboard_runtime, + sizeof(keyboard_runtime) / sizeof(keyboard_runtime[0])); + CHECK(child(path, sizeof(path), package, ".bongo-cat-adapter", false) && + !bongo_cat_path_is_dir(path)); + CHECK(child(path, sizeof(path), package, ".bongo-cat-package.json", false) && + !bongo_cat_path_is_file(path)); + BongoCatApp *app = calloc(1, sizeof(*app)); + CHECK(app != NULL); + if (app) { + snprintf(app->models_root, sizeof(app->models_root), "%s", models_root); + CHECK(child(app->cache_root, sizeof(app->cache_root), root, "cache", + true)); + bongo_cat_models_init(&app->models); + CHECK(bongo_cat_import_installed_models(app, models_root, &error) == + BONGO_CAT_OK); + CHECK(app->models.count == 1 && + app->models.entries[0].source_format == BONGO_CAT_MODEL_SOURCE_MVER && + strcmp(app->models.entries[0].storage_directory, package) == 0 && + strcmp(app->models.entries[0].adapter_directory, package) != 0); + free(app); + } + BongoCatImportCandidate portable = keyboard.candidates[0], installed = {0}; + char portable_root[BONGO_CAT_PATH_CAP]; + portable.mode = BONGO_CAT_MODE_STANDARD; + portable.gamepad_buttons = false; + CHECK(child(portable_root, sizeof(portable_root), root, + "portable-standard", false)); + error = (BongoCatError){0}; + CHECK(bongo_cat_import_tauri_convert_to_mver(&portable, portable_root, + &installed, &error)); + static const char *const standard_runtime[] = { + "img/standard/arm.png", "img/standard/up.png", + "img/standard/mousebg.png", "img/standard/l2dmousebg.png", + "img/standard/mouse.png", "img/standard/mouse_left.png", + "img/standard/mouse_right.png", "img/standard/mouse_side.png", + "img/standard/tabletbg.png", "img/standard/l2dtabletbg.png", + "img/standard/tablet.png", "img/standard/tablet_left.png", + "img/standard/tablet_right.png" + }; + check_portable_images(portable_root, standard_runtime, + sizeof(standard_runtime) / sizeof(standard_runtime[0])); + CHECK(child(path, sizeof(path), portable_root, "img/standard/bg.png", + false) && !bongo_cat_path_is_file(path)); + char adapter_root[BONGO_CAT_PATH_CAP]; + CHECK(child(adapter_root, sizeof(adapter_root), root, + "portable-standard-adapter", true)); + error = (BongoCatError){0}; + CHECK(bongo_cat_import_prepare_adapter(&installed, adapter_root, &error)); + CHECK(child(path, sizeof(path), adapter_root, + BONGO_CAT_MODEL_ADAPTER_FILE, false)); + yyjson_doc *adapter = bongo_cat_json_read_file(path, 0, NULL); + yyjson_val *pointer = adapter ? yyjson_obj_get( + yyjson_doc_get_root(adapter), "standardPointer") : NULL; + CHECK(yyjson_is_obj(pointer) && + !yyjson_get_bool(yyjson_obj_get(pointer, "enabled"))); + yyjson_doc_free(adapter); + CHECK(child(path, sizeof(path), adapter_root, + "resources/mver-pointer/arm.png", false) && + !bongo_cat_path_is_file(path)); + portable.mode = BONGO_CAT_MODE_GAMEPAD; + portable.gamepad_buttons = true; + installed = (BongoCatImportCandidate){0}; + CHECK(child(portable_root, sizeof(portable_root), root, + "portable-gamepad", false)); + error = (BongoCatError){0}; + CHECK(bongo_cat_import_tauri_convert_to_mver(&portable, portable_root, + &installed, &error)); + static const char *const gamepad_runtime[] = { + "img/gamepad/arm_L.png", "img/gamepad/arm_R.png", + "img/gamepad/left_stick.png", "img/gamepad/left_stick_down.png", + "img/gamepad/right_stick.png", "img/gamepad/right_stick_down.png", + "img/gamepad/lefthand/leftup.png", + "img/gamepad/righthand/rightup.png" + }; + check_portable_images(portable_root, gamepad_runtime, + sizeof(gamepad_runtime) / sizeof(gamepad_runtime[0])); + CHECK(bongo_cat_model_remove_tree(root, NULL)); + SDL_free(temporary); +} From ea544bf84526ec96ff5fe967d7d6d2bc75611fa7 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 26 Aug 2026 21:12:58 +0800 Subject: [PATCH 111/334] fix: omit Mver runtime resources on import --- src/runtime/model/import/model_import_format.c | 10 +++------- src/runtime/model/import/model_import_package.c | 3 +++ tests/model_import/test_mver_container.c | 10 ++++++++++ 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/runtime/model/import/model_import_format.c b/src/runtime/model/import/model_import_format.c index 138f105e..0d87c257 100644 --- a/src/runtime/model/import/model_import_format.c +++ b/src/runtime/model/import/model_import_format.c @@ -91,17 +91,13 @@ static bool model_at(const char *directory, char *setting, size_t capacity) { bongo_cat_import_manifest_valid(directory, setting, NULL); } -static bool find_mode_model(const char *package, const char *mode_root, char *directory, +static bool find_mode_model(const char *mode_root, char *directory, size_t directory_capacity, char *setting, size_t setting_capacity) { if (bongo_cat_path_join(directory, directory_capacity, mode_root, "cat_model") && bongo_cat_path_is_dir(directory) && model_at(directory, setting, setting_capacity)) return true; snprintf(directory, directory_capacity, "%s", mode_root); - if (model_at(directory, setting, setting_capacity)) return true; - char resources[BONGO_CAT_PATH_CAP]; - return bongo_cat_path_join(resources, sizeof(resources), package, "Resources") && - bongo_cat_path_join(directory, directory_capacity, resources, "cat") && - bongo_cat_path_is_dir(directory) && model_at(directory, setting, setting_capacity); + return model_at(directory, setting, setting_capacity); } static bool add_mode(BongoCatImportDiscovery *discovery, const char *source, @@ -123,7 +119,7 @@ static bool add_mode(BongoCatImportDiscovery *discovery, const char *source, if (!mode_uses_live2d(mode_config)) return true; if (discovery->count >= BONGO_CAT_IMPORT_CANDIDATE_CAP) return false; BongoCatImportCandidate *candidate = &discovery->candidates[discovery->count]; - if (!find_mode_model(source, mode_root, candidate->directory, sizeof(candidate->directory), + if (!find_mode_model(mode_root, candidate->directory, sizeof(candidate->directory), candidate->setting, sizeof(candidate->setting))) { bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, "Mver mode contains no valid Live2D model: %s", mode_root); diff --git a/src/runtime/model/import/model_import_package.c b/src/runtime/model/import/model_import_package.c index 89af050e..f8cffc45 100644 --- a/src/runtime/model/import/model_import_package.c +++ b/src/runtime/model/import/model_import_package.c @@ -85,6 +85,9 @@ static bool copy_mver_tree(const char *source, const char *target, static BongoCatPathVisit copy_mver_child(void *userdata, const char *dirname, const char *name) { MverDirectoryCopy *context = userdata; + if (context->depth == 0 && name && + SDL_strcasecmp(name, "Resources") == 0) + return BONGO_CAT_PATH_CONTINUE; char source[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(source, sizeof(source), dirname, name)) return BONGO_CAT_PATH_FAILURE; diff --git a/tests/model_import/test_mver_container.c b/tests/model_import/test_mver_container.c index 66858dcd..814fa80b 100644 --- a/tests/model_import/test_mver_container.c +++ b/tests/model_import/test_mver_container.c @@ -31,6 +31,14 @@ void test_mver_container_discovery(void) { CHECK(child(backup, sizeof(backup), package, "config.json.before_console_bak", false)); CHECK(write_text(backup, "backup")); + char runtime_resources[BONGO_CAT_PATH_CAP]; + CHECK(child(runtime_resources, sizeof(runtime_resources), root, + "Resources", true)); + CHECK(child(backup, sizeof(backup), runtime_resources, "cat.ttf", false)); + CHECK(write_text(backup, "runtime font")); + CHECK(child(backup, sizeof(backup), runtime_resources, + "l2dlogo.png", false)); + CHECK(write_text(backup, "runtime logo")); CHECK(child(bundle, sizeof(bundle), root, "bundle", true)); CHECK(child(nested_package, sizeof(nested_package), bundle, "model", true)); CHECK(mver_fixture(nested_package)); @@ -133,6 +141,8 @@ void test_mver_container_discovery(void) { CHECK(child(backup, sizeof(backup), stored, BONGO_CAT_MODEL_ADAPTER_FILE, false) && !bongo_cat_path_is_file(backup)); + CHECK(child(backup, sizeof(backup), stored, "Resources", false) && + !bongo_cat_path_is_dir(backup)); BongoCatApp *app = calloc(1, sizeof(*app)); CHECK(app != NULL); if (app) { From 6b12ad6090407223674192507f6efe023f8fd7bc Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 26 Aug 2026 21:17:54 +0800 Subject: [PATCH 112/334] chore: update developer coffee translations --- resources/assets/locales/de-DE.json | 2 +- resources/assets/locales/en-US.json | 2 +- resources/assets/locales/es-ES.json | 2 +- resources/assets/locales/fr-FR.json | 2 +- resources/assets/locales/ja-JP.json | 2 +- resources/assets/locales/ko-KR.json | 2 +- resources/assets/locales/pt-BR.json | 2 +- resources/assets/locales/ru-RU.json | 2 +- resources/assets/locales/zh-CN.json | 2 +- resources/assets/locales/zh-Hant.json | 2 +- src/ui/preferences/preferences_about_community.c | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index f1bec1f4..b47987a9 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -210,7 +210,7 @@ "refactorDeveloper": "Entwickler, der BongoCat in C neu geschrieben hat und zugleich aktueller Maintainer ist", "community": "Community", "qqGroup": "QQ-Gruppe", - "coffee": "Dem Entwicklungsteam einen Kaffee spendieren?", + "coffee": "Dem Entwickler einen Kaffee spendieren?", "starOnGitHub": "GitHub-Stern!", "by": "von " }, diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index a7fea8ce..af2bf58f 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -210,7 +210,7 @@ "refactorDeveloper": "Developer who rewrote BongoCat in C and is also its current maintainer", "community": "Community", "qqGroup": "QQ Group", - "coffee": "Buy the development team a coffee?", + "coffee": "Buy the developer a coffee?", "starOnGitHub": "Star on GitHub!", "by": "by " }, diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index f82b5990..486dc379 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -210,7 +210,7 @@ "refactorDeveloper": "Desarrollador que reescribió BongoCat en C y que también es su mantenedor actual", "community": "Comunidad", "qqGroup": "Grupo de QQ", - "coffee": "¿Invitar a un café al equipo de desarrollo?", + "coffee": "¿Invitar a un café al desarrollador?", "starOnGitHub": "Estrella GitHub!", "by": "por " }, diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index 9e6d533c..052826a9 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -210,7 +210,7 @@ "refactorDeveloper": "Développeur qui a réécrit BongoCat en C et qui en est aussi le mainteneur actuel", "community": "Communauté", "qqGroup": "Groupe QQ", - "coffee": "Offrir un café à l'équipe de développement ?", + "coffee": "Offrir un café au développeur ?", "starOnGitHub": "Étoile GitHub!", "by": "par " }, diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index 2f664a74..bd2f4459 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -210,7 +210,7 @@ "refactorDeveloper": "BongoCatをC言語で書き直した開発者であり、現在のメンテナーでもある", "community": "コミュニティ", "qqGroup": "QQグループ", - "coffee": "開発チームにコーヒーを贈りますか?", + "coffee": "開発者にコーヒーを贈りますか?", "starOnGitHub": "GitHubでスター!", "by": "開発:" }, diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index 427d6be0..003269a0 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -210,7 +210,7 @@ "refactorDeveloper": "BongoCat을 C 언어로 재작성한 개발자이자 현재 유지보수 담당자", "community": "커뮤니티", "qqGroup": "QQ 그룹", - "coffee": "개발팀에 커피 한 잔을 선물할까요?", + "coffee": "개발자에게 커피 한 잔을 선물할까요?", "starOnGitHub": "GitHub 스타!", "by": "제작: " }, diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index d9f5baf9..dd6f584f 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -210,7 +210,7 @@ "refactorDeveloper": "Desenvolvedor que reescreveu o BongoCat em C e também é seu mantenedor atual", "community": "Comunidade", "qqGroup": "Grupo QQ", - "coffee": "Pagar um café para a equipe de desenvolvimento?", + "coffee": "Pagar um café para o desenvolvedor?", "starOnGitHub": "Estrela GitHub!", "by": "por " }, diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index e3782512..4c043809 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -210,7 +210,7 @@ "refactorDeveloper": "Разработчик, переписавший BongoCat на C, и его нынешний мейнтейнер", "community": "Сообщество", "qqGroup": "Группа QQ", - "coffee": "Угостить команду разработки кофе?", + "coffee": "Угостить разработчика кофе?", "starOnGitHub": "Звезда GitHub!", "by": "от " }, diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index cbab7721..8726d35b 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -210,7 +210,7 @@ "refactorDeveloper": "将BongoCat用C语言重构的开发者,同时也是现任维护者", "community": "社区", "qqGroup": "QQ 群", - "coffee": "请开发团队喝杯咖啡?", + "coffee": "请开发者喝杯咖啡?", "starOnGitHub": "喜欢就点颗星吧!", "by": "by " }, diff --git a/resources/assets/locales/zh-Hant.json b/resources/assets/locales/zh-Hant.json index 9141cfd2..07f1bb20 100644 --- a/resources/assets/locales/zh-Hant.json +++ b/resources/assets/locales/zh-Hant.json @@ -210,7 +210,7 @@ "refactorDeveloper": "將BongoCat用C語言重構的開發者,同時也是現任維護者", "community": "社群", "qqGroup": "QQ 群", - "coffee": "請開發團隊喝杯咖啡?", + "coffee": "請開發者喝杯咖啡?", "starOnGitHub": "喜歡就點顆星吧!", "by": "by " }, diff --git a/src/ui/preferences/preferences_about_community.c b/src/ui/preferences/preferences_about_community.c index 77ed32e3..c4cc117a 100644 --- a/src/ui/preferences/preferences_about_community.c +++ b/src/ui/preferences/preferences_about_community.c @@ -121,7 +121,7 @@ static void coffee(BongoCatPreferences *value, struct nk_context *context, struct nk_command_buffer *canvas, struct nk_rect bounds, BongoCatUIPalette p) { const char *label = tr(value, "native.support.coffee", - "Buy the development team a coffee?"); + "Buy the developer a coffee?"); float width = value->ui.caption_font->width(value->ui.caption_font->userdata, value->ui.caption_font->height, label, nk_strlen(label)) + 48.0f; struct nk_rect pill = nk_rect(bounds.x + (bounds.w - width) * .5f, From 5649f16703ee1a4769abf7a69857ce3462796470 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 26 Aug 2026 21:58:02 +0800 Subject: [PATCH 113/334] fix: omit nested Mver runtime resources --- src/runtime/model/import/model_import_package.c | 13 ++++++++++--- tests/model_import/test_mver_container.c | 12 ++++++++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/runtime/model/import/model_import_package.c b/src/runtime/model/import/model_import_package.c index f8cffc45..5927710c 100644 --- a/src/runtime/model/import/model_import_package.c +++ b/src/runtime/model/import/model_import_package.c @@ -82,15 +82,22 @@ static bool distribution_file(const char *name) { static bool copy_mver_tree(const char *source, const char *target, unsigned depth, BongoCatError *error); +static bool mver_runtime_resources(const char *directory, const char *name) { + if (!name || SDL_strcasecmp(name, "Resources") != 0 || + !bongo_cat_path_is_dir(directory)) return false; + char font[BONGO_CAT_PATH_CAP], logo[BONGO_CAT_PATH_CAP]; + return bongo_cat_path_join(font, sizeof(font), directory, "cat.ttf") && + bongo_cat_path_join(logo, sizeof(logo), directory, "l2dlogo.png") && + bongo_cat_path_is_file(font) && bongo_cat_path_is_file(logo); +} + static BongoCatPathVisit copy_mver_child(void *userdata, const char *dirname, const char *name) { MverDirectoryCopy *context = userdata; - if (context->depth == 0 && name && - SDL_strcasecmp(name, "Resources") == 0) - return BONGO_CAT_PATH_CONTINUE; char source[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(source, sizeof(source), dirname, name)) return BONGO_CAT_PATH_FAILURE; + if (mver_runtime_resources(source, name)) return BONGO_CAT_PATH_CONTINUE; if (distribution_file(name)) return BONGO_CAT_PATH_CONTINUE; char target[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(target, sizeof(target), context->target_root, diff --git a/tests/model_import/test_mver_container.c b/tests/model_import/test_mver_container.c index 814fa80b..e399e3b6 100644 --- a/tests/model_import/test_mver_container.c +++ b/tests/model_import/test_mver_container.c @@ -32,13 +32,18 @@ void test_mver_container_discovery(void) { "config.json.before_console_bak", false)); CHECK(write_text(backup, "backup")); char runtime_resources[BONGO_CAT_PATH_CAP]; - CHECK(child(runtime_resources, sizeof(runtime_resources), root, + CHECK(child(runtime_resources, sizeof(runtime_resources), package, "Resources", true)); CHECK(child(backup, sizeof(backup), runtime_resources, "cat.ttf", false)); CHECK(write_text(backup, "runtime font")); CHECK(child(backup, sizeof(backup), runtime_resources, "l2dlogo.png", false)); CHECK(write_text(backup, "runtime logo")); + char authored_resources[BONGO_CAT_PATH_CAP]; + CHECK(child(authored_resources, sizeof(authored_resources), package, + "img/standard/cat_model/resources", true)); + CHECK(child(backup, sizeof(backup), authored_resources, "keep.bin", false)); + CHECK(write_text(backup, "model resource")); CHECK(child(bundle, sizeof(bundle), root, "bundle", true)); CHECK(child(nested_package, sizeof(nested_package), bundle, "model", true)); CHECK(mver_fixture(nested_package)); @@ -141,8 +146,11 @@ void test_mver_container_discovery(void) { CHECK(child(backup, sizeof(backup), stored, BONGO_CAT_MODEL_ADAPTER_FILE, false) && !bongo_cat_path_is_file(backup)); - CHECK(child(backup, sizeof(backup), stored, "Resources", false) && + CHECK(child(backup, sizeof(backup), stored, "app/Resources", false) && !bongo_cat_path_is_dir(backup)); + CHECK(child(backup, sizeof(backup), stored, + "app/img/standard/cat_model/resources/keep.bin", false) && + bongo_cat_path_is_file(backup)); BongoCatApp *app = calloc(1, sizeof(*app)); CHECK(app != NULL); if (app) { From d8f6e051f97693f4d833f3b73054f1d1443648c0 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 26 Aug 2026 22:04:23 +0800 Subject: [PATCH 114/334] refactor: isolate Mver package copying --- cmake/RuntimeSources.cmake | 1 + .../model/import/model_import_package.c | 170 +---------------- .../import/mver/model_import_mver_copy.c | 177 ++++++++++++++++++ .../import/mver/model_import_mver_copy.h | 10 + .../import/mver/model_import_mver_internal.h | 1 - 5 files changed, 191 insertions(+), 168 deletions(-) create mode 100644 src/runtime/model/import/mver/model_import_mver_copy.c create mode 100644 src/runtime/model/import/mver/model_import_mver_copy.h diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 21ac3407..4b4e16d9 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -95,6 +95,7 @@ set(BONGO_CAT_RUNTIME_IMPORT_SOURCES src/runtime/model/import/model_import_tauri_mver_keys.c src/runtime/model/import/model_import_tauri_mver_tree.c src/runtime/model/import/mver/model_import_mver.c + src/runtime/model/import/mver/model_import_mver_copy.c src/runtime/model/import/mver/model_import_mver_effect.c src/runtime/model/import/mver/model_import_mver_image.c src/runtime/model/import/mver/model_import_mver_labels.c diff --git a/src/runtime/model/import/model_import_package.c b/src/runtime/model/import/model_import_package.c index 5927710c..e88d7cb6 100644 --- a/src/runtime/model/import/model_import_package.c +++ b/src/runtime/model/import/model_import_package.c @@ -1,181 +1,17 @@ #include "model_import.h" +#include "model_import_mver_copy.h" #include "bongo_cat/path.h" -#include #include -#include - -static bool separator(char value) { - return value == '/' || value == '\\'; -} - -static bool character_equal(char left, char right) { - if (separator(left) && separator(right)) return true; -#ifdef _WIN32 - if (left >= 'A' && left <= 'Z') left = (char)(left - 'A' + 'a'); - if (right >= 'A' && right <= 'Z') right = (char)(right - 'A' + 'a'); -#endif - return left == right; -} - -static bool relative_path(const char *root, const char *path, - char *relative, size_t capacity) { - size_t root_length = root ? strlen(root) : 0; - while (root_length > 1 && separator(root[root_length - 1])) root_length--; - size_t path_length = path ? strlen(path) : 0; - if (!root_length || path_length < root_length) return false; - for (size_t i = 0; i < root_length; ++i) - if (!character_equal(root[i], path[i])) return false; - if (path_length > root_length && !separator(path[root_length])) return false; - const char *value = path + root_length; - while (separator(*value)) value++; - int written = snprintf(relative, capacity, "%s", value); - return written >= 0 && (size_t)written < capacity; -} - -static bool rebase(const char *source_root, const char *installed_root, - const char *source, char *target, size_t capacity) { - char relative[BONGO_CAT_PATH_CAP]; - if (!relative_path(source_root, source, relative, sizeof(relative))) - return false; - if (!relative[0]) { - int written = snprintf(target, capacity, "%s", installed_root); - return written >= 0 && (size_t)written < capacity; - } - return bongo_cat_path_join(target, capacity, installed_root, relative); -} - -static bool copy_relative_file(const char *source_root, const char *source, - const char *target_root, BongoCatError *error) { - char relative[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; - if (!relative_path(source_root, source, relative, sizeof(relative)) || - !relative[0] || !bongo_cat_path_join(target, sizeof(target), - target_root, relative)) return false; - if (bongo_cat_path_is_file(target)) return true; - if (bongo_cat_path_copy_file(source, target)) return true; - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot copy imported model file: %s", source); - return false; -} - -typedef struct MverDirectoryCopy { - const char *target_root; - BongoCatError *error; - unsigned depth; -} MverDirectoryCopy; - -static bool distribution_file(const char *name) { - if (!name || name[0] == '.') return true; - size_t length = strlen(name); - if (length >= 4 && SDL_strcasecmp(name + length - 4, "_bak") == 0) - return true; - const char *dot = strrchr(name, '.'); - if (!dot) return false; - static const char *const extensions[] = { - ".exe", ".dll", ".pdf", ".bak", ".lock", ".pdb", ".zip" - }; - for (size_t i = 0; i < sizeof(extensions) / sizeof(extensions[0]); ++i) - if (SDL_strcasecmp(dot, extensions[i]) == 0) return true; - return false; -} - -static bool copy_mver_tree(const char *source, const char *target, - unsigned depth, BongoCatError *error); - -static bool mver_runtime_resources(const char *directory, const char *name) { - if (!name || SDL_strcasecmp(name, "Resources") != 0 || - !bongo_cat_path_is_dir(directory)) return false; - char font[BONGO_CAT_PATH_CAP], logo[BONGO_CAT_PATH_CAP]; - return bongo_cat_path_join(font, sizeof(font), directory, "cat.ttf") && - bongo_cat_path_join(logo, sizeof(logo), directory, "l2dlogo.png") && - bongo_cat_path_is_file(font) && bongo_cat_path_is_file(logo); -} - -static BongoCatPathVisit copy_mver_child(void *userdata, - const char *dirname, const char *name) { - MverDirectoryCopy *context = userdata; - char source[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(source, sizeof(source), dirname, name)) - return BONGO_CAT_PATH_FAILURE; - if (mver_runtime_resources(source, name)) return BONGO_CAT_PATH_CONTINUE; - if (distribution_file(name)) return BONGO_CAT_PATH_CONTINUE; - char target[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(target, sizeof(target), context->target_root, - name)) return BONGO_CAT_PATH_FAILURE; - if (!bongo_cat_path_is_dir(source)) { - if (context->depth == 0) return BONGO_CAT_PATH_CONTINUE; - return bongo_cat_path_copy_file(source, target) - ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; - } - return copy_mver_tree(source, target, context->depth + 1, context->error) - ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; -} - -static bool copy_mver_tree(const char *source, const char *target, - unsigned depth, BongoCatError *error) { - if (depth > 32 || !bongo_cat_path_create_directory(target)) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot create imported model directory: %s", target); - return false; - } - MverDirectoryCopy context = {target, error, depth}; - return bongo_cat_path_enumerate(source, copy_mver_child, &context); -} - -static bool copy_mver_relative_directory(const char *source_root, - const char *source, const char *target_root, BongoCatError *error) { - char relative[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; - if (!relative_path(source_root, source, relative, sizeof(relative)) || - !relative[0] || !bongo_cat_path_join(target, sizeof(target), - target_root, relative)) return false; - return copy_mver_tree(source, target, 1, error); -} - -static bool copy_mver_package(const BongoCatImportCandidate *candidate, - const char *target, BongoCatError *error) { - if (!bongo_cat_path_create_directory(target)) return false; - if (!copy_mver_tree(candidate->package_root, target, 0, error)) return false; - return copy_relative_file(candidate->package_root, candidate->config, - target, error); -} static bool copy_package_files(const BongoCatImportCandidate *candidate, const char *target, BongoCatImportCandidate *installed, BongoCatError *error) { - char base[BONGO_CAT_PATH_CAP]; if (candidate->format == BONGO_CAT_IMPORT_TAURI) return bongo_cat_import_tauri_convert_to_mver(candidate, target, installed, error); - bool patch = candidate->format == BONGO_CAT_IMPORT_MVER_PATCH; - if (patch) { - char patch_relative[BONGO_CAT_PATH_CAP]; - bool patch_inside_package = relative_path(candidate->package_root, - candidate->patch_root, patch_relative, sizeof(patch_relative)); - const char *patch_source_root = patch_inside_package - ? candidate->package_root : candidate->patch_root; - snprintf(base, sizeof(base), "%s", target); - if (!copy_mver_package(candidate, base, error) || - (!patch_inside_package && candidate->overrides[0] && - !copy_mver_relative_directory(patch_source_root, - candidate->overrides, target, error)) || - (candidate->overrides[0] && !rebase(patch_source_root, target, - candidate->overrides, installed->overrides, - sizeof(installed->overrides))) || - !rebase(patch_source_root, target, candidate->patch_root, - installed->patch_root, sizeof(installed->patch_root))) - return false; - } else if (candidate->format == BONGO_CAT_IMPORT_MVER) { - snprintf(base, sizeof(base), "%s", target); - if (!copy_mver_package(candidate, base, error)) return false; - } else return false; - if (!rebase(candidate->package_root, base, candidate->directory, - installed->directory, sizeof(installed->directory)) || - !rebase(candidate->package_root, base, candidate->assets, - installed->assets, sizeof(installed->assets)) || - !rebase(candidate->package_root, base, candidate->config, - installed->config, sizeof(installed->config))) return false; - installed->format = candidate->format; - return true; + return bongo_cat_import_mver_copy_package(candidate, target, installed, + error); } bool bongo_cat_import_prepare_package(const BongoCatImportCandidate *candidate, diff --git a/src/runtime/model/import/mver/model_import_mver_copy.c b/src/runtime/model/import/mver/model_import_mver_copy.c new file mode 100644 index 00000000..681b417b --- /dev/null +++ b/src/runtime/model/import/mver/model_import_mver_copy.c @@ -0,0 +1,177 @@ +#include "model_import_mver_copy.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +typedef struct MverDirectoryCopy { + const char *target_root; + BongoCatError *error; + unsigned depth; +} MverDirectoryCopy; + +static bool separator(char value) { + return value == '/' || value == '\\'; +} + +static bool character_equal(char left, char right) { + if (separator(left) && separator(right)) return true; +#ifdef _WIN32 + if (left >= 'A' && left <= 'Z') left = (char)(left - 'A' + 'a'); + if (right >= 'A' && right <= 'Z') right = (char)(right - 'A' + 'a'); +#endif + return left == right; +} + +static bool relative_path(const char *root, const char *path, + char *relative, size_t capacity) { + size_t root_length = root ? strlen(root) : 0; + while (root_length > 1 && separator(root[root_length - 1])) root_length--; + size_t path_length = path ? strlen(path) : 0; + if (!root_length || path_length < root_length) return false; + for (size_t i = 0; i < root_length; ++i) + if (!character_equal(root[i], path[i])) return false; + if (path_length > root_length && !separator(path[root_length])) return false; + const char *value = path + root_length; + while (separator(*value)) value++; + int written = snprintf(relative, capacity, "%s", value); + return written >= 0 && (size_t)written < capacity; +} + +static bool rebase(const char *source_root, const char *installed_root, + const char *source, char *target, size_t capacity) { + char relative[BONGO_CAT_PATH_CAP]; + if (!relative_path(source_root, source, relative, sizeof(relative))) + return false; + if (!relative[0]) { + int written = snprintf(target, capacity, "%s", installed_root); + return written >= 0 && (size_t)written < capacity; + } + return bongo_cat_path_join(target, capacity, installed_root, relative); +} + +static bool copy_relative_file(const char *source_root, const char *source, + const char *target_root, BongoCatError *error) { + char relative[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; + if (!relative_path(source_root, source, relative, sizeof(relative)) || + !relative[0] || !bongo_cat_path_join(target, sizeof(target), + target_root, relative)) return false; + if (bongo_cat_path_is_file(target)) return true; + if (bongo_cat_path_copy_file(source, target)) return true; + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot copy imported model file: %s", source); + return false; +} + +static bool distribution_file(const char *name) { + if (!name || name[0] == '.') return true; + size_t length = strlen(name); + if (length >= 4 && SDL_strcasecmp(name + length - 4, "_bak") == 0) + return true; + const char *dot = strrchr(name, '.'); + if (!dot) return false; + static const char *const extensions[] = { + ".exe", ".dll", ".pdf", ".bak", ".lock", ".pdb", ".zip" + }; + for (size_t i = 0; i < sizeof(extensions) / sizeof(extensions[0]); ++i) + if (SDL_strcasecmp(dot, extensions[i]) == 0) return true; + return false; +} + +static bool runtime_resources(const char *directory, const char *name) { + if (!name || SDL_strcasecmp(name, "Resources") != 0 || + !bongo_cat_path_is_dir(directory)) return false; + char font[BONGO_CAT_PATH_CAP], logo[BONGO_CAT_PATH_CAP]; + return bongo_cat_path_join(font, sizeof(font), directory, "cat.ttf") && + bongo_cat_path_join(logo, sizeof(logo), directory, "l2dlogo.png") && + bongo_cat_path_is_file(font) && bongo_cat_path_is_file(logo); +} + +static bool copy_tree(const char *source, const char *target, + unsigned depth, BongoCatError *error); + +static BongoCatPathVisit copy_child(void *userdata, const char *dirname, + const char *name) { + MverDirectoryCopy *context = userdata; + char source[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(source, sizeof(source), dirname, name)) + return BONGO_CAT_PATH_FAILURE; + if (runtime_resources(source, name) || distribution_file(name)) + return BONGO_CAT_PATH_CONTINUE; + char target[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(target, sizeof(target), context->target_root, + name)) return BONGO_CAT_PATH_FAILURE; + if (!bongo_cat_path_is_dir(source)) { + if (context->depth == 0) return BONGO_CAT_PATH_CONTINUE; + return bongo_cat_path_copy_file(source, target) + ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; + } + return copy_tree(source, target, context->depth + 1, context->error) + ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; +} + +static bool copy_tree(const char *source, const char *target, + unsigned depth, BongoCatError *error) { + if (depth > 32 || !bongo_cat_path_create_directory(target)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot create imported model directory: %s", target); + return false; + } + MverDirectoryCopy context = {target, error, depth}; + return bongo_cat_path_enumerate(source, copy_child, &context); +} + +static bool copy_relative_directory(const char *source_root, + const char *source, const char *target_root, BongoCatError *error) { + char relative[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; + if (!relative_path(source_root, source, relative, sizeof(relative)) || + !relative[0] || !bongo_cat_path_join(target, sizeof(target), + target_root, relative)) return false; + return copy_tree(source, target, 1, error); +} + +static bool copy_package(const BongoCatImportCandidate *candidate, + const char *target, BongoCatError *error) { + if (!bongo_cat_path_create_directory(target) || + !copy_tree(candidate->package_root, target, 0, error)) return false; + return copy_relative_file(candidate->package_root, candidate->config, + target, error); +} + +bool bongo_cat_import_mver_copy_package( + const BongoCatImportCandidate *candidate, const char *target, + BongoCatImportCandidate *installed, BongoCatError *error) { + if (!candidate || !target || !installed) return false; + char base[BONGO_CAT_PATH_CAP]; + bool patch = candidate->format == BONGO_CAT_IMPORT_MVER_PATCH; + if (patch) { + char patch_relative[BONGO_CAT_PATH_CAP]; + bool patch_inside_package = relative_path(candidate->package_root, + candidate->patch_root, patch_relative, sizeof(patch_relative)); + const char *patch_source_root = patch_inside_package + ? candidate->package_root : candidate->patch_root; + snprintf(base, sizeof(base), "%s", target); + if (!copy_package(candidate, base, error) || + (!patch_inside_package && candidate->overrides[0] && + !copy_relative_directory(patch_source_root, + candidate->overrides, target, error)) || + (candidate->overrides[0] && !rebase(patch_source_root, target, + candidate->overrides, installed->overrides, + sizeof(installed->overrides))) || + !rebase(patch_source_root, target, candidate->patch_root, + installed->patch_root, sizeof(installed->patch_root))) + return false; + } else if (candidate->format == BONGO_CAT_IMPORT_MVER) { + snprintf(base, sizeof(base), "%s", target); + if (!copy_package(candidate, base, error)) return false; + } else return false; + if (!rebase(candidate->package_root, base, candidate->directory, + installed->directory, sizeof(installed->directory)) || + !rebase(candidate->package_root, base, candidate->assets, + installed->assets, sizeof(installed->assets)) || + !rebase(candidate->package_root, base, candidate->config, + installed->config, sizeof(installed->config))) return false; + installed->format = candidate->format; + return true; +} diff --git a/src/runtime/model/import/mver/model_import_mver_copy.h b/src/runtime/model/import/mver/model_import_mver_copy.h new file mode 100644 index 00000000..53f64629 --- /dev/null +++ b/src/runtime/model/import/mver/model_import_mver_copy.h @@ -0,0 +1,10 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_MVER_COPY_H +#define BONGO_CAT_MODEL_IMPORT_MVER_COPY_H + +#include "model_import.h" + +bool bongo_cat_import_mver_copy_package( + const BongoCatImportCandidate *candidate, const char *target, + BongoCatImportCandidate *installed, BongoCatError *error); + +#endif diff --git a/src/runtime/model/import/mver/model_import_mver_internal.h b/src/runtime/model/import/mver/model_import_mver_internal.h index 83cbb377..c10b7764 100644 --- a/src/runtime/model/import/mver/model_import_mver_internal.h +++ b/src/runtime/model/import/mver/model_import_mver_internal.h @@ -37,5 +37,4 @@ bool bongo_cat_mver_labels_load(const char *path, const char *mode, BongoCatMverLabels *labels); const char *bongo_cat_mver_label(const BongoCatMverLabels *labels, const char *field, size_t index); - #endif From 12b31bb5c86516867c8253f7c2390f4c7f25f710 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 27 Aug 2026 11:32:00 +0800 Subject: [PATCH 115/334] feat: add Windows update and release automation --- .github/scripts/check-publish-guards.ps1 | 31 +- .github/scripts/scan-virustotal.py | 384 ++++++++++++++++++ .github/workflows/release.yml | 156 ++++++- CMakeLists.txt | 3 +- cmake/ApplicationResources.cmake | 3 +- cmake/PlatformRuntime.cmake | 3 +- cmake/RuntimeSources.cmake | 7 + cmake/Tests.cmake | 1 + include/bongo_cat/app.h | 2 + include/bongo_cat/common.h | 1 + include/bongo_cat/config.h | 2 + include/bongo_cat/update.h | 30 ++ packaging/microsoft-store/AppxManifest.xml.in | 2 +- packaging/microsoft-store/README.md | 9 +- .../validate-store-package.ps1 | 4 +- resources/assets/locales/de-DE.json | 7 + resources/assets/locales/en-US.json | 7 + resources/assets/locales/es-ES.json | 7 + resources/assets/locales/fr-FR.json | 7 + resources/assets/locales/ja-JP.json | 7 + resources/assets/locales/ko-KR.json | 7 + resources/assets/locales/pt-BR.json | 7 + resources/assets/locales/ru-RU.json | 7 + resources/assets/locales/zh-CN.json | 7 + resources/assets/locales/zh-Hant.json | 7 + src/core/config_session_defaults.c | 5 + src/core/config_session_io.c | 9 + src/core/update_release.c | 108 +++++ src/core/update_version.c | 138 +++++++ src/runtime/lifecycle/app.c | 6 + src/runtime/lifecycle/config_store.c | 2 + src/runtime/lifecycle/shutdown.c | 2 + .../import/mver/model_import_mver_shortcut.c | 6 +- src/runtime/runtime.h | 1 + src/runtime/update/update_channel.c | 67 +++ src/runtime/update/update_http.c | 166 ++++++++ src/runtime/update/update_internal.h | 44 ++ src/runtime/update/update_service.c | 239 +++++++++++ src/runtime/update/update_service.h | 37 ++ src/ui/preferences/preferences_about_footer.c | 49 ++- tests/core/test_config.c | 5 + tests/core/test_main.c | 1 + tests/core/test_update.c | 59 +++ tests/support/test.h | 1 + 44 files changed, 1603 insertions(+), 50 deletions(-) create mode 100644 .github/scripts/scan-virustotal.py create mode 100644 include/bongo_cat/update.h create mode 100644 src/core/update_release.c create mode 100644 src/core/update_version.c create mode 100644 src/runtime/update/update_channel.c create mode 100644 src/runtime/update/update_http.c create mode 100644 src/runtime/update/update_internal.h create mode 100644 src/runtime/update/update_service.c create mode 100644 src/runtime/update/update_service.h create mode 100644 tests/core/test_update.c diff --git a/.github/scripts/check-publish-guards.ps1 b/.github/scripts/check-publish-guards.ps1 index a0c829c8..22b93416 100644 --- a/.github/scripts/check-publish-guards.ps1 +++ b/.github/scripts/check-publish-guards.ps1 @@ -11,11 +11,17 @@ $workflowDirectory = Join-Path (Split-Path $PSScriptRoot -Parent) 'workflows' $sensitiveMarkers = @( 'build-store-package.ps1', 'CUBISM_SDK_ARCHIVE_URL', + 'VIRUSTOTAL_API_KEY', 'actions/upload-artifact', 'softprops/action-gh-release' ) -$artifactMarkers = @( - 'actions/upload-artifact', +$runtimeArtifactMarkers = @( + 'build-store-package.ps1', + 'Upload desktop release', + 'Upload Microsoft Store MSIX', + 'Upload Windows desktop release', + 'Upload Windows portable release', + 'name: bongo-cat-release-', 'softprops/action-gh-release' ) @@ -31,8 +37,14 @@ function Get-PublishingGuardFailures { $jobPattern = '(?ms)^ ([A-Za-z0-9_-]+):\s*\r?\n' + '(.*?)(?=^ [A-Za-z0-9_-]+:\s*\r?\n|\z)' $failures = @() + $jobMatches = [regex]::Matches($jobsText, $jobPattern) + $buildJob = @($jobMatches | Where-Object { + $_.Groups[1].Value -eq 'build' + } | Select-Object -First 1) + $protectedBuild = $buildJob.Count -eq 1 -and + $buildJob[0].Value.Contains('-DBONGO_CAT_REQUIRE_CUBISM=ON') - foreach ($job in [regex]::Matches($jobsText, $jobPattern)) { + foreach ($job in $jobMatches) { $jobName = $job.Groups[1].Value $jobText = $job.Value $matched = @($sensitiveMarkers | Where-Object { $jobText.Contains($_) }) @@ -44,13 +56,16 @@ function Get-PublishingGuardFailures { $failures += "$Label`: job '$jobName' lacks upstream guard; " + "matched $($matched -join ', ')" } - $publishesArtifacts = @($artifactMarkers | Where-Object { + $publishesRuntimeArtifacts = @($runtimeArtifactMarkers | Where-Object { $jobText.Contains($_) }).Count -gt 0 - if ($publishesArtifacts -and - -not $jobText.Contains('-DBONGO_CAT_REQUIRE_CUBISM=ON')) { + $usesProtectedBuild = $protectedBuild -and $jobText -match + '(?m)^\s+needs:\s*(?:build|\[[^\]]*\bbuild\b[^\]]*\])\s*$' + if ($publishesRuntimeArtifacts -and + -not $jobText.Contains('-DBONGO_CAT_REQUIRE_CUBISM=ON') -and + -not $usesProtectedBuild) { $failures += "$Label`: job '$jobName' publishes artifacts without " + - 'requiring the Cubism SDK' + 'requiring a Cubism SDK build' } } return $failures @@ -105,7 +120,7 @@ if ($SelfTest) { '-DBONGO_CAT_REQUIRE_CUBISM=OFF') $cubismFailures = @(Get-PublishingGuardFailures ` -Text $withoutCubism -Label 'self-test') - if (-not ($cubismFailures -match 'requiring the Cubism SDK')) { + if (-not ($cubismFailures -match 'requiring a Cubism SDK build')) { $failures += 'Self-test did not reject an SDK-free artifact job.' } } diff --git a/.github/scripts/scan-virustotal.py b/.github/scripts/scan-virustotal.py new file mode 100644 index 00000000..7d1bf383 --- /dev/null +++ b/.github/scripts/scan-virustotal.py @@ -0,0 +1,384 @@ +#!/usr/bin/env python3 +"""Upload one release artifact to VirusTotal and publish a CI summary. + +Detections are advisory because unsigned Windows software can trigger a small +number of heuristic engines. API and report-processing failures still fail the +scan job, while the independent release job remains available. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import sys +import time +import urllib.error +import urllib.request +import uuid +from pathlib import Path +from typing import Any + + +API_ROOT = "https://www.virustotal.com/api/v3" +MAX_DIRECT_UPLOAD_BYTES = 32 * 1024 * 1024 + + +class VirusTotalError(RuntimeError): + """An API or result-processing failure.""" + + +def escape_workflow_value(value: object) -> str: + """Keep third-party result text from creating workflow commands.""" + return ( + str(value) + .replace("%", "%25") + .replace("\r", "%0D") + .replace("\n", "%0A") + ) + + +def escape_markdown_cell(value: object) -> str: + return ( + str(value) + .replace("`", "\\`") + .replace("|", "\\|") + .replace("\r", " ") + .replace("\n", " ") + ) + + +def api_request( + method: str, + url: str, + api_key: str, + body: bytes | None = None, + content_type: str | None = None, +) -> dict[str, Any]: + headers = { + "accept": "application/json", + "x-apikey": api_key, + "user-agent": "BongoCat-VirusTotal-CI/1", + } + if body is not None: + headers["content-type"] = content_type or "application/octet-stream" + + request = urllib.request.Request( + url, data=body, headers=headers, method=method + ) + for attempt in range(5): + try: + with urllib.request.urlopen(request, timeout=60) as response: + payload = response.read() + try: + return json.loads(payload.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise VirusTotalError( + "VirusTotal returned invalid JSON" + ) from exc + except urllib.error.HTTPError as exc: + response_body = exc.read().decode("utf-8", errors="replace") + if exc.code in (429, 500, 502, 503, 504) and attempt < 4: + retry_after = exc.headers.get("Retry-After", "5") + try: + delay = min(max(int(retry_after), 1), 60) + except ValueError: + delay = 5 + print( + f"VirusTotal HTTP {exc.code}; retrying in {delay}s", + file=sys.stderr, + ) + time.sleep(delay) + continue + detail = response_body[:500].replace("\n", " ") + raise VirusTotalError( + f"VirusTotal HTTP {exc.code}: {detail}" + ) from exc + except (urllib.error.URLError, TimeoutError) as exc: + if attempt < 4: + delay = 2**attempt + print( + f"VirusTotal request failed; retrying in {delay}s", + file=sys.stderr, + ) + time.sleep(delay) + continue + raise VirusTotalError( + f"VirusTotal request failed: {exc}" + ) from exc + + raise VirusTotalError("VirusTotal request failed after retries") + + +def file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def build_multipart(path: Path) -> tuple[bytes, str]: + boundary = f"----BongoCatVirusTotal{uuid.uuid4().hex}" + name = path.name.replace('"', "") + prefix = ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="file"; filename="{name}"\r\n' + "Content-Type: application/octet-stream\r\n\r\n" + ).encode("utf-8") + suffix = f"\r\n--{boundary}--\r\n".encode("ascii") + return ( + prefix + path.read_bytes() + suffix, + f"multipart/form-data; boundary={boundary}", + ) + + +def upload(path: Path, api_key: str) -> str: + upload_url = f"{API_ROOT}/files" + if path.stat().st_size > MAX_DIRECT_UPLOAD_BYTES: + response = api_request( + "GET", f"{API_ROOT}/files/upload_url", api_key + ) + upload_data = response.get("data", {}) + if isinstance(upload_data, str): + upload_url = upload_data + elif isinstance(upload_data, dict): + attributes = upload_data.get("attributes", {}) + links = upload_data.get("links", {}) + if not isinstance(attributes, dict): + attributes = {} + if not isinstance(links, dict): + links = {} + upload_url = ( + attributes.get("url", "") + or links.get("self", "") + or upload_data.get("id", "") + or upload_data.get("url", "") + ) + else: + upload_url = "" + if not upload_url: + raise VirusTotalError( + "VirusTotal did not return an upload URL" + ) + + body, content_type = build_multipart(path) + response = api_request( + "POST", upload_url, api_key, body, content_type + ) + response_data = response.get("data", {}) + analysis_id = ( + response_data.get("id", "") + if isinstance(response_data, dict) + else "" + ) + if not analysis_id: + raise VirusTotalError( + "VirusTotal upload response did not contain an analysis ID" + ) + return analysis_id + + +def wait_for_analysis( + analysis_id: str, + api_key: str, + timeout_seconds: int, + poll_seconds: int, +) -> dict[str, Any]: + deadline = time.monotonic() + timeout_seconds + url = f"{API_ROOT}/analyses/{analysis_id}" + while True: + response = api_request("GET", url, api_key) + response_data = response.get("data", {}) + attributes = ( + response_data.get("attributes", {}) + if isinstance(response_data, dict) + else {} + ) + if not isinstance(attributes, dict): + attributes = {} + status = attributes.get("status", "unknown") + print(f"VirusTotal analysis status: {status}") + if status == "completed": + return attributes + if status in ("failure", "timeout"): + raise VirusTotalError( + f"VirusTotal analysis ended with status: {status}" + ) + if time.monotonic() >= deadline: + raise VirusTotalError( + "VirusTotal analysis polling timed out" + ) + time.sleep(poll_seconds) + + +def write_summary( + path: Path, sha256: str, attributes: dict[str, Any] +) -> None: + stats = attributes.get("stats", {}) + if not isinstance(stats, dict): + stats = {} + keys = ( + "malicious", + "suspicious", + "harmless", + "undetected", + "timeout", + "failure", + ) + counts = {key: int(stats.get(key, 0) or 0) for key in keys} + detections = [] + results = attributes.get("results", {}) + if not isinstance(results, dict): + results = {} + for engine, result in results.items(): + if not isinstance(result, dict): + continue + category = result.get("category") + if category in ("malicious", "suspicious"): + detections.append( + { + "engine": str(engine), + "category": category, + "result": result.get("result") or "unspecified", + } + ) + detections.sort(key=lambda item: (item["category"], item["engine"])) + + report = { + "file_name": path.name, + "sha256": sha256, + "status": attributes.get("status"), + "stats": counts, + "detections": detections, + "file_report_url": ( + f"https://www.virustotal.com/gui/file/{sha256}" + ), + } + report_path = Path( + os.environ.get("VT_REPORT_PATH", "virustotal-report.json") + ) + report_path.write_text( + json.dumps(report, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + total_flagged = counts["malicious"] + counts["suspicious"] + if total_flagged: + print( + f"::warning::VirusTotal flagged {total_flagged} engine(s): " + f"malicious={counts['malicious']}, " + f"suspicious={counts['suspicious']}" + ) + for item in detections[:20]: + print( + "::warning::VirusTotal " + f"{escape_workflow_value(item['category'])} / " + f"{escape_workflow_value(item['engine'])}: " + f"{escape_workflow_value(item['result'])}" + ) + else: + print( + "::notice::VirusTotal completed without malicious or " + "suspicious detections " + f"({counts['harmless']} harmless, " + f"{counts['undetected']} undetected)" + ) + + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_path: + return + lines = [ + "## VirusTotal scan", + "", + f"- File: `{path.name}`", + f"- SHA-256: `{sha256}`", + "- Report: " + f"[open in VirusTotal](https://www.virustotal.com/gui/file/{sha256})", + "- Policy: advisory; review malicious or suspicious engine results", + "- Counts: " + + ", ".join(f"{key}={counts[key]}" for key in keys), + ] + if detections: + lines.extend( + [ + "", + "### Malicious or suspicious engines", + "", + "| Engine | Category | Result |", + "| --- | --- | --- |", + ] + ) + lines.extend( + f"| `{escape_markdown_cell(item['engine'])}` | " + f"`{escape_markdown_cell(item['category'])}` | " + f"`{escape_markdown_cell(item['result'])}` |" + for item in detections[:50] + ) + else: + lines.extend( + ["", "No malicious or suspicious result was reported."] + ) + with Path(summary_path).open("a", encoding="utf-8") as stream: + stream.write("\n".join(lines) + "\n") + + +def write_skip_summary(reason: str) -> None: + print(f"::notice::VirusTotal scan skipped: {reason}") + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if summary_path: + with Path(summary_path).open("a", encoding="utf-8") as stream: + stream.write( + f"## VirusTotal scan\n\nScan skipped: {reason}\n" + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--file", required=True, type=Path) + parser.add_argument("--require-api-key", action="store_true") + parser.add_argument("--timeout-seconds", type=int, default=900) + parser.add_argument("--poll-seconds", type=int, default=15) + args = parser.parse_args() + + if not args.file.is_file(): + print( + f"VirusTotal input file does not exist: {args.file}", + file=sys.stderr, + ) + return 2 + api_key = os.environ.get("VIRUSTOTAL_API_KEY", "").strip() + if not api_key: + if args.require_api_key: + print( + "VIRUSTOTAL_API_KEY is required for this scan", + file=sys.stderr, + ) + return 2 + write_skip_summary("VIRUSTOTAL_API_KEY is not configured") + return 0 + + sha256 = file_sha256(args.file) + print( + f"VirusTotal input: {args.file} " + f"({args.file.stat().st_size} bytes)" + ) + print(f"VirusTotal SHA-256: {sha256}") + try: + analysis_id = upload(args.file, api_key) + attributes = wait_for_analysis( + analysis_id, + api_key, + max(args.timeout_seconds, 30), + max(args.poll_seconds, 5), + ) + write_summary(args.file, sha256, attributes) + return 0 + except VirusTotalError as exc: + print(f"::error::VirusTotal scan failed: {exc}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af0cdc1c..48933c3b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Release BongoCat +name: Release BongoCat for Windows on: push: @@ -6,15 +6,15 @@ on: - "v*" permissions: - contents: write + contents: read concurrency: group: bongo-cat-release-${{ github.ref }} cancel-in-progress: false jobs: - release: - name: release / ${{ matrix.platform }} + build: + name: build / ${{ matrix.platform }} if: github.repository == 'vladelaina/BongoCat' runs-on: windows-2022 strategy: @@ -45,8 +45,7 @@ jobs: throw 'Unable to read the BongoCat version from CMakeLists.txt' } $version = $Matches[1] - $expectedTag = "v$version" - if ($env:GITHUB_REF_NAME -ne $expectedTag) { + if ($env:GITHUB_REF_NAME -ne "v$version") { throw "Tag $env:GITHUB_REF_NAME does not match project version $version" } "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT ` @@ -98,7 +97,6 @@ jobs: -Append -Encoding utf8 - name: Package release - id: package shell: pwsh run: | $name = (Get-Content build-release/bongo-cat-package-name.txt -Raw).Trim() @@ -112,22 +110,139 @@ jobs: $portableSource = "build-release/dist/$name.zip" $portable = "build-release/dist/$name-portable.zip" - $portableChecksumSource = "$portableSource.sha256" $portableChecksum = "$portable.sha256" - $files = @( - $portableSource, - $portableChecksumSource, - "build-release/dist/$name-setup.exe", - "build-release/dist/$name-setup.exe.sha256" - ) - foreach ($file in $files) { + $setup = "build-release/dist/$name-setup.exe" + $setupChecksum = "$setup.sha256" + foreach ($file in @($portableSource, $setup, $setupChecksum)) { if (-not (Test-Path -LiteralPath $file)) { throw "Release file is missing: $file" } } Move-Item -LiteralPath $portableSource -Destination $portable -Force - Move-Item -LiteralPath $portableChecksumSource -Destination $portableChecksum -Force - "name=$name" | Out-File -FilePath $env:GITHUB_OUTPUT ` + $portableHash = (Get-FileHash -LiteralPath $portable -Algorithm SHA256).Hash.ToLowerInvariant() + "$portableHash $([IO.Path]::GetFileName($portable))" | + Set-Content -LiteralPath $portableChecksum -Encoding ascii + + New-Item -ItemType Directory -Path release-output -Force | Out-Null + Copy-Item -LiteralPath $portable, $portableChecksum, $setup, ` + $setupChecksum -Destination release-output + + - name: Upload packaged release + uses: actions/upload-artifact@v7 + with: + name: bongo-cat-release-${{ matrix.platform }} + path: release-output/* + if-no-files-found: error + retention-days: 7 + + virus-total: + name: security / virustotal + needs: build + if: github.repository == 'vladelaina/BongoCat' + runs-on: ubuntu-latest + permissions: + contents: read + actions: write + + steps: + - name: Checkout source + uses: actions/checkout@v5 + + - name: Download packaged releases + uses: actions/download-artifact@v8 + with: + pattern: bongo-cat-release-windows-* + path: vt-input + merge-multiple: true + + - name: Scan Windows installers with VirusTotal + env: + VIRUSTOTAL_API_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }} + run: | + set -euo pipefail + for platform in windows-x64 windows-x86; do + file=$(find vt-input -type f \ + -name "BongoCat-*-$platform-setup.exe" -print -quit) + if [ -z "$file" ]; then + echo "::error::VirusTotal input for $platform was not found" + exit 1 + fi + VT_REPORT_PATH="virustotal-report-$platform.json" \ + python3 .github/scripts/scan-virustotal.py --file "$file" + done + + - name: Add VirusTotal note when scanning is skipped + if: always() && hashFiles('virustotal-report-*.json') == '' + run: | + { + echo "## VirusTotal scans" + echo + echo "No report was produced. Check that VIRUSTOTAL_API_KEY is configured." + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload VirusTotal reports + if: always() && hashFiles('virustotal-report-*.json') != '' + uses: actions/upload-artifact@v7 + with: + name: virustotal-release-reports-${{ github.run_id }}-${{ github.run_attempt }} + path: virustotal-report-*.json + if-no-files-found: error + retention-days: 90 + + publish: + name: publish / windows + needs: build + if: github.repository == 'vladelaina/BongoCat' + runs-on: windows-2022 + permissions: + contents: write + + steps: + - name: Download packaged releases + uses: actions/download-artifact@v8 + with: + pattern: bongo-cat-release-windows-* + path: release-output + merge-multiple: true + + - name: Validate complete release + id: version + shell: pwsh + run: | + if ($env:GITHUB_REF_NAME -notmatch '^v([0-9]+\.[0-9]+\.[0-9]+)$') { + throw "Invalid release tag: $env:GITHUB_REF_NAME" + } + $version = $Matches[1] + $files = foreach ($platform in 'windows-x64', 'windows-x86') { + $base = "BongoCat-$version-$platform" + "release-output/$base-portable.zip" + "release-output/$base-portable.zip.sha256" + "release-output/$base-setup.exe" + "release-output/$base-setup.exe.sha256" + } + foreach ($file in $files) { + if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { + throw "Release is incomplete: $file" + } + } + foreach ($checksum in $files | Where-Object { $_ -like '*.sha256' }) { + $line = (Get-Content -LiteralPath $checksum -Raw).Trim() + if ($line -notmatch '^([0-9a-fA-F]{64})\s+(.+)$') { + throw "Invalid checksum file: $checksum" + } + $expectedHash = $Matches[1] + $checksumName = $Matches[2] + $payload = $checksum.Substring(0, $checksum.Length - 7) + $expectedName = Split-Path $payload -Leaf + if ($checksumName -ne $expectedName) { + throw "Checksum names the wrong file: $checksum" + } + $actual = (Get-FileHash -LiteralPath $payload -Algorithm SHA256).Hash + if ($actual -ne $expectedHash) { + throw "Checksum mismatch: $payload" + } + } + "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT ` -Append -Encoding utf8 - name: Publish GitHub Release @@ -136,8 +251,7 @@ jobs: name: BongoCat v${{ steps.version.outputs.version }} generate_release_notes: true fail_on_unmatched_files: true + make_latest: true files: | - build-release/dist/${{ steps.package.outputs.name }}-portable.zip - build-release/dist/${{ steps.package.outputs.name }}-portable.zip.sha256 - build-release/dist/${{ steps.package.outputs.name }}-setup.exe - build-release/dist/${{ steps.package.outputs.name }}-setup.exe.sha256 + release-output/*-portable.zip + release-output/*-setup.exe diff --git a/CMakeLists.txt b/CMakeLists.txt index 3a75976e..c249200c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -71,7 +71,8 @@ add_library(bongo_cat_core STATIC src/core/mouse_tracking.c src/core/sha256.c src/core/utf8.c src/core/model_catalog.c src/core/model_behavior.c - src/core/path.c src/core/path_io.c src/core/json.c src/core/shortcut.c) + src/core/path.c src/core/path_io.c src/core/json.c src/core/shortcut.c + src/core/update_release.c src/core/update_version.c) target_include_directories(bongo_cat_core PUBLIC "${BONGO_CAT_GENERATED_INCLUDE_DIR}" include) target_link_libraries(bongo_cat_core PUBLIC yyjson PRIVATE bongo_cat_warnings) include(cmake/CubismAssets.cmake) diff --git a/cmake/ApplicationResources.cmake b/cmake/ApplicationResources.cmake index 42560876..dfd68443 100644 --- a/cmake/ApplicationResources.cmake +++ b/cmake/ApplicationResources.cmake @@ -52,8 +52,7 @@ if(WIN32) "${BONGO_CAT_WINDOWS_MANIFEST}" @ONLY) if(MSVC) set(BONGO_CAT_MANIFEST_RESOURCE "") - target_link_options(bongo_cat PRIVATE "/MANIFEST:EMBED" - "/MANIFESTINPUT:${BONGO_CAT_WINDOWS_MANIFEST}") + target_sources(bongo_cat PRIVATE "${BONGO_CAT_WINDOWS_MANIFEST}") else() file(TO_CMAKE_PATH "${BONGO_CAT_WINDOWS_MANIFEST}" BONGO_CAT_MANIFEST_RC) diff --git a/cmake/PlatformRuntime.cmake b/cmake/PlatformRuntime.cmake index 10e84d42..2938c096 100644 --- a/cmake/PlatformRuntime.cmake +++ b/cmake/PlatformRuntime.cmake @@ -21,7 +21,8 @@ if(WIN32) target_include_directories(bongo_cat_runtime PRIVATE src/platform/windows) target_link_libraries(bongo_cat_runtime PRIVATE - dinput8 dxguid dwmapi ole32 shell32 user32 uuid windowscodecs advapi32) + dinput8 dxguid dwmapi ole32 shell32 user32 uuid windowscodecs advapi32 + winhttp) elseif(APPLE) enable_language(OBJC) target_sources(bongo_cat_runtime PRIVATE diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 4b4e16d9..0adc88b6 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -6,6 +6,7 @@ set(BONGO_CAT_RUNTIME_INTERNAL_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/src/runtime/model/import/mver" "${CMAKE_CURRENT_SOURCE_DIR}/src/runtime/model/import/nearby" "${CMAKE_CURRENT_SOURCE_DIR}/src/runtime/shell" + "${CMAKE_CURRENT_SOURCE_DIR}/src/runtime/update" "${CMAKE_CURRENT_SOURCE_DIR}/src/ui/backend" "${CMAKE_CURRENT_SOURCE_DIR}/src/ui/preferences" "${CMAKE_CURRENT_SOURCE_DIR}/src/ui/rendering" @@ -44,6 +45,11 @@ set(BONGO_CAT_RUNTIME_LIFECYCLE_SOURCES src/runtime/lifecycle/system_language.c src/runtime/lifecycle/storage_paths.c) +set(BONGO_CAT_RUNTIME_UPDATE_SOURCES + src/runtime/update/update_channel.c + src/runtime/update/update_http.c + src/runtime/update/update_service.c) + set(BONGO_CAT_RUNTIME_DIAGNOSTIC_SOURCES src/runtime/diagnostics/frame_audit.c src/runtime/diagnostics/frame_presentation_audit.c @@ -202,6 +208,7 @@ set(BONGO_CAT_RUNTIME_SOURCES ${BONGO_CAT_MEDIA_SOURCES} ${BONGO_CAT_RENDER_SOURCES} ${BONGO_CAT_RUNTIME_LIFECYCLE_SOURCES} + ${BONGO_CAT_RUNTIME_UPDATE_SOURCES} ${BONGO_CAT_RUNTIME_DIAGNOSTIC_SOURCES} ${BONGO_CAT_RUNTIME_INPUT_SOURCES} ${BONGO_CAT_RUNTIME_MODEL_SOURCES} diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index 5a8f153a..cc63e136 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -8,6 +8,7 @@ if(BUILD_TESTING) tests/core/test_models.c tests/core/test_mver_pointer.c tests/core/test_shortcut.c + tests/core/test_update.c src/platform/windows/windows_keys.c) target_link_libraries(bongo_cat_core_tests PRIVATE bongo_cat_core bongo_cat_warnings $<$:user32>) diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index 84e6adf4..da99b9c4 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -16,6 +16,7 @@ typedef struct BongoCatPreferences BongoCatPreferences; typedef struct BongoCatI18n BongoCatI18n; typedef struct BongoCatMultiPetRuntime BongoCatMultiPetRuntime; typedef struct BongoCatModelRefresh BongoCatModelRefresh; +typedef struct BongoCatUpdateService BongoCatUpdateService; typedef struct BongoCatApp { BongoCatSettings settings; @@ -43,6 +44,7 @@ typedef struct BongoCatApp { BongoCatPreferences *preferences; BongoCatMultiPetRuntime *multi_pet; BongoCatModelRefresh *model_refresh; + BongoCatUpdateService *update; SDL_Window *window; void *gl_context; char settings_path[BONGO_CAT_PATH_CAP]; diff --git a/include/bongo_cat/common.h b/include/bongo_cat/common.h index 6d02347c..3f0dacca 100644 --- a/include/bongo_cat/common.h +++ b/include/bongo_cat/common.h @@ -26,6 +26,7 @@ #define BONGO_CAT_BEHAVIOR_BINDING_CAP 256 #define BONGO_CAT_MENU_LABEL_CAP (BONGO_CAT_ID_CAP + BONGO_CAT_SHORTCUT_CAP + 4) #define BONGO_CAT_SCHEDULED_RELEASE_CAP 64 +#define BONGO_CAT_UPDATE_VERSION_CAP 32 #define BONGO_CAT_MODEL_ADAPTER_FILE ".bongo-cat-adapter.json" #define BONGO_CAT_MODEL_BUILTIN_MARKER ".bongo-cat-builtin" #define BONGO_CAT_MODEL_ADAPTER_SCHEMA 1 diff --git a/include/bongo_cat/config.h b/include/bongo_cat/config.h index 873ea70a..003efcd0 100644 --- a/include/bongo_cat/config.h +++ b/include/bongo_cat/config.h @@ -125,6 +125,8 @@ typedef struct BongoCatSettings { typedef struct BongoCatSessionState { BongoCatWindowState window; char active_model_id[BONGO_CAT_ID_CAP]; + int last_update_check_day; + char last_update_check_version[BONGO_CAT_UPDATE_VERSION_CAP]; char additional_model_ids[BONGO_CAT_ADDITIONAL_MODEL_CAP][BONGO_CAT_ID_CAP]; size_t additional_model_count; BongoCatActiveBehavior active_behaviors[BONGO_CAT_BEHAVIOR_BINDING_CAP]; diff --git a/include/bongo_cat/update.h b/include/bongo_cat/update.h new file mode 100644 index 00000000..e58033f9 --- /dev/null +++ b/include/bongo_cat/update.h @@ -0,0 +1,30 @@ +#ifndef BONGO_CAT_UPDATE_H +#define BONGO_CAT_UPDATE_H + +#include "bongo_cat/common.h" + +#define BONGO_CAT_UPDATE_URL_CAP 512 +#define BONGO_CAT_UPDATE_NOTES_CAP 8192 + +typedef struct BongoCatUpdateRelease { + char version[BONGO_CAT_UPDATE_VERSION_CAP]; + char release_url[BONGO_CAT_UPDATE_URL_CAP]; + char installer_url[BONGO_CAT_UPDATE_URL_CAP]; + char portable_url[BONGO_CAT_UPDATE_URL_CAP]; + char notes[BONGO_CAT_UPDATE_NOTES_CAP]; +} BongoCatUpdateRelease; + +#ifdef __cplusplus +extern "C" { +#endif + +int bongo_cat_update_compare_versions(const char *left, const char *right); +bool bongo_cat_update_version_valid(const char *version); +bool bongo_cat_update_parse_release(const char *json, const char *platform, + BongoCatUpdateRelease *release, BongoCatError *error); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/packaging/microsoft-store/AppxManifest.xml.in b/packaging/microsoft-store/AppxManifest.xml.in index d041bbc4..fc3f786d 100644 --- a/packaging/microsoft-store/AppxManifest.xml.in +++ b/packaging/microsoft-store/AppxManifest.xml.in @@ -6,7 +6,7 @@ IgnorableNamespaces="uap rescap"> diff --git a/packaging/microsoft-store/README.md b/packaging/microsoft-store/README.md index 623c22ba..8efd0ba2 100644 --- a/packaging/microsoft-store/README.md +++ b/packaging/microsoft-store/README.md @@ -8,11 +8,12 @@ certificate during submission. ## Store identity -- Package name: `vladelaina.bongocat` +- Package name: `vladelaina.31811E74C2C3B` - Publisher: `CN=5503A135-7FA4-466B-815C-DBE627F4065F` - Publisher display name: `vladelaina` -- Package family name: `vladelaina.bongocat_hnew8t3b8e0t6` -- Store ID: `9P41MLSX72XW` +- Package family name: `vladelaina.31811E74C2C3B_hnew8t3b8e0t6` +- Package SID: `S-1-15-2-3169861090-3766868583-4047506201-632799277-592763880-2179043671-4289936085` +- Store ID: `9NX7G84J3WM6` The package name and publisher are stored in `AppxManifest.xml.in`. The PFN and package SID are derived by Windows and must not be used as signing @@ -83,7 +84,7 @@ Import-Certificate ` -CertStoreLocation Cert:\LocalMachine\TrustedPeople | Out-Null Add-AppxPackage ` .\output\microsoft-store\bongocat_0.1.1_x64-local-test.msix -Get-AppxPackage -Name vladelaina.bongocat +Get-AppxPackage -Name vladelaina.31811E74C2C3B ``` The `-local-test` certificate and package are only for sideload testing. Do not diff --git a/packaging/microsoft-store/validate-store-package.ps1 b/packaging/microsoft-store/validate-store-package.ps1 index 29cfcae9..9511a890 100644 --- a/packaging/microsoft-store/validate-store-package.ps1 +++ b/packaging/microsoft-store/validate-store-package.ps1 @@ -94,7 +94,7 @@ try { [xml]$manifest = Get-Content -LiteralPath $manifestPath -Raw $identity = $manifest.Package.Identity $expectedIdentity = @{ - Name = 'vladelaina.bongocat' + Name = 'vladelaina.31811E74C2C3B' Publisher = 'CN=5503A135-7FA4-466B-815C-DBE627F4065F' Version = $ExpectedPackageVersion ProcessorArchitecture = $ExpectedArchitecture @@ -147,7 +147,7 @@ try { throw "Publisher ID calculation failed with Windows error $publisherResult." } $packageFamilyName = "$($identity.Name)_$publisherId" - if ($packageFamilyName -ne 'vladelaina.bongocat_hnew8t3b8e0t6') { + if ($packageFamilyName -ne 'vladelaina.31811E74C2C3B_hnew8t3b8e0t6') { throw "Derived package family name is unexpected: $packageFamilyName" } diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index b47987a9..42413bce 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -205,6 +205,13 @@ "openLogs": "Protokolle öffnen", "openLogsFailed": "Protokollordner konnte nicht geöffnet werden", "latest": "Bereits aktuell", + "checkingUpdate": "Wird geprüft...", + "updateAvailable": "Neue Version verfügbar:", + "updateFailed": "Updates konnten nicht geprüft werden", + "downloadUpdate": "Version v%s herunterladen", + "openStore": "Microsoft Store öffnen", + "viewReleases": "Versionen anzeigen", + "openUpdateFailed": "Die Update-Seite konnte nicht geöffnet werden", "works": "Weitere Anwendungen", "worksText": "Weitere Software von vladelaina", "refactorDeveloper": "Entwickler, der BongoCat in C neu geschrieben hat und zugleich aktueller Maintainer ist", diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index af2bf58f..592da119 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -205,6 +205,13 @@ "openLogs": "Open logs", "openLogsFailed": "Unable to open logs folder", "latest": "Already up to date", + "checkingUpdate": "Checking...", + "updateAvailable": "New version available:", + "updateFailed": "Unable to check for updates", + "downloadUpdate": "Download v%s", + "openStore": "Open Microsoft Store", + "viewReleases": "View releases", + "openUpdateFailed": "Unable to open the update page", "works": "More apps", "worksText": "More software from vladelaina", "refactorDeveloper": "Developer who rewrote BongoCat in C and is also its current maintainer", diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index 486dc379..fa7b6a09 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -205,6 +205,13 @@ "openLogs": "Abrir registros", "openLogsFailed": "No se pudo abrir la carpeta de registros", "latest": "Ya está actualizado", + "checkingUpdate": "Comprobando...", + "updateAvailable": "Nueva versión disponible:", + "updateFailed": "No se pudieron buscar actualizaciones", + "downloadUpdate": "Descargar v%s", + "openStore": "Abrir Microsoft Store", + "viewReleases": "Ver versiones", + "openUpdateFailed": "No se pudo abrir la página de actualización", "works": "Más aplicaciones", "worksText": "Más software de vladelaina", "refactorDeveloper": "Desarrollador que reescribió BongoCat en C y que también es su mantenedor actual", diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index 052826a9..23a53054 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -205,6 +205,13 @@ "openLogs": "Ouvrir les journaux", "openLogsFailed": "Impossible d'ouvrir le dossier des journaux", "latest": "Déjà à jour", + "checkingUpdate": "Vérification...", + "updateAvailable": "Nouvelle version disponible :", + "updateFailed": "Impossible de vérifier les mises à jour", + "downloadUpdate": "Télécharger v%s", + "openStore": "Ouvrir Microsoft Store", + "viewReleases": "Voir les versions", + "openUpdateFailed": "Impossible d'ouvrir la page de mise à jour", "works": "Plus d'applications", "worksText": "Plus de logiciels de vladelaina", "refactorDeveloper": "Développeur qui a réécrit BongoCat en C et qui en est aussi le mainteneur actuel", diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index bd2f4459..205c9371 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -205,6 +205,13 @@ "openLogs": "ログを開く", "openLogsFailed": "ログフォルダーを開けません", "latest": "最新バージョンです", + "checkingUpdate": "確認中...", + "updateAvailable": "新しいバージョンがあります:", + "updateFailed": "更新を確認できませんでした", + "downloadUpdate": "v%s をダウンロード", + "openStore": "Microsoft Store を開く", + "viewReleases": "リリースを表示", + "openUpdateFailed": "更新ページを開けませんでした", "works": "その他のアプリ", "worksText": "vladelainaのその他のソフトウェア", "refactorDeveloper": "BongoCatをC言語で書き直した開発者であり、現在のメンテナーでもある", diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index 003269a0..6e749ea4 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -205,6 +205,13 @@ "openLogs": "로그 열기", "openLogsFailed": "로그 폴더를 열 수 없습니다", "latest": "최신 버전입니다", + "checkingUpdate": "확인 중...", + "updateAvailable": "새 버전 사용 가능:", + "updateFailed": "업데이트를 확인할 수 없습니다", + "downloadUpdate": "v%s 다운로드", + "openStore": "Microsoft Store 열기", + "viewReleases": "릴리스 보기", + "openUpdateFailed": "업데이트 페이지를 열 수 없습니다", "works": "더 많은 앱", "worksText": "vladelaina의 다른 소프트웨어", "refactorDeveloper": "BongoCat을 C 언어로 재작성한 개발자이자 현재 유지보수 담당자", diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index dd6f584f..40e9d18c 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -205,6 +205,13 @@ "openLogs": "Abrir logs", "openLogsFailed": "Não foi possível abrir a pasta de logs", "latest": "Já está atualizado", + "checkingUpdate": "Verificando...", + "updateAvailable": "Nova versão disponível:", + "updateFailed": "Não foi possível verificar atualizações", + "downloadUpdate": "Baixar v%s", + "openStore": "Abrir Microsoft Store", + "viewReleases": "Ver versões", + "openUpdateFailed": "Não foi possível abrir a página de atualização", "works": "Mais aplicativos", "worksText": "Outros softwares de vladelaina", "refactorDeveloper": "Desenvolvedor que reescreveu o BongoCat em C e também é seu mantenedor atual", diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index 4c043809..4ccc5bda 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -205,6 +205,13 @@ "openLogs": "Открыть журналы", "openLogsFailed": "Не удалось открыть папку журналов", "latest": "Установлена последняя версия", + "checkingUpdate": "Проверка...", + "updateAvailable": "Доступна новая версия:", + "updateFailed": "Не удалось проверить обновления", + "downloadUpdate": "Скачать v%s", + "openStore": "Открыть Microsoft Store", + "viewReleases": "Посмотреть выпуски", + "openUpdateFailed": "Не удалось открыть страницу обновления", "works": "Другие приложения", "worksText": "Другие программы от vladelaina", "refactorDeveloper": "Разработчик, переписавший BongoCat на C, и его нынешний мейнтейнер", diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index 8726d35b..c224ebe8 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -205,6 +205,13 @@ "openLogs": "打开日志", "openLogsFailed": "无法打开日志文件夹", "latest": "已是最新版本", + "checkingUpdate": "正在检查...", + "updateAvailable": "发现新版本:", + "updateFailed": "无法检查更新", + "downloadUpdate": "下载 v%s", + "openStore": "打开 Microsoft Store", + "viewReleases": "查看发布版本", + "openUpdateFailed": "无法打开更新页面", "works": "更多作品", "worksText": "来自 vladelaina 的其他软件", "refactorDeveloper": "将BongoCat用C语言重构的开发者,同时也是现任维护者", diff --git a/resources/assets/locales/zh-Hant.json b/resources/assets/locales/zh-Hant.json index 07f1bb20..5204b180 100644 --- a/resources/assets/locales/zh-Hant.json +++ b/resources/assets/locales/zh-Hant.json @@ -205,6 +205,13 @@ "openLogs": "開啟日誌", "openLogsFailed": "無法開啟日誌資料夾", "latest": "已是最新版本", + "checkingUpdate": "正在檢查...", + "updateAvailable": "發現新版本:", + "updateFailed": "無法檢查更新", + "downloadUpdate": "下載 v%s", + "openStore": "開啟 Microsoft Store", + "viewReleases": "查看發佈版本", + "openUpdateFailed": "無法開啟更新頁面", "works": "更多作品", "worksText": "來自 vladelaina 的其他軟體", "refactorDeveloper": "將BongoCat用C語言重構的開發者,同時也是現任維護者", diff --git a/src/core/config_session_defaults.c b/src/core/config_session_defaults.c index b6dc3e14..4dd170fd 100644 --- a/src/core/config_session_defaults.c +++ b/src/core/config_session_defaults.c @@ -115,6 +115,11 @@ void bongo_cat_session_validate(BongoCatSessionState *session) { sizeof(session->active_model_id)) || !session->active_model_id[0]) memcpy(session->active_model_id, "standard", sizeof("standard")); + if (session->last_update_check_day < 0 || + session->last_update_check_day > 99991231) + session->last_update_check_day = 0; + normalize_text(session->last_update_check_version, + sizeof(session->last_update_check_version)); compact_additional_models(session); compact_active_behaviors(session); } diff --git a/src/core/config_session_io.c b/src/core/config_session_io.c index a46ce7f3..ea911d48 100644 --- a/src/core/config_session_io.c +++ b/src/core/config_session_io.c @@ -146,6 +146,11 @@ BongoCatResult bongo_cat_session_load(const char *path, } } if (valid) valid = read_active_model(root, &loaded, error) && + read_int(root, "lastUpdateCheckDay", + &loaded.last_update_check_day, false, error) && + bongo_cat_config_read_text("Session", root, + "lastUpdateCheckVersion", loaded.last_update_check_version, + sizeof(loaded.last_update_check_version), error) && read_additional_models(additional_models, &loaded, error) && read_active_behaviors(active_behaviors, &loaded, error); yyjson_doc_free(document); @@ -234,6 +239,10 @@ BongoCatResult bongo_cat_session_save(const char *path, canonical.window.content_height) && yyjson_mut_obj_add_strcpy(doc, root, "activeModelId", canonical.active_model_id) && + yyjson_mut_obj_add_int(doc, root, "lastUpdateCheckDay", + canonical.last_update_check_day) && + yyjson_mut_obj_add_strcpy(doc, root, "lastUpdateCheckVersion", + canonical.last_update_check_version) && write_additional_models(doc, root, &canonical) && write_active_behaviors(doc, root, &canonical); if (!built) { diff --git a/src/core/update_release.c b/src/core/update_release.c new file mode 100644 index 00000000..e75b519f --- /dev/null +++ b/src/core/update_release.c @@ -0,0 +1,108 @@ +#include "bongo_cat/update.h" + +#include +#include +#include + +#define RELEASE_PREFIX \ + "https://github.com/vladelaina/BongoCat/releases/tag/" +#define DOWNLOAD_PREFIX \ + "https://github.com/vladelaina/BongoCat/releases/download/" + +static bool copy_text(char *target, size_t capacity, yyjson_val *value, + bool required, bool truncate) { + if (!value || yyjson_is_null(value)) return !required; + if (!yyjson_is_str(value)) return false; + const char *text = yyjson_get_str(value); + size_t length = yyjson_get_len(value); + if (!text || strlen(text) != length || (required && !length)) return false; + if (length >= capacity) { + if (!truncate) return false; + length = capacity - 1; + } + memcpy(target, text, length); + target[length] = '\0'; + return true; +} + +static bool safe_url(const char *url, const char *prefix) { + if (!url || strncmp(url, prefix, strlen(prefix)) != 0) return false; + for (const unsigned char *at = (const unsigned char *)url; *at; ++at) + if (*at <= 0x20 || *at == '\\') return false; + return true; +} + +static bool copy_asset_url(yyjson_val *asset, const char *expected, + char *target, size_t capacity) { + yyjson_val *name_value = yyjson_obj_get(asset, "name"); + yyjson_val *url_value = yyjson_obj_get(asset, "browser_download_url"); + if (!yyjson_is_str(name_value) || !yyjson_is_str(url_value) || + strcmp(yyjson_get_str(name_value), expected) != 0) return false; + const char *url = yyjson_get_str(url_value); + const char *filename = url ? strrchr(url, '/') : NULL; + if (!safe_url(url, DOWNLOAD_PREFIX) || !filename || + strcmp(filename + 1, expected) != 0 || strlen(url) >= capacity) + return false; + snprintf(target, capacity, "%s", url); + return true; +} + +static bool read_assets(yyjson_val *root, const char *platform, + BongoCatUpdateRelease *release) { + yyjson_val *assets = yyjson_obj_get(root, "assets"); + if (!yyjson_is_arr(assets)) return false; + char installer[128], portable[128]; + int installer_length = snprintf(installer, sizeof(installer), + "BongoCat-%s-%s-setup.exe", release->version, platform); + int portable_length = snprintf(portable, sizeof(portable), + "BongoCat-%s-%s-portable.zip", release->version, platform); + if (installer_length < 0 || (size_t)installer_length >= sizeof(installer) || + portable_length < 0 || (size_t)portable_length >= sizeof(portable)) + return false; + size_t index, count; + yyjson_val *asset; + yyjson_arr_foreach(assets, index, count, asset) { + if (!yyjson_is_obj(asset)) continue; + if (!release->installer_url[0]) copy_asset_url(asset, installer, + release->installer_url, sizeof(release->installer_url)); + if (!release->portable_url[0]) copy_asset_url(asset, portable, + release->portable_url, sizeof(release->portable_url)); + } + return true; +} + +bool bongo_cat_update_parse_release(const char *json, const char *platform, + BongoCatUpdateRelease *release, BongoCatError *error) { + if (!json || !platform || !release) return false; + memset(release, 0, sizeof(*release)); + yyjson_doc *document = yyjson_read(json, strlen(json), 0); + yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; + yyjson_val *draft = yyjson_is_obj(root) + ? yyjson_obj_get(root, "draft") : NULL; + yyjson_val *prerelease = yyjson_is_obj(root) + ? yyjson_obj_get(root, "prerelease") : NULL; + bool valid = yyjson_is_obj(root) && yyjson_is_bool(draft) && + yyjson_is_bool(prerelease) && !yyjson_get_bool(draft) && + !yyjson_get_bool(prerelease); + char tag[BONGO_CAT_UPDATE_VERSION_CAP + 1] = {0}; + if (valid) valid = copy_text(tag, sizeof(tag), + yyjson_obj_get(root, "tag_name"), true, false); + const char *version = tag[0] == 'v' || tag[0] == 'V' ? tag + 1 : tag; + if (valid) valid = bongo_cat_update_version_valid(version); + if (valid) snprintf(release->version, sizeof(release->version), "%s", + version); + if (valid) valid = copy_text(release->release_url, + sizeof(release->release_url), yyjson_obj_get(root, "html_url"), true, + false) && + safe_url(release->release_url, RELEASE_PREFIX); + if (valid) { + yyjson_val *body = yyjson_obj_get(root, "body"); + if (body && !copy_text(release->notes, sizeof(release->notes), + body, false, true)) valid = false; + } + if (valid) valid = read_assets(root, platform, release); + yyjson_doc_free(document); + if (!valid) bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "GitHub returned invalid BongoCat release metadata"); + return valid; +} diff --git a/src/core/update_version.c b/src/core/update_version.c new file mode 100644 index 00000000..672e16c9 --- /dev/null +++ b/src/core/update_version.c @@ -0,0 +1,138 @@ +#include "bongo_cat/update.h" + +#include +#include +#include + +typedef struct ParsedVersion { + uint64_t core[3]; + const char *prerelease; + size_t prerelease_length; + bool valid; +} ParsedVersion; + +static bool identifier_character(char value) { + unsigned char byte = (unsigned char)value; + return isalnum(byte) || value == '-'; +} + +static bool parse_number(const char **cursor, uint64_t *number) { + const char *start = *cursor; + if (!isdigit((unsigned char)*start)) return false; + if (*start == '0' && isdigit((unsigned char)start[1])) return false; + uint64_t value = 0; + while (isdigit((unsigned char)**cursor)) { + unsigned digit = (unsigned)(**cursor - '0'); + if (value > (UINT64_MAX - digit) / 10) return false; + value = value * 10 + digit; + (*cursor)++; + } + *number = value; + return true; +} + +static bool validate_identifiers(const char *value, size_t length) { + if (!length) return false; + size_t identifier_start = 0; + for (size_t i = 0; i <= length; ++i) { + if (i < length && value[i] != '.') { + if (!identifier_character(value[i])) return false; + continue; + } + if (i == identifier_start) return false; + bool numeric = true; + for (size_t j = identifier_start; j < i; ++j) + if (!isdigit((unsigned char)value[j])) numeric = false; + if (numeric && i - identifier_start > 1 && + value[identifier_start] == '0') return false; + identifier_start = i + 1; + } + return true; +} + +static ParsedVersion parse_version(const char *text) { + ParsedVersion parsed = {0}; + if (!text || !text[0]) return parsed; + const char *cursor = text; + if (*cursor == 'v' || *cursor == 'V') cursor++; + for (size_t i = 0; i < 3; ++i) { + if (!parse_number(&cursor, &parsed.core[i])) return parsed; + if (i < 2) { + if (*cursor != '.') return parsed; + cursor++; + } + } + if (*cursor == '-') { + const char *start = ++cursor; + while (*cursor && *cursor != '+') cursor++; + parsed.prerelease = start; + parsed.prerelease_length = (size_t)(cursor - start); + if (!validate_identifiers(start, parsed.prerelease_length)) + return (ParsedVersion){0}; + } + if (*cursor == '+') { + const char *start = ++cursor; + while (*cursor) cursor++; + if (!validate_identifiers(start, (size_t)(cursor - start))) + return (ParsedVersion){0}; + } + parsed.valid = *cursor == '\0'; + return parsed; +} + +static int compare_identifier(const char *left, size_t left_length, + const char *right, size_t right_length) { + bool left_numeric = true, right_numeric = true; + for (size_t i = 0; i < left_length; ++i) + if (!isdigit((unsigned char)left[i])) left_numeric = false; + for (size_t i = 0; i < right_length; ++i) + if (!isdigit((unsigned char)right[i])) right_numeric = false; + if (left_numeric != right_numeric) return left_numeric ? -1 : 1; + if (left_numeric && left_length != right_length) + return left_length < right_length ? -1 : 1; + size_t common = left_length < right_length ? left_length : right_length; + int compared = memcmp(left, right, common); + if (compared) return compared < 0 ? -1 : 1; + return left_length == right_length ? 0 : left_length < right_length ? -1 : 1; +} + +static int compare_prerelease(const ParsedVersion *left, + const ParsedVersion *right) { + if (!left->prerelease_length || !right->prerelease_length) { + if (left->prerelease_length == right->prerelease_length) return 0; + return left->prerelease_length ? -1 : 1; + } + size_t left_at = 0, right_at = 0; + while (left_at < left->prerelease_length && + right_at < right->prerelease_length) { + size_t left_end = left_at, right_end = right_at; + while (left_end < left->prerelease_length && + left->prerelease[left_end] != '.') left_end++; + while (right_end < right->prerelease_length && + right->prerelease[right_end] != '.') right_end++; + int compared = compare_identifier(left->prerelease + left_at, + left_end - left_at, right->prerelease + right_at, + right_end - right_at); + if (compared) return compared; + left_at = left_end + 1; + right_at = right_end + 1; + } + bool left_done = left_at > left->prerelease_length; + bool right_done = right_at > right->prerelease_length; + return left_done == right_done ? 0 : left_done ? -1 : 1; +} + +int bongo_cat_update_compare_versions(const char *left, const char *right) { + ParsedVersion parsed_left = parse_version(left); + ParsedVersion parsed_right = parse_version(right); + if (!parsed_left.valid || !parsed_right.valid) return 0; + for (size_t i = 0; i < 3; ++i) { + if (parsed_left.core[i] == parsed_right.core[i]) continue; + return parsed_left.core[i] < parsed_right.core[i] ? -1 : 1; + } + return compare_prerelease(&parsed_left, &parsed_right); +} + +bool bongo_cat_update_version_valid(const char *version) { + return parse_version(version).valid; +} diff --git a/src/runtime/lifecycle/app.c b/src/runtime/lifecycle/app.c index 46bce43a..459b5424 100644 --- a/src/runtime/lifecycle/app.c +++ b/src/runtime/lifecycle/app.c @@ -116,6 +116,11 @@ bool bongo_cat_app_initialize(BongoCatApp *app, int argc, char **argv, if (!app->secondary_pet && !app->preferences) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Preferences are unavailable because their state could not be allocated"); + app->update = app->secondary_pet ? NULL : bongo_cat_update_create(app); + if (!app->secondary_pet && !app->update) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "The update checker could not be initialized"); + bongo_cat_update_start_automatic(app->update); if (!app->secondary_pet && !app->tray && !app->session.window.visible) { bongo_cat_window_set_visible(app, true); } @@ -154,6 +159,7 @@ bool bongo_cat_app_initialize(BongoCatApp *app, int argc, char **argv, return true; } static void handle_event(BongoCatApp *app, const SDL_Event *event) { + if (bongo_cat_update_event(app->update, event)) return; if (bongo_cat_model_refresh_event(app, event)) return; if (bongo_cat_preferences_event(app->preferences, event)) return; if (!bongo_cat_window_event(app, event)) app->running = false; diff --git a/src/runtime/lifecycle/config_store.c b/src/runtime/lifecycle/config_store.c index 4972d635..b9147661 100644 --- a/src/runtime/lifecycle/config_store.c +++ b/src/runtime/lifecycle/config_store.c @@ -59,6 +59,8 @@ static uint64_t session_hash(const BongoCatSessionState *session) { HASH_FIELD(session->window.content_width); HASH_FIELD(session->window.content_height); HASH_FIELD(session->active_model_id); + HASH_FIELD(session->last_update_check_day); + HASH_FIELD(session->last_update_check_version); HASH_FIELD(session->additional_model_count); size_t model_count = session->additional_model_count; if (model_count > BONGO_CAT_ADDITIONAL_MODEL_CAP) diff --git a/src/runtime/lifecycle/shutdown.c b/src/runtime/lifecycle/shutdown.c index 1b24d2fb..a0caecc5 100644 --- a/src/runtime/lifecycle/shutdown.c +++ b/src/runtime/lifecycle/shutdown.c @@ -17,6 +17,8 @@ void bongo_cat_app_shutdown(BongoCatApp *app, const char *stage, bongo_cat_multi_pet_shutdown(app); bongo_cat_model_refresh_shutdown(app); bongo_cat_import_nearby_shutdown(); + bongo_cat_update_destroy(app->update); + app->update = NULL; bongo_cat_preferences_destroy(app->preferences); bongo_cat_i18n_destroy(app->i18n); bongo_cat_tray_destroy(app->tray); diff --git a/src/runtime/model/import/mver/model_import_mver_shortcut.c b/src/runtime/model/import/mver/model_import_mver_shortcut.c index bb6dcb3e..51ffd9b6 100644 --- a/src/runtime/model/import/mver/model_import_mver_shortcut.c +++ b/src/runtime/model/import/mver/model_import_mver_shortcut.c @@ -13,7 +13,11 @@ static bool append(char *output, size_t capacity, const char *value) { static const char *shortcut_key(int code, char generated[16]) { BongoCatMverKeyNames names = bongo_cat_mver_device_names(code, 0, 1); - const char *name = names.items[0] ? names.items[0] : names.generated; + const char *name = names.items[0]; + if (!name && names.generated[0]) { + snprintf(generated, 16, "%s", names.generated); + name = generated; + } if (!name || !*name || bongo_cat_mver_modifier_index(code) >= 0) return NULL; if (strncmp(name, "Key", 3) == 0 || strncmp(name, "Num", 3) == 0) { generated[0] = name[3]; generated[1] = '\0'; return generated; diff --git a/src/runtime/runtime.h b/src/runtime/runtime.h index a5ca84ff..789aa571 100644 --- a/src/runtime/runtime.h +++ b/src/runtime/runtime.h @@ -3,6 +3,7 @@ #include "bongo_cat/app.h" #include "bongo_cat/memory_policy.h" +#include "update_service.h" #include BongoCatResult bongo_cat_window_create(BongoCatApp *app, BongoCatError *error); diff --git a/src/runtime/update/update_channel.c b/src/runtime/update/update_channel.c new file mode 100644 index 00000000..efd81db7 --- /dev/null +++ b/src/runtime/update/update_channel.c @@ -0,0 +1,67 @@ +#include "update_internal.h" + +#ifdef _WIN32 +#include + +bool bongo_cat_update_platform_supported(void) { + return true; +} + +bool bongo_cat_update_platform_store(void) { + typedef LONG (WINAPI *GetCurrentPackageFullNameFn)(UINT32 *, PWSTR); + HMODULE kernel = GetModuleHandleW(L"kernel32.dll"); + GetCurrentPackageFullNameFn get_package_name = kernel ? + (GetCurrentPackageFullNameFn)(void *)GetProcAddress(kernel, + "GetCurrentPackageFullName") : NULL; + if (!get_package_name) return false; + UINT32 length = 0; + LONG result = get_package_name(&length, NULL); + return result == ERROR_INSUFFICIENT_BUFFER; +} + +static bool current_executable(wchar_t *path, size_t capacity) { + DWORD length = GetModuleFileNameW(NULL, path, (DWORD)capacity); + return length > 0 && length < capacity; +} + +bool bongo_cat_update_platform_installed(void) { + wchar_t running[2048] = {0}; + if (!current_executable(running, _countof(running))) return false; + const DWORD views[] = { + RRF_SUBKEY_WOW6464KEY, RRF_SUBKEY_WOW6432KEY + }; + for (size_t index = 0; index < _countof(views); ++index) { + wchar_t install_root[2048] = {0}; + DWORD size = sizeof(install_root); + LSTATUS result = RegGetValueW(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\BongoCat", + L"InstallLocation", RRF_RT_REG_SZ | views[index], NULL, + install_root, &size); + if (result != ERROR_SUCCESS || !install_root[0]) continue; + size_t length = wcslen(install_root); + while (length && (install_root[length - 1] == L'\\' || + install_root[length - 1] == L'/')) install_root[--length] = L'\0'; + static const wchar_t executable[] = L"\\BongoCat.exe"; + if (length + _countof(executable) > _countof(install_root)) continue; + memcpy(install_root + length, executable, sizeof(executable)); + if (_wcsicmp(running, install_root) == 0) return true; + } + return false; +} + +const char *bongo_cat_update_platform_asset(void) { +#ifdef _WIN64 + return "windows-x64"; +#else + return "windows-x86"; +#endif +} + +#else + +bool bongo_cat_update_platform_supported(void) { return false; } +bool bongo_cat_update_platform_store(void) { return false; } +bool bongo_cat_update_platform_installed(void) { return false; } +const char *bongo_cat_update_platform_asset(void) { return "unsupported"; } + +#endif diff --git a/src/runtime/update/update_http.c b/src/runtime/update/update_http.c new file mode 100644 index 00000000..321f8c67 --- /dev/null +++ b/src/runtime/update/update_http.c @@ -0,0 +1,166 @@ +#include "update_internal.h" + +#include +#include +#include + +#ifdef _WIN32 +#include +#include + +#define UPDATE_RESPONSE_LIMIT (1024u * 1024u) +#define UPDATE_INITIAL_CAPACITY 8192u + +static bool track_handle(BongoCatUpdateService *service, void **slot, + HINTERNET handle) { + SDL_LockMutex(service->http_mutex); + bool accepted = !service->http_cancelled; + if (accepted) *slot = handle; + SDL_UnlockMutex(service->http_mutex); + if (!accepted) WinHttpCloseHandle(handle); + return accepted; +} + +static void release_handle(BongoCatUpdateService *service, void **slot, + HINTERNET handle) { + SDL_LockMutex(service->http_mutex); + bool owned = *slot == handle; + if (owned) *slot = NULL; + SDL_UnlockMutex(service->http_mutex); + if (owned) WinHttpCloseHandle(handle); +} + +static bool cancelled(BongoCatUpdateService *service) { + SDL_LockMutex(service->http_mutex); + bool value = service->http_cancelled; + SDL_UnlockMutex(service->http_mutex); + return value; +} + +static bool response_status(HINTERNET request, DWORD *status) { + DWORD size = sizeof(*status); + return WinHttpQueryHeaders(request, + WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_HEADER_NAME_BY_INDEX, status, &size, + WINHTTP_NO_HEADER_INDEX) != FALSE; +} + +static char *read_response(BongoCatUpdateService *service, + HINTERNET request) { + size_t capacity = UPDATE_INITIAL_CAPACITY, length = 0; + char *buffer = malloc(capacity); + if (!buffer) return NULL; + while (!cancelled(service)) { + DWORD available = 0; + if (!WinHttpQueryDataAvailable(request, &available)) break; + if (!available) { + buffer[length] = '\0'; + return buffer; + } + if (available > UPDATE_RESPONSE_LIMIT - length) break; + size_t required = length + available + 1; + if (required > capacity) { + size_t next = capacity; + while (next < required && next <= UPDATE_RESPONSE_LIMIT / 2) + next *= 2; + if (next < required) next = required; + char *resized = next <= UPDATE_RESPONSE_LIMIT + 1 + ? realloc(buffer, next) : NULL; + if (!resized) break; + buffer = resized; + capacity = next; + } + DWORD read = 0; + if (!WinHttpReadData(request, buffer + length, available, &read) || + !read) break; + length += read; + } + free(buffer); + return NULL; +} + +BongoCatUpdateFetchResult bongo_cat_update_http_fetch( + BongoCatUpdateService *service, char **response, char *error, + size_t error_capacity) { + *response = NULL; + HINTERNET session = WinHttpOpen(L"BongoCat Update Checker/1.0", + WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, WINHTTP_NO_PROXY_NAME, + WINHTTP_NO_PROXY_BYPASS, 0); + if (!session || !track_handle(service, &service->http_session, session)) + return cancelled(service) ? BONGO_CAT_UPDATE_FETCH_CANCELLED : + BONGO_CAT_UPDATE_FETCH_NETWORK; + WinHttpSetTimeouts(session, 5000, 5000, 10000, 10000); + HINTERNET connection = WinHttpConnect(session, L"api.github.com", + INTERNET_DEFAULT_HTTPS_PORT, 0); + bool connected = connection && track_handle(service, + &service->http_connection, connection); + HINTERNET request = connected ? WinHttpOpenRequest(connection, L"GET", + L"/repos/vladelaina/BongoCat/releases/latest", NULL, + WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, + WINHTTP_FLAG_SECURE) : NULL; + bool requested = request && track_handle(service, + &service->http_request, request); + static const wchar_t headers[] = + L"Accept: application/vnd.github+json\r\n" + L"X-GitHub-Api-Version: 2022-11-28\r\n"; + bool sent = requested && WinHttpSendRequest(request, headers, + (DWORD)-1L, WINHTTP_NO_REQUEST_DATA, 0, 0, 0) && + WinHttpReceiveResponse(request, NULL); + DWORD status = 0; + bool valid_status = sent && response_status(request, &status) && + status == 200; + if (valid_status) *response = read_response(service, request); + if (request) release_handle(service, &service->http_request, request); + if (connection) release_handle(service, &service->http_connection, + connection); + release_handle(service, &service->http_session, session); + if (cancelled(service)) return BONGO_CAT_UPDATE_FETCH_CANCELLED; + if (!sent) { + snprintf(error, error_capacity, "Cannot connect to GitHub"); + return BONGO_CAT_UPDATE_FETCH_NETWORK; + } + if (!valid_status) { + snprintf(error, error_capacity, + status == 404 ? "No published BongoCat release is available" : + "GitHub returned HTTP status %lu", (unsigned long)status); + return BONGO_CAT_UPDATE_FETCH_RESPONSE; + } + if (!*response) { + snprintf(error, error_capacity, "Cannot read the GitHub response"); + return BONGO_CAT_UPDATE_FETCH_MEMORY; + } + return BONGO_CAT_UPDATE_FETCH_OK; +} + +void bongo_cat_update_http_cancel(BongoCatUpdateService *service) { + if (!service || !service->http_mutex) return; + SDL_LockMutex(service->http_mutex); + service->http_cancelled = true; + HINTERNET request = service->http_request; + HINTERNET connection = service->http_connection; + HINTERNET session = service->http_session; + service->http_request = service->http_connection = + service->http_session = NULL; + SDL_UnlockMutex(service->http_mutex); + if (request) WinHttpCloseHandle(request); + if (connection) WinHttpCloseHandle(connection); + if (session) WinHttpCloseHandle(session); +} + +#else + +BongoCatUpdateFetchResult bongo_cat_update_http_fetch( + BongoCatUpdateService *service, char **response, char *error, + size_t error_capacity) { + (void)service; + *response = NULL; + snprintf(error, error_capacity, + "Automatic updates are not available on this platform yet"); + return BONGO_CAT_UPDATE_FETCH_UNSUPPORTED; +} + +void bongo_cat_update_http_cancel(BongoCatUpdateService *service) { + (void)service; +} + +#endif diff --git a/src/runtime/update/update_internal.h b/src/runtime/update/update_internal.h new file mode 100644 index 00000000..3792b209 --- /dev/null +++ b/src/runtime/update/update_internal.h @@ -0,0 +1,44 @@ +#ifndef BONGO_CAT_UPDATE_INTERNAL_H +#define BONGO_CAT_UPDATE_INTERNAL_H + +#include "update_service.h" +#include "bongo_cat/app.h" +#include + +typedef enum BongoCatUpdateFetchResult { + BONGO_CAT_UPDATE_FETCH_OK, + BONGO_CAT_UPDATE_FETCH_CANCELLED, + BONGO_CAT_UPDATE_FETCH_UNSUPPORTED, + BONGO_CAT_UPDATE_FETCH_NETWORK, + BONGO_CAT_UPDATE_FETCH_RESPONSE, + BONGO_CAT_UPDATE_FETCH_MEMORY +} BongoCatUpdateFetchResult; + +struct BongoCatUpdateService { + BongoCatApp *app; + SDL_Mutex *mutex; + SDL_Mutex *http_mutex; + SDL_Thread *worker; + Uint32 event_type; + BongoCatUpdateStatus status; + BongoCatUpdateRelease release; + char error[256]; + bool manual; + bool installed; + bool shutting_down; + bool http_cancelled; + void *http_session; + void *http_connection; + void *http_request; +}; + +BongoCatUpdateFetchResult bongo_cat_update_http_fetch( + BongoCatUpdateService *service, char **response, char *error, + size_t error_capacity); +void bongo_cat_update_http_cancel(BongoCatUpdateService *service); +bool bongo_cat_update_platform_supported(void); +bool bongo_cat_update_platform_store(void); +bool bongo_cat_update_platform_installed(void); +const char *bongo_cat_update_platform_asset(void); + +#endif diff --git a/src/runtime/update/update_service.c b/src/runtime/update/update_service.c new file mode 100644 index 00000000..018ab07a --- /dev/null +++ b/src/runtime/update/update_service.c @@ -0,0 +1,239 @@ +#include "update_internal.h" +#include "preferences_notice.h" +#include "bongo_cat/i18n.h" +#include "bongo_cat/preferences.h" + +#include +#include +#include +#include + +#define RELEASES_URL \ + "https://github.com/vladelaina/BongoCat/releases/latest" +#define STORE_URI "ms-windows-store://pdp/?ProductId=9NX7G84J3WM6" +#define STORE_WEB_URL "https://apps.microsoft.com/detail/9NX7G84J3WM6" + +static void reap_worker(BongoCatUpdateService *service) { + SDL_LockMutex(service->mutex); + SDL_Thread *worker = service->status != BONGO_CAT_UPDATE_CHECKING + ? service->worker : NULL; + if (worker) service->worker = NULL; + SDL_UnlockMutex(service->mutex); + if (worker) SDL_WaitThread(worker, NULL); +} + +static int local_day(void) { + time_t timestamp = time(NULL); + struct tm local = {0}; +#ifdef _WIN32 + if (localtime_s(&local, ×tamp) != 0) return 0; +#else + if (!localtime_r(×tamp, &local)) return 0; +#endif + return (local.tm_year + 1900) * 10000 + + (local.tm_mon + 1) * 100 + local.tm_mday; +} + +static void complete(BongoCatUpdateService *service, + BongoCatUpdateStatus status, const BongoCatUpdateRelease *release, + const char *error) { + SDL_LockMutex(service->mutex); + bool notify = !service->shutting_down; + if (notify) { + service->status = status; + if (release) service->release = *release; + else memset(&service->release, 0, sizeof(service->release)); + snprintf(service->error, sizeof(service->error), "%s", + error ? error : ""); + } + SDL_UnlockMutex(service->mutex); + if (!notify) return; + SDL_Event event = {0}; + event.type = service->event_type; + event.user.data1 = service; + SDL_PushEvent(&event); +} + +static int SDLCALL update_worker(void *userdata) { + BongoCatUpdateService *service = userdata; + char *response = NULL; + char message[256] = {0}; + BongoCatUpdateFetchResult fetched = bongo_cat_update_http_fetch(service, + &response, message, sizeof(message)); + if (fetched != BONGO_CAT_UPDATE_FETCH_OK) { + if (fetched != BONGO_CAT_UPDATE_FETCH_CANCELLED) + complete(service, BONGO_CAT_UPDATE_ERROR, NULL, message); + return 0; + } + BongoCatUpdateRelease release; + BongoCatError error = {0}; + bool parsed = bongo_cat_update_parse_release(response, + bongo_cat_update_platform_asset(), &release, &error); + free(response); + if (!parsed) { + complete(service, BONGO_CAT_UPDATE_ERROR, NULL, error.message); + return 0; + } + BongoCatUpdateStatus status = bongo_cat_update_compare_versions( + release.version, BONGO_CAT_VERSION) > 0 + ? BONGO_CAT_UPDATE_AVAILABLE : BONGO_CAT_UPDATE_CURRENT; + complete(service, status, &release, NULL); + return 0; +} + +BongoCatUpdateService *bongo_cat_update_create(BongoCatApp *app) { + if (!app) return NULL; + BongoCatUpdateService *service = calloc(1, sizeof(*service)); + if (!service) return NULL; + service->app = app; + service->mutex = SDL_CreateMutex(); + service->http_mutex = SDL_CreateMutex(); + service->event_type = SDL_RegisterEvents(1); + if (!service->mutex || !service->http_mutex || + service->event_type == (Uint32)-1) { + if (service->mutex) SDL_DestroyMutex(service->mutex); + if (service->http_mutex) SDL_DestroyMutex(service->http_mutex); + free(service); + return NULL; + } + if (!bongo_cat_update_platform_supported()) + service->status = BONGO_CAT_UPDATE_UNSUPPORTED; + else if (bongo_cat_update_platform_store()) + service->status = BONGO_CAT_UPDATE_STORE; + else { + service->status = BONGO_CAT_UPDATE_IDLE; + service->installed = bongo_cat_update_platform_installed(); + } + return service; +} + +bool bongo_cat_update_check(BongoCatUpdateService *service, bool manual) { + if (!service) return false; + reap_worker(service); + SDL_LockMutex(service->mutex); + if (service->shutting_down || service->worker || + service->status == BONGO_CAT_UPDATE_STORE || + service->status == BONGO_CAT_UPDATE_UNSUPPORTED) { + SDL_UnlockMutex(service->mutex); + return false; + } + service->status = BONGO_CAT_UPDATE_CHECKING; + service->manual = manual; + service->error[0] = '\0'; + SDL_LockMutex(service->http_mutex); + service->http_cancelled = false; + SDL_UnlockMutex(service->http_mutex); + service->worker = SDL_CreateThread(update_worker, + "bongo-cat-update-check", service); + bool started = service->worker != NULL; + if (!started) { + service->status = BONGO_CAT_UPDATE_ERROR; + snprintf(service->error, sizeof(service->error), + "Cannot start the update checker"); + } + SDL_UnlockMutex(service->mutex); + return started; +} + +void bongo_cat_update_start_automatic(BongoCatUpdateService *service) { + if (!service || !service->app || service->app->smoke || + service->app->secondary_pet) return; + int today = local_day(); + BongoCatSessionState *session = &service->app->session; + if (today && session->last_update_check_day == today && + strcmp(session->last_update_check_version, BONGO_CAT_VERSION) == 0) + return; + if (!bongo_cat_update_check(service, false)) return; + session->last_update_check_day = today; + snprintf(session->last_update_check_version, + sizeof(session->last_update_check_version), "%s", BONGO_CAT_VERSION); +} + +void bongo_cat_update_snapshot(BongoCatUpdateService *service, + BongoCatUpdateSnapshot *snapshot) { + if (!snapshot) return; + memset(snapshot, 0, sizeof(*snapshot)); + if (!service) { + snapshot->status = BONGO_CAT_UPDATE_UNSUPPORTED; + return; + } + SDL_LockMutex(service->mutex); + snapshot->status = service->status; + snapshot->release = service->release; + snapshot->installed = service->installed; + snprintf(snapshot->error, sizeof(snapshot->error), "%s", service->error); + SDL_UnlockMutex(service->mutex); +} + +static const char *tr(BongoCatUpdateService *service, const char *key, + const char *fallback) { + return bongo_cat_i18n_get(service->app->i18n, key, fallback); +} + +static void show_completion(BongoCatUpdateService *service) { + BongoCatUpdateSnapshot snapshot; + bongo_cat_update_snapshot(service, &snapshot); + char message[192]; + if (snapshot.status == BONGO_CAT_UPDATE_CURRENT) { + snprintf(message, sizeof(message), "%s v%s", tr(service, + "native.support.latest", "Already up to date"), + BONGO_CAT_VERSION); + bongo_cat_preferences_notice_show(service->app, message, false); + } else if (snapshot.status == BONGO_CAT_UPDATE_AVAILABLE) { + snprintf(message, sizeof(message), "%s v%s", tr(service, + "native.support.updateAvailable", "New version available:"), + snapshot.release.version); + bongo_cat_preferences_notice_show(service->app, message, false); + } else if (snapshot.status == BONGO_CAT_UPDATE_ERROR) { + bongo_cat_preferences_notice_show(service->app, tr(service, + "native.support.updateFailed", "Unable to check for updates"), + true); + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Update check failed: %s", snapshot.error); + } +} + +bool bongo_cat_update_event(BongoCatUpdateService *service, + const SDL_Event *event) { + if (!service || !event || event->type != service->event_type || + event->user.data1 != service) return false; + SDL_LockMutex(service->mutex); + bool manual = service->manual; + SDL_UnlockMutex(service->mutex); + reap_worker(service); + bongo_cat_preferences_invalidate(service->app->preferences); + if (manual) show_completion(service); + return true; +} + +bool bongo_cat_update_open(BongoCatUpdateService *service) { + BongoCatUpdateSnapshot snapshot; + bongo_cat_update_snapshot(service, &snapshot); + if (snapshot.status == BONGO_CAT_UPDATE_STORE) + return SDL_OpenURL(STORE_URI) || SDL_OpenURL(STORE_WEB_URL); + const char *url = RELEASES_URL; + if (snapshot.status == BONGO_CAT_UPDATE_AVAILABLE) { + if (snapshot.installed && snapshot.release.installer_url[0]) + url = snapshot.release.installer_url; + else if (!snapshot.installed && snapshot.release.portable_url[0]) + url = snapshot.release.portable_url; + else if (snapshot.release.release_url[0]) + url = snapshot.release.release_url; + } + return SDL_OpenURL(url); +} + +void bongo_cat_update_destroy(BongoCatUpdateService *service) { + if (!service) return; + SDL_LockMutex(service->mutex); + service->shutting_down = true; + SDL_UnlockMutex(service->mutex); + bongo_cat_update_http_cancel(service); + if (service->worker) SDL_WaitThread(service->worker, NULL); + SDL_Event event; + while (SDL_PeepEvents(&event, 1, SDL_GETEVENT, service->event_type, + service->event_type) > 0) {} + SDL_DestroyMutex(service->http_mutex); + SDL_DestroyMutex(service->mutex); + free(service); +} diff --git a/src/runtime/update/update_service.h b/src/runtime/update/update_service.h new file mode 100644 index 00000000..dc1d6a85 --- /dev/null +++ b/src/runtime/update/update_service.h @@ -0,0 +1,37 @@ +#ifndef BONGO_CAT_UPDATE_SERVICE_H +#define BONGO_CAT_UPDATE_SERVICE_H + +#include "bongo_cat/update.h" + +typedef struct BongoCatApp BongoCatApp; +typedef union SDL_Event SDL_Event; +typedef struct BongoCatUpdateService BongoCatUpdateService; + +typedef enum BongoCatUpdateStatus { + BONGO_CAT_UPDATE_IDLE, + BONGO_CAT_UPDATE_CHECKING, + BONGO_CAT_UPDATE_CURRENT, + BONGO_CAT_UPDATE_AVAILABLE, + BONGO_CAT_UPDATE_ERROR, + BONGO_CAT_UPDATE_STORE, + BONGO_CAT_UPDATE_UNSUPPORTED +} BongoCatUpdateStatus; + +typedef struct BongoCatUpdateSnapshot { + BongoCatUpdateStatus status; + BongoCatUpdateRelease release; + char error[256]; + bool installed; +} BongoCatUpdateSnapshot; + +BongoCatUpdateService *bongo_cat_update_create(BongoCatApp *app); +void bongo_cat_update_start_automatic(BongoCatUpdateService *service); +bool bongo_cat_update_check(BongoCatUpdateService *service, bool manual); +bool bongo_cat_update_event(BongoCatUpdateService *service, + const SDL_Event *event); +void bongo_cat_update_snapshot(BongoCatUpdateService *service, + BongoCatUpdateSnapshot *snapshot); +bool bongo_cat_update_open(BongoCatUpdateService *service); +void bongo_cat_update_destroy(BongoCatUpdateService *service); + +#endif diff --git a/src/ui/preferences/preferences_about_footer.c b/src/ui/preferences/preferences_about_footer.c index e646e749..87f1fa6a 100644 --- a/src/ui/preferences/preferences_about_footer.c +++ b/src/ui/preferences/preferences_about_footer.c @@ -6,6 +6,7 @@ #include "ui_catime.h" #include "ui_icons.h" #include "ui_paint.h" +#include "update_service.h" #include #include @@ -74,7 +75,7 @@ static void open_logs(BongoCatPreferences *value) { static void draw_update(BongoCatPreferences *value, struct nk_context *context, struct nk_command_buffer *canvas, struct nk_rect bounds, const char *label, float content_width, - BongoCatUIPalette p) { + BongoCatUIPalette p, const BongoCatUpdateSnapshot *snapshot) { if (p.effects) bongo_cat_ui_paint_shadow(context, bounds, 10, 0, 4, 14, 0, nk_rgba(p.accent.r, p.accent.g, p.accent.b, 89)); nk_fill_rect(canvas, bounds, 10, p.accent); @@ -86,11 +87,38 @@ static void draw_update(BongoCatPreferences *value, nk_rgb(255, 255, 255)); link_cursor(context, bounds); if (hit(context, bounds)) { - char message[160]; - snprintf(message, sizeof(message), "%s v%s", tr(value, - "native.support.latest", "Already up to date"), - BONGO_CAT_VERSION); - bongo_cat_preferences_notice_show(value->app, message, false); + if (snapshot->status == BONGO_CAT_UPDATE_CHECKING) return; + if (snapshot->status == BONGO_CAT_UPDATE_AVAILABLE || + snapshot->status == BONGO_CAT_UPDATE_STORE || + snapshot->status == BONGO_CAT_UPDATE_UNSUPPORTED) { + if (!bongo_cat_update_open(value->app->update)) + bongo_cat_preferences_notice_show(value->app, tr(value, + "native.support.openUpdateFailed", + "Unable to open the update page"), true); + } else if (!bongo_cat_update_check(value->app->update, true)) { + bongo_cat_preferences_notice_show(value->app, tr(value, + "native.support.updateFailed", + "Unable to check for updates"), true); + } else value->render_dirty = true; + } +} + +static const char *update_label(BongoCatPreferences *value, + const BongoCatUpdateSnapshot *snapshot, char *buffer, size_t capacity) { + switch (snapshot->status) { + case BONGO_CAT_UPDATE_CHECKING: + return tr(value, "native.support.checkingUpdate", "Checking..."); + case BONGO_CAT_UPDATE_AVAILABLE: + snprintf(buffer, capacity, tr(value, + "native.support.downloadUpdate", "Download v%s"), + snapshot->release.version); + return buffer; + case BONGO_CAT_UPDATE_STORE: + return tr(value, "native.support.openStore", "Open Microsoft Store"); + case BONGO_CAT_UPDATE_UNSUPPORTED: + return tr(value, "native.support.viewReleases", "View releases"); + default: + return tr(value, "native.support.checkUpdate", "Check for updates"); } } @@ -130,8 +158,11 @@ void bongo_cat_preferences_about_footer(BongoCatPreferences *value, struct nk_rect bounds, BongoCatUIPalette p) { const char *version_label = tr(value, "native.support.version", "App version"); - const char *update = tr(value, - "native.support.checkUpdate", "Check for updates"); + BongoCatUpdateSnapshot update_snapshot; + bongo_cat_update_snapshot(value->app->update, &update_snapshot); + char update_buffer[96]; + const char *update = update_label(value, &update_snapshot, + update_buffer, sizeof(update_buffer)); const char *feedback = tr(value, "native.support.feedback", "Feedback"); const char *logs = tr(value, "native.support.openLogs", "Open logs"); const struct nk_user_font *small_font = logs_font(value); @@ -164,7 +195,7 @@ void bongo_cat_preferences_about_footer(BongoCatPreferences *value, struct nk_rect update_button = nk_rect(actions_x, actions_y, update_width, 36); draw_update(value, context, canvas, update_button, update, - update_content_width, p); + update_content_width, p, &update_snapshot); struct nk_rect feedback_link = nk_rect(actions_x + update_width + 14, actions_y, feedback_width, 36); struct nk_rect logs_link = nk_rect(feedback_link.x + feedback_width + 20, diff --git a/tests/core/test_config.c b/tests/core/test_config.c index 2fd88564..07878322 100644 --- a/tests/core/test_config.c +++ b/tests/core/test_config.c @@ -72,6 +72,8 @@ void test_config(void) { session.window.content_width = 612; session.window.content_height = 354; memcpy(session.active_model_id, "model", sizeof("model")); + session.last_update_check_day = 20260827; + memcpy(session.last_update_check_version, "0.1.0", sizeof("0.1.0")); CHECK(bongo_cat_session_add_model(&session, "keyboard")); CHECK(bongo_cat_session_add_model(&session, "gamepad")); session.active_behavior_count = 2; @@ -105,6 +107,7 @@ void test_config(void) { CHECK(contains_text(session_path, "\"contentWidth\": 612") && contains_text(session_path, "\"contentHeight\": 354")); CHECK(contains_text(session_path, "\"activeModelId\": \"model\"")); + CHECK(contains_text(session_path, "\"lastUpdateCheckDay\": 20260827")); CHECK(contains_text(session_path, "\"additionalModelIds\"")); CHECK(contains_text(session_path, "\"activeBehaviors\"")); CHECK(contains_text(session_path, @@ -143,6 +146,8 @@ void test_config(void) { loaded_session.window.content_width == 612 && loaded_session.window.content_height == 354); CHECK(strcmp(loaded_session.active_model_id, "model") == 0); + CHECK(loaded_session.last_update_check_day == 20260827 && + strcmp(loaded_session.last_update_check_version, "0.1.0") == 0); CHECK(loaded_session.additional_model_count == 2 && bongo_cat_session_model_active(&loaded_session, "keyboard") && bongo_cat_session_model_active(&loaded_session, "gamepad")); diff --git a/tests/core/test_main.c b/tests/core/test_main.c index f4516f33..51ee8ff7 100644 --- a/tests/core/test_main.c +++ b/tests/core/test_main.c @@ -9,6 +9,7 @@ int main(void) { test_models(); test_mver_pointer(); test_shortcut(); + test_update(); if (bongo_cat_test_failures) { fprintf(stderr, "%d checks failed\n", bongo_cat_test_failures); return 1; diff --git a/tests/core/test_update.c b/tests/core/test_update.c new file mode 100644 index 00000000..83b7c231 --- /dev/null +++ b/tests/core/test_update.c @@ -0,0 +1,59 @@ +#include "test.h" +#include "bongo_cat/update.h" + +#include + +static const char release_json[] = + "{" + "\"tag_name\":\"v1.2.3\"," + "\"html_url\":\"https://github.com/vladelaina/BongoCat/releases/tag/v1.2.3\"," + "\"draft\":false,\"prerelease\":false," + "\"body\":\"Fixed input and rendering.\"," + "\"assets\":[" + "{\"name\":\"BongoCat-1.2.3-windows-x64-portable.zip.sha256\"," + "\"browser_download_url\":\"https://github.com/vladelaina/BongoCat/" + "releases/download/v1.2.3/BongoCat-1.2.3-windows-x64-portable.zip.sha256\"}," + "{\"name\":\"BongoCat-1.2.3-windows-x64-setup.exe\"," + "\"browser_download_url\":\"https://github.com/vladelaina/BongoCat/" + "releases/download/v1.2.3/BongoCat-1.2.3-windows-x64-setup.exe\"}," + "{\"name\":\"BongoCat-1.2.3-windows-x64-portable.zip\"," + "\"browser_download_url\":\"https://github.com/vladelaina/BongoCat/" + "releases/download/v1.2.3/BongoCat-1.2.3-windows-x64-portable.zip\"}]}"; + +void test_update(void) { + CHECK(bongo_cat_update_version_valid("1.2.3-rc.1+build.4")); + CHECK(!bongo_cat_update_version_valid("1")); + CHECK(!bongo_cat_update_version_valid("1.")); + CHECK(!bongo_cat_update_version_valid("1.2")); + CHECK(bongo_cat_update_compare_versions("1.2.3", "1.2.2") > 0); + CHECK(bongo_cat_update_compare_versions("v2.0.0", "1.99.99") > 0); + CHECK(bongo_cat_update_compare_versions("1.0.0", "1.0.0-rc.1") > 0); + CHECK(bongo_cat_update_compare_versions("1.0.0-beta.2", + "1.0.0-beta.11") < 0); + CHECK(bongo_cat_update_compare_versions("1.0.0+build.2", + "1.0.0+build.1") == 0); + + BongoCatUpdateRelease release; + BongoCatError error = {0}; + CHECK(bongo_cat_update_parse_release(release_json, "windows-x64", + &release, &error)); + CHECK(strcmp(release.version, "1.2.3") == 0); + CHECK(strstr(release.installer_url, "windows-x64-setup.exe") != NULL); + CHECK(strstr(release.portable_url, "windows-x64-portable.zip") != NULL); + CHECK(strcmp(release.notes, "Fixed input and rendering.") == 0); + + const char unsafe[] = + "{\"tag_name\":\"v2.0.0\",\"draft\":false," + "\"prerelease\":false,\"html_url\":\"https://example.com/v2\"," + "\"assets\":[]}"; + CHECK(!bongo_cat_update_parse_release(unsafe, "windows-x64", + &release, &error)); + + const char oversized_tag[] = + "{\"tag_name\":\"v1234567890123456789012345678901234567890\"," + "\"draft\":false,\"prerelease\":false," + "\"html_url\":\"https://github.com/vladelaina/BongoCat/" + "releases/tag/oversized\",\"assets\":[]}"; + CHECK(!bongo_cat_update_parse_release(oversized_tag, "windows-x64", + &release, &error)); +} diff --git a/tests/support/test.h b/tests/support/test.h index 6986d827..9b4023f7 100644 --- a/tests/support/test.h +++ b/tests/support/test.h @@ -15,5 +15,6 @@ void test_input(void); void test_models(void); void test_mver_pointer(void); void test_shortcut(void); +void test_update(void); #endif From 47ee0e9e15f13c67db308d02095b15d00c873e2e Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 27 Aug 2026 11:47:02 +0800 Subject: [PATCH 116/334] fix: use SDL time API for update checks --- src/runtime/update/update_service.c | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/runtime/update/update_service.c b/src/runtime/update/update_service.c index 018ab07a..9f1204d7 100644 --- a/src/runtime/update/update_service.c +++ b/src/runtime/update/update_service.c @@ -6,7 +6,6 @@ #include #include #include -#include #define RELEASES_URL \ "https://github.com/vladelaina/BongoCat/releases/latest" @@ -23,15 +22,11 @@ static void reap_worker(BongoCatUpdateService *service) { } static int local_day(void) { - time_t timestamp = time(NULL); - struct tm local = {0}; -#ifdef _WIN32 - if (localtime_s(&local, ×tamp) != 0) return 0; -#else - if (!localtime_r(×tamp, &local)) return 0; -#endif - return (local.tm_year + 1900) * 10000 + - (local.tm_mon + 1) * 100 + local.tm_mday; + SDL_Time timestamp = 0; + SDL_DateTime local = {0}; + if (!SDL_GetCurrentTime(×tamp) || + !SDL_TimeToDateTime(timestamp, &local, true)) return 0; + return local.year * 10000 + local.month * 100 + local.day; } static void complete(BongoCatUpdateService *service, From 36c0eb713592ea0344b1e8572f653988280bbf55 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 27 Aug 2026 13:06:34 +0800 Subject: [PATCH 117/334] ci: pause macOS jobs --- .github/workflows/ci.yml | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98864c7c..b8632ea0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,12 +105,6 @@ jobs: - os: ubuntu-latest platform: linux-x64 cmake_arch: '' - - os: macos-13 - platform: macos-x64 - cmake_arch: x86_64 - - os: macos-14 - platform: macos-arm64 - cmake_arch: arm64 runs-on: ${{ matrix.os }} steps: - name: Checkout source @@ -187,12 +181,6 @@ jobs: - os: ubuntu-latest platform: linux-x64 cmake_arch: '' - - os: macos-13 - platform: macos-x64 - cmake_arch: x86_64 - - os: macos-14 - platform: macos-arm64 - cmake_arch: arm64 runs-on: ${{ matrix.os }} env: CUBISM_SDK_ARCHIVE_URL: ${{ secrets.CUBISM_SDK_ARCHIVE_URL }} From d8097f6bedd8b44554d9f665647f8ea8be749cbc Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 27 Aug 2026 15:17:39 +0800 Subject: [PATCH 118/334] ci: scan all release artifacts with VirusTotal --- .github/workflows/ci.yml | 76 +++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 21 +++++----- 2 files changed, 88 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8632ea0..851853ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,6 +105,12 @@ jobs: - os: ubuntu-latest platform: linux-x64 cmake_arch: '' + - os: macos-15-intel + platform: macos-x64 + cmake_arch: x86_64 + - os: macos-15 + platform: macos-arm64 + cmake_arch: arm64 runs-on: ${{ matrix.os }} steps: - name: Checkout source @@ -181,6 +187,12 @@ jobs: - os: ubuntu-latest platform: linux-x64 cmake_arch: '' + - os: macos-15-intel + platform: macos-x64 + cmake_arch: x86_64 + - os: macos-15 + platform: macos-arm64 + cmake_arch: arm64 runs-on: ${{ matrix.os }} env: CUBISM_SDK_ARCHIVE_URL: ${{ secrets.CUBISM_SDK_ARCHIVE_URL }} @@ -245,6 +257,7 @@ jobs: - name: Upload desktop release uses: actions/upload-artifact@v7 with: + name: bongo-cat-vt-${{ matrix.platform }}-archive-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ steps.package.outputs.file }} if-no-files-found: error retention-days: 30 @@ -382,6 +395,7 @@ jobs: if: matrix.store_arch == 'x64' uses: actions/upload-artifact@v7 with: + name: bongo-cat-vt-windows-x64-msix-${{ github.run_id }}-${{ github.run_attempt }} path: store-output/bongocat_${{ steps.version.outputs.version }}_${{ matrix.store_arch }}.msix if-no-files-found: error retention-days: 90 @@ -391,6 +405,7 @@ jobs: - name: Upload Windows desktop release uses: actions/upload-artifact@v7 with: + name: bongo-cat-vt-${{ matrix.platform }}-setup-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ steps.desktop-package.outputs.file }} if-no-files-found: error retention-days: 30 @@ -400,8 +415,69 @@ jobs: - name: Upload Windows portable release uses: actions/upload-artifact@v7 with: + name: bongo-cat-vt-${{ matrix.platform }}-portable-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ steps.desktop-package.outputs.portable }} if-no-files-found: error retention-days: 30 compression-level: 0 archive: false + + virus-total: + name: security / virustotal + if: >- + github.repository == 'vladelaina/BongoCat' && + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && + github.ref == 'refs/heads/main' + needs: [desktop-package, store-package] + runs-on: ubuntu-latest + permissions: + contents: read + actions: write + + steps: + - name: Checkout source + uses: actions/checkout@v5 + + - name: Download release artifacts + uses: actions/download-artifact@v8 + with: + pattern: bongo-cat-vt-* + path: vt-input + merge-multiple: true + + - name: Scan all release artifacts with VirusTotal + env: + VIRUSTOTAL_API_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }} + run: | + set -euo pipefail + mapfile -d '' files < <(find vt-input -type f \( \ + -iname '*.exe' -o -iname '*.zip' -o -iname '*.tar.gz' -o \ + -iname '*.msix' \) -print0 | sort -z) + if [ "${#files[@]}" -ne 8 ]; then + echo "::error::Expected 8 VirusTotal inputs, found ${#files[@]}" + printf 'Found: %s\n' "${files[@]}" + exit 1 + fi + for file in "${files[@]}"; do + report=$(basename "$file" | tr -c 'A-Za-z0-9._-' '_') + VT_REPORT_PATH="virustotal-report-$report.json" \ + python3 .github/scripts/scan-virustotal.py --file "$file" + done + + - name: Add VirusTotal note when scanning is skipped + if: always() && hashFiles('virustotal-report-*.json') == '' + run: | + { + echo "## VirusTotal scans" + echo + echo "No report was produced. Check that VIRUSTOTAL_API_KEY is configured." + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload VirusTotal reports + if: always() && hashFiles('virustotal-report-*.json') != '' + uses: actions/upload-artifact@v7 + with: + name: virustotal-ci-reports-${{ github.run_id }}-${{ github.run_attempt }} + path: virustotal-report-*.json + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48933c3b..1d4f36c8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -155,19 +155,22 @@ jobs: path: vt-input merge-multiple: true - - name: Scan Windows installers with VirusTotal + - name: Scan all Windows release artifacts with VirusTotal env: VIRUSTOTAL_API_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }} run: | set -euo pipefail - for platform in windows-x64 windows-x86; do - file=$(find vt-input -type f \ - -name "BongoCat-*-$platform-setup.exe" -print -quit) - if [ -z "$file" ]; then - echo "::error::VirusTotal input for $platform was not found" - exit 1 - fi - VT_REPORT_PATH="virustotal-report-$platform.json" \ + mapfile -d '' files < <(find vt-input -type f \( \ + -iname '*-setup.exe' -o -iname '*-portable.zip' \) \ + -print0 | sort -z) + if [ "${#files[@]}" -ne 4 ]; then + echo "::error::Expected 4 VirusTotal inputs, found ${#files[@]}" + printf 'Found: %s\n' "${files[@]}" + exit 1 + fi + for file in "${files[@]}"; do + report=$(basename "$file" | tr -c 'A-Za-z0-9._-' '_') + VT_REPORT_PATH="virustotal-report-$report.json" \ python3 .github/scripts/scan-virustotal.py --file "$file" done From f75792af3cf5f2baf12dec9f0562d23bc22fb75a Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 27 Aug 2026 17:24:40 +0800 Subject: [PATCH 119/334] fix(windows): isolate MSIX application storage --- cmake/PlatformRuntime.cmake | 1 + packaging/microsoft-store/README.md | 16 ++++++ src/platform/windows/windows_package.c | 75 ++++++++++++++++++++++++++ src/platform/windows/windows_package.h | 15 ++++++ src/runtime/lifecycle/startup.c | 5 ++ src/runtime/lifecycle/storage_paths.c | 40 +++++++------- src/runtime/update/update_channel.c | 11 +--- tests/platform/test_windows_capture.c | 16 ++++++ 8 files changed, 150 insertions(+), 29 deletions(-) create mode 100644 src/platform/windows/windows_package.c create mode 100644 src/platform/windows/windows_package.h diff --git a/cmake/PlatformRuntime.cmake b/cmake/PlatformRuntime.cmake index 2938c096..cd9a32af 100644 --- a/cmake/PlatformRuntime.cmake +++ b/cmake/PlatformRuntime.cmake @@ -4,6 +4,7 @@ if(WIN32) src/platform/windows/windows.c src/platform/windows/windows_menu.c src/platform/windows/windows_directory.c + src/platform/windows/windows_package.c src/platform/windows/windows_startup.c src/platform/windows/windows_borderless.c src/platform/windows/windows_capture.c diff --git a/packaging/microsoft-store/README.md b/packaging/microsoft-store/README.md index 8efd0ba2..4a1c8fb5 100644 --- a/packaging/microsoft-store/README.md +++ b/packaging/microsoft-store/README.md @@ -20,6 +20,22 @@ and package SID are derived by Windows and must not be used as signing secrets. The Store package is x64; the separate Windows x86 workflow target only publishes its desktop installer and portable archive. +## App data isolation + +Ordinary EXE, installer, and portable launches store data under +`%LOCALAPPDATA%\BongoCat`. When the process has an MSIX package identity, +BongoCat instead resolves the package-private virtualization location as +`%LOCALAPPDATA%\Packages\\LocalCache\Local\BongoCat`. Configuration, +session state, imported models, caches, and logs therefore remain separate +between the desktop and Microsoft Store versions. An explicit +`--storage-root` argument remains isolated at the requested location. +In particular, the desktop settings file is +`%LOCALAPPDATA%\BongoCat\config\settings.json`, while the Store settings file +is `%LOCALAPPDATA%\Packages\\LocalCache\Local\BongoCat\config\settings.json`. + +Package identity is detected at runtime with `GetCurrentPackageFullName`, and +the selected mode and resolved storage path are written to `BongoCat.log`. + MSIX versions must have a non-zero major component and a zero revision. The script maps app version `0.1.1` to package version `1.1.1.0` by default; pass `-PackageVersion` when advancing the Store version independently. The app diff --git a/src/platform/windows/windows_package.c b/src/platform/windows/windows_package.c new file mode 100644 index 00000000..0f71eac6 --- /dev/null +++ b/src/platform/windows/windows_package.c @@ -0,0 +1,75 @@ +#include "windows_package.h" +#include "windows_utf8.h" + +#ifdef _WIN32 +#include "bongo_cat/common.h" + +#include +#include +#include +#include + +typedef LONG (WINAPI *BongoCatGetPackageName)(UINT32 *, PWSTR); + +static BongoCatGetPackageName package_api(const char *name) { + HMODULE kernel = GetModuleHandleW(L"kernel32.dll"); + return kernel ? (BongoCatGetPackageName)(void *)GetProcAddress(kernel, name) + : NULL; +} + +bool bongo_cat_windows_is_packaged(void) { + BongoCatGetPackageName get_name = package_api("GetCurrentPackageFullName"); + if (!get_name) return false; + UINT32 length = 0; + return get_name(&length, NULL) == ERROR_INSUFFICIENT_BUFFER; +} + +static wchar_t *package_family(void) { + BongoCatGetPackageName get_family = + package_api("GetCurrentPackageFamilyName"); + if (!get_family) return NULL; + UINT32 length = 0; + if (get_family(&length, NULL) != ERROR_INSUFFICIENT_BUFFER || !length) + return NULL; + wchar_t *family = calloc(length, sizeof(*family)); + if (!family || get_family(&length, family) != ERROR_SUCCESS) { + free(family); + return NULL; + } + return family; +} + +static bool user_profile(wchar_t output[BONGO_CAT_PATH_CAP]) { + DWORD length = GetEnvironmentVariableW(L"USERPROFILE", output, + BONGO_CAT_PATH_CAP); + if (length > 0 && length < BONGO_CAT_PATH_CAP) return true; + output[0] = L'\0'; + return SUCCEEDED(SHGetFolderPathW(NULL, CSIDL_PROFILE, NULL, 0, output)); +} + +bool bongo_cat_windows_package_storage_root_for(const wchar_t *profile, + const wchar_t *family, char *output, size_t capacity) { + if (!output || !capacity || capacity > INT_MAX) return false; + output[0] = '\0'; + if (!profile || !profile[0] || !family || !family[0]) return false; + wchar_t path[BONGO_CAT_PATH_CAP]; + int length = swprintf(path, BONGO_CAT_PATH_CAP, + L"%ls\\AppData\\Local\\Packages\\%ls\\LocalCache\\Local\\%ls", + profile, family, BONGO_CAT_NAME_W); + return length > 0 && length < BONGO_CAT_PATH_CAP && + bongo_cat_windows_utf8(path, output, capacity); +} + +bool bongo_cat_windows_package_storage_root(char *output, size_t capacity) { + if (!output || !capacity) return false; + output[0] = '\0'; + if (!bongo_cat_windows_is_packaged()) return false; + wchar_t profile[BONGO_CAT_PATH_CAP] = {0}; + wchar_t *family = package_family(); + bool resolved = family && user_profile(profile) && + bongo_cat_windows_package_storage_root_for(profile, family, + output, capacity); + free(family); + return resolved; +} +#endif diff --git a/src/platform/windows/windows_package.h b/src/platform/windows/windows_package.h new file mode 100644 index 00000000..39438712 --- /dev/null +++ b/src/platform/windows/windows_package.h @@ -0,0 +1,15 @@ +#ifndef BONGO_CAT_WINDOWS_PACKAGE_H +#define BONGO_CAT_WINDOWS_PACKAGE_H + +#ifdef _WIN32 +#include +#include +#include + +bool bongo_cat_windows_is_packaged(void); +bool bongo_cat_windows_package_storage_root(char *output, size_t capacity); +bool bongo_cat_windows_package_storage_root_for(const wchar_t *profile, + const wchar_t *family, char *output, size_t capacity); +#endif + +#endif diff --git a/src/runtime/lifecycle/startup.c b/src/runtime/lifecycle/startup.c index 1d7f948e..b057e3d3 100644 --- a/src/runtime/lifecycle/startup.c +++ b/src/runtime/lifecycle/startup.c @@ -11,6 +11,7 @@ #include #ifdef _WIN32 +#include "windows_package.h" #include #endif @@ -131,6 +132,10 @@ static void begin_log(BongoCatApp *app) { SDL_Log("[runtime] Process started: version=%s platform=%s storage=%s", BONGO_CAT_VERSION, SDL_GetPlatform(), app->storage_root[0] ? app->storage_root : app->config_root); +#ifdef _WIN32 + SDL_Log("[runtime] Package identity: %s", + bongo_cat_windows_is_packaged() ? "MSIX" : "unpackaged Win32"); +#endif if (interrupted[0]) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Previous startup ended before readiness at stage: %s", interrupted); if (unexpected[0] && !strstr(unexpected, "stage=clean-shutdown")) diff --git a/src/runtime/lifecycle/storage_paths.c b/src/runtime/lifecycle/storage_paths.c index dfea5006..87a1e983 100644 --- a/src/runtime/lifecycle/storage_paths.c +++ b/src/runtime/lifecycle/storage_paths.c @@ -8,6 +8,10 @@ #include #include +#ifdef _WIN32 +#include "windows_package.h" +#endif + static bool child_path(char *target, size_t capacity, const char *root, const char *child) { return root && root[0] && @@ -26,38 +30,34 @@ static bool writable_directory(const char *root) { return written; } -static bool isolated_roots(BongoCatApp *app) { +static bool roots_under(BongoCatApp *app, const char *root) { return child_path(app->config_root, sizeof(app->config_root), - app->storage_root, "config") && + root, "config") && child_path(app->data_root, sizeof(app->data_root), - app->storage_root, "data") && + root, "data") && child_path(app->models_root, sizeof(app->models_root), - app->storage_root, "models") && + root, "models") && child_path(app->cache_root, sizeof(app->cache_root), - app->storage_root, "cache") && + root, "cache") && child_path(app->state_root, sizeof(app->state_root), - app->storage_root, "state") && + root, "state") && child_path(app->log_root, sizeof(app->log_root), - app->storage_root, "logs"); + root, "logs"); +} + +static bool isolated_roots(BongoCatApp *app) { + return roots_under(app, app->storage_root); } #ifdef _WIN32 static bool platform_roots(BongoCatApp *app) { - const char *local = SDL_getenv("LOCALAPPDATA"); char local_app[BONGO_CAT_PATH_CAP]; + if (bongo_cat_windows_is_packaged()) + return bongo_cat_windows_package_storage_root(local_app, + sizeof(local_app)) && roots_under(app, local_app); + const char *local = SDL_getenv("LOCALAPPDATA"); return child_path(local_app, sizeof(local_app), local, BONGO_CAT_NAME) && - child_path(app->config_root, sizeof(app->config_root), - local_app, "config") && - child_path(app->data_root, sizeof(app->data_root), - local_app, "data") && - child_path(app->models_root, sizeof(app->models_root), - local_app, "models") && - child_path(app->cache_root, sizeof(app->cache_root), - local_app, "cache") && - child_path(app->state_root, sizeof(app->state_root), - local_app, "state") && - child_path(app->log_root, sizeof(app->log_root), - local_app, "logs"); + roots_under(app, local_app); } #elif defined(__APPLE__) static bool platform_roots(BongoCatApp *app) { diff --git a/src/runtime/update/update_channel.c b/src/runtime/update/update_channel.c index efd81db7..77885a91 100644 --- a/src/runtime/update/update_channel.c +++ b/src/runtime/update/update_channel.c @@ -1,6 +1,7 @@ #include "update_internal.h" #ifdef _WIN32 +#include "windows_package.h" #include bool bongo_cat_update_platform_supported(void) { @@ -8,15 +9,7 @@ bool bongo_cat_update_platform_supported(void) { } bool bongo_cat_update_platform_store(void) { - typedef LONG (WINAPI *GetCurrentPackageFullNameFn)(UINT32 *, PWSTR); - HMODULE kernel = GetModuleHandleW(L"kernel32.dll"); - GetCurrentPackageFullNameFn get_package_name = kernel ? - (GetCurrentPackageFullNameFn)(void *)GetProcAddress(kernel, - "GetCurrentPackageFullName") : NULL; - if (!get_package_name) return false; - UINT32 length = 0; - LONG result = get_package_name(&length, NULL); - return result == ERROR_INSUFFICIENT_BUFFER; + return bongo_cat_windows_is_packaged(); } static bool current_executable(wchar_t *path, size_t capacity) { diff --git a/tests/platform/test_windows_capture.c b/tests/platform/test_windows_capture.c index 6f3f764b..ba7cc270 100644 --- a/tests/platform/test_windows_capture.c +++ b/tests/platform/test_windows_capture.c @@ -3,6 +3,7 @@ #include "windows_diagnostics.h" #include "windows_direct_input.h" #include "windows_input.h" +#include "windows_package.h" #include "windows_popup.h" #include "windows_tray.h" @@ -19,6 +20,20 @@ static int nonclient_size_messages; failures++; \ } } while (0) +static void test_package_storage_root(void) { + char path[BONGO_CAT_PATH_CAP]; + CHECK(bongo_cat_windows_package_storage_root_for( + L"C:\\Users\\Alice", L"publisher.BongoCat_abcd1234", + path, sizeof(path))); + CHECK(strcmp(path, "C:\\Users\\Alice\\AppData\\Local\\Packages\\" + "publisher.BongoCat_abcd1234\\LocalCache\\Local\\BongoCat") == 0); + CHECK(!bongo_cat_windows_package_storage_root_for( + L"C:\\Users\\Alice", L"publisher.BongoCat_abcd1234", path, 8)); + CHECK(path[0] == '\0'); + CHECK(!bongo_cat_windows_package_storage_root_for( + L"C:\\Users\\Alice", L"", path, sizeof(path))); +} + static LRESULT CALLBACK click_through_test_proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) { if (message == WM_WINDOWPOSCHANGED) window_position_messages++; @@ -170,6 +185,7 @@ static void test_capture_styles(void) { } int main(void) { + test_package_storage_root(); test_click_through_does_not_refresh_frame(); test_capture_styles(); test_tray_restart_notification(); From 9cf082dc61504ed5ec2b59953432d38a275ff5d3 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 27 Aug 2026 18:05:11 +0800 Subject: [PATCH 120/334] ci: limit VirusTotal scans to key artifacts --- .github/workflows/ci.yml | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 851853ca..1cd8da30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -438,10 +438,31 @@ jobs: - name: Checkout source uses: actions/checkout@v5 - - name: Download release artifacts + - name: Download Linux x64 archive uses: actions/download-artifact@v8 with: - pattern: bongo-cat-vt-* + pattern: bongo-cat-vt-linux-x64-archive-${{ github.run_id }}-* + path: vt-input + merge-multiple: true + + - name: Download macOS arm64 archive + uses: actions/download-artifact@v8 + with: + pattern: bongo-cat-vt-macos-arm64-archive-${{ github.run_id }}-* + path: vt-input + merge-multiple: true + + - name: Download Windows x64 installer + uses: actions/download-artifact@v8 + with: + pattern: bongo-cat-vt-windows-x64-setup-${{ github.run_id }}-* + path: vt-input + merge-multiple: true + + - name: Download Windows x64 MSIX + uses: actions/download-artifact@v8 + with: + pattern: bongo-cat-vt-windows-x64-msix-${{ github.run_id }}-* path: vt-input merge-multiple: true @@ -451,10 +472,13 @@ jobs: run: | set -euo pipefail mapfile -d '' files < <(find vt-input -type f \( \ - -iname '*.exe' -o -iname '*.zip' -o -iname '*.tar.gz' -o \ - -iname '*.msix' \) -print0 | sort -z) - if [ "${#files[@]}" -ne 8 ]; then - echo "::error::Expected 8 VirusTotal inputs, found ${#files[@]}" + -iname '*-windows-x64-setup.exe' -o \ + -iname 'bongocat_*_x64.msix' -o \ + -iname '*-linux-x64.tar.gz' -o \ + -iname '*-macos-arm64.zip' \ + \) -print0 | sort -z) + if [ "${#files[@]}" -ne 4 ]; then + echo "::error::Expected 4 representative VirusTotal inputs, found ${#files[@]}" printf 'Found: %s\n' "${files[@]}" exit 1 fi From 7c435b87b898e236466273dd5e8ff3284b5d1294 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 27 Aug 2026 18:18:26 +0800 Subject: [PATCH 121/334] fix(ci): download exact VirusTotal artifacts --- .github/workflows/ci.yml | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cd8da30..e048eff9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -441,30 +441,26 @@ jobs: - name: Download Linux x64 archive uses: actions/download-artifact@v8 with: - pattern: bongo-cat-vt-linux-x64-archive-${{ github.run_id }}-* + name: bongo-cat-vt-linux-x64-archive-${{ github.run_id }}-${{ github.run_attempt }} path: vt-input - merge-multiple: true - name: Download macOS arm64 archive uses: actions/download-artifact@v8 with: - pattern: bongo-cat-vt-macos-arm64-archive-${{ github.run_id }}-* + name: bongo-cat-vt-macos-arm64-archive-${{ github.run_id }}-${{ github.run_attempt }} path: vt-input - merge-multiple: true - name: Download Windows x64 installer uses: actions/download-artifact@v8 with: - pattern: bongo-cat-vt-windows-x64-setup-${{ github.run_id }}-* + name: bongo-cat-vt-windows-x64-setup-${{ github.run_id }}-${{ github.run_attempt }} path: vt-input - merge-multiple: true - name: Download Windows x64 MSIX uses: actions/download-artifact@v8 with: - pattern: bongo-cat-vt-windows-x64-msix-${{ github.run_id }}-* + name: bongo-cat-vt-windows-x64-msix-${{ github.run_id }}-${{ github.run_attempt }} path: vt-input - merge-multiple: true - name: Scan all release artifacts with VirusTotal env: From 2d02241753f6cd14ec1393adaa25225339bedd5e Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 27 Aug 2026 18:41:30 +0800 Subject: [PATCH 122/334] fix(ci): match raw VirusTotal artifact names --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e048eff9..289a20d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -441,25 +441,25 @@ jobs: - name: Download Linux x64 archive uses: actions/download-artifact@v8 with: - name: bongo-cat-vt-linux-x64-archive-${{ github.run_id }}-${{ github.run_attempt }} + pattern: BongoCat-*-linux-x64.tar.gz path: vt-input - name: Download macOS arm64 archive uses: actions/download-artifact@v8 with: - name: bongo-cat-vt-macos-arm64-archive-${{ github.run_id }}-${{ github.run_attempt }} + pattern: BongoCat-*-macos-arm64.zip path: vt-input - name: Download Windows x64 installer uses: actions/download-artifact@v8 with: - name: bongo-cat-vt-windows-x64-setup-${{ github.run_id }}-${{ github.run_attempt }} + pattern: BongoCat-*-windows-x64-setup.exe path: vt-input - name: Download Windows x64 MSIX uses: actions/download-artifact@v8 with: - name: bongo-cat-vt-windows-x64-msix-${{ github.run_id }}-${{ github.run_attempt }} + pattern: bongocat_*_x64.msix path: vt-input - name: Scan all release artifacts with VirusTotal From da9bca335ca5448680f60e32c964fad613f2e603 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 27 Aug 2026 19:12:51 +0800 Subject: [PATCH 123/334] fix(store): update Microsoft Store package identity --- .github/workflows/ci.yml | 6 +++--- packaging/microsoft-store/AppxManifest.xml.in | 2 +- packaging/microsoft-store/README.md | 10 +++++----- packaging/microsoft-store/validate-store-package.ps1 | 4 ++-- src/runtime/update/update_service.c | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 289a20d2..a25891bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -444,10 +444,10 @@ jobs: pattern: BongoCat-*-linux-x64.tar.gz path: vt-input - - name: Download macOS arm64 archive + - name: Download Windows x64 portable archive uses: actions/download-artifact@v8 with: - pattern: BongoCat-*-macos-arm64.zip + pattern: BongoCat-*-windows-x64-portable.zip path: vt-input - name: Download Windows x64 installer @@ -471,7 +471,7 @@ jobs: -iname '*-windows-x64-setup.exe' -o \ -iname 'bongocat_*_x64.msix' -o \ -iname '*-linux-x64.tar.gz' -o \ - -iname '*-macos-arm64.zip' \ + -iname '*-windows-x64-portable.zip' \ \) -print0 | sort -z) if [ "${#files[@]}" -ne 4 ]; then echo "::error::Expected 4 representative VirusTotal inputs, found ${#files[@]}" diff --git a/packaging/microsoft-store/AppxManifest.xml.in b/packaging/microsoft-store/AppxManifest.xml.in index fc3f786d..d041bbc4 100644 --- a/packaging/microsoft-store/AppxManifest.xml.in +++ b/packaging/microsoft-store/AppxManifest.xml.in @@ -6,7 +6,7 @@ IgnorableNamespaces="uap rescap"> diff --git a/packaging/microsoft-store/README.md b/packaging/microsoft-store/README.md index 4a1c8fb5..e17eac7f 100644 --- a/packaging/microsoft-store/README.md +++ b/packaging/microsoft-store/README.md @@ -8,12 +8,12 @@ certificate during submission. ## Store identity -- Package name: `vladelaina.31811E74C2C3B` +- Package name: `vladelaina.bongocat` - Publisher: `CN=5503A135-7FA4-466B-815C-DBE627F4065F` - Publisher display name: `vladelaina` -- Package family name: `vladelaina.31811E74C2C3B_hnew8t3b8e0t6` -- Package SID: `S-1-15-2-3169861090-3766868583-4047506201-632799277-592763880-2179043671-4289936085` -- Store ID: `9NX7G84J3WM6` +- Package family name: `vladelaina.bongocat_hnew8t3b8e0t6` +- Package SID: `S-1-15-2-1569586416-2650217304-2351454042-2692954497-3079119466-4141654193-1648471479` +- Store ID: `9P41MLSX72XW` The package name and publisher are stored in `AppxManifest.xml.in`. The PFN and package SID are derived by Windows and must not be used as signing @@ -100,7 +100,7 @@ Import-Certificate ` -CertStoreLocation Cert:\LocalMachine\TrustedPeople | Out-Null Add-AppxPackage ` .\output\microsoft-store\bongocat_0.1.1_x64-local-test.msix -Get-AppxPackage -Name vladelaina.31811E74C2C3B +Get-AppxPackage -Name vladelaina.bongocat ``` The `-local-test` certificate and package are only for sideload testing. Do not diff --git a/packaging/microsoft-store/validate-store-package.ps1 b/packaging/microsoft-store/validate-store-package.ps1 index 9511a890..29cfcae9 100644 --- a/packaging/microsoft-store/validate-store-package.ps1 +++ b/packaging/microsoft-store/validate-store-package.ps1 @@ -94,7 +94,7 @@ try { [xml]$manifest = Get-Content -LiteralPath $manifestPath -Raw $identity = $manifest.Package.Identity $expectedIdentity = @{ - Name = 'vladelaina.31811E74C2C3B' + Name = 'vladelaina.bongocat' Publisher = 'CN=5503A135-7FA4-466B-815C-DBE627F4065F' Version = $ExpectedPackageVersion ProcessorArchitecture = $ExpectedArchitecture @@ -147,7 +147,7 @@ try { throw "Publisher ID calculation failed with Windows error $publisherResult." } $packageFamilyName = "$($identity.Name)_$publisherId" - if ($packageFamilyName -ne 'vladelaina.31811E74C2C3B_hnew8t3b8e0t6') { + if ($packageFamilyName -ne 'vladelaina.bongocat_hnew8t3b8e0t6') { throw "Derived package family name is unexpected: $packageFamilyName" } diff --git a/src/runtime/update/update_service.c b/src/runtime/update/update_service.c index 9f1204d7..971e9789 100644 --- a/src/runtime/update/update_service.c +++ b/src/runtime/update/update_service.c @@ -9,8 +9,8 @@ #define RELEASES_URL \ "https://github.com/vladelaina/BongoCat/releases/latest" -#define STORE_URI "ms-windows-store://pdp/?ProductId=9NX7G84J3WM6" -#define STORE_WEB_URL "https://apps.microsoft.com/detail/9NX7G84J3WM6" +#define STORE_URI "ms-windows-store://pdp/?ProductId=9P41MLSX72XW" +#define STORE_WEB_URL "https://apps.microsoft.com/detail/9P41MLSX72XW" static void reap_worker(BongoCatUpdateService *service) { SDL_LockMutex(service->mutex); From 11e902560ed3ba0906b9a1995520185196e38f59 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 27 Aug 2026 20:47:59 +0800 Subject: [PATCH 124/334] fix(ci): scan intact VirusTotal artifacts --- .github/workflows/ci.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a25891bf..580205ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -444,10 +444,12 @@ jobs: pattern: BongoCat-*-linux-x64.tar.gz path: vt-input - - name: Download Windows x64 portable archive + # Raw ZIP artifacts are unpacked by download-artifact, so scan formats + # that remain intact as standalone files in the workspace. + - name: Download Windows x86 installer uses: actions/download-artifact@v8 with: - pattern: BongoCat-*-windows-x64-portable.zip + pattern: BongoCat-*-windows-x86-setup.exe path: vt-input - name: Download Windows x64 installer @@ -471,7 +473,7 @@ jobs: -iname '*-windows-x64-setup.exe' -o \ -iname 'bongocat_*_x64.msix' -o \ -iname '*-linux-x64.tar.gz' -o \ - -iname '*-windows-x64-portable.zip' \ + -iname '*-windows-x86-setup.exe' \ \) -print0 | sort -z) if [ "${#files[@]}" -ne 4 ]; then echo "::error::Expected 4 representative VirusTotal inputs, found ${#files[@]}" From f49c6d8e4404efc6e23bf2dfa50745e11609b665 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 28 Aug 2026 07:27:57 +0800 Subject: [PATCH 125/334] fix(windows): reduce installer antivirus false positives --- cmake/PackagingPlatform.cmake | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cmake/PackagingPlatform.cmake b/cmake/PackagingPlatform.cmake index 304b67ad..e4ed2426 100644 --- a/cmake/PackagingPlatform.cmake +++ b/cmake/PackagingPlatform.cmake @@ -33,6 +33,10 @@ elseif(WIN32) set(CPACK_NSIS_INSTALL_ROOT "$LOCALAPPDATA/Programs") set(CPACK_NSIS_DISPLAY_NAME "BongoCat") set(CPACK_NSIS_PACKAGE_NAME "BongoCat") + # LZMA-packed NSIS payloads are frequently classified as cryptors by + # antivirus heuristics. zlib changes only installer compression, not the + # packaged files or installation behavior. + set(CPACK_NSIS_COMPRESSOR "zlib") set(CPACK_NSIS_INSTALLED_ICON_NAME "BongoCat.exe") set(CPACK_NSIS_MUI_ICON "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/icon.ico") From 7d055c18c462abd32a7c553e76af8b52ba530a27 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 28 Aug 2026 08:34:43 +0800 Subject: [PATCH 126/334] fix: streamline Windows packaging --- .github/scripts/validate-windows-portable.ps1 | 28 ++++++++++++++++++ .github/workflows/ci.yml | 10 +++---- .github/workflows/release.yml | 8 ++--- cmake/ApplicationResources.cmake | 6 +++- cmake/Packaging.cmake | 26 ++++++++++++++++ cmake/PackagingPlatform.cmake | 2 ++ resources/icons/nsis-header.bmp | Bin 0 -> 34254 bytes 7 files changed, 70 insertions(+), 10 deletions(-) create mode 100644 .github/scripts/validate-windows-portable.ps1 create mode 100644 resources/icons/nsis-header.bmp diff --git a/.github/scripts/validate-windows-portable.ps1 b/.github/scripts/validate-windows-portable.ps1 new file mode 100644 index 00000000..d01163e6 --- /dev/null +++ b/.github/scripts/validate-windows-portable.ps1 @@ -0,0 +1,28 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$PackagePath, + [Parameter(Mandatory = $true)][string]$ExecutableName +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +if (-not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) { + throw "Portable package is missing: $PackagePath" +} + +Add-Type -AssemblyName System.IO.Compression.FileSystem +$archive = [IO.Compression.ZipFile]::OpenRead((Resolve-Path -LiteralPath $PackagePath)) +try { + $entries = @($archive.Entries) + if ($entries.Count -ne 1 -or $entries[0].FullName -ne $ExecutableName -or + $entries[0].FullName -match '/$') { + $actual = ($entries | ForEach-Object FullName) -join ', ' + throw "Portable package must contain only $ExecutableName; found: $actual" + } +} +finally { + $archive.Dispose() +} + +Write-Host "Portable package contents validated: $ExecutableName" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 580205ae..eda854de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -355,18 +355,18 @@ jobs: throw "Cubism desktop package is not production-ready: $name" } - cmake --build build-store --config Release --target package + cmake --build build-store --config Release --target package-portable cmake --build build-store --config Release --target package-installer - $portableSource = "build-store/dist/$name.zip" $portable = "build-store/dist/$name-portable.zip" $setup = "build-store/dist/$name-setup.exe" - if (-not (Test-Path -LiteralPath $portableSource)) { - throw "Production portable package is missing: $portableSource" + if (-not (Test-Path -LiteralPath $portable)) { + throw "Production portable package is missing: $portable" } if (-not (Test-Path -LiteralPath $setup)) { throw "Production desktop installer is missing: $setup" } - Move-Item -LiteralPath $portableSource -Destination $portable -Force + & ./.github/scripts/validate-windows-portable.ps1 ` + -PackagePath $portable -ExecutableName "$name.exe" "portable=$portable" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 "file=$setup" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d4f36c8..7520166f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -105,20 +105,20 @@ jobs: throw "Unexpected release package name: $name" } - cmake --build build-release --config Release --target package + cmake --build build-release --config Release --target package-portable cmake --build build-release --config Release --target package-installer - $portableSource = "build-release/dist/$name.zip" $portable = "build-release/dist/$name-portable.zip" $portableChecksum = "$portable.sha256" $setup = "build-release/dist/$name-setup.exe" $setupChecksum = "$setup.sha256" - foreach ($file in @($portableSource, $setup, $setupChecksum)) { + foreach ($file in @($portable, $setup, $setupChecksum)) { if (-not (Test-Path -LiteralPath $file)) { throw "Release file is missing: $file" } } - Move-Item -LiteralPath $portableSource -Destination $portable -Force + & ./.github/scripts/validate-windows-portable.ps1 ` + -PackagePath $portable -ExecutableName "$name.exe" $portableHash = (Get-FileHash -LiteralPath $portable -Algorithm SHA256).Hash.ToLowerInvariant() "$portableHash $([IO.Path]::GetFileName($portable))" | Set-Content -LiteralPath $portableChecksum -Encoding ascii diff --git a/cmake/ApplicationResources.cmake b/cmake/ApplicationResources.cmake index dfd68443..0d6f80c0 100644 --- a/cmake/ApplicationResources.cmake +++ b/cmake/ApplicationResources.cmake @@ -37,10 +37,14 @@ if(WIN32) COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/resources/assets" "${BONGO_CAT_ASSET_STAGE}/assets" + COMMAND ${CMAKE_COMMAND} -E copy + "${CMAKE_CURRENT_SOURCE_DIR}/LICENSE" + "${BONGO_CAT_ASSET_STAGE}/assets/LICENSE" ${BONGO_CAT_ASSET_EXTRA_COMMANDS} COMMAND "$" "${BONGO_CAT_ASSET_STAGE}/assets" "${BONGO_CAT_ASSET_PACK}" - DEPENDS ${BONGO_CAT_ASSET_INPUTS} bongo_cat_asset_packer + DEPENDS ${BONGO_CAT_ASSET_INPUTS} "${CMAKE_CURRENT_SOURCE_DIR}/LICENSE" + bongo_cat_asset_packer VERBATIM) add_custom_target(bongo_cat_asset_pack DEPENDS "${BONGO_CAT_ASSET_PACK}") file(TO_CMAKE_PATH "${BONGO_CAT_ASSET_PACK}" BONGO_CAT_ASSET_PACK_RC) diff --git a/cmake/Packaging.cmake b/cmake/Packaging.cmake index 85ad561b..904d977b 100644 --- a/cmake/Packaging.cmake +++ b/cmake/Packaging.cmake @@ -36,6 +36,27 @@ set(CPACK_INSTALL_CMAKE_PROJECTS "${CMAKE_BINARY_DIR};${PROJECT_NAME};Runtime;/") if(WIN32) + set(BONGO_CAT_PORTABLE_ARCHIVE + "${CMAKE_BINARY_DIR}/dist/${BONGO_CAT_PACKAGE_NAME}-portable.zip") + set(BONGO_CAT_PORTABLE_STAGE + "${CMAKE_CURRENT_BINARY_DIR}/portable-stage") + add_custom_command(OUTPUT "${BONGO_CAT_PORTABLE_ARCHIVE}" + COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/dist" + COMMAND ${CMAKE_COMMAND} -E remove_directory + "${BONGO_CAT_PORTABLE_STAGE}" + COMMAND ${CMAKE_COMMAND} -E make_directory + "${BONGO_CAT_PORTABLE_STAGE}" + COMMAND ${CMAKE_COMMAND} -E copy "$" + "${BONGO_CAT_PORTABLE_STAGE}/${BONGO_CAT_PACKAGE_NAME}.exe" + COMMAND ${CMAKE_COMMAND} -E chdir "${BONGO_CAT_PORTABLE_STAGE}" + ${CMAKE_COMMAND} -E tar cf "${BONGO_CAT_PORTABLE_ARCHIVE}" + --format=zip "${BONGO_CAT_PACKAGE_NAME}.exe" + DEPENDS bongo_cat + COMMENT "Building the BongoCat Windows portable package" + VERBATIM) + add_custom_target(package-portable + DEPENDS "${BONGO_CAT_PORTABLE_ARCHIVE}") + # CPack's stock NSIS template requests elevation and switches administrator # accounts to the machine-wide shell context. Generate a small, versioned # override from the CMake-provided template so this installer is always @@ -77,6 +98,11 @@ if(WIN32) !include "WordFunc.nsh" !include "Win\RestartManager.nsh"]=] BONGO_CAT_NSIS_TEMPLATE "${BONGO_CAT_NSIS_TEMPLATE}") + # Keep the AGPL text in the installed package, but do not interrupt the + # install flow with a license agreement page. The license does not require + # affirmative acceptance at install time. + string(REPLACE [=[ @CPACK_NSIS_LICENSE_PAGE@]=] "" + BONGO_CAT_NSIS_TEMPLATE "${BONGO_CAT_NSIS_TEMPLATE}") string(REPLACE [=[ Var IS_DEFAULT_INSTALLDIR]=] [=[ Var IS_DEFAULT_INSTALLDIR Var BONGO_CAT_UPGRADE_DIR diff --git a/cmake/PackagingPlatform.cmake b/cmake/PackagingPlatform.cmake index e4ed2426..15bde5a3 100644 --- a/cmake/PackagingPlatform.cmake +++ b/cmake/PackagingPlatform.cmake @@ -42,6 +42,8 @@ elseif(WIN32) "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/icon.ico") set(CPACK_NSIS_MUI_UNIICON "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/icon.ico") + set(CPACK_NSIS_MUI_HEADERIMAGE + "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/nsis-header.bmp") set(CPACK_NSIS_ENABLE_UNINSTALL_BEFORE_INSTALL OFF) set(CPACK_NSIS_MODIFY_PATH OFF) set(CPACK_NSIS_MUI_FINISHPAGE_RUN "BongoCat.exe") diff --git a/resources/icons/nsis-header.bmp b/resources/icons/nsis-header.bmp new file mode 100644 index 0000000000000000000000000000000000000000..51693e04ffe798d98e8274030c07bfbb2d80b7c0 GIT binary patch literal 34254 zcmeI4YfKbZ6vrXPeh?HJEea@7r3f2YRKNwRh`g-<9x<8k8ylL8E1P%FBP}4sj~F%euR;yTG~0#hH8O+;i@a zxcngmT*69xq6(du3R-Vu;P9Wlq*vV3|M_rFb>M=*RS<%milOu-a#UR z)FME>r4}|4A_7E!2oM1xKm>>Y5g-CYfC%W6z{QIf;keilQBhH7Yi-l1Eu>t(2sAb} z3I?4#c@jQ8KG?Z)CptSjg?2>;VtadVF+jeaIdewQWoozg_w}nSOM==V(AC*BSP>T{ z-5$RkHlNwReT6&X;^HuK=1lC`wF|DUu6%v<>eXPGch20Fy9s_(QBmoY^!E=uTMF_E zaQ)hKL1|4*4cgn=h4YqwTkzn)1L0g-2h6^dlobBn`TY5FZK)+aDiUaIZH35A#QRq4 zrda6vzKKjGgQ1}z&YnH1;!NpsZ4+Q?^YZd?^!4>=TeZsin2qM<=Ey#tt+Ff$5P_ix zxJV?3kB=Yf66zxYDia6^2}NLFpvrP2Fnk1*#R7&;llDeRNJxNKEY_Yv()1pIpfy40 z>F#;2eKa?u9!`a=t?g)@!l<`>(V|6sj2!xx4yrcW?=!xKot>T9@+7D^ffp}cKqL~4 zDc?#}#*G_Tx^yYR!@?kIk!fr!`}?!Hx*9CkhUEr2IXYqD#EHBwIXF1*&pB0c zY^tiN_&Dg6EnDE@?Zf9aYiuM9pfv)AQx9Wf)JCQH*t4kOj|$}H=i}(nWBBgiL2QcI z1Ya&knZ?RXKQYCW$&+DbX2$y~^M~BrJg{6ZbqmXnkByCmySqE;>+97WhU9caAT%Td zL*)#w@>>N7mrvm5=La`8H$F$1t&KKs-i$+s4k0fu50@@o!o!CT(Zj9DsSp81;PU0m z@bdJO3*%JIDHt1123sppAp)urU~B7R-1F`2($>}nlPM-BE-rq%nU0CT2NPiH7aJQJ z-23~UAQK)Ij(~sw;hYYLfI0*U3JUl=pZoXkW8c1gm|;2tEVo=<>F(~Pzx(BZp*Fb$ zSX|OY;==D&GyjrTSWJQWT}MZUyl!eE0&)qk{pz6#d*_htxlkbj!%2X>2hHLNREWTE z5n%g3EMG;Fc;|50LgS1b0xTDmN zMfyAX`n&aEN@Rl`5McW>>^(kJo`X{96;8$<4z}TN_;fE8%-#=M%V;<|eU7WrKlNZ% zWP&~rU@?Y_jEoO9#nD-WIa8CdjSI0R1amcH;9sAGf5FVu8g>6@|63v-Y&>14H3BSm z%EQA0w{PE8suOVnj8i=SY{zQCPL{rbiw!_aP#I(czJn& z#pqjFS`<$DmvcF=oEFL7qu%%w^DkDntpV1rY=^|jk9*aT@*osK9A_OYmzjMZGBGg` zQmIt#?9g|DnZq<0e{Jzv7nvIxOJ+>HzLeMh+f$8jTz&nwzT zJazgQEKH)2#J!@$?pNP%>#ODVKf-$MLU_4adcZajMSsRdO>Li+aoy~?Vqm^Rf466@0lSaKB~e-wywzvk>Y5g-CYfCvx)B0vO)01+SpM1Tkof$>8? P-(tJthdj-6G!pm^csvil literal 0 HcmV?d00001 From abb11ab4d3f8257a6e6bbefd18d57b8d02c68bcb Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 28 Aug 2026 10:25:29 +0800 Subject: [PATCH 127/334] fix(ci): stabilize Windows installer packaging --- .github/workflows/ci.yml | 6 ++++++ .github/workflows/release.yml | 6 ++++++ cmake/Packaging.cmake | 25 ++++++++++++++++--------- cmake/PackagingPlatform.cmake | 1 + cmake/RequireFile.cmake | 7 +++++++ 5 files changed, 36 insertions(+), 9 deletions(-) create mode 100644 cmake/RequireFile.cmake diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eda854de..4dceaeeb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -356,7 +356,13 @@ jobs: } cmake --build build-store --config Release --target package-portable + if ($LASTEXITCODE -ne 0) { + throw 'Windows portable packaging failed' + } cmake --build build-store --config Release --target package-installer + if ($LASTEXITCODE -ne 0) { + throw 'Windows installer packaging failed' + } $portable = "build-store/dist/$name-portable.zip" $setup = "build-store/dist/$name-setup.exe" if (-not (Test-Path -LiteralPath $portable)) { diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7520166f..1070f612 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -106,7 +106,13 @@ jobs: } cmake --build build-release --config Release --target package-portable + if ($LASTEXITCODE -ne 0) { + throw 'Windows portable packaging failed' + } cmake --build build-release --config Release --target package-installer + if ($LASTEXITCODE -ne 0) { + throw 'Windows installer packaging failed' + } $portable = "build-release/dist/$name-portable.zip" $portableChecksum = "$portable.sha256" diff --git a/cmake/Packaging.cmake b/cmake/Packaging.cmake index 904d977b..60ce0c3e 100644 --- a/cmake/Packaging.cmake +++ b/cmake/Packaging.cmake @@ -98,11 +98,6 @@ if(WIN32) !include "WordFunc.nsh" !include "Win\RestartManager.nsh"]=] BONGO_CAT_NSIS_TEMPLATE "${BONGO_CAT_NSIS_TEMPLATE}") - # Keep the AGPL text in the installed package, but do not interrupt the - # install flow with a license agreement page. The license does not require - # affirmative acceptance at install time. - string(REPLACE [=[ @CPACK_NSIS_LICENSE_PAGE@]=] "" - BONGO_CAT_NSIS_TEMPLATE "${BONGO_CAT_NSIS_TEMPLATE}") string(REPLACE [=[ Var IS_DEFAULT_INSTALLDIR]=] [=[ Var IS_DEFAULT_INSTALLDIR Var BONGO_CAT_UPGRADE_DIR @@ -290,11 +285,23 @@ endif() include(CPack) if(WIN32) + set(BONGO_CAT_INSTALLER_CPACK_CONFIG + "${CMAKE_BINARY_DIR}/CPackInstallerConfig.cmake") + string(CONCAT BONGO_CAT_INSTALLER_CPACK_CONTENT + "include(\"${CMAKE_BINARY_DIR}/CPackConfig.cmake\")\n" + "set(CPACK_GENERATOR \"NSIS\")\n" + "set(CPACK_PACKAGE_FILE_NAME \"${BONGO_CAT_PACKAGE_NAME}-setup\")\n" + "set(CPACK_INCLUDE_TOPLEVEL_DIRECTORY OFF)\n") + file(GENERATE OUTPUT "${BONGO_CAT_INSTALLER_CPACK_CONFIG}" + CONTENT "${BONGO_CAT_INSTALLER_CPACK_CONTENT}") + set(BONGO_CAT_INSTALLER_FILE + "${CMAKE_BINARY_DIR}/dist/${BONGO_CAT_PACKAGE_NAME}-setup.exe") add_custom_target(package-installer - COMMAND "${CMAKE_CPACK_COMMAND}" -G NSIS -C "$" - --config "${CMAKE_BINARY_DIR}/CPackConfig.cmake" - -D "CPACK_PACKAGE_FILE_NAME=${BONGO_CAT_PACKAGE_NAME}-setup" - -D "CPACK_INCLUDE_TOPLEVEL_DIRECTORY=OFF" + COMMAND "${CMAKE_CPACK_COMMAND}" -C "$" + --config "${BONGO_CAT_INSTALLER_CPACK_CONFIG}" + COMMAND "${CMAKE_COMMAND}" + -D "REQUIRED_FILE=${BONGO_CAT_INSTALLER_FILE}" + -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/RequireFile.cmake" DEPENDS bongo_cat WORKING_DIRECTORY "${CMAKE_BINARY_DIR}" COMMENT "Building the BongoCat Windows installer" diff --git a/cmake/PackagingPlatform.cmake b/cmake/PackagingPlatform.cmake index 15bde5a3..9f9eb737 100644 --- a/cmake/PackagingPlatform.cmake +++ b/cmake/PackagingPlatform.cmake @@ -44,6 +44,7 @@ elseif(WIN32) "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/icon.ico") set(CPACK_NSIS_MUI_HEADERIMAGE "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/nsis-header.bmp") + set(CPACK_NSIS_IGNORE_LICENSE_PAGE ON) set(CPACK_NSIS_ENABLE_UNINSTALL_BEFORE_INSTALL OFF) set(CPACK_NSIS_MODIFY_PATH OFF) set(CPACK_NSIS_MUI_FINISHPAGE_RUN "BongoCat.exe") diff --git a/cmake/RequireFile.cmake b/cmake/RequireFile.cmake new file mode 100644 index 00000000..7a5d11a0 --- /dev/null +++ b/cmake/RequireFile.cmake @@ -0,0 +1,7 @@ +if(NOT DEFINED REQUIRED_FILE OR REQUIRED_FILE STREQUAL "") + message(FATAL_ERROR "REQUIRED_FILE must name the expected package file") +endif() + +if(NOT EXISTS "${REQUIRED_FILE}") + message(FATAL_ERROR "Required package file was not generated: ${REQUIRED_FILE}") +endif() From 7900c4a9a7df793a0bd5f12b4696e5fbcf0257ff Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 28 Aug 2026 11:05:19 +0800 Subject: [PATCH 128/334] fix(ci): restore proven NSIS packaging path --- cmake/Packaging.cmake | 20 ++++---------------- cmake/PackagingPlatform.cmake | 2 -- cmake/RequireFile.cmake | 7 ------- resources/icons/nsis-header.bmp | Bin 34254 -> 0 bytes 4 files changed, 4 insertions(+), 25 deletions(-) delete mode 100644 cmake/RequireFile.cmake delete mode 100644 resources/icons/nsis-header.bmp diff --git a/cmake/Packaging.cmake b/cmake/Packaging.cmake index 60ce0c3e..c698d0e0 100644 --- a/cmake/Packaging.cmake +++ b/cmake/Packaging.cmake @@ -285,23 +285,11 @@ endif() include(CPack) if(WIN32) - set(BONGO_CAT_INSTALLER_CPACK_CONFIG - "${CMAKE_BINARY_DIR}/CPackInstallerConfig.cmake") - string(CONCAT BONGO_CAT_INSTALLER_CPACK_CONTENT - "include(\"${CMAKE_BINARY_DIR}/CPackConfig.cmake\")\n" - "set(CPACK_GENERATOR \"NSIS\")\n" - "set(CPACK_PACKAGE_FILE_NAME \"${BONGO_CAT_PACKAGE_NAME}-setup\")\n" - "set(CPACK_INCLUDE_TOPLEVEL_DIRECTORY OFF)\n") - file(GENERATE OUTPUT "${BONGO_CAT_INSTALLER_CPACK_CONFIG}" - CONTENT "${BONGO_CAT_INSTALLER_CPACK_CONTENT}") - set(BONGO_CAT_INSTALLER_FILE - "${CMAKE_BINARY_DIR}/dist/${BONGO_CAT_PACKAGE_NAME}-setup.exe") add_custom_target(package-installer - COMMAND "${CMAKE_CPACK_COMMAND}" -C "$" - --config "${BONGO_CAT_INSTALLER_CPACK_CONFIG}" - COMMAND "${CMAKE_COMMAND}" - -D "REQUIRED_FILE=${BONGO_CAT_INSTALLER_FILE}" - -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/RequireFile.cmake" + COMMAND "${CMAKE_CPACK_COMMAND}" -G NSIS -C "$" + --config "${CMAKE_BINARY_DIR}/CPackConfig.cmake" + -D "CPACK_PACKAGE_FILE_NAME=${BONGO_CAT_PACKAGE_NAME}-setup" + -D "CPACK_INCLUDE_TOPLEVEL_DIRECTORY=OFF" DEPENDS bongo_cat WORKING_DIRECTORY "${CMAKE_BINARY_DIR}" COMMENT "Building the BongoCat Windows installer" diff --git a/cmake/PackagingPlatform.cmake b/cmake/PackagingPlatform.cmake index 9f9eb737..9692beac 100644 --- a/cmake/PackagingPlatform.cmake +++ b/cmake/PackagingPlatform.cmake @@ -42,8 +42,6 @@ elseif(WIN32) "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/icon.ico") set(CPACK_NSIS_MUI_UNIICON "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/icon.ico") - set(CPACK_NSIS_MUI_HEADERIMAGE - "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/nsis-header.bmp") set(CPACK_NSIS_IGNORE_LICENSE_PAGE ON) set(CPACK_NSIS_ENABLE_UNINSTALL_BEFORE_INSTALL OFF) set(CPACK_NSIS_MODIFY_PATH OFF) diff --git a/cmake/RequireFile.cmake b/cmake/RequireFile.cmake deleted file mode 100644 index 7a5d11a0..00000000 --- a/cmake/RequireFile.cmake +++ /dev/null @@ -1,7 +0,0 @@ -if(NOT DEFINED REQUIRED_FILE OR REQUIRED_FILE STREQUAL "") - message(FATAL_ERROR "REQUIRED_FILE must name the expected package file") -endif() - -if(NOT EXISTS "${REQUIRED_FILE}") - message(FATAL_ERROR "Required package file was not generated: ${REQUIRED_FILE}") -endif() diff --git a/resources/icons/nsis-header.bmp b/resources/icons/nsis-header.bmp deleted file mode 100644 index 51693e04ffe798d98e8274030c07bfbb2d80b7c0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34254 zcmeI4YfKbZ6vrXPeh?HJEea@7r3f2YRKNwRh`g-<9x<8k8ylL8E1P%FBP}4sj~F%euR;yTG~0#hH8O+;i@a zxcngmT*69xq6(du3R-Vu;P9Wlq*vV3|M_rFb>M=*RS<%milOu-a#UR z)FME>r4}|4A_7E!2oM1xKm>>Y5g-CYfC%W6z{QIf;keilQBhH7Yi-l1Eu>t(2sAb} z3I?4#c@jQ8KG?Z)CptSjg?2>;VtadVF+jeaIdewQWoozg_w}nSOM==V(AC*BSP>T{ z-5$RkHlNwReT6&X;^HuK=1lC`wF|DUu6%v<>eXPGch20Fy9s_(QBmoY^!E=uTMF_E zaQ)hKL1|4*4cgn=h4YqwTkzn)1L0g-2h6^dlobBn`TY5FZK)+aDiUaIZH35A#QRq4 zrda6vzKKjGgQ1}z&YnH1;!NpsZ4+Q?^YZd?^!4>=TeZsin2qM<=Ey#tt+Ff$5P_ix zxJV?3kB=Yf66zxYDia6^2}NLFpvrP2Fnk1*#R7&;llDeRNJxNKEY_Yv()1pIpfy40 z>F#;2eKa?u9!`a=t?g)@!l<`>(V|6sj2!xx4yrcW?=!xKot>T9@+7D^ffp}cKqL~4 zDc?#}#*G_Tx^yYR!@?kIk!fr!`}?!Hx*9CkhUEr2IXYqD#EHBwIXF1*&pB0c zY^tiN_&Dg6EnDE@?Zf9aYiuM9pfv)AQx9Wf)JCQH*t4kOj|$}H=i}(nWBBgiL2QcI z1Ya&knZ?RXKQYCW$&+DbX2$y~^M~BrJg{6ZbqmXnkByCmySqE;>+97WhU9caAT%Td zL*)#w@>>N7mrvm5=La`8H$F$1t&KKs-i$+s4k0fu50@@o!o!CT(Zj9DsSp81;PU0m z@bdJO3*%JIDHt1123sppAp)urU~B7R-1F`2($>}nlPM-BE-rq%nU0CT2NPiH7aJQJ z-23~UAQK)Ij(~sw;hYYLfI0*U3JUl=pZoXkW8c1gm|;2tEVo=<>F(~Pzx(BZp*Fb$ zSX|OY;==D&GyjrTSWJQWT}MZUyl!eE0&)qk{pz6#d*_htxlkbj!%2X>2hHLNREWTE z5n%g3EMG;Fc;|50LgS1b0xTDmN zMfyAX`n&aEN@Rl`5McW>>^(kJo`X{96;8$<4z}TN_;fE8%-#=M%V;<|eU7WrKlNZ% zWP&~rU@?Y_jEoO9#nD-WIa8CdjSI0R1amcH;9sAGf5FVu8g>6@|63v-Y&>14H3BSm z%EQA0w{PE8suOVnj8i=SY{zQCPL{rbiw!_aP#I(czJn& z#pqjFS`<$DmvcF=oEFL7qu%%w^DkDntpV1rY=^|jk9*aT@*osK9A_OYmzjMZGBGg` zQmIt#?9g|DnZq<0e{Jzv7nvIxOJ+>HzLeMh+f$8jTz&nwzT zJazgQEKH)2#J!@$?pNP%>#ODVKf-$MLU_4adcZajMSsRdO>Li+aoy~?Vqm^Rf466@0lSaKB~e-wywzvk>Y5g-CYfCvx)B0vO)01+SpM1Tkof$>8? P-(tJthdj-6G!pm^csvil From a1802cc6a59518ea5d86a8d0bd67bba723613622 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 28 Aug 2026 12:27:29 +0800 Subject: [PATCH 129/334] feat(windows): simplify installer and portable release --- .github/scripts/validate-windows-portable.ps1 | 28 ------------ .github/workflows/ci.yml | 4 +- .github/workflows/release.yml | 12 +++-- cmake/Packaging.cmake | 44 ++++++++++++------- packaging/microsoft-store/README.md | 2 +- src/core/update_release.c | 2 +- tests/core/test_update.c | 10 ++--- 7 files changed, 41 insertions(+), 61 deletions(-) delete mode 100644 .github/scripts/validate-windows-portable.ps1 diff --git a/.github/scripts/validate-windows-portable.ps1 b/.github/scripts/validate-windows-portable.ps1 deleted file mode 100644 index d01163e6..00000000 --- a/.github/scripts/validate-windows-portable.ps1 +++ /dev/null @@ -1,28 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory = $true)][string]$PackagePath, - [Parameter(Mandatory = $true)][string]$ExecutableName -) - -$ErrorActionPreference = 'Stop' -Set-StrictMode -Version Latest - -if (-not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) { - throw "Portable package is missing: $PackagePath" -} - -Add-Type -AssemblyName System.IO.Compression.FileSystem -$archive = [IO.Compression.ZipFile]::OpenRead((Resolve-Path -LiteralPath $PackagePath)) -try { - $entries = @($archive.Entries) - if ($entries.Count -ne 1 -or $entries[0].FullName -ne $ExecutableName -or - $entries[0].FullName -match '/$') { - $actual = ($entries | ForEach-Object FullName) -join ', ' - throw "Portable package must contain only $ExecutableName; found: $actual" - } -} -finally { - $archive.Dispose() -} - -Write-Host "Portable package contents validated: $ExecutableName" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4dceaeeb..7b3f3f4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -363,7 +363,7 @@ jobs: if ($LASTEXITCODE -ne 0) { throw 'Windows installer packaging failed' } - $portable = "build-store/dist/$name-portable.zip" + $portable = "build-store/dist/$name-portable.exe" $setup = "build-store/dist/$name-setup.exe" if (-not (Test-Path -LiteralPath $portable)) { throw "Production portable package is missing: $portable" @@ -371,8 +371,6 @@ jobs: if (-not (Test-Path -LiteralPath $setup)) { throw "Production desktop installer is missing: $setup" } - & ./.github/scripts/validate-windows-portable.ps1 ` - -PackagePath $portable -ExecutableName "$name.exe" "portable=$portable" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 "file=$setup" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1070f612..9d6f185d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -114,7 +114,7 @@ jobs: throw 'Windows installer packaging failed' } - $portable = "build-release/dist/$name-portable.zip" + $portable = "build-release/dist/$name-portable.exe" $portableChecksum = "$portable.sha256" $setup = "build-release/dist/$name-setup.exe" $setupChecksum = "$setup.sha256" @@ -123,8 +123,6 @@ jobs: throw "Release file is missing: $file" } } - & ./.github/scripts/validate-windows-portable.ps1 ` - -PackagePath $portable -ExecutableName "$name.exe" $portableHash = (Get-FileHash -LiteralPath $portable -Algorithm SHA256).Hash.ToLowerInvariant() "$portableHash $([IO.Path]::GetFileName($portable))" | Set-Content -LiteralPath $portableChecksum -Encoding ascii @@ -167,7 +165,7 @@ jobs: run: | set -euo pipefail mapfile -d '' files < <(find vt-input -type f \( \ - -iname '*-setup.exe' -o -iname '*-portable.zip' \) \ + -iname '*-setup.exe' -o -iname '*-portable.exe' \) \ -print0 | sort -z) if [ "${#files[@]}" -ne 4 ]; then echo "::error::Expected 4 VirusTotal inputs, found ${#files[@]}" @@ -224,8 +222,8 @@ jobs: $version = $Matches[1] $files = foreach ($platform in 'windows-x64', 'windows-x86') { $base = "BongoCat-$version-$platform" - "release-output/$base-portable.zip" - "release-output/$base-portable.zip.sha256" + "release-output/$base-portable.exe" + "release-output/$base-portable.exe.sha256" "release-output/$base-setup.exe" "release-output/$base-setup.exe.sha256" } @@ -262,5 +260,5 @@ jobs: fail_on_unmatched_files: true make_latest: true files: | - release-output/*-portable.zip + release-output/*-portable.exe release-output/*-setup.exe diff --git a/cmake/Packaging.cmake b/cmake/Packaging.cmake index c698d0e0..d05ec547 100644 --- a/cmake/Packaging.cmake +++ b/cmake/Packaging.cmake @@ -36,26 +36,17 @@ set(CPACK_INSTALL_CMAKE_PROJECTS "${CMAKE_BINARY_DIR};${PROJECT_NAME};Runtime;/") if(WIN32) - set(BONGO_CAT_PORTABLE_ARCHIVE - "${CMAKE_BINARY_DIR}/dist/${BONGO_CAT_PACKAGE_NAME}-portable.zip") - set(BONGO_CAT_PORTABLE_STAGE - "${CMAKE_CURRENT_BINARY_DIR}/portable-stage") - add_custom_command(OUTPUT "${BONGO_CAT_PORTABLE_ARCHIVE}" + set(BONGO_CAT_PORTABLE_EXECUTABLE + "${CMAKE_BINARY_DIR}/dist/${BONGO_CAT_PACKAGE_NAME}-portable.exe") + add_custom_command(OUTPUT "${BONGO_CAT_PORTABLE_EXECUTABLE}" COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/dist" - COMMAND ${CMAKE_COMMAND} -E remove_directory - "${BONGO_CAT_PORTABLE_STAGE}" - COMMAND ${CMAKE_COMMAND} -E make_directory - "${BONGO_CAT_PORTABLE_STAGE}" - COMMAND ${CMAKE_COMMAND} -E copy "$" - "${BONGO_CAT_PORTABLE_STAGE}/${BONGO_CAT_PACKAGE_NAME}.exe" - COMMAND ${CMAKE_COMMAND} -E chdir "${BONGO_CAT_PORTABLE_STAGE}" - ${CMAKE_COMMAND} -E tar cf "${BONGO_CAT_PORTABLE_ARCHIVE}" - --format=zip "${BONGO_CAT_PACKAGE_NAME}.exe" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "$" + "${BONGO_CAT_PORTABLE_EXECUTABLE}" DEPENDS bongo_cat - COMMENT "Building the BongoCat Windows portable package" + COMMENT "Building the BongoCat Windows portable executable" VERBATIM) add_custom_target(package-portable - DEPENDS "${BONGO_CAT_PORTABLE_ARCHIVE}") + DEPENDS "${BONGO_CAT_PORTABLE_EXECUTABLE}") # CPack's stock NSIS template requests elevation and switches administrator # accounts to the machine-wide shell context. Generate a small, versioned @@ -98,6 +89,21 @@ if(WIN32) !include "WordFunc.nsh" !include "Win\RestartManager.nsh"]=] BONGO_CAT_NSIS_TEMPLATE "${BONGO_CAT_NSIS_TEMPLATE}") + # The only choice before installation is the destination directory. + # Shortcuts are created automatically using the fixed BongoCat folder. + string(REPLACE [=[ !insertmacro MUI_PAGE_WELCOME]=] "" + BONGO_CAT_NSIS_TEMPLATE "${BONGO_CAT_NSIS_TEMPLATE}") + string(REPLACE [=[ Page custom InstallOptionsPage]=] "" + BONGO_CAT_NSIS_TEMPLATE "${BONGO_CAT_NSIS_TEMPLATE}") + string(REPLACE + [=[ !insertmacro MUI_PAGE_STARTMENU Application $STARTMENU_FOLDER]=] + [=[ !define MUI_PAGE_CUSTOMFUNCTION_PRE bongo_cat_startmenu_page_pre + !insertmacro MUI_PAGE_STARTMENU Application $STARTMENU_FOLDER]=] + BONGO_CAT_NSIS_TEMPLATE "${BONGO_CAT_NSIS_TEMPLATE}") + string(REPLACE + [=[ !insertmacro MUI_INSTALLOPTIONS_READ $INSTALL_DESKTOP "NSIS.InstallOptions.ini" "Field 5" "State"]=] + [=[ StrCpy $INSTALL_DESKTOP "1"]=] BONGO_CAT_NSIS_TEMPLATE + "${BONGO_CAT_NSIS_TEMPLATE}") string(REPLACE [=[ Var IS_DEFAULT_INSTALLDIR]=] [=[ Var IS_DEFAULT_INSTALLDIR Var BONGO_CAT_UPGRADE_DIR @@ -114,6 +120,10 @@ if(WIN32) bongo_cat_directory_page_show: FunctionEnd +Function bongo_cat_startmenu_page_pre + Abort +FunctionEnd + Function InstallOptionsPage]=] BONGO_CAT_NSIS_TEMPLATE "${BONGO_CAT_NSIS_TEMPLATE}") string(REPLACE [=[@CPACK_NSIS_CREATE_ICONS_EXTRA@]=] [=[ @@ -209,6 +219,8 @@ bongo_cat_upgrade_ready: bongo_cat_replace_nsis_function(BONGO_CAT_NSIS_TEMPLATE ".onInit" [=[Function .onInit SetShellVarContext current + StrCpy $STARTMENU_FOLDER "BongoCat" + StrCpy $INSTALL_DESKTOP "1" StrCpy $BONGO_CAT_UPGRADE_DIR "" StrCpy $BONGO_CAT_UPDATE_SHUTDOWN "" diff --git a/packaging/microsoft-store/README.md b/packaging/microsoft-store/README.md index e17eac7f..4f24e6de 100644 --- a/packaging/microsoft-store/README.md +++ b/packaging/microsoft-store/README.md @@ -18,7 +18,7 @@ certificate during submission. The package name and publisher are stored in `AppxManifest.xml.in`. The PFN and package SID are derived by Windows and must not be used as signing secrets. The Store package is x64; the separate Windows x86 workflow target -only publishes its desktop installer and portable archive. +only publishes its desktop installer and portable executable. ## App data isolation diff --git a/src/core/update_release.c b/src/core/update_release.c index e75b519f..edb4957a 100644 --- a/src/core/update_release.c +++ b/src/core/update_release.c @@ -55,7 +55,7 @@ static bool read_assets(yyjson_val *root, const char *platform, int installer_length = snprintf(installer, sizeof(installer), "BongoCat-%s-%s-setup.exe", release->version, platform); int portable_length = snprintf(portable, sizeof(portable), - "BongoCat-%s-%s-portable.zip", release->version, platform); + "BongoCat-%s-%s-portable.exe", release->version, platform); if (installer_length < 0 || (size_t)installer_length >= sizeof(installer) || portable_length < 0 || (size_t)portable_length >= sizeof(portable)) return false; diff --git a/tests/core/test_update.c b/tests/core/test_update.c index 83b7c231..d00b5e51 100644 --- a/tests/core/test_update.c +++ b/tests/core/test_update.c @@ -10,15 +10,15 @@ static const char release_json[] = "\"draft\":false,\"prerelease\":false," "\"body\":\"Fixed input and rendering.\"," "\"assets\":[" - "{\"name\":\"BongoCat-1.2.3-windows-x64-portable.zip.sha256\"," + "{\"name\":\"BongoCat-1.2.3-windows-x64-portable.exe.sha256\"," "\"browser_download_url\":\"https://github.com/vladelaina/BongoCat/" - "releases/download/v1.2.3/BongoCat-1.2.3-windows-x64-portable.zip.sha256\"}," + "releases/download/v1.2.3/BongoCat-1.2.3-windows-x64-portable.exe.sha256\"}," "{\"name\":\"BongoCat-1.2.3-windows-x64-setup.exe\"," "\"browser_download_url\":\"https://github.com/vladelaina/BongoCat/" "releases/download/v1.2.3/BongoCat-1.2.3-windows-x64-setup.exe\"}," - "{\"name\":\"BongoCat-1.2.3-windows-x64-portable.zip\"," + "{\"name\":\"BongoCat-1.2.3-windows-x64-portable.exe\"," "\"browser_download_url\":\"https://github.com/vladelaina/BongoCat/" - "releases/download/v1.2.3/BongoCat-1.2.3-windows-x64-portable.zip\"}]}"; + "releases/download/v1.2.3/BongoCat-1.2.3-windows-x64-portable.exe\"}]}"; void test_update(void) { CHECK(bongo_cat_update_version_valid("1.2.3-rc.1+build.4")); @@ -39,7 +39,7 @@ void test_update(void) { &release, &error)); CHECK(strcmp(release.version, "1.2.3") == 0); CHECK(strstr(release.installer_url, "windows-x64-setup.exe") != NULL); - CHECK(strstr(release.portable_url, "windows-x64-portable.zip") != NULL); + CHECK(strstr(release.portable_url, "windows-x64-portable.exe") != NULL); CHECK(strcmp(release.notes, "Fixed input and rendering.") == 0); const char unsafe[] = From 4e7752a5d552dba24b57f7b53bc8ab6e8d1b9070 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 28 Aug 2026 12:42:53 +0800 Subject: [PATCH 130/334] feat(windows): preserve user data on uninstall --- cmake/Packaging.cmake | 42 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/cmake/Packaging.cmake b/cmake/Packaging.cmake index d05ec547..471dd236 100644 --- a/cmake/Packaging.cmake +++ b/cmake/Packaging.cmake @@ -86,6 +86,7 @@ if(WIN32) "${BONGO_CAT_NSIS_TEMPLATE}") string(REPLACE [=[ !include "MUI.nsh"]=] [=[ !include "MUI.nsh" + !include "nsDialogs.nsh" !include "WordFunc.nsh" !include "Win\RestartManager.nsh"]=] BONGO_CAT_NSIS_TEMPLATE "${BONGO_CAT_NSIS_TEMPLATE}") @@ -104,10 +105,17 @@ if(WIN32) [=[ !insertmacro MUI_INSTALLOPTIONS_READ $INSTALL_DESKTOP "NSIS.InstallOptions.ini" "Field 5" "State"]=] [=[ StrCpy $INSTALL_DESKTOP "1"]=] BONGO_CAT_NSIS_TEMPLATE "${BONGO_CAT_NSIS_TEMPLATE}") + # Silent uninstalls are used during upgrades, so user data defaults to kept. + string(REPLACE [=[ !insertmacro MUI_UNPAGE_CONFIRM]=] + [=[ UninstPage custom un.bongo_cat_uninstall_options_page \ + un.bongo_cat_uninstall_options_leave]=] BONGO_CAT_NSIS_TEMPLATE + "${BONGO_CAT_NSIS_TEMPLATE}") string(REPLACE [=[ Var IS_DEFAULT_INSTALLDIR]=] [=[ Var IS_DEFAULT_INSTALLDIR Var BONGO_CAT_UPGRADE_DIR - Var BONGO_CAT_UPDATE_SHUTDOWN]=] BONGO_CAT_NSIS_TEMPLATE + Var BONGO_CAT_UPDATE_SHUTDOWN + Var BONGO_CAT_KEEP_USER_DATA + Var BONGO_CAT_KEEP_USER_DATA_CHECKBOX]=] BONGO_CAT_NSIS_TEMPLATE "${BONGO_CAT_NSIS_TEMPLATE}") string(REPLACE [=[ !insertmacro MUI_PAGE_DIRECTORY]=] [=[ !define MUI_PAGE_CUSTOMFUNCTION_PRE bongo_cat_directory_page_pre @@ -124,6 +132,32 @@ Function bongo_cat_startmenu_page_pre Abort FunctionEnd +Function un.bongo_cat_uninstall_options_page + !insertmacro MUI_HEADER_TEXT "Uninstall BongoCat" \ + "Choose whether to keep your user data" + nsDialogs::Create 1018 + Pop $0 + StrCmp "$0" "error" 0 bongo_cat_uninstall_options_ready + Abort +bongo_cat_uninstall_options_ready: + ${NSD_CreateLabel} 0 0 100% 24u \ + "BongoCat will be removed from this computer." + Pop $0 + ${NSD_CreateCheckbox} 0 36u 100% 18u \ + "Keep settings, imported models, and other user data" + Pop $BONGO_CAT_KEEP_USER_DATA_CHECKBOX + StrCmp "$BONGO_CAT_KEEP_USER_DATA" "1" 0 \ + bongo_cat_uninstall_options_show + ${NSD_Check} $BONGO_CAT_KEEP_USER_DATA_CHECKBOX +bongo_cat_uninstall_options_show: + nsDialogs::Show +FunctionEnd + +Function un.bongo_cat_uninstall_options_leave + ${NSD_GetState} $BONGO_CAT_KEEP_USER_DATA_CHECKBOX \ + $BONGO_CAT_KEEP_USER_DATA +FunctionEnd + Function InstallOptionsPage]=] BONGO_CAT_NSIS_TEMPLATE "${BONGO_CAT_NSIS_TEMPLATE}") string(REPLACE [=[@CPACK_NSIS_CREATE_ICONS_EXTRA@]=] [=[ @@ -144,6 +178,11 @@ bongo_cat_shortcuts_done: WriteRegStr SHCTX \ "Software\Microsoft\Windows\CurrentVersion\Uninstall\BongoCat" \ "InstallLocation" "$INSTDIR" +]=] BONGO_CAT_NSIS_TEMPLATE "${BONGO_CAT_NSIS_TEMPLATE}") + string(REPLACE [=[@CPACK_NSIS_EXTRA_UNINSTALL_COMMANDS@]=] [=[ + StrCmp "$BONGO_CAT_KEEP_USER_DATA" "1" bongo_cat_user_data_done + RMDir /r "$LOCALAPPDATA\BongoCat" +bongo_cat_user_data_done: ]=] BONGO_CAT_NSIS_TEMPLATE "${BONGO_CAT_NSIS_TEMPLATE}") string(REPLACE [=[@CPACK_NSIS_EXTRA_PREINSTALL_COMMANDS@]=] [=[ StrCmp "$BONGO_CAT_UPGRADE_DIR" "" bongo_cat_upgrade_ready @@ -287,6 +326,7 @@ FunctionEnd]=] bongo_cat_replace_nsis_function(BONGO_CAT_NSIS_TEMPLATE "un.onInit" [=[Function un.onInit SetShellVarContext current + StrCpy $BONGO_CAT_KEEP_USER_DATA "1" FunctionEnd]=] BONGO_CAT_NSIS_TEMPLATE) file(WRITE "${BONGO_CAT_CPACK_TEMPLATE_DIR}/NSIS.template.in" From b67ffe1673cbe3c9f9495b61a80c56716bdb8974 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 28 Aug 2026 12:43:17 +0800 Subject: [PATCH 131/334] chore(release): centralize version at 1.1.0 --- .github/workflows/ci.yml | 17 +++----- .github/workflows/release.yml | 17 ++++---- CMakeLists.txt | 2 +- packaging/get-project-version.ps1 | 26 +++++++++++ packaging/microsoft-store/README.md | 34 ++++++++------- .../microsoft-store/build-store-package.ps1 | 43 ++----------------- .../validate-store-package.ps1 | 30 +++++++------ 7 files changed, 80 insertions(+), 89 deletions(-) create mode 100644 packaging/get-project-version.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b3f3f4a..2b033103 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -233,11 +233,8 @@ jobs: id: package shell: pwsh run: | - $header = Get-Content build-package/generated/bongo_cat/version.h -Raw - if ($header -notmatch '#define\s+BONGO_CAT_VERSION\s+"([^"]+)"') { - throw 'Unable to read the BongoCat version' - } - $version = $Matches[1] + $projectVersion = & ./packaging/get-project-version.ps1 + $version = $projectVersion.AppVersion $name = (Get-Content build-package/bongo-cat-package-name.txt -Raw).Trim() $platform = "${{ matrix.platform }}" if ($name -notmatch "^BongoCat-$([regex]::Escape($version))-$platform$") { @@ -319,11 +316,9 @@ jobs: id: version shell: pwsh run: | - $header = Get-Content build-store/generated/bongo_cat/version.h -Raw - if ($header -notmatch '#define\s+BONGO_CAT_VERSION\s+"([^"]+)"') { - throw 'Unable to read BongoCat version from generated header' - } - "version=$($Matches[1])" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + $projectVersion = & ./packaging/get-project-version.ps1 + "version=$($projectVersion.AppVersion)" | + Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - name: Install NSIS shell: pwsh @@ -380,7 +375,6 @@ jobs: run: | ./packaging/microsoft-store/build-store-package.ps1 ` -ExecutablePath "./build-store/Release/BongoCat.exe" ` - -Version "${{ steps.version.outputs.version }}" ` -Architecture "${{ matrix.store_arch }}" ` -OutputDirectory "./store-output" $msix = Get-Item "store-output/bongocat_${{ steps.version.outputs.version }}_${{ matrix.store_arch }}.msix" @@ -392,7 +386,6 @@ jobs: run: | ./packaging/microsoft-store/validate-store-package.ps1 ` -PackagePath "./store-output/bongocat_${{ steps.version.outputs.version }}_${{ matrix.store_arch }}.msix" ` - -ExpectedAppVersion "${{ steps.version.outputs.version }}" ` -ExpectedArchitecture "${{ matrix.store_arch }}" - name: Upload Microsoft Store MSIX diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d6f185d..544fd228 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,11 +40,8 @@ jobs: id: version shell: pwsh run: | - $cmake = Get-Content CMakeLists.txt -Raw - if ($cmake -notmatch '(?m)^project\(BongoCat VERSION ([0-9]+\.[0-9]+\.[0-9]+)\b') { - throw 'Unable to read the BongoCat version from CMakeLists.txt' - } - $version = $Matches[1] + $projectVersion = & ./packaging/get-project-version.ps1 + $version = $projectVersion.AppVersion if ($env:GITHUB_REF_NAME -ne "v$version") { throw "Tag $env:GITHUB_REF_NAME does not match project version $version" } @@ -205,6 +202,9 @@ jobs: contents: write steps: + - name: Checkout source + uses: actions/checkout@v5 + - name: Download packaged releases uses: actions/download-artifact@v8 with: @@ -216,10 +216,11 @@ jobs: id: version shell: pwsh run: | - if ($env:GITHUB_REF_NAME -notmatch '^v([0-9]+\.[0-9]+\.[0-9]+)$') { - throw "Invalid release tag: $env:GITHUB_REF_NAME" + $projectVersion = & ./packaging/get-project-version.ps1 + $version = $projectVersion.AppVersion + if ($env:GITHUB_REF_NAME -ne "v$version") { + throw "Tag $env:GITHUB_REF_NAME does not match project version $version" } - $version = $Matches[1] $files = foreach ($platform in 'windows-x64', 'windows-x86') { $base = "BongoCat-$version-$platform" "release-output/$base-portable.exe" diff --git a/CMakeLists.txt b/CMakeLists.txt index c249200c..e0b3f933 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -project(BongoCat VERSION 0.1.0 DESCRIPTION "BongoCat" HOMEPAGE_URL "https://github.com/vladelaina/BongoCat" LANGUAGES C CXX) +project(BongoCat VERSION 1.1.0 DESCRIPTION "BongoCat" HOMEPAGE_URL "https://github.com/vladelaina/BongoCat" LANGUAGES C CXX) include(cmake/Version.cmake) include(FetchContent) include(CTest) diff --git a/packaging/get-project-version.ps1 b/packaging/get-project-version.ps1 new file mode 100644 index 00000000..55ac51b9 --- /dev/null +++ b/packaging/get-project-version.ps1 @@ -0,0 +1,26 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repositoryRoot = Split-Path $PSScriptRoot -Parent +$cmakeLists = Get-Content -LiteralPath ( + Join-Path $repositoryRoot 'CMakeLists.txt') -Raw +if ($cmakeLists -notmatch + '(?m)^project\(BongoCat VERSION ([0-9]+)\.([0-9]+)\.([0-9]+)\b') { + throw 'Unable to read the BongoCat version from CMakeLists.txt.' +} + +$parts = @([int]$Matches[1], [int]$Matches[2], [int]$Matches[3]) +if ($parts | Where-Object { $_ -gt 65535 }) { + throw 'Version components must be between 0 and 65535.' +} +if ($parts[0] -ge 65535) { + throw 'The major version is too large for the Microsoft Store mapping.' +} + +[PSCustomObject]@{ + AppVersion = $parts -join '.' + StorePackageVersion = "$($parts[0] + 1).$($parts[1]).$($parts[2]).0" +} diff --git a/packaging/microsoft-store/README.md b/packaging/microsoft-store/README.md index 4f24e6de..4ba13516 100644 --- a/packaging/microsoft-store/README.md +++ b/packaging/microsoft-store/README.md @@ -36,10 +36,11 @@ is `%LOCALAPPDATA%\Packages\\LocalCache\Local\BongoCat\config\settings.json Package identity is detected at runtime with `GetCurrentPackageFullName`, and the selected mode and resolved storage path are written to `BongoCat.log`. -MSIX versions must have a non-zero major component and a zero revision. The -script maps app version `0.1.1` to package version `1.1.1.0` by default; pass -`-PackageVersion` when advancing the Store version independently. The app -version remains in the filename so GitHub artifacts are easy to identify. +The app version is declared only in the top-level `CMakeLists.txt` and read by +all Store scripts through `packaging/get-project-version.ps1`. MSIX versions +must have a non-zero major component and a zero revision, so the helper maps +the app version to `(major + 1).minor.patch.0`. The app version remains in the +filename so GitHub artifacts are easy to identify. ## Actions SDK setup @@ -68,20 +69,20 @@ cmake -S . -B build-cubism -G "Visual Studio 17 2022" -A x64 ` cmake --build build-cubism --config Release --target bongo_cat --parallel 2 .\packaging\microsoft-store\build-store-package.ps1 ` - -ExecutablePath .\build-cubism\Release\BongoCat.exe ` - -Version 0.1.1 + -ExecutablePath .\build-cubism\Release\BongoCat.exe ``` The script writes the unsigned Store submission package to -`output\microsoft-store\bongocat_0.1.1_x64.msix`. Upload that file directly to -Partner Center. It cannot be installed locally until Microsoft Store signs it. -Validate the exact Identity, PFN, version, architecture, payload, and unsigned -submission state with: +`output\microsoft-store\bongocat__x64.msix`. Upload that file +directly to Partner Center. It cannot be installed locally until Microsoft +Store signs it. Validate the exact Identity, PFN, manifest and executable +versions, architecture, payload, and unsigned submission state with: ```powershell +$projectVersion = .\packaging\get-project-version.ps1 +$msix = ".\output\microsoft-store\bongocat_$($projectVersion.AppVersion)_x64.msix" .\packaging\microsoft-store\validate-store-package.ps1 ` - -PackagePath .\output\microsoft-store\bongocat_0.1.1_x64.msix ` - -ExpectedAppVersion 0.1.1 + -PackagePath $msix ``` The protected Actions job runs this validation before uploading its artifact. @@ -90,16 +91,17 @@ To test local deployment before submission, explicitly build a separate self-signed package: ```powershell +$projectVersion = .\packaging\get-project-version.ps1 +$localBase = ".\output\microsoft-store\bongocat_$($projectVersion.AppVersion)_x64-local-test" .\packaging\microsoft-store\build-store-package.ps1 ` -ExecutablePath .\build-cubism\Release\BongoCat.exe ` - -Version 0.1.1 -SignForLocalTesting + -SignForLocalTesting # Run these certificate/deployment commands from an elevated PowerShell. Import-Certificate ` - .\output\microsoft-store\bongocat_0.1.1_x64-local-test.cer ` + "$localBase.cer" ` -CertStoreLocation Cert:\LocalMachine\TrustedPeople | Out-Null -Add-AppxPackage ` - .\output\microsoft-store\bongocat_0.1.1_x64-local-test.msix +Add-AppxPackage "$localBase.msix" Get-AppxPackage -Name vladelaina.bongocat ``` diff --git a/packaging/microsoft-store/build-store-package.ps1 b/packaging/microsoft-store/build-store-package.ps1 index d5ed0b13..8a39942a 100644 --- a/packaging/microsoft-store/build-store-package.ps1 +++ b/packaging/microsoft-store/build-store-package.ps1 @@ -3,10 +3,6 @@ param( [Parameter(Mandatory = $true)] [string]$ExecutablePath, - [Parameter(Mandatory = $true)] - [string]$Version, - - [string]$PackageVersion, [string]$OutputDirectory, [string]$CertificateOutputPath, [ValidateSet('x86', 'x64')] @@ -19,6 +15,9 @@ Set-StrictMode -Version Latest $scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path $repositoryRoot = (Resolve-Path (Join-Path $scriptDirectory "..\..")).Path +$projectVersion = & (Join-Path $repositoryRoot 'packaging\get-project-version.ps1') +$Version = $projectVersion.AppVersion +$PackageVersion = $projectVersion.StorePackageVersion if (-not $OutputDirectory) { $OutputDirectory = Join-Path $repositoryRoot "output\microsoft-store" @@ -27,42 +26,6 @@ if (-not $OutputDirectory) { $ExecutablePath = (Resolve-Path $ExecutablePath).Path $OutputDirectory = [System.IO.Path]::GetFullPath($OutputDirectory) -if ($Version -notmatch '^([0-9]+)\.([0-9]+)\.([0-9]+)$') { - throw "Version must use Major.Minor.Patch format, for example 0.1.1." -} - -$versionParts = @( - [int]$Matches[1], - [int]$Matches[2], - [int]$Matches[3] -) -if ($versionParts | Where-Object { $_ -gt 65535 }) { - throw "MSIX version components must be between 0 and 65535." -} - -# Microsoft Store package versions cannot start at zero. Keep the app's -# semantic version in the filename and reserve the first package component -# for Store versioning, which makes pre-1.0 builds valid and monotonic. -if (-not $PackageVersion) { - $packageMajor = $versionParts[0] + 1 - $PackageVersion = "$packageMajor.$($versionParts[1]).$($versionParts[2]).0" -} -if ($PackageVersion -notmatch '^([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)$') { - throw "PackageVersion must use Major.Minor.Patch.Revision format." -} -$packageParts = @( - [int]$Matches[1], - [int]$Matches[2], - [int]$Matches[3], - [int]$Matches[4] -) -if ($packageParts[0] -eq 0 -or ($packageParts | Where-Object { $_ -gt 65535 })) { - throw "MSIX version components must be between 0 and 65535 and Major cannot be zero." -} -if ($packageParts[3] -ne 0) { - throw "PackageVersion revision must be zero for Microsoft Store submissions." -} - $packageBaseName = if ($SignForLocalTesting) { "bongocat_${Version}_${Architecture}-local-test" } diff --git a/packaging/microsoft-store/validate-store-package.ps1 b/packaging/microsoft-store/validate-store-package.ps1 index 29cfcae9..830162c0 100644 --- a/packaging/microsoft-store/validate-store-package.ps1 +++ b/packaging/microsoft-store/validate-store-package.ps1 @@ -3,24 +3,22 @@ param( [Parameter(Mandatory = $true)] [string]$PackagePath, - [Parameter(Mandatory = $true)] - [string]$ExpectedAppVersion, - [ValidateSet('x86', 'x64')] - [string]$ExpectedArchitecture = 'x64', - - [string]$ExpectedPackageVersion + [string]$ExpectedArchitecture = 'x64' ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest +$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path +$repositoryRoot = (Resolve-Path (Join-Path $scriptDirectory '..\..')).Path +$projectVersion = & (Join-Path $repositoryRoot 'packaging\get-project-version.ps1') +$ExpectedAppVersion = $projectVersion.AppVersion +$ExpectedPackageVersion = $projectVersion.StorePackageVersion $PackagePath = (Resolve-Path $PackagePath).Path -if ($ExpectedAppVersion -notmatch '^([0-9]+)\.([0-9]+)\.([0-9]+)$') { - throw 'ExpectedAppVersion must use Major.Minor.Patch format.' -} -if (-not $ExpectedPackageVersion) { - $ExpectedPackageVersion = "$([int]$Matches[1] + 1).$($Matches[2]).$($Matches[3]).0" +if ((Split-Path $PackagePath -Leaf) -notmatch + "^bongocat_$([regex]::Escape($ExpectedAppVersion))_") { + throw "Package filename does not match app version $ExpectedAppVersion." } function Get-WindowsSdkTool { @@ -127,7 +125,15 @@ try { throw 'Unsigned Store submission package unexpectedly contains AppxSignature.p7x.' } - $machine = Get-PeMachine (Join-Path $temporaryRoot 'BongoCat.exe') + $executablePath = Join-Path $temporaryRoot 'BongoCat.exe' + $versionInfo = (Get-Item -LiteralPath $executablePath).VersionInfo + $packagedAppVersion = '{0}.{1}.{2}' -f $versionInfo.FileMajorPart, + $versionInfo.FileMinorPart, $versionInfo.FileBuildPart + if ($packagedAppVersion -ne $ExpectedAppVersion) { + throw "Packaged executable version is $packagedAppVersion; expected $ExpectedAppVersion." + } + + $machine = Get-PeMachine $executablePath $expectedMachine = if ($ExpectedArchitecture -eq 'x64') { 0x8664 } else { 0x014C } if ($machine -ne $expectedMachine) { throw ('Packaged executable machine is 0x{0:X4}; expected {1}.' -f From f161e3d8b1e4fd9a91f28b2d7b620f3f3d1aaae1 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 28 Aug 2026 13:21:12 +0800 Subject: [PATCH 132/334] fix(store): align MSIX version with app version --- packaging/get-project-version.ps1 | 6 +++--- packaging/microsoft-store/README.md | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packaging/get-project-version.ps1 b/packaging/get-project-version.ps1 index 55ac51b9..76d239b8 100644 --- a/packaging/get-project-version.ps1 +++ b/packaging/get-project-version.ps1 @@ -16,11 +16,11 @@ $parts = @([int]$Matches[1], [int]$Matches[2], [int]$Matches[3]) if ($parts | Where-Object { $_ -gt 65535 }) { throw 'Version components must be between 0 and 65535.' } -if ($parts[0] -ge 65535) { - throw 'The major version is too large for the Microsoft Store mapping.' +if ($parts[0] -eq 0) { + throw 'The major version must be greater than zero for Microsoft Store packages.' } [PSCustomObject]@{ AppVersion = $parts -join '.' - StorePackageVersion = "$($parts[0] + 1).$($parts[1]).$($parts[2]).0" + StorePackageVersion = "$($parts[0]).$($parts[1]).$($parts[2]).0" } diff --git a/packaging/microsoft-store/README.md b/packaging/microsoft-store/README.md index 4ba13516..3fc00c0a 100644 --- a/packaging/microsoft-store/README.md +++ b/packaging/microsoft-store/README.md @@ -38,9 +38,10 @@ the selected mode and resolved storage path are written to `BongoCat.log`. The app version is declared only in the top-level `CMakeLists.txt` and read by all Store scripts through `packaging/get-project-version.ps1`. MSIX versions -must have a non-zero major component and a zero revision, so the helper maps -the app version to `(major + 1).minor.patch.0`. The app version remains in the -filename so GitHub artifacts are easy to identify. +must use four numeric components, have a non-zero major component, and use a +zero revision. The helper therefore converts `major.minor.patch` to the same +version with a `.0` suffix, for example `1.1.0` to `1.1.0.0`. The app version +remains in the filename so GitHub artifacts are easy to identify. ## Actions SDK setup From 88d826bdc84e2b13ff8742d7d9dbb52a2176a1a1 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 28 Aug 2026 14:16:56 +0800 Subject: [PATCH 133/334] docs: add privacy policy --- PRIVACY.md | 111 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 PRIVACY.md diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 00000000..50fd2a9a --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,111 @@ +# Privacy Policy + +## Introduction + +**BongoCat** is a desktop application that displays an interactive desktop +pet and responds to local keyboard, mouse, and gamepad input. We value your +privacy. This policy explains what BongoCat does and does not collect, and +how information is stored. + +## Data Collection and Usage + +### Information We Do **Not** Collect + +BongoCat does not: + +* Create accounts, unique user identifiers, trackers, or profiling systems +* Collect usage statistics, telemetry, analytics, or advertising data +* Upload keyboard, mouse, gamepad, model, configuration, or system data +* Record the content of your keystrokes or input events for transmission +* Sell or share personal data with third parties + +Keyboard, mouse, and gamepad input is processed locally to animate the pet and +is not sent to a remote service. + +### Locally Stored Data + +BongoCat stores application data only on your device. The exact platform +locations are: + +* **Windows:** `%LOCALAPPDATA%\BongoCat\` (or the private package storage for + the Microsoft Store/MSIX version) +* **macOS:** `~/Library/Application Support/BongoCat/`, with caches and logs + in the corresponding macOS cache and log directories +* **Linux:** the XDG configuration, data, cache, and state directories for + `bongocat` (normally under `~/.config`, `~/.local/share`, `~/.cache`, and + `~/.local/state`) + +The following categories may be stored locally: + +1. **Settings** + User preferences, window and input options, selected models, shortcuts, + labels, and other application settings are stored in `config/settings.json`. + +2. **Session and runtime state** + Temporary session information and startup state are stored under the local + state directory. These files support normal operation and crash recovery. + +3. **Logs** + Diagnostic messages, startup and shutdown records, warnings, errors, and + limited platform information may be written to `logs/BongoCat.log`. + Logs are intended for local troubleshooting only and are never uploaded by + BongoCat. + +4. **Models and user resources** + Built-in or imported model packages, behavior definitions, sounds, fonts, + and other resources are stored in the local data and models directories. + BongoCat does not transmit these files. + +## Network Access + +BongoCat does not require a network connection for normal operation. Network +access may occur in these cases: + +* **Update checking on Windows:** When an update check is initiated or + scheduled, BongoCat sends a basic HTTPS `GET` request to the GitHub Releases + API endpoint + `https://api.github.com/repos/vladelaina/BongoCat/releases/latest`. + The request retrieves release information and does not include personal + data. The user agent identifies the application as `BongoCat Update + Checker/1.0`. Automatic update checks are not currently available on macOS + or Linux. +* **Opening external links:** If you choose a website or feedback link in the + application, BongoCat asks your operating system to open that URL in your + default browser. The browser and the destination website then apply their + own privacy policies. + +BongoCat does not use remote images, advertising networks, or analytics +services as part of its normal runtime. + +## Data Protection and Deletion + +Application data remains on your device and is not synchronized by BongoCat. +You can remove it by closing BongoCat and deleting its platform-specific +configuration, data, cache, state, and log directories. Deleting these files +also removes your settings, imported models, and local logs. + +## Permission Usage + +Permissions used by BongoCat are limited to core features: + +* **File system access:** Reading and writing settings, session state, logs, + models, and user-provided resources +* **Keyboard, mouse, and gamepad access:** Detecting local input so the pet can + react and follow the configured behavior +* **Network access on Windows:** Checking GitHub for release information when + update checking is used +* **Opening external URLs:** Only when you select a link in the application +* **Startup integration:** Only when you enable a start-with-system option, + where supported + +## Changes to This Policy + +If this policy changes materially, the updated version will be published on +the BongoCat project homepage or repository. + +## Contact + +For questions or suggestions about this privacy policy, please use the GitHub +project page: . + +> Last Updated: August 28, 2026 From a732d2c7a7090888b3b9b763f9e141a2b79300a7 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 28 Aug 2026 18:20:34 +0800 Subject: [PATCH 134/334] fix: unify displayed application version --- cmake/version.h.in | 1 + include/bongo_cat/common.h | 2 +- src/ui/preferences/preferences_about_footer.c | 3 +-- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmake/version.h.in b/cmake/version.h.in index a9901a32..ca9b3c43 100644 --- a/cmake/version.h.in +++ b/cmake/version.h.in @@ -2,5 +2,6 @@ #define BONGO_CAT_VERSION_H #define BONGO_CAT_VERSION "@PROJECT_VERSION@" +#define BONGO_CAT_VERSION_DISPLAY "v" BONGO_CAT_VERSION #endif diff --git a/include/bongo_cat/common.h b/include/bongo_cat/common.h index 3f0dacca..7994c51e 100644 --- a/include/bongo_cat/common.h +++ b/include/bongo_cat/common.h @@ -9,7 +9,7 @@ #define BONGO_CAT_NAME "BongoCat" #define BONGO_CAT_PET_WINDOW_TITLE BONGO_CAT_NAME -#define BONGO_CAT_TRAY_TITLE BONGO_CAT_NAME " v" BONGO_CAT_VERSION " \xC2\xB7 by vladelaina" +#define BONGO_CAT_TRAY_TITLE BONGO_CAT_NAME " " BONGO_CAT_VERSION_DISPLAY " \xC2\xB7 by vladelaina" #define BONGO_CAT_SLUG "bongo-cat" #define BONGO_CAT_EXECUTABLE "BongoCat" #define BONGO_CAT_APP_ID "com.bongocat.desktop" diff --git a/src/ui/preferences/preferences_about_footer.c b/src/ui/preferences/preferences_about_footer.c index 87f1fa6a..c9656a36 100644 --- a/src/ui/preferences/preferences_about_footer.c +++ b/src/ui/preferences/preferences_about_footer.c @@ -166,8 +166,7 @@ void bongo_cat_preferences_about_footer(BongoCatPreferences *value, const char *feedback = tr(value, "native.support.feedback", "Feedback"); const char *logs = tr(value, "native.support.openLogs", "Open logs"); const struct nk_user_font *small_font = logs_font(value); - char version[32]; - snprintf(version, sizeof(version), "v%s", BONGO_CAT_VERSION); + const char *version = BONGO_CAT_VERSION_DISPLAY; float label_width = width(value->ui.caption_font, version_label) + 2; float version_width = width(value->ui.label_font, version) + 2; float update_text_width = width(value->ui.caption_font, update); From 172594b76bb30721a443de8c4eb09b9f2b20966a Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 28 Aug 2026 18:49:13 +0800 Subject: [PATCH 135/334] Update README.md --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 0b3655e3..1d930863 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,16 @@

+## 📥 Download + + + + + +- GitHub Releases + + Download the latest release from [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest). + ## Project Status From e3ae66a0c08304dc4122210bc406ba235be132f9 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 28 Aug 2026 19:06:39 +0800 Subject: [PATCH 136/334] Unify application branding on bongocat logo --- cmake/ApplicationResources.cmake | 6 ------ cmake/CubismAssets.cmake | 1 - cmake/Info.plist.in | 1 - cmake/Packaging.cmake | 2 +- cmake/PackagingPlatform.cmake | 4 ---- cmake/windows_resources.rc.in | 1 - .../microsoft-store/build-store-package.ps1 | 2 +- resources/assets/bongocat.png | Bin 0 -> 97460 bytes resources/assets/logo-mac.png | Bin 15378 -> 0 bytes resources/assets/logo.png | Bin 13210 -> 0 bytes resources/assets/tray-mac.png | Bin 9488 -> 0 bytes resources/assets/tray.png | Bin 13210 -> 0 bytes resources/icons/128x128.png | Bin 7991 -> 0 bytes resources/icons/icon.icns | Bin 351940 -> 0 bytes resources/icons/icon.ico | Bin 26620 -> 0 bytes resources/icons/icon.png | Bin 63187 -> 0 bytes src/platform/windows/windows_assets.c | 3 +-- src/runtime/lifecycle/assets.c | 4 ++-- src/runtime/shell/tray.c | 7 +------ src/tools/asset_packer.c | 2 +- src/ui/preferences/preferences_assets.c | 2 +- tests/model_import/test_mver_support.c | 2 +- tests/model_import/test_tauri_portable.c | 2 +- 23 files changed, 10 insertions(+), 29 deletions(-) create mode 100644 resources/assets/bongocat.png delete mode 100644 resources/assets/logo-mac.png delete mode 100644 resources/assets/logo.png delete mode 100644 resources/assets/tray-mac.png delete mode 100644 resources/assets/tray.png delete mode 100644 resources/icons/128x128.png delete mode 100644 resources/icons/icon.icns delete mode 100644 resources/icons/icon.ico delete mode 100644 resources/icons/icon.png diff --git a/cmake/ApplicationResources.cmake b/cmake/ApplicationResources.cmake index 0d6f80c0..c43c4fb5 100644 --- a/cmake/ApplicationResources.cmake +++ b/cmake/ApplicationResources.cmake @@ -1,12 +1,8 @@ if(APPLE) - set_source_files_properties(resources/icons/icon.icns PROPERTIES - MACOSX_PACKAGE_LOCATION Resources) - target_sources(bongo_cat PRIVATE resources/icons/icon.icns) set_target_properties(bongo_cat PROPERTIES MACOSX_BUNDLE TRUE MACOSX_BUNDLE_BUNDLE_NAME "BongoCat" MACOSX_BUNDLE_GUI_IDENTIFIER com.bongocat.desktop - MACOSX_BUNDLE_ICON_FILE icon.icns MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/cmake/Info.plist.in") add_custom_command(TARGET bongo_cat POST_BUILD @@ -48,8 +44,6 @@ if(WIN32) VERBATIM) add_custom_target(bongo_cat_asset_pack DEPENDS "${BONGO_CAT_ASSET_PACK}") file(TO_CMAKE_PATH "${BONGO_CAT_ASSET_PACK}" BONGO_CAT_ASSET_PACK_RC) - file(TO_CMAKE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/icon.ico" - BONGO_CAT_ICON_RC) set(BONGO_CAT_WINDOWS_MANIFEST "${CMAKE_CURRENT_BINARY_DIR}/windows.manifest") configure_file(cmake/windows.manifest.in diff --git a/cmake/CubismAssets.cmake b/cmake/CubismAssets.cmake index 305136c5..bdcac960 100644 --- a/cmake/CubismAssets.cmake +++ b/cmake/CubismAssets.cmake @@ -15,7 +15,6 @@ endfunction() function(bongo_cat_stage_cubism_assets target) if(APPLE) add_custom_command(TARGET ${target} POST_BUILD COMMAND ${CMAKE_COMMAND} -E rm -f - "$/assets/logo-mac.png" "$/assets/ui-icons.png" "$/assets/ui-symbols-1x.png" "$/assets/ui-symbols-4x.png") diff --git a/cmake/Info.plist.in b/cmake/Info.plist.in index 54ab56b3..514f1f12 100644 --- a/cmake/Info.plist.in +++ b/cmake/Info.plist.in @@ -6,7 +6,6 @@ CFBundleDevelopmentRegionen CFBundleDisplayNameBongoCat CFBundleExecutableBongoCat - CFBundleIconFileicon.icns CFBundleIdentifiercom.bongocat.desktop CFBundleInfoDictionaryVersion6.0 CFBundleNameBongoCat diff --git a/cmake/Packaging.cmake b/cmake/Packaging.cmake index 471dd236..07a1e451 100644 --- a/cmake/Packaging.cmake +++ b/cmake/Packaging.cmake @@ -11,7 +11,7 @@ if(NOT BONGO_CAT_CUBISM_ENABLED) endif() if(UNIX AND NOT APPLE) install(DIRECTORY resources/assets DESTINATION . COMPONENT Runtime - PATTERN "logo-mac.png" EXCLUDE PATTERN "ui-icons.png" EXCLUDE + PATTERN "ui-icons.png" EXCLUDE PATTERN "ui-symbols-1x.png" EXCLUDE PATTERN "ui-symbols-4x.png" EXCLUDE) endif() diff --git a/cmake/PackagingPlatform.cmake b/cmake/PackagingPlatform.cmake index 9692beac..4bcfe8e7 100644 --- a/cmake/PackagingPlatform.cmake +++ b/cmake/PackagingPlatform.cmake @@ -38,10 +38,6 @@ elseif(WIN32) # packaged files or installation behavior. set(CPACK_NSIS_COMPRESSOR "zlib") set(CPACK_NSIS_INSTALLED_ICON_NAME "BongoCat.exe") - set(CPACK_NSIS_MUI_ICON - "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/icon.ico") - set(CPACK_NSIS_MUI_UNIICON - "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/icon.ico") set(CPACK_NSIS_IGNORE_LICENSE_PAGE ON) set(CPACK_NSIS_ENABLE_UNINSTALL_BEFORE_INSTALL OFF) set(CPACK_NSIS_MODIFY_PATH OFF) diff --git a/cmake/windows_resources.rc.in b/cmake/windows_resources.rc.in index 29a3853c..ba13849b 100644 --- a/cmake/windows_resources.rc.in +++ b/cmake/windows_resources.rc.in @@ -1,6 +1,5 @@ #include -1 ICON "@BONGO_CAT_ICON_RC@" @BONGO_CAT_MANIFEST_RESOURCE@ 101 RCDATA "@BONGO_CAT_ASSET_PACK_RC@" diff --git a/packaging/microsoft-store/build-store-package.ps1 b/packaging/microsoft-store/build-store-package.ps1 index 8a39942a..1aed7337 100644 --- a/packaging/microsoft-store/build-store-package.ps1 +++ b/packaging/microsoft-store/build-store-package.ps1 @@ -126,7 +126,7 @@ try { [System.Text.UTF8Encoding]::new($false)) Add-Type -AssemblyName System.Drawing - $sourceLogo = [System.Drawing.Image]::FromFile((Join-Path $repositoryRoot "resources\icons\icon.png")) + $sourceLogo = [System.Drawing.Image]::FromFile((Join-Path $repositoryRoot "resources\assets\bongocat.png")) try { New-StoreLogo $sourceLogo 50 50 (Join-Path $assetsDirectory "StoreLogo.png") New-StoreLogo $sourceLogo 44 44 (Join-Path $assetsDirectory "Square44x44Logo.png") diff --git a/resources/assets/bongocat.png b/resources/assets/bongocat.png new file mode 100644 index 0000000000000000000000000000000000000000..7194d588124bb270657ffabd6be7baade52d8439 GIT binary patch literal 97460 zcmeEt`#%$2{6CdcLM6Az-rZ2SBr(^mQb`%zDVJeHxy&W^*(yb@3+2+Vr09Z~F!#xQ zZcAii7@Pb3GPBJt-#*_TKYzpLhx2%y*Lgh7IgiKdTwdq(dOlCwEgMVeeMk0*iHS*D zUB7ZiOl-%$>JG6zyZ;GR*Z}XJ5D&Uzd0DKaS9R{6vdi-l
e9xcUpmH4Oc^}qfw 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

^!;u)=`CI^VT{V*E`qf(z= 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

(9~$&32-He^0;Gt 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%0A-%qM+l$!WkmX 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 a6gs~3BI=bdZ*~CiabHtkqvEb@`2Pc7-CIZi literal 0 HcmV?d00001 diff --git a/resources/assets/logo-mac.png b/resources/assets/logo-mac.png deleted file mode 100644 index 16b3e715e3bd5a9b0d44a9ea13d33ecb586f07b1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15378 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 diff --git a/resources/assets/tray-mac.png b/resources/assets/tray-mac.png deleted file mode 100644 index 5932d39a6a87b6330fc6ba1de2ca868a5ae4fb26..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9488 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 diff --git a/resources/icons/128x128.png b/resources/icons/128x128.png deleted file mode 100644 index 6083ccf8a90b2535733dd8ce986de0fcd371e8f0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7991 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 diff --git a/resources/icons/icon.icns b/resources/icons/icon.icns deleted file mode 100644 index 7d3d0ee3842a02c2d14b265da3e4a2e82389bf38..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 351940 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�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 TY$?yt00000NkvXXu0mjfL~N~{ diff --git a/resources/icons/icon.ico b/resources/icons/icon.ico deleted file mode 100644 index 73a2c9fee8fbbd80fac0ab8219030ad559aa7de0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 26620 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 diff --git a/resources/icons/icon.png b/resources/icons/icon.png deleted file mode 100644 index 610221f15de94887492778260e9fca7828d461d6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 63187 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�% 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 f`F{G+o(4_Is3_GLRrls40P|5-(N->3vIzMapp = app; bongo_cat_modal_frame_init(&tray->modal_frame, app); char path[BONGO_CAT_PATH_CAP]; - bongo_cat_path_join(path, sizeof(path), app->asset_root, -#ifdef __APPLE__ - "tray-mac.png"); -#else - "tray.png"); -#endif + bongo_cat_path_join(path, sizeof(path), app->asset_root, "bongocat.png"); if (bongo_cat_image_load(path, &tray->icon, error) != BONGO_CAT_OK) { bongo_cat_tray_destroy(tray); return NULL; diff --git a/src/tools/asset_packer.c b/src/tools/asset_packer.c index 9d4f5a02..60659c39 100644 --- a/src/tools/asset_packer.c +++ b/src/tools/asset_packer.c @@ -94,7 +94,7 @@ static int read_file(const wchar_t *path, unsigned char **data, size_t *size) { } static int excluded_path(const char *path) { - return strcmp(path, "assets/logo-mac.png") == 0 || + return strcmp(path, "assets/bongocat.png") == 0 || strcmp(path, "assets/ui-icons.png") == 0 || strcmp(path, "assets/ui-symbols-1x.png") == 0 || strcmp(path, "assets/ui-symbols-4x.png") == 0; diff --git a/src/ui/preferences/preferences_assets.c b/src/ui/preferences/preferences_assets.c index a8e800fe..6a262121 100644 --- a/src/ui/preferences/preferences_assets.c +++ b/src/ui/preferences/preferences_assets.c @@ -36,7 +36,7 @@ static int raster_size(const BongoCatPreferences *value, int logical) { } void bongo_cat_preferences_assets_load(BongoCatPreferences *value) { - value->logo_texture = load(value, "logo.png", raster_size(value, 192), + value->logo_texture = load(value, "bongocat.png", raster_size(value, 192), &value->logo_width, &value->logo_height); int width = 0, height = 0; value->icon_texture = load(value, "ui-symbols.png", 0, &width, &height); diff --git a/tests/model_import/test_mver_support.c b/tests/model_import/test_mver_support.c index a0bfae36..107816e6 100644 --- a/tests/model_import/test_mver_support.c +++ b/tests/model_import/test_mver_support.c @@ -42,7 +42,7 @@ bool mver_fixture(const char *root) { "{\"Moc\":\"cat.moc3\",\"Textures\":[\"texture.png\"]}}") || !child(path, sizeof(path), model, "cat.moc3", false) || !write_text(path, "MOC3")) return false; - snprintf(source, sizeof(source), "%s/resources/assets/tray.png", + snprintf(source, sizeof(source), "%s/resources/assets/bongocat.png", BONGO_CAT_NATIVE_SOURCE_DIR); if (!child(path, sizeof(path), model, "texture.png", false) || !SDL_CopyFile(source, path) || diff --git a/tests/model_import/test_tauri_portable.c b/tests/model_import/test_tauri_portable.c index 911da094..9f2c89cc 100644 --- a/tests/model_import/test_tauri_portable.c +++ b/tests/model_import/test_tauri_portable.c @@ -41,7 +41,7 @@ void test_tauri_portable(void) { CHECK(child(path, sizeof(path), model, "cat.moc3", false)); CHECK(write_text(path, "MOC3")); char tray[BONGO_CAT_PATH_CAP]; - snprintf(tray, sizeof(tray), "%s/resources/assets/tray.png", + snprintf(tray, sizeof(tray), "%s/resources/assets/bongocat.png", BONGO_CAT_NATIVE_SOURCE_DIR); char resources[BONGO_CAT_PATH_CAP], left_keys[BONGO_CAT_PATH_CAP]; char right_keys[BONGO_CAT_PATH_CAP], east[BONGO_CAT_PATH_CAP]; From 0ba108a9a437d391fa50db064e26107f903da43b Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 28 Aug 2026 19:17:47 +0800 Subject: [PATCH 137/334] Embed bongocat logo in runtime asset pack --- src/tools/asset_packer.c | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/tools/asset_packer.c b/src/tools/asset_packer.c index 60659c39..af3187c0 100644 --- a/src/tools/asset_packer.c +++ b/src/tools/asset_packer.c @@ -94,8 +94,7 @@ static int read_file(const wchar_t *path, unsigned char **data, size_t *size) { } static int excluded_path(const char *path) { - return strcmp(path, "assets/bongocat.png") == 0 || - strcmp(path, "assets/ui-icons.png") == 0 || + return strcmp(path, "assets/ui-icons.png") == 0 || strcmp(path, "assets/ui-symbols-1x.png") == 0 || strcmp(path, "assets/ui-symbols-4x.png") == 0; } From 6486e90144cac2fe1a6b533c930a7249853f221a Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 28 Aug 2026 19:47:10 +0800 Subject: [PATCH 138/334] Restore Windows icon from bongocat logo --- cmake/ApplicationResources.cmake | 2 ++ cmake/PackagingPlatform.cmake | 4 ++++ cmake/windows_resources.rc.in | 1 + resources/icons/icon.ico | Bin 0 -> 97482 bytes 4 files changed, 7 insertions(+) create mode 100644 resources/icons/icon.ico diff --git a/cmake/ApplicationResources.cmake b/cmake/ApplicationResources.cmake index c43c4fb5..6935ea71 100644 --- a/cmake/ApplicationResources.cmake +++ b/cmake/ApplicationResources.cmake @@ -44,6 +44,8 @@ if(WIN32) VERBATIM) add_custom_target(bongo_cat_asset_pack DEPENDS "${BONGO_CAT_ASSET_PACK}") file(TO_CMAKE_PATH "${BONGO_CAT_ASSET_PACK}" BONGO_CAT_ASSET_PACK_RC) + file(TO_CMAKE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/icon.ico" + BONGO_CAT_ICON_RC) set(BONGO_CAT_WINDOWS_MANIFEST "${CMAKE_CURRENT_BINARY_DIR}/windows.manifest") configure_file(cmake/windows.manifest.in diff --git a/cmake/PackagingPlatform.cmake b/cmake/PackagingPlatform.cmake index 4bcfe8e7..9692beac 100644 --- a/cmake/PackagingPlatform.cmake +++ b/cmake/PackagingPlatform.cmake @@ -38,6 +38,10 @@ elseif(WIN32) # packaged files or installation behavior. set(CPACK_NSIS_COMPRESSOR "zlib") set(CPACK_NSIS_INSTALLED_ICON_NAME "BongoCat.exe") + set(CPACK_NSIS_MUI_ICON + "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/icon.ico") + set(CPACK_NSIS_MUI_UNIICON + "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/icon.ico") set(CPACK_NSIS_IGNORE_LICENSE_PAGE ON) set(CPACK_NSIS_ENABLE_UNINSTALL_BEFORE_INSTALL OFF) set(CPACK_NSIS_MODIFY_PATH OFF) diff --git a/cmake/windows_resources.rc.in b/cmake/windows_resources.rc.in index ba13849b..29a3853c 100644 --- a/cmake/windows_resources.rc.in +++ b/cmake/windows_resources.rc.in @@ -1,5 +1,6 @@ #include +1 ICON "@BONGO_CAT_ICON_RC@" @BONGO_CAT_MANIFEST_RESOURCE@ 101 RCDATA "@BONGO_CAT_ASSET_PACK_RC@" diff --git a/resources/icons/icon.ico b/resources/icons/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..0653770a8286ff26bbb18527af282dfd85098361 GIT binary patch literal 97482 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 tdx Date: Fri, 28 Aug 2026 23:25:04 +0800 Subject: [PATCH 139/334] Polish preferences branding and unify bongocat naming --- .github/gcc-problem-matcher.json | 2 +- .github/scripts/build-unix.sh | 2 +- .github/scripts/build-windows.ps1 | 2 +- .github/scripts/check-publish-guards.ps1 | 2 +- .github/scripts/test-windows.ps1 | 4 +- .github/workflows/ci.yml | 14 +-- .github/workflows/release.yml | 10 +- cmake/ApplicationResources.cmake | 2 +- cmake/CubismAssets.cmake | 6 -- cmake/HtmlLayoutProbe.js | 100 ------------------ cmake/Packaging.cmake | 6 +- include/bongo_cat/common.h | 2 +- resources/assets/ui-icons.png | Bin 1272 -> 0 bytes resources/assets/ui-symbols-1x.png | Bin 181 -> 0 bytes resources/assets/ui-symbols-4x.png | Bin 811 -> 0 bytes resources/linux/bongo-cat.desktop | 8 -- src/media/image_decode.c | 4 +- src/platform/linux/linux.c | 6 +- src/platform/linux/linux_x11.c | 4 +- src/platform/macos/macos.m | 3 +- src/platform/macos/macos_input.m | 2 +- src/runtime/model/model_catalog_refresh.c | 2 +- src/runtime/update/update_service.c | 2 +- src/tools/asset_packer.c | 7 -- src/ui/preferences/preferences_about.c | 41 ++++--- src/ui/preferences/preferences_import.c | 3 +- src/ui/preferences/preferences_render.c | 2 +- src/ui/theme/ui_catime.c | 37 +++---- .../model_import/test_model_import_identity.c | 6 +- tests/model_import/test_mver_container.c | 10 +- tests/model_import/test_mver_import.c | 4 +- tests/model_import/test_mver_missing_motion.c | 4 +- .../model_import/test_mver_nearby_identity.c | 8 +- tests/model_import/test_slim_package.c | 4 +- tests/model_import/test_tauri_portable.c | 2 +- 35 files changed, 92 insertions(+), 219 deletions(-) delete mode 100644 cmake/HtmlLayoutProbe.js delete mode 100644 resources/assets/ui-icons.png delete mode 100644 resources/assets/ui-symbols-1x.png delete mode 100644 resources/assets/ui-symbols-4x.png delete mode 100644 resources/linux/bongo-cat.desktop diff --git a/.github/gcc-problem-matcher.json b/.github/gcc-problem-matcher.json index 7e293929..821ede71 100644 --- a/.github/gcc-problem-matcher.json +++ b/.github/gcc-problem-matcher.json @@ -1,7 +1,7 @@ { "problemMatcher": [ { - "owner": "bongo-cat-gcc", + "owner": "bongocat-gcc", "pattern": [ { "regexp": "^(.+?):(\\d+):(\\d+):\\s+(warning|error|fatal error):\\s+(.*)$", diff --git a/.github/scripts/build-unix.sh b/.github/scripts/build-unix.sh index 123aaf46..f6e76570 100644 --- a/.github/scripts/build-unix.sh +++ b/.github/scripts/build-unix.sh @@ -2,7 +2,7 @@ set -uo pipefail -log="${RUNNER_TEMP:-.}/bongo-cat-native-build.log" +log="${RUNNER_TEMP:-.}/bongocat-native-build.log" set +e "$@" 2>&1 | tee "$log" status=${PIPESTATUS[0]} diff --git a/.github/scripts/build-windows.ps1 b/.github/scripts/build-windows.ps1 index 8165f4df..9c16731a 100644 --- a/.github/scripts/build-windows.ps1 +++ b/.github/scripts/build-windows.ps1 @@ -4,7 +4,7 @@ param( ) $tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { '.' } -$log = Join-Path $tempRoot 'bongo-cat-native-build.log' +$log = Join-Path $tempRoot 'bongocat-native-build.log' $executable = $BuildCommand[0] $arguments = if ($BuildCommand.Count -gt 1) { $BuildCommand[1..($BuildCommand.Count - 1)] } else { @() } diff --git a/.github/scripts/check-publish-guards.ps1 b/.github/scripts/check-publish-guards.ps1 index 22b93416..63b88fbc 100644 --- a/.github/scripts/check-publish-guards.ps1 +++ b/.github/scripts/check-publish-guards.ps1 @@ -21,7 +21,7 @@ $runtimeArtifactMarkers = @( 'Upload Microsoft Store MSIX', 'Upload Windows desktop release', 'Upload Windows portable release', - 'name: bongo-cat-release-', + 'name: bongocat-release-', 'softprops/action-gh-release' ) diff --git a/.github/scripts/test-windows.ps1 b/.github/scripts/test-windows.ps1 index 525037e4..77f8c65d 100644 --- a/.github/scripts/test-windows.ps1 +++ b/.github/scripts/test-windows.ps1 @@ -4,8 +4,8 @@ param( ) $tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { '.' } -$log = Join-Path $tempRoot 'bongo-cat-native-tests.log' -$junit = Join-Path $tempRoot 'bongo-cat-native-tests.xml' +$log = Join-Path $tempRoot 'bongocat-native-tests.log' +$junit = Join-Path $tempRoot 'bongocat-native-tests.xml' $executable = $TestCommand[0] $arguments = if ($TestCommand.Count -gt 1) { $TestCommand[1..($TestCommand.Count - 1)] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b033103..38b877e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ permissions: contents: read concurrency: - group: bongo-cat-ci-${{ github.workflow }}-${{ github.ref }} + group: bongocat-ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: @@ -235,7 +235,7 @@ jobs: run: | $projectVersion = & ./packaging/get-project-version.ps1 $version = $projectVersion.AppVersion - $name = (Get-Content build-package/bongo-cat-package-name.txt -Raw).Trim() + $name = (Get-Content build-package/bongocat-package-name.txt -Raw).Trim() $platform = "${{ matrix.platform }}" if ($name -notmatch "^BongoCat-$([regex]::Escape($version))-$platform$") { throw "Cubism desktop package is not production-ready: $name" @@ -254,7 +254,7 @@ jobs: - name: Upload desktop release uses: actions/upload-artifact@v7 with: - name: bongo-cat-vt-${{ matrix.platform }}-archive-${{ github.run_id }}-${{ github.run_attempt }} + name: bongocat-vt-${{ matrix.platform }}-archive-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ steps.package.outputs.file }} if-no-files-found: error retention-days: 30 @@ -344,7 +344,7 @@ jobs: id: desktop-package shell: pwsh run: | - $name = (Get-Content build-store/bongo-cat-package-name.txt -Raw).Trim() + $name = (Get-Content build-store/bongocat-package-name.txt -Raw).Trim() $expected = "BongoCat-${{ steps.version.outputs.version }}-${{ matrix.platform }}" if ($name -ne $expected) { throw "Cubism desktop package is not production-ready: $name" @@ -392,7 +392,7 @@ jobs: if: matrix.store_arch == 'x64' uses: actions/upload-artifact@v7 with: - name: bongo-cat-vt-windows-x64-msix-${{ github.run_id }}-${{ github.run_attempt }} + name: bongocat-vt-windows-x64-msix-${{ github.run_id }}-${{ github.run_attempt }} path: store-output/bongocat_${{ steps.version.outputs.version }}_${{ matrix.store_arch }}.msix if-no-files-found: error retention-days: 90 @@ -402,7 +402,7 @@ jobs: - name: Upload Windows desktop release uses: actions/upload-artifact@v7 with: - name: bongo-cat-vt-${{ matrix.platform }}-setup-${{ github.run_id }}-${{ github.run_attempt }} + name: bongocat-vt-${{ matrix.platform }}-setup-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ steps.desktop-package.outputs.file }} if-no-files-found: error retention-days: 30 @@ -412,7 +412,7 @@ jobs: - name: Upload Windows portable release uses: actions/upload-artifact@v7 with: - name: bongo-cat-vt-${{ matrix.platform }}-portable-${{ github.run_id }}-${{ github.run_attempt }} + name: bongocat-vt-${{ matrix.platform }}-portable-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ steps.desktop-package.outputs.portable }} if-no-files-found: error retention-days: 30 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 544fd228..8d40e941 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,7 @@ permissions: contents: read concurrency: - group: bongo-cat-release-${{ github.ref }} + group: bongocat-release-${{ github.ref }} cancel-in-progress: false jobs: @@ -96,7 +96,7 @@ jobs: - name: Package release shell: pwsh run: | - $name = (Get-Content build-release/bongo-cat-package-name.txt -Raw).Trim() + $name = (Get-Content build-release/bongocat-package-name.txt -Raw).Trim() $expected = "BongoCat-${{ steps.version.outputs.version }}-${{ matrix.platform }}" if ($name -ne $expected) { throw "Unexpected release package name: $name" @@ -131,7 +131,7 @@ jobs: - name: Upload packaged release uses: actions/upload-artifact@v7 with: - name: bongo-cat-release-${{ matrix.platform }} + name: bongocat-release-${{ matrix.platform }} path: release-output/* if-no-files-found: error retention-days: 7 @@ -152,7 +152,7 @@ jobs: - name: Download packaged releases uses: actions/download-artifact@v8 with: - pattern: bongo-cat-release-windows-* + pattern: bongocat-release-windows-* path: vt-input merge-multiple: true @@ -208,7 +208,7 @@ jobs: - name: Download packaged releases uses: actions/download-artifact@v8 with: - pattern: bongo-cat-release-windows-* + pattern: bongocat-release-windows-* path: release-output merge-multiple: true diff --git a/cmake/ApplicationResources.cmake b/cmake/ApplicationResources.cmake index 6935ea71..a4b8af15 100644 --- a/cmake/ApplicationResources.cmake +++ b/cmake/ApplicationResources.cmake @@ -22,7 +22,7 @@ if(WIN32) _CRT_SECURE_NO_WARNINGS) target_link_libraries(bongo_cat_asset_packer PRIVATE bongo_cat_warnings) set(BONGO_CAT_ASSET_STAGE "${CMAKE_CURRENT_BINARY_DIR}/embedded-assets") - set(BONGO_CAT_ASSET_PACK "${CMAKE_CURRENT_BINARY_DIR}/bongo-cat-assets.pak") + set(BONGO_CAT_ASSET_PACK "${CMAKE_CURRENT_BINARY_DIR}/bongocat-assets.pak") file(GLOB_RECURSE BONGO_CAT_ASSET_INPUTS CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/resources/assets/*") bongo_cat_configure_embedded_cubism_assets() diff --git a/cmake/CubismAssets.cmake b/cmake/CubismAssets.cmake index bdcac960..718d2e1b 100644 --- a/cmake/CubismAssets.cmake +++ b/cmake/CubismAssets.cmake @@ -13,12 +13,6 @@ function(bongo_cat_configure_embedded_cubism_assets) endfunction() function(bongo_cat_stage_cubism_assets target) - if(APPLE) - add_custom_command(TARGET ${target} POST_BUILD COMMAND ${CMAKE_COMMAND} -E rm -f - "$/assets/ui-icons.png" - "$/assets/ui-symbols-1x.png" - "$/assets/ui-symbols-4x.png") - endif() if(NOT BONGO_CAT_CUBISM_ENABLED) return() endif() diff --git a/cmake/HtmlLayoutProbe.js b/cmake/HtmlLayoutProbe.js deleted file mode 100644 index cf3c5d12..00000000 --- a/cmake/HtmlLayoutProbe.js +++ /dev/null @@ -1,100 +0,0 @@ -import { spawn } from "node:child_process"; -import { resolve } from "node:path"; - -const endpoint = process.argv[2] || "http://127.0.0.1:9231"; -const debugPort = new URL(endpoint).port || "9231"; -const edge = process.argv[3] || - "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe"; -const profile = resolve(process.argv[4] || `build-final/layout-cdp-profile-${process.pid}`); -const reference = process.argv[5] || "http://127.0.0.1:8765/index.html#model"; - -const browser = spawn(edge, ["--headless=new", "--disable-gpu", - "--no-first-run", "--no-default-browser-check", - `--remote-debugging-port=${debugPort}`, "--remote-allow-origins=*", - `--user-data-dir=${profile}`, "--window-size=934,773", reference], - { stdio: "ignore" }); - -let version; -for (let attempt = 0; attempt < 100 && !version; attempt++) { - try { version = await (await fetch(`${endpoint}/json/version`)).json(); } - catch { await new Promise(resolve => setTimeout(resolve, 100)); } -} -if (!version) { - browser.kill(); - throw new Error("Browser debug endpoint did not start"); -} - -await new Promise(resolve => setTimeout(resolve, 800)); -const targets = await (await fetch(`${endpoint}/json`)).json(); -const target = targets.find(item => item.type === "page" && - item.url.includes("127.0.0.1:8765")); -if (!target) throw new Error("No browser page target found"); - -const socket = new WebSocket(target.webSocketDebuggerUrl); -const expression = String.raw`(async () => { - const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); - for (let attempt = 0; attempt < 50 && - !document.querySelector('[data-page="2"]'); attempt++) await delay(100); - const snapshot = selectors => Object.fromEntries(selectors.map(selector => [ - selector, [...document.querySelectorAll(selector)].map(element => { - const rect = element.getBoundingClientRect(); - const style = getComputedStyle(element); - return { - x: rect.x, y: rect.y, width: rect.width, height: rect.height, - font: style.font, color: style.color, background: style.background, - border: style.border, radius: style.borderRadius, shadow: style.boxShadow - }; - }) - ])); - const common = [".bongocat-surface", ".side-nav", ".brand-logo-wrap", ".menu-item", - ".content-header", ".page-scroll", ".section-title"]; - const pages = {}; - document.querySelector('[data-page="2"]').click(); - await delay(300); - pages.model = snapshot([...common, ".model-grid", ".upload-card", ".model-card", - ".model-preview", ".model-info", ".card-actions", ".card-action"]); - document.querySelector('[data-action="open-behavior"]')?.click(); - await delay(100); - pages.modal = snapshot([".modal-mask", ".modal", ".modal-head", ".modal-close", - ".modal-body", ".segmented", ".segment", ".behavior-list", - ".behavior-row", ".shortcut-editor", ".behavior-play-button"]); - document.querySelector('[data-page="4"]').click(); - await delay(300); - pages.support = snapshot([...common, ".support-page", ".support-hero", - ".support-hero-logo-wrap", ".github-star", ".support-hero h2", ".support-byline", - ".support-hero p", ".support-hero-footer", ".support-version", ".btn-primary", - ".feedback-link", ".support-section", ".support-section-title", - ".support-section-subtitle", ".support-icon-list", ".support-icon-link", - ".support-app-icon"]); - return pages; -})()`; - -const result = await new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error("Browser probe timed out")), 10000); - socket.addEventListener("open", () => socket.send(JSON.stringify({ - id: 1, - method: "Runtime.evaluate", - params: { expression, awaitPromise: true, returnByValue: true } - }))); - socket.addEventListener("message", event => { - const message = JSON.parse(event.data); - if (message.id !== 1) return; - clearTimeout(timeout); - if (message.error) reject(new Error(JSON.stringify(message.error))); - else if (message.result.exceptionDetails) reject(new Error( - JSON.stringify(message.result.exceptionDetails))); - else resolve(message.result.result.value); - socket.close(); - }); - socket.addEventListener("error", reject); -}); - -process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); -const browserSocket = new WebSocket(version.webSocketDebuggerUrl); -await new Promise(resolve => { - browserSocket.addEventListener("open", () => browserSocket.send(JSON.stringify({ - id: 2, method: "Browser.close" - }))); - browserSocket.addEventListener("close", resolve); - browserSocket.addEventListener("error", resolve); -}); diff --git a/cmake/Packaging.cmake b/cmake/Packaging.cmake index 07a1e451..39a458ea 100644 --- a/cmake/Packaging.cmake +++ b/cmake/Packaging.cmake @@ -10,16 +10,14 @@ if(NOT BONGO_CAT_CUBISM_ENABLED) COMPONENT Runtime) endif() if(UNIX AND NOT APPLE) - install(DIRECTORY resources/assets DESTINATION . COMPONENT Runtime - PATTERN "ui-icons.png" EXCLUDE - PATTERN "ui-symbols-1x.png" EXCLUDE PATTERN "ui-symbols-4x.png" EXCLUDE) + install(DIRECTORY resources/assets DESTINATION . COMPONENT Runtime) endif() include(cmake/PackagingPlatform.cmake) set(BONGO_CAT_PACKAGE_NAME "${BONGO_CAT_PACKAGE_PRODUCT}-${PROJECT_VERSION}-${BONGO_CAT_PACKAGE_PLATFORM}") -file(GENERATE OUTPUT "${CMAKE_BINARY_DIR}/bongo-cat-package-name.txt" +file(GENERATE OUTPUT "${CMAKE_BINARY_DIR}/bongocat-package-name.txt" CONTENT "${BONGO_CAT_PACKAGE_NAME}\n") set(CPACK_PACKAGE_NAME "${BONGO_CAT_PACKAGE_PRODUCT}") diff --git a/include/bongo_cat/common.h b/include/bongo_cat/common.h index 7994c51e..b5d3c962 100644 --- a/include/bongo_cat/common.h +++ b/include/bongo_cat/common.h @@ -10,7 +10,7 @@ #define BONGO_CAT_NAME "BongoCat" #define BONGO_CAT_PET_WINDOW_TITLE BONGO_CAT_NAME #define BONGO_CAT_TRAY_TITLE BONGO_CAT_NAME " " BONGO_CAT_VERSION_DISPLAY " \xC2\xB7 by vladelaina" -#define BONGO_CAT_SLUG "bongo-cat" +#define BONGO_CAT_SLUG "bongocat" #define BONGO_CAT_EXECUTABLE "BongoCat" #define BONGO_CAT_APP_ID "com.bongocat.desktop" #define BONGO_CAT_NAME_W L"BongoCat" diff --git a/resources/assets/ui-icons.png b/resources/assets/ui-icons.png deleted file mode 100644 index bef5a002ee4c69935c6ef35797d0905eddb63801..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1272 zcmV5Tj@Uj{yX#FaJ@6nJoBtyF7t;!XXc!9@1414?wxzj z%rL7EG=^D)piu>Q2vDolM#9-}S1m>_ya|@-U&?jD;|utWg4^K}qOLLULzRPl952m!NYJ+-X2Db8{hS};^NXxPDbS%4EDq{b1>Ae! zBT*Fn+Mv!2h)IrmCFN##e0L&FOsV?eXng-m`vo_Gx$%dSY4R(03ygAnPlm6+y>JH{ zTcG|3KE&boRxG%}RJfIL*XPvRZifa}n2zldkN#Y^E;-%^JKf+4L5<)fK<+3}Al`xh zJNi@L&2V^1w~S7D(DK`O8*!bLheq;ZPrbU1Kj2Z0X&(MduwRMX?35Xe_z_OT_KUBv z&;on`;&UsHjdax2hp~P^4_XK9{O+lL4&v#Qsvg|A9P6Q2#rbq=0-;UOt=`WO4eS@? z*xrTed`;UkE&D+j0nf{@ErqWp$8Kacm#QqX3tO*rp^e~g;Fneo19ouq zWMPCyx6=)-kaYfi=Eukk{b0EyxSA~%XSivGSdm^pCfg)!&Ds+=h@b+j5r7zF<)FS8 zu;#JNvajbagm3ZF@ER%mDPl>Ei(UsW*Mp3|7HAj3UGU<%Q&0isW=}($ol@k8YfjE1qjohreL{(b{iKfFR#vjf4EAFUUdcgSj!i z>wdO)YjS6XoASkL;7Jx|mVDR(x5H~;HyyjiY3EVIqKvZICaq7dLrIidVONIlHPpSq zXP^x&7HxcP%$Q1>cDrf1Od)Q}{fF zPlqpWoTLmtw$`v8j-~$HJ|k^>fjHmdjNEwq`cpQ=YbCrZr~XBhnO{E%CX>a@t5e>C znp(9VZnNf~%+j%NH67HQ(~k1LH(Gf-PW?~j)USv26F3FG4?K3gR#(H@GpbOgtc|k& z>LjE^mJ;on_OyyV%i`&>2>L7@5n7;Wf4QfDzw*~gKs39h+6yP=1y{hX-1!7wa1*=Q z)obXDj4Jl0uPx}$g~_%_??SC~^^LaE;w{n-P??_3;9I`0Fa_#9!{X0{MZWjG4(HI$ zZ#G1reWg{E(73@dEu@_%>L=Cr;1)qgPqp|1fiI1zt(a1Zo9r+k39|P1v!SNs`Ly*+ z+NTQe5J11qSe$fNVDVoa3hY9Q7j|W`W6icd_;ArypKGfE95||fvI55e00030|B6jH iXaE2J21!IgR09C2r{=hY9kxCI0000m&$&X#X)PPi$r;B4q#hf=+40#z0cn)mH zlB(aa*IQ{h@1go~_J(uP3_(e(9i5^IYGDmCA{dj%=ad00`~Tm@CZGt&U<298%pk$T VESjSl1#}n#gQu&X%Q~loCII*;GM)ec diff --git a/resources/assets/ui-symbols-4x.png b/resources/assets/ui-symbols-4x.png deleted file mode 100644 index 3ebbc8e4fd009a8089b428d66aebddedf743116a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 811 zcmeAS@N?(olHy`uVBq!ia0y~yU{?UL6F8WF @@ -164,7 +165,8 @@ bool bongo_cat_linux_x11_start(BongoCatPlatform *platform, BongoCatError *error) atomic_init(&state->running, true); atomic_init(&state->supported, false); platform->native = state; if (!display || !window) return true; - state->thread = SDL_CreateThread(input_thread, "bongo-cat-x11-input", state); + state->thread = SDL_CreateThread(input_thread, + BONGO_CAT_SLUG "-x11-input", state); if (!state->thread) { bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, "Cannot start X11 input listener"); return false; diff --git a/src/platform/macos/macos.m b/src/platform/macos/macos.m index 7a865876..d02ba51f 100644 --- a/src/platform/macos/macos.m +++ b/src/platform/macos/macos.m @@ -261,7 +261,8 @@ void bongo_cat_platform_set_tray_callbacks(void *tray, NSEventMaskRightMouseUp]; } bool bongo_cat_platform_single_instance_begin(void) { - char path[96]; snprintf(path, sizeof(path), "/tmp/bongo-cat-%lu.lock", + char path[96]; snprintf(path, sizeof(path), + "/tmp/" BONGO_CAT_SLUG "-%lu.lock", (unsigned long)getuid()); instance_lock = open(path, O_CREAT | O_RDWR, 0600); if (instance_lock < 0 || flock(instance_lock, LOCK_EX | LOCK_NB) == 0) { diff --git a/src/platform/macos/macos_input.m b/src/platform/macos/macos_input.m index 9ae7f792..556ba258 100644 --- a/src/platform/macos/macos_input.m +++ b/src/platform/macos/macos_input.m @@ -137,7 +137,7 @@ bool bongo_cat_macos_input_start(BongoCatPlatform *platform, BongoCatError *erro atomic_init(&state->stop_requested, false); state->ready = SDL_CreateSemaphore(0); state->thread = state->ready ? SDL_CreateThread(input_thread, - "bongo-cat-macos-input", state) : NULL; + BONGO_CAT_SLUG "-macos-input", state) : NULL; if (!state->ready || !state->thread) { bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, "Cannot start the macOS input listener"); diff --git a/src/runtime/model/model_catalog_refresh.c b/src/runtime/model/model_catalog_refresh.c index d90dd282..85345dd8 100644 --- a/src/runtime/model/model_catalog_refresh.c +++ b/src/runtime/model/model_catalog_refresh.c @@ -90,7 +90,7 @@ static bool start_refresh(BongoCatApp *app) { refresh->busy = true; refresh->rerun = false; refresh->worker = SDL_CreateThread(refresh_worker, - "bongo-cat-model-catalog", job); + BONGO_CAT_SLUG "-model-catalog", job); if (refresh->worker) return true; refresh->busy = false; free(job); diff --git a/src/runtime/update/update_service.c b/src/runtime/update/update_service.c index 971e9789..b8a2aa8b 100644 --- a/src/runtime/update/update_service.c +++ b/src/runtime/update/update_service.c @@ -119,7 +119,7 @@ bool bongo_cat_update_check(BongoCatUpdateService *service, bool manual) { service->http_cancelled = false; SDL_UnlockMutex(service->http_mutex); service->worker = SDL_CreateThread(update_worker, - "bongo-cat-update-check", service); + BONGO_CAT_SLUG "-update-check", service); bool started = service->worker != NULL; if (!started) { service->status = BONGO_CAT_UPDATE_ERROR; diff --git a/src/tools/asset_packer.c b/src/tools/asset_packer.c index af3187c0..b8acf3df 100644 --- a/src/tools/asset_packer.c +++ b/src/tools/asset_packer.c @@ -93,12 +93,6 @@ static int read_file(const wchar_t *path, unsigned char **data, size_t *size) { return ok; } -static int excluded_path(const char *path) { - return strcmp(path, "assets/ui-icons.png") == 0 || - strcmp(path, "assets/ui-symbols-1x.png") == 0 || - strcmp(path, "assets/ui-symbols-4x.png") == 0; -} - static int append_asset(AssetList *list, const wchar_t *file, const char *relative) { Asset *asset; @@ -112,7 +106,6 @@ static int append_asset(AssetList *list, const wchar_t *file, if (!path) { free(data); return 0; } memcpy(path, "assets/", 7); memcpy(path + 7, relative, relative_length + 1); - if (excluded_path(path)) { free(path); free(data); return 1; } if (list->count == list->capacity && !reserve_assets(list)) { free(path); free(data); return 0; } diff --git a/src/ui/preferences/preferences_about.c b/src/ui/preferences/preferences_about.c index 06987521..ca736cc6 100644 --- a/src/ui/preferences/preferences_about.c +++ b/src/ui/preferences/preferences_about.c @@ -78,26 +78,24 @@ static void image_contain(struct nk_command_buffer *canvas, unsigned int texture struct nk_image image = nk_image_id((int)texture); nk_draw_image(canvas, target, &image, nk_rgb(255, 255, 255)); } -static void logo(BongoCatPreferences *value, struct nk_context *context, +static void support_logo(BongoCatPreferences *value, + struct nk_context *context, struct nk_command_buffer *canvas, struct nk_rect bounds, BongoCatUIPalette p) { bool hover = nk_input_is_mouse_hovering_rect(&context->input, bounds); float lift = bongo_cat_ui_animate_eased(context, "support-logo-hover", hover ? 1.0f : 0.0f, 250.0f, BONGO_CAT_UI_EASE_SPRING); - struct nk_rect raised = bounds; raised.y -= 5.0f * lift; - if (p.effects) { - bongo_cat_ui_paint_shadow(context, raised, 36, 0, 18, 42, 0, - nk_rgba(p.accent.r, p.accent.g, p.accent.b, 89)); - bongo_cat_ui_paint_shadow(context, raised, 36, 0, 8, 22, 0, - nk_rgba(p.pink.r, p.pink.g, p.pink.b, 89)); - bongo_cat_ui_paint_gradient(context, raised, 36, p.accent, p.pink); - } else nk_fill_rect(canvas, raised, 36, p.accent); - struct nk_rect inner = nk_rect(raised.x + 5, raised.y + 5, - raised.w - 10, raised.h - 10); - nk_fill_rect(canvas, inner, 31, p.surface); + float scale = 1.0f + .04f * lift; + struct nk_rect raised = nk_rect( + bounds.x + (bounds.w - bounds.w * scale) * .5f, + bounds.y - 5.0f * lift + (bounds.h - bounds.h * scale) * .5f, + bounds.w * scale, bounds.h * scale); + /* The logo asset is the complete brand mark; avoid wrapping it in a + second card so the support page and sidebar share the same identity. */ + if (p.effects) bongo_cat_ui_paint_shadow(context, raised, 42, 0, 10, 24, 0, + nk_rgba(p.pink.r, p.pink.g, p.pink.b, 70)); image_contain(canvas, value->logo_texture, value->logo_width, - value->logo_height, nk_rect(inner.x + 12, inner.y + 12, - inner.w - 24, inner.h - 24)); + value->logo_height, raised); link_cursor(context, bounds); if (hit(context, bounds)) open_url("https://bongocat.pet"); } @@ -154,7 +152,7 @@ static void hero_title(BongoCatPreferences *value, struct nk_context *context, void bongo_cat_preferences_about_hero(BongoCatPreferences *value, struct nk_context *context) { struct nk_rect bounds; - nk_layout_row_dynamic(context, 320, 1); + nk_layout_row_dynamic(context, 340, 1); if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return; BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); struct nk_command_buffer *canvas = nk_window_get_canvas(context); @@ -169,21 +167,22 @@ void bongo_cat_preferences_about_hero(BongoCatPreferences *value, } star_button(value, context, canvas, nk_rect(bounds.x + bounds.w - 188, bounds.y + 6, 177, 46), p); - logo(value, context, canvas, nk_rect(bounds.x + (bounds.w - 144) * .5f + 3, - bounds.y + 18, 144, 144), p); - hero_title(value, context, canvas, nk_rect(bounds.x + 3, bounds.y + 178, + support_logo(value, context, canvas, + nk_rect(bounds.x + (bounds.w - 168) * .5f + 3, + bounds.y + 10, 168, 168), p); + hero_title(value, context, canvas, nk_rect(bounds.x + 3, bounds.y + 190, bounds.w, 36), p); bongo_cat_preferences_about_localized_link(value, context, canvas, - nk_rect(bounds.x, bounds.y + 214, bounds.w, 24), + nk_rect(bounds.x, bounds.y + 226, bounds.w, 24), "native.support.website", "Website: bongocat.pet", "https://bongocat.pet", "support-website-hover", p); - centered_wrapped(canvas, nk_rect(bounds.x + 36, bounds.y + 242, + centered_wrapped(canvas, nk_rect(bounds.x + 36, bounds.y + 254, bounds.w - 72, 40), tr(value, "native.support.heroText", "Thank you for your support. Every use and share helps BongoCat grow."), value->ui.caption_font, p.muted); bongo_cat_preferences_about_footer(value, context, canvas, - nk_rect(bounds.x, bounds.y + 286, bounds.w, 34), p); + nk_rect(bounds.x, bounds.y + 300, bounds.w, 34), p); } static void project(BongoCatPreferences *value, struct nk_context *context, struct nk_command_buffer *canvas, struct nk_rect bounds, unsigned int texture, diff --git a/src/ui/preferences/preferences_import.c b/src/ui/preferences/preferences_import.c index 49cc7971..104e8384 100644 --- a/src/ui/preferences/preferences_import.c +++ b/src/ui/preferences/preferences_import.c @@ -169,7 +169,8 @@ static bool start_job(BongoCatImportDialog *dialog, BongoCatApp *app, dialog->completed = 0; dialog->total = job->count; ++dialog->references; - dialog->worker = SDL_CreateThread(import_worker, "bongo-cat-model-import", job); + dialog->worker = SDL_CreateThread(import_worker, + BONGO_CAT_SLUG "-model-import", job); if (!dialog->worker) { dialog->worker_job = NULL; dialog->busy = false; diff --git a/src/ui/preferences/preferences_render.c b/src/ui/preferences/preferences_render.c index 90a00aa5..d3eb5b64 100644 --- a/src/ui/preferences/preferences_render.c +++ b/src/ui/preferences/preferences_render.c @@ -75,7 +75,7 @@ static bool draw_shell(BongoCatPreferences *value, struct nk_context *context, bool title_clicked = false; if (nk_group_begin(context, "preferences-sidebar", NK_WINDOW_NO_SCROLLBAR)) { bongo_cat_ui_header(context, "BongoCat", - value->ui.caption_font, value->logo_texture, &title_clicked, !modal, dark, native_chrome); + value->ui.heading_font, value->logo_texture, &title_clicked, !modal, dark, native_chrome); if (title_clicked && !SDL_OpenURL("https://bongocat.pet")) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Cannot open website: %s", SDL_GetError()); bongo_cat_ui_set_icons(draw_icon, value); diff --git a/src/ui/theme/ui_catime.c b/src/ui/theme/ui_catime.c index 22a4a4d2..e2607488 100644 --- a/src/ui/theme/ui_catime.c +++ b/src/ui/theme/ui_catime.c @@ -111,44 +111,35 @@ bool bongo_cat_ui_header(struct nk_context *context, const char *title, const struct nk_user_font *font, unsigned int logo_texture, bool *title_clicked, bool interactive, bool dark, bool native_chrome) { struct nk_rect bounds; - bool narrow = nk_window_get_content_region(context).w < 100; - float height = narrow ? (native_chrome ? 148.0f : 118.0f) : + bool compact = nk_window_get_content_region(context).w < 100; + float height = compact ? (native_chrome ? 148.0f : 118.0f) : (native_chrome ? 176.0f : 148.0f); nk_layout_row_dynamic(context, height, 1); if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return false; BongoCatUIPalette p = bongo_cat_ui_palette(dark); struct nk_command_buffer *canvas = nk_window_get_canvas(context); - float size = bounds.w < 100 ? 54.0f : 72.0f; + /* The current logo asset already contains its own rounded brand mark. + Give it room to read at a glance instead of framing it a second time. */ + float base_size = compact ? 68.0f : 84.0f; bool hover = interactive && nk_input_is_mouse_hovering_rect(&context->input, nk_rect(bounds.x, bounds.y, bounds.w, bounds.h)); float hover_amount = bongo_cat_ui_animate_eased(context, "brand-logo-hover", hover ? 1.0f : 0.0f, 250.0f, BONGO_CAT_UI_EASE_SPRING); - size *= 1.0f + .08f * hover_amount; - float top = native_chrome ? 41.0f : (narrow ? 10.0f : 13.0f); - struct nk_rect frame = nk_rect(bounds.x + (bounds.w - size) * .5f, - bounds.y + top - - (size - (bounds.w < 100 ? 54.0f : 72.0f)) * .5f, size, size); - float rounding = bounds.w < 100 ? 14.0f : 18.0f; - if (p.effects) bongo_cat_ui_paint_shadow(context, frame, rounding, - 0, 5, 16, 0, nk_rgba(p.pink.r, p.pink.g, p.pink.b, 89)); - if (p.effects) bongo_cat_ui_paint_gradient(context, frame, rounding, - p.accent, hover ? p.pink_hover : p.pink); - else nk_fill_rect(canvas, frame, rounding, p.accent); - struct nk_rect inner = nk_rect(frame.x + 3, frame.y + 3, - frame.w - 6, frame.h - 6); - nk_fill_rect(canvas, inner, bounds.w < 100 ? 11.0f : 15.0f, p.surface); + float size = base_size * (1.0f + .08f * hover_amount); + float top = native_chrome ? 34.0f : (compact ? 6.0f : 8.0f); + struct nk_rect logo = nk_rect(bounds.x + (bounds.w - size) * .5f, + bounds.y + top - (size - base_size) * .5f, size, size); + if (p.effects) bongo_cat_ui_paint_shadow(context, logo, + compact ? 17.0f : 21.0f, 0, 4, 14, 0, + nk_rgba(p.pink.r, p.pink.g, p.pink.b, 64)); if (logo_texture) { struct nk_image image = nk_image_id((int)logo_texture); - float image_size = (bounds.w < 100 ? 42.0f : 56.0f) * - (1.0f + .08f * hover_amount); - struct nk_rect image_bounds = nk_rect(frame.x + (size - image_size) * .5f, - frame.y + (size - image_size) * .5f, image_size, image_size); - nk_draw_image(canvas, image_bounds, &image, nk_rgb(255, 255, 255)); + nk_draw_image(canvas, logo, &image, nk_rgb(255, 255, 255)); } if (bounds.w >= 100) { if (!font) font = bongo_cat_ui_caption_font(context); - float title_y = native_chrome ? 116.0f : 88.0f; + float title_y = native_chrome ? 124.0f : 94.0f; centered(canvas, nk_rect(bounds.x, bounds.y + title_y, bounds.w, 28), title, font, p.pink); if (native_chrome) { diff --git a/tests/model_import/test_model_import_identity.c b/tests/model_import/test_model_import_identity.c index 3a630eb8..f0852a31 100644 --- a/tests/model_import/test_model_import_identity.c +++ b/tests/model_import/test_model_import_identity.c @@ -18,7 +18,7 @@ static int failures; static void disabled_mver_live2d_is_not_imported(const char *temporary) { char root[BONGO_CAT_PATH_CAP], config[BONGO_CAT_PATH_CAP]; - snprintf(root, sizeof(root), "%s/bongo-cat-disabled-mver-%llu", temporary, + snprintf(root, sizeof(root), "%s/bongocat-disabled-mver-%llu", temporary, (unsigned long long)SDL_GetTicksNS()); CHECK(mver_fixture(root)); CHECK(child(config, sizeof(config), root, "config.json", false)); @@ -105,9 +105,9 @@ int test_model_import_identity(void) { char source[BONGO_CAT_PATH_CAP], config[BONGO_CAT_PATH_CAP]; char manifest[BONGO_CAT_PATH_CAP], missing[BONGO_CAT_PATH_CAP]; unsigned long long nonce = (unsigned long long)SDL_GetTicksNS(); - snprintf(root, sizeof(root), "%s/bongo-cat-import-identity-%llu", + snprintf(root, sizeof(root), "%s/bongocat-import-identity-%llu", temporary, nonce); - snprintf(data, sizeof(data), "%s/bongo-cat-import-data-%llu", + snprintf(data, sizeof(data), "%s/bongocat-import-data-%llu", temporary, nonce); CHECK(SDL_CreateDirectory(root)); CHECK(SDL_CreateDirectory(data)); diff --git a/tests/model_import/test_mver_container.c b/tests/model_import/test_mver_container.c index e399e3b6..eaa387e6 100644 --- a/tests/model_import/test_mver_container.c +++ b/tests/model_import/test_mver_container.c @@ -22,7 +22,7 @@ void test_mver_container_discovery(void) { CHECK(temporary != NULL); if (!temporary) return; unsigned long long stamp = (unsigned long long)SDL_GetTicksNS(); - snprintf(root, sizeof(root), "%s/bongo-cat-container-%llu", temporary, + snprintf(root, sizeof(root), "%s/bongocat-container-%llu", temporary, stamp); bongo_cat_model_remove_tree(root, NULL); CHECK(SDL_CreateDirectory(root)); @@ -94,7 +94,7 @@ void test_mver_container_discovery(void) { CHECK(patch_count == 1); char installed_root[BONGO_CAT_PATH_CAP]; snprintf(installed_root, sizeof(installed_root), - "%s/bongo-cat-container-installed-%llu", temporary, stamp); + "%s/bongocat-container-installed-%llu", temporary, stamp); BongoCatImportCandidate installed = {0}; CHECK(contained_patch && bongo_cat_import_prepare_package(contained_patch, installed_root, &installed, &error)); @@ -111,7 +111,7 @@ void test_mver_container_discovery(void) { &error) == 1); char external_root[BONGO_CAT_PATH_CAP]; snprintf(external_root, sizeof(external_root), - "%s/bongo-cat-container-external-%llu", temporary, stamp); + "%s/bongocat-container-external-%llu", temporary, stamp); installed = (BongoCatImportCandidate){0}; CHECK(external_patch.count == 1 && bongo_cat_import_prepare_package(&external_patch.candidates[0], @@ -125,9 +125,9 @@ void test_mver_container_discovery(void) { char models_root[BONGO_CAT_PATH_CAP], cache_root[BONGO_CAT_PATH_CAP]; snprintf(models_root, sizeof(models_root), - "%s/bongo-cat-container-models-%llu", temporary, stamp); + "%s/bongocat-container-models-%llu", temporary, stamp); snprintf(cache_root, sizeof(cache_root), - "%s/bongo-cat-container-cache-%llu", temporary, stamp); + "%s/bongocat-container-cache-%llu", temporary, stamp); CHECK(SDL_CreateDirectory(models_root)); CHECK(SDL_CreateDirectory(cache_root)); BongoCatImportReceipt receipt = {0}; diff --git a/tests/model_import/test_mver_import.c b/tests/model_import/test_mver_import.c index 0abccfe0..802ab0b2 100644 --- a/tests/model_import/test_mver_import.c +++ b/tests/model_import/test_mver_import.c @@ -41,7 +41,7 @@ static void labels_from_shortcut_rows(void) { CHECK(temporary != NULL); if (!temporary) return; char root[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; - snprintf(root, sizeof(root), "%s/bongo-cat-labels-%llu", temporary, + snprintf(root, sizeof(root), "%s/bongocat-labels-%llu", temporary, (unsigned long long)SDL_GetTicksNS()); CHECK(SDL_CreateDirectory(root)); CHECK(child(path, sizeof(path), root, "config.json", false)); @@ -70,7 +70,7 @@ static void metadata_backfills_labels(void) { CHECK(temporary != NULL); if (!temporary) return; char root[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; - snprintf(root, sizeof(root), "%s/bongo-cat-metadata-%llu", temporary, + snprintf(root, sizeof(root), "%s/bongocat-metadata-%llu", temporary, (unsigned long long)SDL_GetTicksNS()); CHECK(SDL_CreateDirectory(root)); CHECK(child(path, sizeof(path), root, ".bongo-cat-adapter.json", false)); diff --git a/tests/model_import/test_mver_missing_motion.c b/tests/model_import/test_mver_missing_motion.c index b7857245..f5eb5a0c 100644 --- a/tests/model_import/test_mver_missing_motion.c +++ b/tests/model_import/test_mver_missing_motion.c @@ -20,9 +20,9 @@ int test_mver_missing_motion_groups(void) { unsigned long long stamp = (unsigned long long)SDL_GetTicksNS(); char source[BONGO_CAT_PATH_CAP], data[BONGO_CAT_PATH_CAP]; char path[BONGO_CAT_PATH_CAP]; - snprintf(source, sizeof(source), "%s/bongo-cat-stale-motion-%llu", + snprintf(source, sizeof(source), "%s/bongocat-stale-motion-%llu", temporary, stamp); - snprintf(data, sizeof(data), "%s/bongo-cat-stale-motion-data-%llu", + snprintf(data, sizeof(data), "%s/bongocat-stale-motion-data-%llu", temporary, stamp); CHECK(mver_fixture(source)); CHECK(SDL_CreateDirectory(data)); diff --git a/tests/model_import/test_mver_nearby_identity.c b/tests/model_import/test_mver_nearby_identity.c index 56cd5630..1a8849fc 100644 --- a/tests/model_import/test_mver_nearby_identity.c +++ b/tests/model_import/test_mver_nearby_identity.c @@ -32,9 +32,9 @@ int test_mver_nearby_identity(void) { char source[BONGO_CAT_PATH_CAP], moved[BONGO_CAT_PATH_CAP]; char again[BONGO_CAT_PATH_CAP], duplicate[BONGO_CAT_PATH_CAP]; unsigned long long nonce = (unsigned long long)SDL_GetTicksNS(); - snprintf(root, sizeof(root), "%s/bongo-cat-nearby-move-%llu", + snprintf(root, sizeof(root), "%s/bongocat-nearby-move-%llu", temporary, nonce); - snprintf(data, sizeof(data), "%s/bongo-cat-nearby-data-%llu", + snprintf(data, sizeof(data), "%s/bongocat-nearby-data-%llu", temporary, nonce); CHECK(SDL_CreateDirectory(root)); CHECK(SDL_CreateDirectory(data)); @@ -127,9 +127,9 @@ int test_mver_nearby_refresh(void) { char root[BONGO_CAT_PATH_CAP], data[BONGO_CAT_PATH_CAP]; char startup_source[BONGO_CAT_PATH_CAP], sync_source[BONGO_CAT_PATH_CAP]; char background_source[BONGO_CAT_PATH_CAP]; - snprintf(root, sizeof(root), "%s/bongo-cat-nearby-root-%llu", + snprintf(root, sizeof(root), "%s/bongocat-nearby-root-%llu", temporary, nonce); - snprintf(data, sizeof(data), "%s/bongo-cat-nearby-refresh-data-%llu", + snprintf(data, sizeof(data), "%s/bongocat-nearby-refresh-data-%llu", temporary, nonce); CHECK(SDL_CreateDirectory(root)); CHECK(SDL_CreateDirectory(data)); diff --git a/tests/model_import/test_slim_package.c b/tests/model_import/test_slim_package.c index 6161d22a..e2a69913 100644 --- a/tests/model_import/test_slim_package.c +++ b/tests/model_import/test_slim_package.c @@ -20,9 +20,9 @@ int test_slim_package(void) { char source[BONGO_CAT_PATH_CAP], data[BONGO_CAT_PATH_CAP]; char config[BONGO_CAT_PATH_CAP], slim[BONGO_CAT_PATH_CAP]; unsigned long long nonce = (unsigned long long)SDL_GetTicksNS(); - snprintf(source, sizeof(source), "%s/bongo-cat-slim-%llu", + snprintf(source, sizeof(source), "%s/bongocat-slim-%llu", temporary, nonce); - snprintf(data, sizeof(data), "%s/bongo-cat-slim-data-%llu", + snprintf(data, sizeof(data), "%s/bongocat-slim-data-%llu", temporary, nonce); CHECK_SLIM(mver_fixture(source)); CHECK_SLIM(SDL_CreateDirectory(data)); diff --git a/tests/model_import/test_tauri_portable.c b/tests/model_import/test_tauri_portable.c index 9f2c89cc..a033226c 100644 --- a/tests/model_import/test_tauri_portable.c +++ b/tests/model_import/test_tauri_portable.c @@ -30,7 +30,7 @@ void test_tauri_portable(void) { if (!temporary) return; char root[BONGO_CAT_PATH_CAP], model[BONGO_CAT_PATH_CAP]; char path[BONGO_CAT_PATH_CAP]; - snprintf(root, sizeof(root), "%s/bongo-cat-tauri-%llu", temporary, + snprintf(root, sizeof(root), "%s/bongocat-tauri-%llu", temporary, (unsigned long long)SDL_GetTicksNS()); CHECK(SDL_CreateDirectory(root)); CHECK(child(model, sizeof(model), root, "keyboard", true)); From 1fb7cb33764ebfd6f62e3af901061c710adb6851 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 29 Aug 2026 10:32:38 +0800 Subject: [PATCH 140/334] fix: add Store desktop shortcut and right-click handling --- packaging/microsoft-store/AppxManifest.xml.in | 14 +++++++- packaging/microsoft-store/README.md | 7 ++++ .../validate-store-package.ps1 | 23 +++++++++++++ src/platform/windows/windows_borderless.c | 33 ++++++++++++++++--- src/platform/windows/windows_borderless.h | 5 ++- src/platform/windows/windows_keys.c | 4 +++ src/platform/windows/windows_keys.h | 1 + src/platform/windows/windows_pointer.c | 4 +-- tests/core/test_config.c | 1 + tests/platform/test_windows_capture.c | 15 +++++++-- 10 files changed, 96 insertions(+), 11 deletions(-) diff --git a/packaging/microsoft-store/AppxManifest.xml.in b/packaging/microsoft-store/AppxManifest.xml.in index d041bbc4..5a174cd4 100644 --- a/packaging/microsoft-store/AppxManifest.xml.in +++ b/packaging/microsoft-store/AppxManifest.xml.in @@ -2,8 +2,9 @@ + IgnorableNamespaces="uap desktop7 rescap"> + + + + + diff --git a/packaging/microsoft-store/README.md b/packaging/microsoft-store/README.md index 3fc00c0a..9be53df8 100644 --- a/packaging/microsoft-store/README.md +++ b/packaging/microsoft-store/README.md @@ -20,6 +20,13 @@ and package SID are derived by Windows and must not be used as signing secrets. The Store package is x64; the separate Windows x86 workflow target only publishes its desktop installer and portable executable. +The manifest declares a system-managed `BongoCat.lnk` on the current user's +desktop through the `desktop7:windows.shortcut` extension. Windows creates the +link during package registration and removes it during package removal. The +shortcut extension is supported starting with Windows build 19645; older +supported Windows releases ignore that extension while continuing to install +and run the app. + ## App data isolation Ordinary EXE, installer, and portable launches store data under diff --git a/packaging/microsoft-store/validate-store-package.ps1 b/packaging/microsoft-store/validate-store-package.ps1 index 830162c0..701bf88e 100644 --- a/packaging/microsoft-store/validate-store-package.ps1 +++ b/packaging/microsoft-store/validate-store-package.ps1 @@ -110,6 +110,29 @@ try { throw 'Manifest application entry does not describe the BongoCat full-trust executable.' } + $namespaceManager = [Xml.XmlNamespaceManager]::new($manifest.NameTable) + $namespaceManager.AddNamespace( + 'desktop7', + 'http://schemas.microsoft.com/appx/manifest/desktop/windows10/7') + $shortcutExtension = $manifest.SelectSingleNode( + "//*[local-name()='Application']/*[local-name()='Extensions']/" + + "desktop7:Extension[@Category='windows.shortcut']", + $namespaceManager) + $shortcut = if ($shortcutExtension) { + $shortcutExtension.SelectSingleNode('desktop7:Shortcut', $namespaceManager) + } + else { + $null + } + if (-not $shortcutExtension -or + $shortcutExtension.Executable -ne 'BongoCat.exe' -or + $shortcutExtension.EntryPoint -ne 'Windows.FullTrustApplication' -or + -not $shortcut -or + $shortcut.File -ne '$(Desktop)\BongoCat.lnk' -or + $shortcut.Icon -ne 'BongoCat.exe') { + throw 'Manifest does not declare the expected system-managed desktop shortcut.' + } + $requiredFiles = @( 'BongoCat.exe', 'Assets\StoreLogo.png', diff --git a/src/platform/windows/windows_borderless.c b/src/platform/windows/windows_borderless.c index 03c5e147..eaad98fe 100644 --- a/src/platform/windows/windows_borderless.c +++ b/src/platform/windows/windows_borderless.c @@ -1,5 +1,6 @@ #include "windows_borderless.h" #include "windows_capture.h" +#include "windows_keys.h" #include "windows_tray.h" #ifdef _WIN32 @@ -12,6 +13,13 @@ static const wchar_t drag_binding_property[] = L"BongoCat.DragBinding"; #define BONGO_CAT_MENU_PREVIEW_TIMER ((UINT_PTR)0xBC4E) #define BONGO_CAT_DRAG_MODAL_TIMER ((UINT_PTR)0xBC50) #define BONGO_CAT_DRAG_FRAME_INTERVAL_MS 16 +#define BONGO_CAT_CLICK_THROUGH_FORCED ((INT_PTR)1) +#define BONGO_CAT_CLICK_THROUGH_TRANSPARENT_PIXEL ((INT_PTR)2) + +bool bongo_cat_windows_borderless_hit_transparent(bool forced, + bool pointer_transparent, bool right_button_down) { + return forced || (pointer_transparent && !right_button_down); +} typedef struct WindowsMenuBinding { BongoCatMenuPreview preview; @@ -50,8 +58,18 @@ static LRESULT CALLBACK borderless_window_proc(HWND window, UINT message, WindowsDragBinding *drag = GetPropW(window, drag_binding_property); if (bongo_cat_windows_capture_handle_message(window, message, wparam)) { return 0; - } else if (message == WM_NCHITTEST && GetPropW(window, click_through_property)) { - return HTTRANSPARENT; + } else if (message == WM_NCHITTEST) { + INT_PTR click_through = (INT_PTR)GetPropW( + window, click_through_property); + if (bongo_cat_windows_borderless_hit_transparent( + click_through == BONGO_CAT_CLICK_THROUGH_FORCED, + click_through == BONGO_CAT_CLICK_THROUGH_TRANSPARENT_PIXEL, + bongo_cat_windows_right_button_down() || + GetCapture() == window)) return HTTRANSPARENT; + } else if (message == WM_RBUTTONDOWN && + (INT_PTR)GetPropW(window, click_through_property) == + BONGO_CAT_CLICK_THROUGH_TRANSPARENT_PIXEL) { + SetCapture(window); } else if (message == WM_SYSCOMMAND && (wparam & 0xFFF0u) == SC_MINIMIZE) { return 0; @@ -89,6 +107,8 @@ static LRESULT CALLBACK borderless_window_proc(HWND window, UINT message, } LRESULT result = CallWindowProcW(original ? original : DefWindowProcW, window, message, wparam, lparam); + if (message == WM_RBUTTONUP && GetCapture() == window) + ReleaseCapture(); /* Move messages keep frames flowing when the low-priority timer is starved. */ if (drag && (message == WM_MOUSEMOVE || message == WM_NCMOUSEMOVE)) drag_tick(drag); @@ -161,12 +181,15 @@ void bongo_cat_windows_borderless_uninstall(HWND window) { RemovePropW(window, click_through_property); } -void bongo_cat_windows_borderless_set_click_through(HWND window, bool enabled) { +void bongo_cat_windows_borderless_set_click_through(HWND window, + bool forced, bool pointer_transparent) { if (!window) return; - if (enabled) SetPropW(window, click_through_property, (HANDLE)(INT_PTR)1); + INT_PTR mode = forced ? BONGO_CAT_CLICK_THROUGH_FORCED : + pointer_transparent ? BONGO_CAT_CLICK_THROUGH_TRANSPARENT_PIXEL : 0; + if (mode) SetPropW(window, click_through_property, (HANDLE)mode); else RemovePropW(window, click_through_property); LONG_PTR style = GetWindowLongPtrW(window, GWL_EXSTYLE); - LONG_PTR next = enabled ? style | WS_EX_TRANSPARENT : + LONG_PTR next = forced ? style | WS_EX_TRANSPARENT : style & ~WS_EX_TRANSPARENT; /* The hit-test property is immediate; a frame refresh flickers OpenGL windows. */ if (next != style) SetWindowLongPtrW(window, GWL_EXSTYLE, next); diff --git a/src/platform/windows/windows_borderless.h b/src/platform/windows/windows_borderless.h index 33d72d6b..62180506 100644 --- a/src/platform/windows/windows_borderless.h +++ b/src/platform/windows/windows_borderless.h @@ -7,7 +7,10 @@ void bongo_cat_windows_borderless_install(HWND window); void bongo_cat_windows_borderless_uninstall(HWND window); -void bongo_cat_windows_borderless_set_click_through(HWND window, bool enabled); +bool bongo_cat_windows_borderless_hit_transparent(bool forced, + bool pointer_transparent, bool right_button_down); +void bongo_cat_windows_borderless_set_click_through(HWND window, + bool forced, bool pointer_transparent); void bongo_cat_windows_menu_preview(HWND window, BongoCatMenuPreview preview, void (*tick)(void *userdata), void *userdata); void bongo_cat_windows_begin_drag(HWND window, BongoCatModalTick modal_tick, diff --git a/src/platform/windows/windows_keys.c b/src/platform/windows/windows_keys.c index bdc6dd9b..c233f3dd 100644 --- a/src/platform/windows/windows_keys.c +++ b/src/platform/windows/windows_keys.c @@ -134,4 +134,8 @@ void bongo_cat_windows_keyboard_reconcile(BongoCatWindowsKeyboard *state, (GetAsyncKeyState((int)code) & 0x8000) != 0, emit, userdata); } } + +bool bongo_cat_windows_right_button_down(void) { + return (GetAsyncKeyState(VK_RBUTTON) & 0x8000) != 0; +} #endif diff --git a/src/platform/windows/windows_keys.h b/src/platform/windows/windows_keys.h index 3b3d62a3..5791415b 100644 --- a/src/platform/windows/windows_keys.h +++ b/src/platform/windows/windows_keys.h @@ -23,6 +23,7 @@ bool bongo_cat_windows_keyboard_reconcile_key(BongoCatWindowsKeyboard *state, BongoCatWindowsKeyEmit emit, void *userdata); void bongo_cat_windows_keyboard_reconcile(BongoCatWindowsKeyboard *state, uint64_t now_ms, BongoCatWindowsKeyEmit emit, void *userdata); +bool bongo_cat_windows_right_button_down(void); #endif #endif diff --git a/src/platform/windows/windows_pointer.c b/src/platform/windows/windows_pointer.c index 6011eb5c..dba8b7cb 100644 --- a/src/platform/windows/windows_pointer.c +++ b/src/platform/windows/windows_pointer.c @@ -30,8 +30,8 @@ void bongo_cat_platform_set_click_through(BongoCatPlatform *platform, bool forced, bool pointer_transparent) { HWND window = native_window(platform); bongo_cat_windows_layered_set_click_through(platform, forced); - bongo_cat_windows_borderless_set_click_through(window, - forced || pointer_transparent); + bongo_cat_windows_borderless_set_click_through(window, forced, + pointer_transparent); } void bongo_cat_platform_raise_window(SDL_Window *window) { diff --git a/tests/core/test_config.c b/tests/core/test_config.c index 07878322..ceb273a3 100644 --- a/tests/core/test_config.c +++ b/tests/core/test_config.c @@ -42,6 +42,7 @@ void test_config(void) { static BongoCatSessionState session; bongo_cat_settings_defaults(&settings); bongo_cat_session_defaults(&session); + CHECK(!settings.window.pass_through); settings.model.max_fps = 30; settings.model.multiple_pets = true; settings.model.mirror = true; diff --git a/tests/platform/test_windows_capture.c b/tests/platform/test_windows_capture.c index ba7cc270..2550827c 100644 --- a/tests/platform/test_windows_capture.c +++ b/tests/platform/test_windows_capture.c @@ -42,6 +42,10 @@ static LRESULT CALLBACK click_through_test_proc(HWND window, UINT message, } static void test_click_through_does_not_refresh_frame(void) { + CHECK(!bongo_cat_windows_borderless_hit_transparent(false, false, false)); + CHECK(bongo_cat_windows_borderless_hit_transparent(false, true, false)); + CHECK(!bongo_cat_windows_borderless_hit_transparent(false, true, true)); + CHECK(bongo_cat_windows_borderless_hit_transparent(true, false, true)); const wchar_t class_name[] = L"BongoCat click-through test"; WNDCLASSW type = {.lpfnWndProc = click_through_test_proc, .hInstance = GetModuleHandleW(NULL), .lpszClassName = class_name}; @@ -55,10 +59,17 @@ static void test_click_through_does_not_refresh_frame(void) { bongo_cat_windows_borderless_install(window); window_position_messages = 0; nonclient_size_messages = 0; - bongo_cat_windows_borderless_set_click_through(window, true); + bongo_cat_windows_borderless_set_click_through(window, false, true); + CHECK((GetWindowLongPtrW(window, GWL_EXSTYLE) & WS_EX_TRANSPARENT) == 0); + CHECK(SendMessageW(window, WM_NCHITTEST, 0, 0) == HTTRANSPARENT); + SendMessageW(window, WM_RBUTTONDOWN, MK_RBUTTON, 0); + CHECK(GetCapture() == window); + SendMessageW(window, WM_RBUTTONUP, 0, 0); + CHECK(GetCapture() != window); + bongo_cat_windows_borderless_set_click_through(window, true, false); CHECK((GetWindowLongPtrW(window, GWL_EXSTYLE) & WS_EX_TRANSPARENT) != 0); CHECK(SendMessageW(window, WM_NCHITTEST, 0, 0) == HTTRANSPARENT); - bongo_cat_windows_borderless_set_click_through(window, false); + bongo_cat_windows_borderless_set_click_through(window, false, false); CHECK((GetWindowLongPtrW(window, GWL_EXSTYLE) & WS_EX_TRANSPARENT) == 0); CHECK(window_position_messages == 0); CHECK(nonclient_size_messages == 0); From 42a5c8ce2986d83bdff3c758531d5a2e79eb8f35 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 29 Aug 2026 11:17:37 +0800 Subject: [PATCH 141/334] Update settings logo hover styling --- src/ui/theme/ui_catime.c | 35 +++++++---------------------------- 1 file changed, 7 insertions(+), 28 deletions(-) diff --git a/src/ui/theme/ui_catime.c b/src/ui/theme/ui_catime.c index e2607488..2a36db48 100644 --- a/src/ui/theme/ui_catime.c +++ b/src/ui/theme/ui_catime.c @@ -4,7 +4,6 @@ #include "ui_icons.h" #include "ui_paint.h" -#include static BongoCatUIIconDraw external_icon_draw; static void *external_icon_userdata; @@ -87,26 +86,6 @@ void bongo_cat_ui_shell_draw(struct nk_context *context, float width, p.border_subtle); } -static void signature(struct nk_command_buffer *canvas, struct nk_rect bounds, - BongoCatUIPalette p) { - float x = bounds.x + (bounds.w - 90) * .5f, y = bounds.y + 124; - struct nk_color glow = p.effects ? - nk_rgba(p.pink.r, p.pink.g, p.pink.b, 89) : p.pink; - nk_stroke_curve(canvas, x + 3, y + 8, x + 25, y + 12, - x + 55, y + 10, x + 87, y + 6, 6, glow); - float previous_x = x + 3, previous_y = y + 8; - for (int i = 1; i <= 24; ++i) { - float t = i / 24.0f, u = 1.0f - t; - float next_x = x + u * u * u * 3 + 3 * u * u * t * 25 + - 3 * u * t * t * 55 + t * t * t * 87; - float next_y = y + u * u * u * 8 + 3 * u * u * t * 12 + - 3 * u * t * t * 10 + t * t * t * 6; - nk_stroke_line(canvas, previous_x, previous_y, next_x, next_y, 3, - bongo_cat_ui_color_mix(p.accent, p.pink, t)); - previous_x = next_x; previous_y = next_y; - } -} - bool bongo_cat_ui_header(struct nk_context *context, const char *title, const struct nk_user_font *font, unsigned int logo_texture, bool *title_clicked, bool interactive, bool dark, bool native_chrome) { @@ -121,13 +100,17 @@ bool bongo_cat_ui_header(struct nk_context *context, const char *title, /* The current logo asset already contains its own rounded brand mark. Give it room to read at a glance instead of framing it a second time. */ float base_size = compact ? 68.0f : 84.0f; + float top = native_chrome ? 34.0f : (compact ? 6.0f : 8.0f); + struct nk_rect base_logo = nk_rect(bounds.x + (bounds.w - base_size) * .5f, + bounds.y + top, base_size, base_size); + bool logo_hover = interactive && nk_input_is_mouse_hovering_rect( + &context->input, base_logo); bool hover = interactive && nk_input_is_mouse_hovering_rect(&context->input, nk_rect(bounds.x, bounds.y, bounds.w, bounds.h)); float hover_amount = bongo_cat_ui_animate_eased(context, "brand-logo-hover", hover ? 1.0f : 0.0f, 250.0f, BONGO_CAT_UI_EASE_SPRING); float size = base_size * (1.0f + .08f * hover_amount); - float top = native_chrome ? 34.0f : (compact ? 6.0f : 8.0f); struct nk_rect logo = nk_rect(bounds.x + (bounds.w - size) * .5f, bounds.y + top - (size - base_size) * .5f, size, size); if (p.effects) bongo_cat_ui_paint_shadow(context, logo, @@ -140,13 +123,9 @@ bool bongo_cat_ui_header(struct nk_context *context, const char *title, if (bounds.w >= 100) { if (!font) font = bongo_cat_ui_caption_font(context); float title_y = native_chrome ? 124.0f : 94.0f; + struct nk_color title_color = logo_hover ? nk_rgb(84, 174, 255) : p.pink; centered(canvas, nk_rect(bounds.x, bounds.y + title_y, bounds.w, 28), title, - font, p.pink); - if (native_chrome) { - struct nk_rect signature_bounds = nk_rect(bounds.x, bounds.y + 28, - bounds.w, bounds.h - 28); - signature(canvas, signature_bounds, p); - } else signature(canvas, bounds, p); + font, title_color); } if (hover) bongo_cat_ui_cursor_hover_rect(context, bounds, BONGO_CAT_UI_CURSOR_POINTER); From 9dbfa0d9f793c100018d2dd88195e7725fb535db Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 29 Aug 2026 12:17:44 +0800 Subject: [PATCH 142/334] Update model library link styling --- resources/assets/locales/zh-CN.json | 2 +- resources/assets/locales/zh-Hant.json | 2 +- src/ui/preferences/preferences_model_section.c | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index c224ebe8..dd494cb1 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -101,7 +101,7 @@ "tooltips": { "createModel": "制作模型", "convertModel": "转换模型", - "moreModels": "更多模型" + "moreModels": "模型库" }, "behaviorModal": { "title": "动作与表情", diff --git a/resources/assets/locales/zh-Hant.json b/resources/assets/locales/zh-Hant.json index 5204b180..c98aa702 100644 --- a/resources/assets/locales/zh-Hant.json +++ b/resources/assets/locales/zh-Hant.json @@ -101,7 +101,7 @@ "tooltips": { "createModel": "製作模型", "convertModel": "轉換模型", - "moreModels": "更多模型" + "moreModels": "模型庫" }, "behaviorModal": { "title": "動作與表情", diff --git a/src/ui/preferences/preferences_model_section.c b/src/ui/preferences/preferences_model_section.c index 0590cee9..15f71784 100644 --- a/src/ui/preferences/preferences_model_section.c +++ b/src/ui/preferences/preferences_model_section.c @@ -44,8 +44,7 @@ bool bongo_cat_preferences_model_section(BongoCatPreferences *value, float hover_amount = bongo_cat_ui_animate_eased(context, "model-more-link-hover", action_hover ? 1.0f : 0.0f, 180.0f, BONGO_CAT_UI_EASE_STANDARD); - struct nk_color action_color = bongo_cat_ui_color_mix( - p.accent, p.pink, hover_amount); + struct nk_color action_color = p.danger; nk_draw_text(canvas, nk_rect(action.x, action.y + (action.h - action_font->height) * .5f, label_width + 1, action_font->height), label, nk_strlen(label), action_font, From 1599abf055b43e6cc3153c90583349159c961321 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 29 Aug 2026 12:40:36 +0800 Subject: [PATCH 143/334] Rename support section to about --- resources/assets/locales/de-DE.json | 2 +- resources/assets/locales/en-US.json | 2 +- resources/assets/locales/es-ES.json | 2 +- resources/assets/locales/fr-FR.json | 2 +- resources/assets/locales/ja-JP.json | 2 +- resources/assets/locales/ko-KR.json | 2 +- resources/assets/locales/pt-BR.json | 2 +- resources/assets/locales/ru-RU.json | 2 +- resources/assets/locales/zh-CN.json | 2 +- resources/assets/locales/zh-Hant.json | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index 42413bce..bb601ccb 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -196,7 +196,7 @@ "diagnosticLog": "Diagnoseprotokoll:" }, "support": { - "title": "Entwicklungsteam unterstützen", + "title": "Info", "website": "Website: bongocat.pet", "heroText": "Vielen Dank für Ihre Unterstützung. Jede Nutzung und Weitergabe hilft BongoCat, weiter zu wachsen.", "version": "Anwendungsversion", diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index 592da119..c91e271a 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -196,7 +196,7 @@ "diagnosticLog": "Diagnostic log:" }, "support": { - "title": "Support the Developer", + "title": "About", "website": "Website: bongocat.pet", "heroText": "Thank you for your support. Every use and share helps BongoCat continue to grow.", "version": "App version", diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index fa7b6a09..debdbbfd 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -196,7 +196,7 @@ "diagnosticLog": "Registro de diagnóstico:" }, "support": { - "title": "Apoyar al equipo de desarrollo", + "title": "Acerca de", "website": "Sitio web: bongocat.pet", "heroText": "Gracias por su apoyo. Cada uso y cada difusión ayudan a BongoCat a seguir creciendo.", "version": "Versión de la aplicación", diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index 23a53054..afdc312e 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -196,7 +196,7 @@ "diagnosticLog": "Journal de diagnostic :" }, "support": { - "title": "Soutenir l'équipe de développement", + "title": "À propos", "website": "Site web: bongocat.pet", "heroText": "Merci pour votre soutien. Chaque utilisation et chaque partage aide BongoCat à continuer de grandir.", "version": "Version de l'application", diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index 205c9371..a68303e1 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -196,7 +196,7 @@ "diagnosticLog": "診断ログ:" }, "support": { - "title": "開発チームを支援", + "title": "このアプリについて", "website": "公式サイト: bongocat.pet", "heroText": "ご支援ありがとうございます。利用や共有の一つひとつがBongoCatの成長につながります。", "version": "アプリのバージョン", diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index 6e749ea4..a261bf01 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -196,7 +196,7 @@ "diagnosticLog": "진단 로그:" }, "support": { - "title": "개발팀 지원", + "title": "정보", "website": "공식 웹사이트: bongocat.pet", "heroText": "지원해 주셔서 감사합니다. 사용할 때마다, 공유할 때마다 BongoCat이 계속 성장하는 데 도움이 됩니다.", "version": "앱 버전", diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index 40e9d18c..0987c410 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -196,7 +196,7 @@ "diagnosticLog": "Log de diagnóstico:" }, "support": { - "title": "Apoiar a equipe de desenvolvimento", + "title": "Sobre", "website": "Site: bongocat.pet", "heroText": "Obrigado pelo apoio. Cada uso e compartilhamento ajuda o BongoCat a continuar crescendo.", "version": "Versão do aplicativo", diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index 4ccc5bda..798d4501 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -196,7 +196,7 @@ "diagnosticLog": "Журнал диагностики:" }, "support": { - "title": "Поддержать команду разработки", + "title": "О программе", "website": "Сайт: bongocat.pet", "heroText": "Спасибо за поддержку. Каждое использование и распространение помогает BongoCat развиваться дальше.", "version": "Версия приложения", diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index dd494cb1..0bdd5962 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -196,7 +196,7 @@ "diagnosticLog": "诊断日志:" }, "support": { - "title": "支持开发者", + "title": "关于", "website": "官网: bongocat.pet", "heroText": "感谢你的支持。每一次使用与分享,都让 BongoCat 继续成长。", "version": "应用版本", diff --git a/resources/assets/locales/zh-Hant.json b/resources/assets/locales/zh-Hant.json index c98aa702..f5ee14fe 100644 --- a/resources/assets/locales/zh-Hant.json +++ b/resources/assets/locales/zh-Hant.json @@ -196,7 +196,7 @@ "diagnosticLog": "診斷日誌:" }, "support": { - "title": "支援開發者", + "title": "關於", "website": "官網: bongocat.pet", "heroText": "感謝你的支持。每一次使用與分享,都讓 BongoCat 持續成長。", "version": "應用程式版本", From decc3f32ab8e1c802dbfc7d006576cd9115824ab Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 29 Aug 2026 15:20:40 +0800 Subject: [PATCH 144/334] Refine preferences layout and icons --- cmake/RuntimeSources.cmake | 4 + resources/assets/locales/de-DE.json | 10 +- resources/assets/locales/en-US.json | 10 +- resources/assets/locales/es-ES.json | 10 +- resources/assets/locales/fr-FR.json | 10 +- resources/assets/locales/ja-JP.json | 10 +- resources/assets/locales/ko-KR.json | 10 +- resources/assets/locales/pt-BR.json | 10 +- resources/assets/locales/ru-RU.json | 10 +- resources/assets/locales/zh-CN.json | 12 +- resources/assets/locales/zh-Hant.json | 12 +- src/runtime/lifecycle/startup_arguments.c | 2 +- src/ui/preferences/preferences.c | 8 +- src/ui/preferences/preferences_about_page.c | 2 +- src/ui/preferences/preferences_form_label.c | 60 ++++++ src/ui/preferences/preferences_icons.c | 9 + src/ui/preferences/preferences_icons.h | 37 ++++ .../preferences/preferences_icons_internal.h | 11 ++ src/ui/preferences/preferences_internal.h | 4 +- src/ui/preferences/preferences_model.c | 3 +- .../preferences/preferences_model_section.c | 13 -- .../preferences/preferences_page_dispatch.c | 7 +- src/ui/preferences/preferences_pages.c | 46 ++++- src/ui/preferences/preferences_render.c | 20 +- src/ui/preferences/preferences_row_icons.c | 171 ++++++++++++++++++ .../preferences/preferences_section_icons.c | 66 +++++++ src/ui/preferences/preferences_shortcuts.c | 24 ++- src/ui/preferences/preferences_smoke.c | 2 +- src/ui/preferences/preferences_state.h | 6 +- src/ui/preferences/preferences_theme.c | 10 +- src/ui/preferences/preferences_widgets.c | 39 ++-- src/ui/preferences/preferences_widgets.h | 5 + .../preferences_widgets_internal.h | 1 + src/ui/theme/ui_catime.h | 2 +- src/ui/theme/ui_catime_tabs.c | 14 +- 35 files changed, 527 insertions(+), 143 deletions(-) create mode 100644 src/ui/preferences/preferences_form_label.c create mode 100644 src/ui/preferences/preferences_icons.c create mode 100644 src/ui/preferences/preferences_icons.h create mode 100644 src/ui/preferences/preferences_icons_internal.h create mode 100644 src/ui/preferences/preferences_row_icons.c create mode 100644 src/ui/preferences/preferences_section_icons.c diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 0adc88b6..0f1d06b8 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -170,9 +170,11 @@ set(BONGO_CAT_UI_PREFERENCES_SOURCES src/ui/preferences/preferences_combo.c src/ui/preferences/preferences_controls.c src/ui/preferences/preferences_dialog.c + src/ui/preferences/preferences_form_label.c src/ui/preferences/preferences_fonts.c src/ui/preferences/preferences_import.c src/ui/preferences/preferences_import_dialog.c + src/ui/preferences/preferences_icons.c src/ui/preferences/preferences_language.c src/ui/preferences/preferences_lifecycle.c src/ui/preferences/preferences_live_resize.c @@ -188,8 +190,10 @@ set(BONGO_CAT_UI_PREFERENCES_SOURCES src/ui/preferences/preferences_page_dispatch.c src/ui/preferences/preferences_pages.c src/ui/preferences/preferences_render.c + src/ui/preferences/preferences_row_icons.c src/ui/preferences/preferences_scale.c src/ui/preferences/preferences_scrollbar.c + src/ui/preferences/preferences_section_icons.c src/ui/preferences/preferences_shortcut_clear.c src/ui/preferences/preferences_shortcuts.c src/ui/preferences/preferences_smoke.c diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index bb601ccb..3e8e2523 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -9,14 +9,14 @@ "preference": { "title": "Einstellungen", "cat": { - "title": "Anzeige", + "title": "Einstellungen", "labels": { - "modelSettings": "Modelleinstellungen", + "modelSettings": "Modell", "mirrorMode": "Spiegelmodus", "mouseMirror": "Maus spiegeln", "mouseCentered": "Maus auf Desktop-Begleiter zentriert", "ignoreMouse": "Mausereignisse ignorieren", - "windowSettings": "Fenstereinstellungen", + "windowSettings": "Fenster", "passThrough": "Klicks durchlassen", "alwaysOnTop": "Immer im Vordergrund", "keepInScreen": "Auf dem Bildschirm halten", @@ -40,10 +40,10 @@ "general": { "title": "Allgemein", "labels": { - "appSettings": "Anwendungseinstellungen", + "appSettings": "Anwendung", "launchOnStartup": "Beim Systemstart ausführen", "showTrayIcon": "Infobereichssymbol anzeigen", - "appearanceSettings": "Darstellung", + "appearanceSettings": "Erscheinungsbild", "themeMode": "Design", "language": "Sprache", "permissionsSettings": "Berechtigungen", diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index c91e271a..97e8d881 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -9,14 +9,14 @@ "preference": { "title": "Preferences", "cat": { - "title": "Display", + "title": "Preferences", "labels": { - "modelSettings": "Model Settings", + "modelSettings": "Model", "mirrorMode": "Mirror Mode", "mouseMirror": "Mouse Mirror", "mouseCentered": "Mouse Centered on Desktop Pet", "ignoreMouse": "Ignore Mouse Events", - "windowSettings": "Window Settings", + "windowSettings": "Window", "passThrough": "Pass Through", "alwaysOnTop": "Always on Top", "keepInScreen": "Keep on Screen", @@ -40,10 +40,10 @@ "general": { "title": "General", "labels": { - "appSettings": "Application Settings", + "appSettings": "Application", "launchOnStartup": "Launch on Startup", "showTrayIcon": "Show Tray Icon", - "appearanceSettings": "Appearance Settings", + "appearanceSettings": "Appearance", "themeMode": "Theme", "language": "Language", "permissionsSettings": "Permissions Settings", diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index debdbbfd..c6e90290 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -9,14 +9,14 @@ "preference": { "title": "Preferencias", "cat": { - "title": "Pantalla", + "title": "Preferencias", "labels": { - "modelSettings": "Ajustes del modelo", + "modelSettings": "Modelo", "mirrorMode": "Modo espejo", "mouseMirror": "Invertir ratón", "mouseCentered": "Ratón centrado en la mascota", "ignoreMouse": "Ignorar eventos del ratón", - "windowSettings": "Ajustes de la ventana", + "windowSettings": "Ventana", "passThrough": "Dejar pasar clics", "alwaysOnTop": "Siempre visible", "keepInScreen": "Mantener en pantalla", @@ -40,10 +40,10 @@ "general": { "title": "General", "labels": { - "appSettings": "Ajustes de la aplicación", + "appSettings": "Aplicación", "launchOnStartup": "Iniciar con el sistema", "showTrayIcon": "Mostrar icono en la bandeja", - "appearanceSettings": "Ajustes de apariencia", + "appearanceSettings": "Apariencia", "themeMode": "Tema", "language": "Idioma", "permissionsSettings": "Ajustes de permisos", diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index afdc312e..d12bd59d 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -9,14 +9,14 @@ "preference": { "title": "Préférences", "cat": { - "title": "Affichage", + "title": "Préférences", "labels": { - "modelSettings": "Paramètres du modèle", + "modelSettings": "Modèle", "mirrorMode": "Mode miroir", "mouseMirror": "Miroir de la souris", "mouseCentered": "Souris centrée sur le compagnon", "ignoreMouse": "Ignorer les événements de souris", - "windowSettings": "Paramètres de la fenêtre", + "windowSettings": "Fenêtre", "passThrough": "Clics traversants", "alwaysOnTop": "Toujours au premier plan", "keepInScreen": "Maintenir à l'écran", @@ -40,10 +40,10 @@ "general": { "title": "Général", "labels": { - "appSettings": "Paramètres de l'application", + "appSettings": "Application", "launchOnStartup": "Lancer au démarrage", "showTrayIcon": "Afficher l'icône dans la zone de notification", - "appearanceSettings": "Paramètres d'apparence", + "appearanceSettings": "Apparence", "themeMode": "Thème", "language": "Langue", "permissionsSettings": "Paramètres des autorisations", diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index a68303e1..d4655e47 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -9,14 +9,14 @@ "preference": { "title": "設定", "cat": { - "title": "表示", + "title": "設定", "labels": { - "modelSettings": "モデル設定", + "modelSettings": "モデル", "mirrorMode": "ミラーモード", "mouseMirror": "マウスを反転", "mouseCentered": "マウスをデスクトップペット中心にする", "ignoreMouse": "マウスイベントを無視", - "windowSettings": "ウィンドウ設定", + "windowSettings": "ウィンドウ", "passThrough": "クリックを透過", "alwaysOnTop": "常に手前に表示", "keepInScreen": "画面内に保持", @@ -40,10 +40,10 @@ "general": { "title": "一般", "labels": { - "appSettings": "アプリ設定", + "appSettings": "アプリ", "launchOnStartup": "起動時に実行", "showTrayIcon": "トレイアイコンを表示", - "appearanceSettings": "外観設定", + "appearanceSettings": "外観", "themeMode": "テーマ", "language": "言語", "permissionsSettings": "権限設定", diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index a261bf01..55bab6f1 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -9,14 +9,14 @@ "preference": { "title": "환경 설정", "cat": { - "title": "표시", + "title": "환경 설정", "labels": { - "modelSettings": "모델 설정", + "modelSettings": "모델", "mirrorMode": "미러 모드", "mouseMirror": "마우스 반전", "mouseCentered": "마우스를 데스크톱 펫 중심으로", "ignoreMouse": "마우스 이벤트 무시", - "windowSettings": "창 설정", + "windowSettings": "창", "passThrough": "클릭 통과", "alwaysOnTop": "항상 위", "keepInScreen": "화면 안에 유지", @@ -40,10 +40,10 @@ "general": { "title": "일반", "labels": { - "appSettings": "앱 설정", + "appSettings": "앱", "launchOnStartup": "시작할 때 실행", "showTrayIcon": "트레이 아이콘 표시", - "appearanceSettings": "모양 설정", + "appearanceSettings": "모양", "themeMode": "테마", "language": "언어", "permissionsSettings": "권한 설정", diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index 0987c410..ac0b9f0b 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -9,14 +9,14 @@ "preference": { "title": "Preferências", "cat": { - "title": "Exibição", + "title": "Preferências", "labels": { - "modelSettings": "Configurações do Modelo", + "modelSettings": "Modelo", "mirrorMode": "Modo Espelho", "mouseMirror": "Espelho do Mouse", "mouseCentered": "Mouse centrado no mascote de mesa", "ignoreMouse": "Ignorar Eventos do Mouse", - "windowSettings": "Configurações da Janela", + "windowSettings": "Janela", "passThrough": "Janela Transparente", "alwaysOnTop": "Sempre no Topo", "keepInScreen": "Manter na Tela", @@ -40,10 +40,10 @@ "general": { "title": "Geral", "labels": { - "appSettings": "Configurações do aplicativo", + "appSettings": "Aplicativo", "launchOnStartup": "Iniciar na inicialização", "showTrayIcon": "Mostrar ícone na bandeja", - "appearanceSettings": "Configurações de aparência", + "appearanceSettings": "Aparência", "themeMode": "Tema", "language": "Idiomas", "permissionsSettings": "Configurações de Permissões", diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index 798d4501..be076a0f 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -9,14 +9,14 @@ "preference": { "title": "Настройки", "cat": { - "title": "Отображение", + "title": "Настройки", "labels": { - "modelSettings": "Настройки модели", + "modelSettings": "Модель", "mirrorMode": "Зеркальный режим", "mouseMirror": "Отражение мыши", "mouseCentered": "Мышь относительно питомца", "ignoreMouse": "Игнорировать события мыши", - "windowSettings": "Настройки окна", + "windowSettings": "Окно", "passThrough": "Пропускать щелчки", "alwaysOnTop": "Поверх всех окон", "keepInScreen": "Удерживать на экране", @@ -40,10 +40,10 @@ "general": { "title": "Общие", "labels": { - "appSettings": "Настройки приложения", + "appSettings": "Приложение", "launchOnStartup": "Запускать при входе в систему", "showTrayIcon": "Показывать значок в трее", - "appearanceSettings": "Настройки оформления", + "appearanceSettings": "Оформление", "themeMode": "Тема", "language": "Язык", "permissionsSettings": "Настройки разрешений", diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index 0bdd5962..aecff08c 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -9,14 +9,14 @@ "preference": { "title": "偏好设置", "cat": { - "title": "显示设置", + "title": "偏好", "labels": { - "modelSettings": "模型设置", + "modelSettings": "模型", "mirrorMode": "镜像模式", "mouseMirror": "鼠标镜像", "mouseCentered": "鼠标以桌宠为中心", "ignoreMouse": "忽略鼠标事件", - "windowSettings": "窗口设置", + "windowSettings": "窗口", "passThrough": "窗口穿透", "alwaysOnTop": "窗口置顶", "keepInScreen": "保持在屏幕内", @@ -40,10 +40,10 @@ "general": { "title": "通用设置", "labels": { - "appSettings": "应用设置", + "appSettings": "应用", "launchOnStartup": "开机自启动", "showTrayIcon": "显示托盘图标", - "appearanceSettings": "外观设置", + "appearanceSettings": "外观", "themeMode": "主题", "language": "语言", "permissionsSettings": "权限设置", @@ -77,7 +77,7 @@ } }, "model": { - "title": "模型管理", + "title": "模型", "nearbyTitle": "附近模型", "labels": { "deleteModel": "删除模型" diff --git a/resources/assets/locales/zh-Hant.json b/resources/assets/locales/zh-Hant.json index f5ee14fe..ce05226e 100644 --- a/resources/assets/locales/zh-Hant.json +++ b/resources/assets/locales/zh-Hant.json @@ -9,14 +9,14 @@ "preference": { "title": "偏好設定", "cat": { - "title": "顯示設定", + "title": "偏好", "labels": { - "modelSettings": "模型設定", + "modelSettings": "模型", "mirrorMode": "鏡像模式", "mouseMirror": "滑鼠游標鏡像", "mouseCentered": "滑鼠以桌寵為中心", "ignoreMouse": "忽略滑鼠事件", - "windowSettings": "視窗設定", + "windowSettings": "視窗", "passThrough": "視窗穿透", "alwaysOnTop": "視窗置頂", "keepInScreen": "保持在螢幕內", @@ -40,10 +40,10 @@ "general": { "title": "一般設定", "labels": { - "appSettings": "應用程式設定", + "appSettings": "應用程式", "launchOnStartup": "開機自動啟動", "showTrayIcon": "顯示托盤圖示", - "appearanceSettings": "外觀設定", + "appearanceSettings": "外觀", "themeMode": "主題", "language": "語言", "permissionsSettings": "權限設定", @@ -77,7 +77,7 @@ } }, "model": { - "title": "模型管理", + "title": "模型", "nearbyTitle": "附近模型", "labels": { "deleteModel": "刪除模型" diff --git a/src/runtime/lifecycle/startup_arguments.c b/src/runtime/lifecycle/startup_arguments.c index 78fb539b..0a2294c8 100644 --- a/src/runtime/lifecycle/startup_arguments.c +++ b/src/runtime/lifecycle/startup_arguments.c @@ -55,7 +55,7 @@ bool bongo_cat_startup_arguments(BongoCatApp *app, int argc, char **argv, else if (strcmp(arg, "--ci-freeze-model") == 0) app->smoke_freeze_model = true; else if (strncmp(arg, "--ci-preference-page=", 21) == 0) { int page = atoi(arg + 21); - if (page >= 0 && page < 5) app->smoke_preference_page = page; + if (page >= 0 && page < 4) app->smoke_preference_page = page; } else if (strncmp(arg, "--ci-language=", 14) == 0) { BongoCatLanguage language; if (bongo_cat_language_parse(arg + 14, &language)) diff --git a/src/ui/preferences/preferences.c b/src/ui/preferences/preferences.c index d9b2583c..c1eb710e 100644 --- a/src/ui/preferences/preferences.c +++ b/src/ui/preferences/preferences.c @@ -30,7 +30,7 @@ void bongo_cat_preferences_page_cache_clear(BongoCatPreferences *value, int previous_page, int next_page) { if (!value || previous_page == next_page) return; bool released = false; - if (previous_page == 4 && next_page != 4) { + if (previous_page == 3 && next_page != 3) { bongo_cat_preferences_support_assets_clear(value); released = true; } @@ -45,7 +45,7 @@ BongoCatPreferences *bongo_cat_preferences_create(BongoCatApp *app) { if (!value->import_dialog) { free(value); return NULL; } } if (value && app->smoke_preference_page >= 0) value->page = app->smoke_preference_page; - if (value) value->model_glyphs_loaded = value->page == 2; + if (value) value->model_glyphs_loaded = value->page == 1; return value; } @@ -104,7 +104,7 @@ static Uint32 event_window(const SDL_Event *event) { } static void enter_model_drop(BongoCatPreferences *value) { - value->page = 2; + value->page = 1; value->import_drop_active = true; value->render_dirty = true; } @@ -251,7 +251,7 @@ void bongo_cat_preferences_invalidate(BongoCatPreferences *value) { void bongo_cat_preferences_models_changed(BongoCatPreferences *value) { if (!value) return; value->render_dirty = true; - if (value->page != 2) { + if (value->page != 1) { value->model_glyphs_loaded = false; return; } diff --git a/src/ui/preferences/preferences_about_page.c b/src/ui/preferences/preferences_about_page.c index 0d89ddc0..b755bbdd 100644 --- a/src/ui/preferences/preferences_about_page.c +++ b/src/ui/preferences/preferences_about_page.c @@ -10,6 +10,6 @@ void bongo_cat_preferences_page_about(BongoCatPreferences *value, struct nk_context *context) { bongo_cat_preferences_support_assets_load(value); bongo_cat_preferences_about_hero(value, context); - bongo_cat_preferences_about_projects(value, context); bongo_cat_preferences_about_community(value, context); + bongo_cat_preferences_about_projects(value, context); } diff --git a/src/ui/preferences/preferences_form_label.c b/src/ui/preferences/preferences_form_label.c new file mode 100644 index 00000000..6b399877 --- /dev/null +++ b/src/ui/preferences/preferences_form_label.c @@ -0,0 +1,60 @@ +#include "preferences_widgets.h" +#include "preferences_widgets_internal.h" +#include "ui_backend.h" +#include "ui_catime.h" + +static struct nk_context *icon_context; +static BongoCatPrefIcon pending_icon; +static bool icon_pending; + +void bongo_cat_pref_row_icon(struct nk_context *context, + BongoCatPrefIcon icon) { + icon_context = context; + pending_icon = icon; + icon_pending = true; +} + +void bongo_cat_pref_row_icon_clear(struct nk_context *context) { + if (icon_context != context) return; + icon_context = NULL; + icon_pending = false; +} + +void bongo_cat_pref_form_title_sized(struct nk_context *context, + const char *title, float control_width) { + float available = nk_window_get_content_region(context).w; + float left = NK_MAX(220.0f, available - control_width - 8.0f); + nk_layout_row_begin(context, NK_STATIC, 36, 2); + nk_layout_row_push(context, left); + bongo_cat_pref_form_label(context, title); + nk_layout_row_push(context, NK_MAX(control_width, + available - left - 8.0f)); +} + +void bongo_cat_pref_form_label(struct nk_context *context, const char *title) { + if (!icon_pending || icon_context != context) { + nk_style_push_font(context, bongo_cat_ui_label_font(context)); + struct nk_vec2 padding = context->style.text.padding; + context->style.text.padding.x += 5.0f; + nk_label(context, title, NK_TEXT_LEFT); + context->style.text.padding = padding; + nk_style_pop_font(context); + return; + } + struct nk_rect bounds; + enum nk_widget_layout_states state = nk_widget(&bounds, context); + BongoCatPrefIcon icon = pending_icon; + bongo_cat_pref_row_icon_clear(context); + if (state == NK_WIDGET_INVALID) return; + BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); + struct nk_command_buffer *canvas = nk_window_get_canvas(context); + struct nk_rect icon_bounds = nk_rect(bounds.x + 5, + bounds.y + (bounds.h - 18) * .5f, 18, 18); + bongo_cat_pref_icon_draw(canvas, icon_bounds, icon, p.accent); + const struct nk_user_font *font = bongo_cat_ui_label_font(context); + struct nk_rect text = nk_rect(bounds.x + 33, + bounds.y + (bounds.h - font->height) * .5f, + bounds.w - 33, font->height); + nk_draw_text(canvas, text, title, nk_strlen(title), font, + nk_rgba(0, 0, 0, 0), p.text); +} diff --git a/src/ui/preferences/preferences_icons.c b/src/ui/preferences/preferences_icons.c new file mode 100644 index 00000000..8041065c --- /dev/null +++ b/src/ui/preferences/preferences_icons.c @@ -0,0 +1,9 @@ +#include "preferences_icons.h" +#include "preferences_icons_internal.h" + +void bongo_cat_pref_icon_draw(struct nk_command_buffer *canvas, + struct nk_rect bounds, BongoCatPrefIcon icon, struct nk_color color) { + if (!canvas || icon < 0 || icon >= BONGO_CAT_PREF_ICON_COUNT) return; + if (bongo_cat_pref_section_icon_draw(canvas, bounds, icon, color)) return; + bongo_cat_pref_row_icon_draw(canvas, bounds, icon, color); +} diff --git a/src/ui/preferences/preferences_icons.h b/src/ui/preferences/preferences_icons.h new file mode 100644 index 00000000..b50efddd --- /dev/null +++ b/src/ui/preferences/preferences_icons.h @@ -0,0 +1,37 @@ +#ifndef BONGO_CAT_PREFERENCES_ICONS_H +#define BONGO_CAT_PREFERENCES_ICONS_H + +#include + +#include "nuklear_config.h" + +typedef enum BongoCatPrefIcon { + BONGO_CAT_PREF_ICON_SECTION_WINDOW, + BONGO_CAT_PREF_ICON_SECTION_MODEL, + BONGO_CAT_PREF_ICON_SECTION_APPLICATION, + BONGO_CAT_PREF_ICON_SECTION_APPEARANCE, + BONGO_CAT_PREF_ICON_MULTIPLE_MODELS, + BONGO_CAT_PREF_ICON_PASS_THROUGH, + BONGO_CAT_PREF_ICON_ALWAYS_ON_TOP, + BONGO_CAT_PREF_ICON_KEEP_IN_SCREEN, + BONGO_CAT_PREF_ICON_SOLID_BACKGROUND, + BONGO_CAT_PREF_ICON_WINDOW_SIZE, + BONGO_CAT_PREF_ICON_OPACITY, + BONGO_CAT_PREF_ICON_RANDOM_EXPRESSION, + BONGO_CAT_PREF_ICON_MIRROR, + BONGO_CAT_PREF_ICON_MOUSE_MIRROR, + BONGO_CAT_PREF_ICON_MOUSE_CENTERED, + BONGO_CAT_PREF_ICON_IGNORE_MOUSE, + BONGO_CAT_PREF_ICON_MAX_FPS, + BONGO_CAT_PREF_ICON_AUTOSTART, + BONGO_CAT_PREF_ICON_LANGUAGE, + BONGO_CAT_PREF_ICON_THEME, + BONGO_CAT_PREF_ICON_SHORTCUT_VISIBILITY, + BONGO_CAT_PREF_ICON_SHORTCUT_PREFERENCES, + BONGO_CAT_PREF_ICON_COUNT +} BongoCatPrefIcon; + +void bongo_cat_pref_icon_draw(struct nk_command_buffer *canvas, + struct nk_rect bounds, BongoCatPrefIcon icon, struct nk_color color); + +#endif diff --git a/src/ui/preferences/preferences_icons_internal.h b/src/ui/preferences/preferences_icons_internal.h new file mode 100644 index 00000000..d7e8f51e --- /dev/null +++ b/src/ui/preferences/preferences_icons_internal.h @@ -0,0 +1,11 @@ +#ifndef BONGO_CAT_PREFERENCES_ICONS_INTERNAL_H +#define BONGO_CAT_PREFERENCES_ICONS_INTERNAL_H + +#include "preferences_icons.h" + +bool bongo_cat_pref_section_icon_draw(struct nk_command_buffer *canvas, + struct nk_rect bounds, BongoCatPrefIcon icon, struct nk_color color); +bool bongo_cat_pref_row_icon_draw(struct nk_command_buffer *canvas, + struct nk_rect bounds, BongoCatPrefIcon icon, struct nk_color color); + +#endif diff --git a/src/ui/preferences/preferences_internal.h b/src/ui/preferences/preferences_internal.h index 1dad8905..1c6c8dd6 100644 --- a/src/ui/preferences/preferences_internal.h +++ b/src/ui/preferences/preferences_internal.h @@ -6,8 +6,8 @@ #include "ui_catime.h" #include -void bongo_cat_preferences_page_cat(BongoCatApp *app, struct nk_context *context); -void bongo_cat_preferences_page_general(BongoCatApp *app, struct nk_context *context); +void bongo_cat_preferences_page_settings(BongoCatApp *app, + struct nk_context *context); void bongo_cat_preferences_page_model(BongoCatPreferences *value, struct nk_context *context); bool bongo_cat_preferences_behavior_dialog_active( diff --git a/src/ui/preferences/preferences_model.c b/src/ui/preferences/preferences_model.c index 4dbac0ba..5ee5c84b 100644 --- a/src/ui/preferences/preferences_model.c +++ b/src/ui/preferences/preferences_model.c @@ -177,7 +177,7 @@ void bongo_cat_preferences_import_complete(BongoCatApp *app, } else bongo_cat_preferences_notice_show(app, message, result != BONGO_CAT_OK || partial); if (result == BONGO_CAT_OK) { - if (installed_count && app->preferences->page == 2) { + if (installed_count && app->preferences->page == 1) { app->preferences->font_reload_pending = true; app->preferences->font_reload_defer_once = true; } @@ -242,6 +242,7 @@ void bongo_cat_preferences_page_model(BongoCatPreferences *value, smoke_model_behavior(value); bongo_cat_preferences_model_covers_begin(app); bool multiple = app->settings.model.multiple_pets; + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_MULTIPLE_MODELS); if (bongo_cat_pref_toggle(context, "multiple-pets", tr(app, "native.multiplePets", "Display multiple"), "", &multiple)) diff --git a/src/ui/preferences/preferences_model_section.c b/src/ui/preferences/preferences_model_section.c index 15f71784..75d8cf64 100644 --- a/src/ui/preferences/preferences_model_section.c +++ b/src/ui/preferences/preferences_model_section.c @@ -18,26 +18,13 @@ bool bongo_cat_preferences_model_section(BongoCatPreferences *value, if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return false; BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); struct nk_command_buffer *canvas = nk_window_get_canvas(context); - const struct nk_user_font *title_font = bongo_cat_ui_label_font(context); const struct nk_user_font *action_font = bongo_cat_ui_caption_font(context); - const char *title = tr(value, - "pages.preference.model.title", "Installed models"); - float title_x = bounds.x + 14; - nk_fill_rect(canvas, nk_rect(bounds.x, bounds.y + 2, 4, 18), 2, p.pink); - float title_width = title_font->width(title_font->userdata, - title_font->height, title, nk_strlen(title)); - nk_draw_text(canvas, nk_rect(title_x, - bounds.y + (bounds.h - title_font->height) * .5f, - title_width + 1, title_font->height), title, nk_strlen(title), - title_font, nk_rgba(0, 0, 0, 0), p.text); - const char *label = tr(value, "pages.preference.model.tooltips.moreModels", "More Models"); float label_width = action_font->width(action_font->userdata, action_font->height, label, nk_strlen(label)); float action_width = label_width + 22; float action_x = bounds.x + bounds.w - action_width; - if (action_x < title_x + title_width + 12) return false; struct nk_rect action = nk_rect(action_x, bounds.y, action_width, bounds.h); bool action_hover = nk_input_is_mouse_hovering_rect(&context->input, action); diff --git a/src/ui/preferences/preferences_page_dispatch.c b/src/ui/preferences/preferences_page_dispatch.c index 8954a813..b50425d6 100644 --- a/src/ui/preferences/preferences_page_dispatch.c +++ b/src/ui/preferences/preferences_page_dispatch.c @@ -3,10 +3,9 @@ void bongo_cat_preferences_draw_page(BongoCatPreferences *value, struct nk_context *context) { switch (value->page) { - case 0: bongo_cat_preferences_page_cat(value->app, context); break; - case 1: bongo_cat_preferences_page_general(value->app, context); break; - case 2: bongo_cat_preferences_page_model(value, context); break; - case 3: bongo_cat_preferences_page_shortcuts(value, context); break; + case 0: bongo_cat_preferences_page_settings(value->app, context); break; + case 1: bongo_cat_preferences_page_model(value, context); break; + case 2: bongo_cat_preferences_page_shortcuts(value, context); break; default: bongo_cat_preferences_page_about(value, context); break; } } diff --git a/src/ui/preferences/preferences_pages.c b/src/ui/preferences/preferences_pages.c index 9d0c1eb5..f69c5662 100644 --- a/src/ui/preferences/preferences_pages.c +++ b/src/ui/preferences/preferences_pages.c @@ -19,18 +19,21 @@ static void section_gap(struct nk_context *context, float pixels) { context->current->layout->at_y += pixels; } -void bongo_cat_preferences_page_cat(BongoCatApp *app, struct nk_context *context) { +static void page_display(BongoCatApp *app, struct nk_context *context) { BongoCatModelPreferences *model = &app->settings.model; BongoCatWindowPreferences *window = &app->settings.window; BongoCatWindowState *window_state = &app->session.window; - bongo_cat_pref_section(context, tr(app, "pages.preference.cat.labels.windowSettings", - "Window Settings")); + bongo_cat_pref_section_icon(context, tr(app, + "pages.preference.cat.labels.windowSettings", "Window"), + BONGO_CAT_PREF_ICON_SECTION_WINDOW); + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_PASS_THROUGH); if (bongo_cat_pref_toggle(context, "pass-through", tr(app, "composables.useAppMenu.labels.passThrough", "Pass Through"), "", &window->pass_through)) { bongo_cat_window_mark_hit_dirty(app); bongo_cat_window_sync_click_through(app); } + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_ALWAYS_ON_TOP); if (bongo_cat_pref_toggle(context, "always-top", tr(app, "composables.useAppMenu.labels.alwaysOnTop", "Always on Top"), "", &window->always_on_top)) { @@ -38,10 +41,12 @@ void bongo_cat_preferences_page_cat(BongoCatApp *app, struct nk_context *context bongo_cat_window_mark_hit_dirty(app); bongo_cat_window_sync_click_through(app); } + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_KEEP_IN_SCREEN); if (bongo_cat_pref_toggle(context, "keep-in-screen", tr(app, "pages.preference.cat.labels.keepInScreen", "Keep on Screen"), "", &window->keep_in_screen) && window->keep_in_screen) bongo_cat_window_clamp_to_display(app); + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_SOLID_BACKGROUND); if (bongo_cat_pref_obs_background(context, "obs-background", tr(app, "pages.preference.cat.labels.obsBackground", "Solid Background"), tr(app, "pages.preference.cat.hints.obsBackground", "Window capture is black?"), tr(app, @@ -51,6 +56,7 @@ void bongo_cat_preferences_page_cat(BongoCatApp *app, struct nk_context *context &window->obs_background, &window->obs_background_color)) app->dirty = true; float old_scale = window_state->scale_percent; + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_WINDOW_SIZE); bongo_cat_pref_float(context, "window-size", tr(app, "pages.preference.cat.labels.windowSize", "Window Size"), tr(app, "composables.useAppMenu.labels.wheelSizeHint", "Wheel: resize"), @@ -63,6 +69,7 @@ void bongo_cat_preferences_page_cat(BongoCatApp *app, struct nk_context *context bongo_cat_window_set_scale(app, requested_scale); } float old_opacity = window_state->opacity_percent; + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_OPACITY); bongo_cat_pref_slider(context, "opacity", tr(app, "pages.preference.cat.labels.opacity", "Opacity"), tr(app, "composables.useAppMenu.labels.wheelOpacityHint", "Ctrl+Wheel: opacity"), @@ -73,6 +80,7 @@ void bongo_cat_preferences_page_cat(BongoCatApp *app, struct nk_context *context if (old_opacity != window_state->opacity_percent && !app->hover_hidden) bongo_cat_platform_set_opacity(&app->platform, window_state->opacity_percent / 100.0f); + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_RANDOM_EXPRESSION); bongo_cat_pref_toggle_float(context, "random-expression", tr(app, "pages.preference.cat.labels.randomExpression", "Random Expressions"), &window->random_expression, 1.0f, @@ -80,31 +88,37 @@ void bongo_cat_preferences_page_cat(BongoCatApp *app, struct nk_context *context BONGO_CAT_DEFAULT_RANDOM_EXPRESSION_SECONDS); section_gap(context, 10); - bongo_cat_pref_section(context, tr(app, "pages.preference.cat.labels.modelSettings", - "Model Settings")); + bongo_cat_pref_section_icon(context, tr(app, + "pages.preference.cat.labels.modelSettings", "Model"), + BONGO_CAT_PREF_ICON_SECTION_MODEL); + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_MIRROR); if (bongo_cat_pref_toggle(context, "mirror", tr(app, "pages.preference.cat.labels.mirrorMode", "Mirror Mode"), "", &model->mirror)) { app->model_pointer_anchor_ready = false; app->dirty = true; } + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_MOUSE_MIRROR); if (bongo_cat_pref_toggle(context, "mouse-mirror", tr(app, "pages.preference.cat.labels.mouseMirror", "Mouse Mirror"), "", &model->mouse_mirror)) { app->pointer_known = false; app->dirty = true; } + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_MOUSE_CENTERED); if (bongo_cat_pref_toggle(context, "mouse-centered", tr(app, "pages.preference.cat.labels.mouseCentered", "Mouse Centered on Desktop Pet"), "", &model->mouse_centered)) { bongo_cat_app_reset_pointer_tracking(app); } + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_IGNORE_MOUSE); if (bongo_cat_pref_toggle(context, "ignore-mouse", tr(app, "pages.preference.cat.labels.ignoreMouse", "Ignore Mouse Events"), "", &model->ignore_mouse)) { app->pointer_known = false; app->dirty = true; } + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_MAX_FPS); bongo_cat_pref_int(context, "max-fps", tr(app, "pages.preference.cat.labels.maxFPS", "Max Frame Rate"), "", 1, &model->max_fps, 240, 1, BONGO_CAT_DEFAULT_MAX_FPS); @@ -120,22 +134,25 @@ static void update_autostart(BongoCatApp *app, bool old_value) { "Unable to update launch-on-startup settings"), true); } -void bongo_cat_preferences_page_general(BongoCatApp *app, struct nk_context *context) { +static void page_general(BongoCatApp *app, struct nk_context *context) { BongoCatApplicationPreferences *options = &app->settings.app; // Keep each option in its own native language so the list is recognizable // regardless of the language currently used by the settings window. const char *ui_languages[] = {"简体中文", "繁體中文", "English", "Français", "Deutsch", "日本語", "한국어", "Português", "Русский", "Español"}; - bongo_cat_pref_section(context, tr(app, "pages.preference.general.labels.appSettings", - "Application Settings")); + bongo_cat_pref_section_icon(context, tr(app, + "pages.preference.general.labels.appSettings", "Application"), + BONGO_CAT_PREF_ICON_SECTION_APPLICATION); bool old_autostart = options->autostart; + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_AUTOSTART); if (bongo_cat_pref_toggle(context, "autostart", tr(app, "pages.preference.general.labels.launchOnStartup", "Launch on Startup"), "", &options->autostart)) update_autostart(app, old_autostart); section_gap(context, 7); - bongo_cat_pref_section(context, tr(app, - "pages.preference.general.labels.appearanceSettings", "Appearance Settings")); + bongo_cat_pref_section_icon(context, tr(app, + "pages.preference.general.labels.appearanceSettings", "Appearance"), + BONGO_CAT_PREF_ICON_SECTION_APPEARANCE); section_gap(context, 6); const int language_to_ui[] = {2, 0, 1, 3, 4, 5, 6, 7, 8, 9}; const BongoCatLanguage ui_to_language[] = { @@ -144,6 +161,7 @@ void bongo_cat_preferences_page_general(BongoCatApp *app, struct nk_context *con BONGO_CAT_LANG_DE_DE, BONGO_CAT_LANG_JA_JP, BONGO_CAT_LANG_KO_KR, BONGO_CAT_LANG_PT_BR, BONGO_CAT_LANG_RU_RU, BONGO_CAT_LANG_ES_ES}; + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_LANGUAGE); int selected = bongo_cat_pref_combo(context, "language", tr(app, "pages.preference.general.labels.language", "Language"), "", ui_languages, BONGO_CAT_LANG_COUNT, @@ -153,7 +171,15 @@ void bongo_cat_preferences_page_general(BongoCatApp *app, struct nk_context *con tr(app, "pages.preference.general.options.auto", "System"), tr(app, "pages.preference.general.options.lightMode", "Light"), tr(app, "pages.preference.general.options.darkMode", "Dark")}; + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_THEME); options->theme = (BongoCatTheme)bongo_cat_pref_theme(context, "theme", tr(app, "pages.preference.general.labels.themeMode", "Theme"), themes, options->theme); } + +void bongo_cat_preferences_page_settings(BongoCatApp *app, + struct nk_context *context) { + page_display(app, context); + section_gap(context, 10); + page_general(app, context); +} diff --git a/src/ui/preferences/preferences_render.c b/src/ui/preferences/preferences_render.c index d3eb5b64..1f429cdd 100644 --- a/src/ui/preferences/preferences_render.c +++ b/src/ui/preferences/preferences_render.c @@ -4,6 +4,7 @@ #include "preferences_notice.h" #include "ui_catime.h" #include "ui_animation.h" +#include "ui_icons.h" #include "ui_paint.h" #include "bongo_cat/memory_policy.h" #include "bongo_cat/tray.h" @@ -52,10 +53,12 @@ static void root_style_restore(struct nk_context *context, static bool draw_shell(BongoCatPreferences *value, struct nk_context *context, float width, float height, bool dark) { static const char *page_ids[] = { - "page-cat", "page-general", "page-model", "page-shortcuts", "page-about"}; + "page-settings", "page-model", "page-shortcuts", "page-about"}; + static const int menu_icons[] = { + BONGO_CAT_UI_ICON_CAT, BONGO_CAT_UI_ICON_WAND, + BONGO_CAT_UI_ICON_KEYBOARD, BONGO_CAT_UI_ICON_HEART}; const char *menus[] = { - tr(value, "pages.preference.cat.title", "Cat"), - tr(value, "pages.preference.general.title", "General"), + tr(value, "pages.preference.cat.title", "Preferences"), tr(value, "pages.preference.model.title", "Model"), tr(value, "pages.preference.shortcut.title", "Shortcuts"), tr(value, "native.support.title", "Support the Developer")}; @@ -79,13 +82,13 @@ static bool draw_shell(BongoCatPreferences *value, struct nk_context *context, if (title_clicked && !SDL_OpenURL("https://bongocat.pet")) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Cannot open website: %s", SDL_GetError()); bongo_cat_ui_set_icons(draw_icon, value); - bongo_cat_ui_tabs(context, menus, 5, &value->page, !modal, dark, - interior_height, + bongo_cat_ui_tabs(context, menus, menu_icons, 4, &value->page, + !modal, dark, interior_height, draw_icon, value); if (!value->page_seen) { value->page_seen = true; value->last_page = value->page; } else if (value->last_page != value->page) { - bool load_model_glyphs = value->page == 2 && !value->model_glyphs_loaded; + bool load_model_glyphs = value->page == 1 && !value->model_glyphs_loaded; bongo_cat_preferences_page_cache_clear(value, value->last_page, value->page); value->last_page = value->page; @@ -102,7 +105,8 @@ static bool draw_shell(BongoCatPreferences *value, struct nk_context *context, bool close_requested = false; if (nk_group_begin(context, "preferences-content", NK_WINDOW_NO_SCROLLBAR)) { close_requested = bongo_cat_ui_content_header(context, - menus[value->page], value->page, !modal, dark, native_chrome); + menus[value->page], menu_icons[value->page], !modal, dark, + native_chrome); float body_height = interior_height - BONGO_CAT_UI_HEADER_HEIGHT; nk_layout_row_dynamic(context, NK_MAX(120.0f, body_height), 1); struct nk_rect body_bounds = nk_widget_bounds(context); @@ -120,7 +124,7 @@ static bool draw_shell(BongoCatPreferences *value, struct nk_context *context, context->style.window.group_padding = nk_vec2(24, 16 + 6.0f * (1.0f - page_progress)); context->style.window.spacing = nk_vec2(10, 10); - int scroll_page = NK_CLAMP(0, value->page, 4); + int scroll_page = NK_CLAMP(0, value->page, 3); bool scroll_animating = fabsf(value->scroll_current[scroll_page] - value->scroll_target[scroll_page]) > .5f; struct nk_style_scrollbar saved_scrollv = context->style.scrollv; struct nk_rect scrollbar_hit = nk_rect(body_bounds.x + body_bounds.w - 14, body_bounds.y, 14, body_bounds.h); diff --git a/src/ui/preferences/preferences_row_icons.c b/src/ui/preferences/preferences_row_icons.c new file mode 100644 index 00000000..a191c575 --- /dev/null +++ b/src/ui/preferences/preferences_row_icons.c @@ -0,0 +1,171 @@ +#include "preferences_icons_internal.h" + +static void line(struct nk_command_buffer *canvas, struct nk_rect b, + float x1, float y1, float x2, float y2, struct nk_color color) { + nk_stroke_line(canvas, b.x + x1, b.y + y1, b.x + x2, b.y + y2, + 1.5f, color); +} + +static void dot(struct nk_command_buffer *canvas, struct nk_rect b, + float x, float y, float size, struct nk_color color) { + nk_fill_circle(canvas, nk_rect(b.x + x - size * .5f, + b.y + y - size * .5f, size, size), color); +} + +static void multiple_models(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + nk_stroke_rect(c, nk_rect(b.x + 1, b.y + 1, 11, 11), 2, 1.5f, color); + nk_stroke_rect(c, nk_rect(b.x + 6, b.y + 6, 11, 11), 2, 1.5f, color); + dot(c, b, 4.5f, 4.5f, 2.0f, color); + dot(c, b, 9.5f, 9.5f, 2.0f, color); +} + +static void pass_through(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + nk_stroke_rect(c, nk_rect(b.x + 1, b.y + 3, 12, 12), 2, 1.5f, color); + line(c, b, 5, 9, 17, 9, color); + line(c, b, 14, 6, 17, 9, color); + line(c, b, 14, 12, 17, 9, color); +} + +static void always_on_top(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + line(c, b, 3, 15, 15, 15, color); + line(c, b, 5, 12, 13, 12, color); + line(c, b, 9, 12, 9, 2, color); + line(c, b, 5, 6, 9, 2, color); + line(c, b, 13, 6, 9, 2, color); +} + +static void keep_in_screen(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + line(c, b, 2, 7, 2, 2, color); line(c, b, 2, 2, 7, 2, color); + line(c, b, 11, 2, 16, 2, color); line(c, b, 16, 2, 16, 7, color); + line(c, b, 2, 11, 2, 16, color); line(c, b, 2, 16, 7, 16, color); + line(c, b, 11, 16, 16, 16, color); line(c, b, 16, 16, 16, 11, color); +} + +static void solid_background(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + nk_stroke_rect(c, nk_rect(b.x + 1, b.y + 1, 16, 16), 2, 1.5f, color); + nk_fill_rect(c, nk_rect(b.x + 5, b.y + 5, 8, 8), 2, color); +} + +static void window_size(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + line(c, b, 3, 15, 15, 3, color); + line(c, b, 3, 10, 3, 15, color); line(c, b, 3, 15, 8, 15, color); + line(c, b, 10, 3, 15, 3, color); line(c, b, 15, 3, 15, 8, color); +} + +static void opacity(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + line(c, b, 9, 1, 4, 9, color); line(c, b, 9, 1, 14, 9, color); + nk_stroke_circle(c, nk_rect(b.x + 4, b.y + 6, 10, 10), 1.5f, color); + line(c, b, 6, 12, 12, 12, color); +} + +static void random_expression(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + nk_stroke_rect(c, nk_rect(b.x + 2, b.y + 2, 14, 14), 3, 1.5f, color); + dot(c, b, 6, 6, 2.5f, color); dot(c, b, 9, 9, 2.5f, color); + dot(c, b, 12, 12, 2.5f, color); +} + +static void mirror(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + line(c, b, 9, 1, 9, 17, color); + nk_stroke_triangle(c, b.x + 2, b.y + 9, b.x + 7, b.y + 4, + b.x + 7, b.y + 14, 1.5f, color); + nk_stroke_triangle(c, b.x + 16, b.y + 9, b.x + 11, b.y + 4, + b.x + 11, b.y + 14, 1.5f, color); +} + +static void mouse_mirror(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + nk_stroke_triangle(c, b.x + 1, b.y + 2, b.x + 2, b.y + 14, + b.x + 7, b.y + 10, 1.5f, color); + nk_stroke_triangle(c, b.x + 17, b.y + 2, b.x + 16, b.y + 14, + b.x + 11, b.y + 10, 1.5f, color); + line(c, b, 9, 2, 9, 6, color); line(c, b, 9, 9, 9, 13, color); +} + +static void mouse_centered(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + nk_stroke_circle(c, nk_rect(b.x + 3, b.y + 3, 12, 12), 1.5f, color); + line(c, b, 9, 0, 9, 6, color); line(c, b, 9, 12, 9, 18, color); + line(c, b, 0, 9, 6, 9, color); line(c, b, 12, 9, 18, 9, color); + dot(c, b, 9, 9, 2.5f, color); +} + +static void ignore_mouse(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + nk_stroke_triangle(c, b.x + 2, b.y + 1, b.x + 3, b.y + 15, + b.x + 8, b.y + 11, 1.5f, color); + line(c, b, 7, 10, 11, 16, color); + line(c, b, 1, 17, 17, 1, color); +} + +static void max_fps(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + nk_stroke_rect(c, nk_rect(b.x + 1, b.y + 2, 16, 14), 2, 1.5f, color); + line(c, b, 4, 13, 4, 10, color); + line(c, b, 8, 13, 8, 7, color); + line(c, b, 12, 13, 12, 4, color); + line(c, b, 3, 4, 15, 4, color); +} + +static void autostart(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + nk_stroke_triangle(c, b.x + 9, b.y + 1, b.x + 4, b.y + 9, + b.x + 14, b.y + 9, 1.5f, color); + line(c, b, 6, 9, 6, 14, color); line(c, b, 12, 9, 12, 14, color); + line(c, b, 6, 14, 12, 14, color); + line(c, b, 8, 15, 8, 18, color); line(c, b, 10, 15, 10, 18, color); +} + +static void language(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + nk_stroke_circle(c, nk_rect(b.x + 1, b.y + 1, 16, 16), 1.5f, color); + line(c, b, 1, 9, 17, 9, color); line(c, b, 9, 1, 9, 17, color); + line(c, b, 4, 4, 14, 4, color); line(c, b, 4, 14, 14, 14, color); +} + +static void theme(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + nk_stroke_rect(c, nk_rect(b.x + 2, b.y + 10, 7, 6), 2, 1.5f, color); + line(c, b, 7, 11, 14, 2, color); line(c, b, 10, 12, 16, 4, color); + line(c, b, 14, 2, 16, 4, color); line(c, b, 3, 17, 2, 18, color); +} + +static void shortcut_visibility(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + line(c, b, 1, 9, 5, 5, color); line(c, b, 5, 5, 9, 4, color); + line(c, b, 9, 4, 13, 5, color); line(c, b, 13, 5, 17, 9, color); + line(c, b, 1, 9, 5, 13, color); line(c, b, 5, 13, 9, 14, color); + line(c, b, 9, 14, 13, 13, color); line(c, b, 13, 13, 17, 9, color); + dot(c, b, 9, 9, 4, color); +} + +static void shortcut_preferences(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + line(c, b, 2, 4, 16, 4, color); dot(c, b, 6, 4, 4, color); + line(c, b, 2, 9, 16, 9, color); dot(c, b, 12, 9, 4, color); + line(c, b, 2, 14, 16, 14, color); dot(c, b, 8, 14, 4, color); +} + +bool bongo_cat_pref_row_icon_draw(struct nk_command_buffer *canvas, + struct nk_rect bounds, BongoCatPrefIcon icon, struct nk_color color) { + typedef void (*Draw)(struct nk_command_buffer *, struct nk_rect, + struct nk_color); + static const Draw draws[] = {multiple_models, pass_through, always_on_top, + keep_in_screen, solid_background, window_size, opacity, + random_expression, mirror, mouse_mirror, mouse_centered, ignore_mouse, + max_fps, autostart, language, theme, shortcut_visibility, + shortcut_preferences}; + int index = icon - BONGO_CAT_PREF_ICON_MULTIPLE_MODELS; + int count = (int)(sizeof(draws) / sizeof(draws[0])); + if (index < 0 || index >= count) return false; + draws[index](canvas, bounds, color); + return true; +} diff --git a/src/ui/preferences/preferences_section_icons.c b/src/ui/preferences/preferences_section_icons.c new file mode 100644 index 00000000..de1f5778 --- /dev/null +++ b/src/ui/preferences/preferences_section_icons.c @@ -0,0 +1,66 @@ +#include "preferences_icons_internal.h" + +static void window_icon(struct nk_command_buffer *canvas, + struct nk_rect bounds, struct nk_color color) { + nk_stroke_rect(canvas, nk_rect(bounds.x + 1, bounds.y + 2, + bounds.w - 2, bounds.h - 4), 2, 1.5f, color); + nk_stroke_line(canvas, bounds.x + 1, bounds.y + 6, + bounds.x + bounds.w - 1, bounds.y + 6, 1.5f, color); + nk_fill_circle(canvas, nk_rect(bounds.x + 3, bounds.y + 3, 2, 2), color); + nk_fill_circle(canvas, nk_rect(bounds.x + 6, bounds.y + 3, 2, 2), color); +} + +static void model_icon(struct nk_command_buffer *canvas, + struct nk_rect bounds, struct nk_color color) { + float left = bounds.x + 2, right = bounds.x + bounds.w - 2; + float center = bounds.x + bounds.w * .5f; + float top = bounds.y + 1, middle = bounds.y + 6; + float joint = bounds.y + 9, bottom = bounds.y + bounds.h - 1; + nk_stroke_line(canvas, center, top, left, middle, 1.5f, color); + nk_stroke_line(canvas, center, top, right, middle, 1.5f, color); + nk_stroke_line(canvas, left, middle, center, joint, 1.5f, color); + nk_stroke_line(canvas, right, middle, center, joint, 1.5f, color); + nk_stroke_line(canvas, left, middle, left, bounds.y + 13, 1.5f, color); + nk_stroke_line(canvas, right, middle, right, bounds.y + 13, 1.5f, color); + nk_stroke_line(canvas, left, bounds.y + 13, center, bottom, 1.5f, color); + nk_stroke_line(canvas, right, bounds.y + 13, center, bottom, 1.5f, color); + nk_stroke_line(canvas, center, joint, center, bottom, 1.5f, color); +} + +static void application_icon(struct nk_command_buffer *canvas, + struct nk_rect bounds, struct nk_color color) { + nk_stroke_rect(canvas, nk_rect(bounds.x + 1, bounds.y + 1, + bounds.w - 2, bounds.h - 2), 3, 1.5f, color); + for (int row = 0; row < 2; ++row) + for (int column = 0; column < 2; ++column) + nk_fill_rect(canvas, nk_rect(bounds.x + 4 + column * 6, + bounds.y + 4 + row * 6, 4, 4), 1, color); +} + +static void appearance_icon(struct nk_command_buffer *canvas, + struct nk_rect bounds, struct nk_color color) { + float x = bounds.x + bounds.w * .5f, y = bounds.y + bounds.h * .5f; + nk_stroke_circle(canvas, nk_rect(x - 4, y - 4, 8, 8), 1.5f, color); + const float rays[][4] = { + {0, -8, 0, -6}, {0, 6, 0, 8}, {-8, 0, -6, 0}, {6, 0, 8, 0}, + {-6, -6, -4.5f, -4.5f}, {4.5f, 4.5f, 6, 6}, + {4.5f, -4.5f, 6, -6}, {-6, 6, -4.5f, 4.5f}}; + for (int i = 0; i < 8; ++i) + nk_stroke_line(canvas, x + rays[i][0], y + rays[i][1], + x + rays[i][2], y + rays[i][3], 1.5f, color); +} + +bool bongo_cat_pref_section_icon_draw(struct nk_command_buffer *canvas, + struct nk_rect bounds, BongoCatPrefIcon icon, + struct nk_color color) { + if (icon == BONGO_CAT_PREF_ICON_SECTION_WINDOW) + window_icon(canvas, bounds, color); + else if (icon == BONGO_CAT_PREF_ICON_SECTION_MODEL) + model_icon(canvas, bounds, color); + else if (icon == BONGO_CAT_PREF_ICON_SECTION_APPLICATION) + application_icon(canvas, bounds, color); + else if (icon == BONGO_CAT_PREF_ICON_SECTION_APPEARANCE) + appearance_icon(canvas, bounds, color); + else return false; + return true; +} diff --git a/src/ui/preferences/preferences_shortcuts.c b/src/ui/preferences/preferences_shortcuts.c index 3f4956f1..07acabbd 100644 --- a/src/ui/preferences/preferences_shortcuts.c +++ b/src/ui/preferences/preferences_shortcuts.c @@ -162,8 +162,9 @@ bool bongo_cat_preferences_shortcuts_blocked( static void shortcut_row(BongoCatPreferences *value, struct nk_context *context, const char *id, const char *label_key, const char *label_fallback, - char *target, int capacity) { + char *target, int capacity, BongoCatPrefIcon icon) { bool active = bongo_cat_preferences_shortcut_active(value, id); + bongo_cat_pref_row_icon(context, icon); int action = bongo_cat_pref_edit(context, id, tr(value, label_key, label_fallback), "", target, active, tr(value, "components.shortcut.hints.clickRecordShortcut", @@ -179,21 +180,24 @@ static void shortcut_row(BongoCatPreferences *value, struct nk_context *context, void bongo_cat_preferences_page_shortcuts(BongoCatPreferences *value, struct nk_context *context) { BongoCatShortcutPreferences *keys = &value->app->settings.shortcuts; - bongo_cat_pref_section(context, tr(value, - "pages.preference.shortcut.title", "Shortcuts")); shortcut_row(value, context, "shortcut-cat", "native.shortcut.toggleCat", "Show/Hide", - keys->toggle_pet_visibility, sizeof(keys->toggle_pet_visibility)); + keys->toggle_pet_visibility, sizeof(keys->toggle_pet_visibility), + BONGO_CAT_PREF_ICON_SHORTCUT_VISIBILITY); shortcut_row(value, context, "shortcut-pref", "native.shortcut.preferences", "Preferences", - keys->visible_preferences, sizeof(keys->visible_preferences)); - shortcut_row(value, context, "shortcut-mirror", "native.shortcut.mirror", - "Mirror", - keys->mirror, sizeof(keys->mirror)); + keys->visible_preferences, sizeof(keys->visible_preferences), + BONGO_CAT_PREF_ICON_SHORTCUT_PREFERENCES); shortcut_row(value, context, "shortcut-pass", "native.shortcut.passThrough", "Pass Through", - keys->pass_through, sizeof(keys->pass_through)); + keys->pass_through, sizeof(keys->pass_through), + BONGO_CAT_PREF_ICON_PASS_THROUGH); shortcut_row(value, context, "shortcut-top", "native.shortcut.alwaysOnTop", "Always on Top", - keys->always_on_top, sizeof(keys->always_on_top)); + keys->always_on_top, sizeof(keys->always_on_top), + BONGO_CAT_PREF_ICON_ALWAYS_ON_TOP); + shortcut_row(value, context, "shortcut-mirror", "native.shortcut.mirror", + "Mirror", + keys->mirror, sizeof(keys->mirror), + BONGO_CAT_PREF_ICON_MIRROR); } diff --git a/src/ui/preferences/preferences_smoke.c b/src/ui/preferences/preferences_smoke.c index 53d0a92c..2a4c44b7 100644 --- a/src/ui/preferences/preferences_smoke.c +++ b/src/ui/preferences/preferences_smoke.c @@ -69,7 +69,7 @@ void bongo_cat_preferences_smoke_frame(BongoCatPreferences *value) { if (!value->app->smoke) return; bool valid = bongo_cat_ui_frame_valid(&value->ui); bool assets_valid = value->logo_texture && value->icon_texture && - (value->page != 4 || + (value->page != 3 || (value->catime_texture && value->vlaina_texture)); if (!value->frame_checked) { value->frame_checked = true; diff --git a/src/ui/preferences/preferences_state.h b/src/ui/preferences/preferences_state.h index 608903cf..7e5f5a50 100644 --- a/src/ui/preferences/preferences_state.h +++ b/src/ui/preferences/preferences_state.h @@ -74,9 +74,9 @@ struct BongoCatPreferences { float pending_raster_scale; uint64_t raster_retry_ns; uint64_t render_retry_ns; - float scroll_current[5]; - float scroll_target[5]; - bool scroll_ready[5]; + float scroll_current[4]; + float scroll_target[4]; + bool scroll_ready[4]; bool page_seen; int last_page; uint64_t page_transition_ns; diff --git a/src/ui/preferences/preferences_theme.c b/src/ui/preferences/preferences_theme.c index a22f17ed..ceff9aca 100644 --- a/src/ui/preferences/preferences_theme.c +++ b/src/ui/preferences/preferences_theme.c @@ -1,4 +1,5 @@ #include "preferences_theme.h" +#include "preferences_widgets_internal.h" #include "ui_animation.h" #include "ui_backend.h" #include "ui_catime.h" @@ -39,10 +40,12 @@ static bool form_begin(struct nk_context *context, const char *id, context->style.window.group_padding = saved->padding; context->style.window.spacing = saved->spacing; context->style.window.group_border = saved->border; + bongo_cat_pref_row_icon_clear(context); return false; } static void form_end(struct nk_context *context, const ThemeFormStyle *saved) { + bongo_cat_pref_row_icon_clear(context); nk_group_end(context); context->style.window.fixed_background = saved->background; context->style.window.background = saved->window_color; @@ -168,12 +171,7 @@ int bongo_cat_pref_theme(struct nk_context *context, const char *id, float left = NK_MAX(220.0f, available - 184.0f); nk_layout_row_begin(context, NK_STATIC, 36, 2); nk_layout_row_push(context, left); - nk_style_push_font(context, bongo_cat_ui_label_font(context)); - struct nk_vec2 padding = context->style.text.padding; - context->style.text.padding.x += 5.0f; - nk_label(context, title, NK_TEXT_LEFT); - context->style.text.padding = padding; - nk_style_pop_font(context); + bongo_cat_pref_form_label(context, title); nk_layout_row_push(context, NK_MAX(176.0f, available - left - 8.0f)); selected = capsule(context, id, labels, selected); nk_layout_row_end(context); diff --git a/src/ui/preferences/preferences_widgets.c b/src/ui/preferences/preferences_widgets.c index 1b6b9ce5..6a8fd90f 100644 --- a/src/ui/preferences/preferences_widgets.c +++ b/src/ui/preferences/preferences_widgets.c @@ -67,6 +67,7 @@ bool bongo_cat_pref_form_begin(struct nk_context *context, const char *id, if (section_context == context) section_first = false; nk_layout_row_dynamic(context, height, 1); if (!nk_group_begin(context, id, NK_WINDOW_NO_SCROLLBAR)) { + bongo_cat_pref_row_icon_clear(context); restore_style(context, saved); return false; } @@ -74,30 +75,13 @@ bool bongo_cat_pref_form_begin(struct nk_context *context, const char *id, } void bongo_cat_pref_form_end(struct nk_context *context, const FormStyle *saved) { + bongo_cat_pref_row_icon_clear(context); nk_group_end(context); restore_style(context, saved); } #define form_begin bongo_cat_pref_form_begin #define form_end bongo_cat_pref_form_end static float left_width(struct nk_context *context) { return NK_MAX(220.0f, nk_window_get_content_region(context).w - 228.0f); } -void bongo_cat_pref_form_title_sized(struct nk_context *context, - const char *title, - float control_width) { - float available = nk_window_get_content_region(context).w; - float left = NK_MAX(220.0f, available - control_width - 8.0f); - nk_layout_row_begin(context, NK_STATIC, 36, 2); - nk_layout_row_push(context, left); - bongo_cat_pref_form_label(context, title); - nk_layout_row_push(context, NK_MAX(control_width, available - left - 8.0f)); -} -void bongo_cat_pref_form_label(struct nk_context *context, const char *title) { - nk_style_push_font(context, bongo_cat_ui_label_font(context)); - struct nk_vec2 text_padding = context->style.text.padding; - context->style.text.padding.x += 5.0f; - nk_label(context, title, NK_TEXT_LEFT); - context->style.text.padding = text_padding; - nk_style_pop_font(context); -} static void form_title(struct nk_context *context, const char *title) { bongo_cat_pref_form_title_sized(context, title, 220.0f); } void bongo_cat_pref_description(struct nk_context *context, const char *text, @@ -171,6 +155,25 @@ void bongo_cat_pref_section(struct nk_context *context, const char *title) { nk_draw_text(canvas, text, title, nk_strlen(title), font, nk_rgba(0, 0, 0, 0), p.text); } +void bongo_cat_pref_section_icon(struct nk_context *context, + const char *title, BongoCatPrefIcon icon) { + section_context = context; section_first = true; + struct nk_rect bounds; + nk_layout_row_dynamic(context, 22, 1); + if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return; + BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); + struct nk_command_buffer *canvas = nk_window_get_canvas(context); + nk_fill_rect(canvas, nk_rect(bounds.x, bounds.y + 2, 4, 18), 2, p.pink); + struct nk_rect icon_bounds = nk_rect(bounds.x + 14, + bounds.y + 2, 18, 18); + bongo_cat_pref_icon_draw(canvas, icon_bounds, icon, p.accent); + const struct nk_user_font *font = bongo_cat_ui_label_font(context); + struct nk_rect text = nk_rect(bounds.x + 42, + bounds.y + (bounds.h - font->height) * .5f, + bounds.w - 42, font->height); + nk_draw_text(canvas, text, title, nk_strlen(title), font, + nk_rgba(0, 0, 0, 0), p.accent); +} bool bongo_cat_pref_toggle(struct nk_context *context, const char *id, const char *title, const char *detail, bool *value) { int lines = bongo_cat_pref_detail_lines(context, detail); FormStyle saved; diff --git a/src/ui/preferences/preferences_widgets.h b/src/ui/preferences/preferences_widgets.h index 614dd81a..c8dd453e 100644 --- a/src/ui/preferences/preferences_widgets.h +++ b/src/ui/preferences/preferences_widgets.h @@ -4,8 +4,13 @@ #include #include "bongo_cat/config.h" #include "nuklear_config.h" +#include "preferences_icons.h" void bongo_cat_pref_section(struct nk_context *context, const char *title); +void bongo_cat_pref_section_icon(struct nk_context *context, + const char *title, BongoCatPrefIcon icon); +void bongo_cat_pref_row_icon(struct nk_context *context, + BongoCatPrefIcon icon); bool bongo_cat_pref_toggle(struct nk_context *context, const char *id, const char *title, const char *description, bool *value); bool bongo_cat_pref_toggle_float(struct nk_context *context, const char *id, diff --git a/src/ui/preferences/preferences_widgets_internal.h b/src/ui/preferences/preferences_widgets_internal.h index e001f569..b904ee07 100644 --- a/src/ui/preferences/preferences_widgets_internal.h +++ b/src/ui/preferences/preferences_widgets_internal.h @@ -18,6 +18,7 @@ bool bongo_cat_pref_form_begin(struct nk_context *context, const char *id, void bongo_cat_pref_form_end(struct nk_context *context, const FormStyle *saved); void bongo_cat_pref_form_label(struct nk_context *context, const char *title); +void bongo_cat_pref_row_icon_clear(struct nk_context *context); int bongo_cat_pref_detail_lines(const struct nk_context *context, const char *text); void bongo_cat_pref_form_title_sized(struct nk_context *context, diff --git a/src/ui/theme/ui_catime.h b/src/ui/theme/ui_catime.h index 3a05a883..f22b37ef 100644 --- a/src/ui/theme/ui_catime.h +++ b/src/ui/theme/ui_catime.h @@ -49,7 +49,7 @@ bool bongo_cat_ui_close_button(struct nk_context *context, struct nk_command_buffer *canvas, struct nk_rect bounds, struct nk_color normal, struct nk_color hover, bool interactive); void bongo_cat_ui_tabs(struct nk_context *context, const char *const *labels, - int count, int *active, bool interactive, bool dark, + const int *icons, int count, int *active, bool interactive, bool dark, float available_height, BongoCatUIIconDraw draw_icon, void *icon_userdata); void bongo_cat_ui_set_icons(BongoCatUIIconDraw draw_icon, diff --git a/src/ui/theme/ui_catime_tabs.c b/src/ui/theme/ui_catime_tabs.c index b4303126..46a0fafe 100644 --- a/src/ui/theme/ui_catime_tabs.c +++ b/src/ui/theme/ui_catime_tabs.c @@ -70,7 +70,7 @@ static void tab_content(struct nk_command_buffer *canvas, } void bongo_cat_ui_tabs(struct nk_context *context, const char *const *labels, - int count, int *active, bool interactive, bool dark, + const int *icons, int count, int *active, bool interactive, bool dark, float available_height, BongoCatUIIconDraw draw_icon, void *icon_userdata) { BongoCatUIPalette p = bongo_cat_ui_palette(dark); @@ -78,12 +78,10 @@ void bongo_cat_ui_tabs(struct nk_context *context, const char *const *labels, float header_height = nk_window_get_content_region(context).w < 100.0f ? 118.0f : 148.0f; float room = NK_MAX(1.0f, available_height - header_height); - float top_padding = NK_MIN(16.0f, NK_MAX(4.0f, room * .05f)); - float row_height = 68.0f; - float gap = count > 1 ? NK_CLAMP(0.0f, - (room - top_padding - row_height * count) / (count - 1), 8.0f) : 0.0f; - if (top_padding + row_height * count > room) - row_height = NK_MAX(40.0f, (room - top_padding) / count); + float slot_height = room / count; + float row_height = NK_CLAMP(40.0f, slot_height - 8.0f, 68.0f); + float gap = NK_MAX(0.0f, slot_height - row_height); + float top_padding = gap * .5f; for (int i = 0; i < count; ++i) { struct nk_rect bounds; nk_layout_row_dynamic(context, row_height, 1); @@ -111,7 +109,7 @@ void bongo_cat_ui_tabs(struct nk_context *context, const char *const *labels, nk_rgba(p.pink.r, p.pink.g, p.pink.b, (nk_byte)(255 * weight))); struct nk_color color = selected ? nk_rgb(255, 255, 255) : (hover ? p.accent : p.muted); - tab_content(canvas, context, tile, labels[i], i, color, + tab_content(canvas, context, tile, labels[i], icons ? icons[i] : i, color, draw_icon, icon_userdata); if (hover) bongo_cat_ui_cursor_hover_rect(context, hit, BONGO_CAT_UI_CURSOR_POINTER); From b41788f64b79bd1a7228b242725db7bdc190cea0 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 29 Aug 2026 15:39:24 +0800 Subject: [PATCH 145/334] Clarify supported model import formats --- resources/assets/locales/de-DE.json | 3 ++- resources/assets/locales/en-US.json | 3 ++- resources/assets/locales/es-ES.json | 3 ++- resources/assets/locales/fr-FR.json | 3 ++- resources/assets/locales/ja-JP.json | 3 ++- resources/assets/locales/ko-KR.json | 3 ++- resources/assets/locales/pt-BR.json | 3 ++- resources/assets/locales/ru-RU.json | 3 ++- resources/assets/locales/zh-CN.json | 3 ++- resources/assets/locales/zh-Hant.json | 3 ++- src/ui/preferences/preferences_model_card.c | 13 +++++++++++-- 11 files changed, 31 insertions(+), 12 deletions(-) diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index 3e8e2523..68d3f2ce 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -95,7 +95,8 @@ "importOutOfMemory": "Zum Importieren dieses Modells ist nicht genügend Arbeitsspeicher verfügbar", "importLoadFailed": "Das Modell wurde importiert, konnte aber nicht angezeigt werden", "importFailed": "Der Modellimport ist fehlgeschlagen. Prüfen Sie die ausgewählten Dateien und versuchen Sie es erneut", - "clickOrDragToImport": "Zum Importieren klicken oder hierher ziehen", + "clickOrDragToImport": "Modellordner wählen oder ziehen", + "supportedImportFormats": "Unterstützt Mver und Tauri", "openDirectoryFailed": "Modellordner konnte nicht geöffnet werden" }, "tooltips": { diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index 97e8d881..1dc73fe1 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -95,7 +95,8 @@ "importOutOfMemory": "There is not enough memory to import this model", "importLoadFailed": "The model was imported, but it could not be displayed", "importFailed": "Model import failed. Check the selected files and try again", - "clickOrDragToImport": "Click or drag here to import", + "clickOrDragToImport": "Click or drag a model folder", + "supportedImportFormats": "Supports Mver and Tauri formats", "openDirectoryFailed": "Unable to open model directory" }, "tooltips": { diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index c6e90290..f896096b 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -95,7 +95,8 @@ "importOutOfMemory": "No hay memoria suficiente para importar este modelo", "importLoadFailed": "El modelo se importó, pero no se pudo mostrar", "importFailed": "Error al importar el modelo. Compruebe los archivos seleccionados e inténtelo de nuevo", - "clickOrDragToImport": "Haga clic o arrastre aquí para importar", + "clickOrDragToImport": "Haz clic o arrastra una carpeta", + "supportedImportFormats": "Compatible con Mver y Tauri", "openDirectoryFailed": "No se pudo abrir la carpeta del modelo" }, "tooltips": { diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index d12bd59d..bbe53c63 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -95,7 +95,8 @@ "importOutOfMemory": "La mémoire disponible est insuffisante pour importer ce modèle", "importLoadFailed": "Le modèle a été importé, mais il n'a pas pu être affiché", "importFailed": "Échec de l'importation du modèle. Vérifiez les fichiers sélectionnés et réessayez", - "clickOrDragToImport": "Cliquez ou déposez ici pour importer", + "clickOrDragToImport": "Cliquez ou déposez un dossier", + "supportedImportFormats": "Formats Mver et Tauri pris en charge", "openDirectoryFailed": "Impossible d'ouvrir le dossier du modèle" }, "tooltips": { diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index d4655e47..d2a4abe5 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -95,7 +95,8 @@ "importOutOfMemory": "メモリが不足しているため、このモデルをインポートできません", "importLoadFailed": "モデルはインポートされましたが、表示できませんでした", "importFailed": "モデルのインポートに失敗しました。選択したファイルを確認して、もう一度お試しください", - "clickOrDragToImport": "クリックまたはここへドラッグしてインポート", + "clickOrDragToImport": "モデルフォルダーをクリックまたはドラッグ", + "supportedImportFormats": "Mver・Tauri形式に対応", "openDirectoryFailed": "モデルフォルダーを開けません" }, "tooltips": { diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index 55bab6f1..6aa5f54b 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -95,7 +95,8 @@ "importOutOfMemory": "메모리가 부족하여 이 모델을 가져올 수 없습니다", "importLoadFailed": "모델을 가져왔지만 표시할 수 없습니다", "importFailed": "모델을 가져오지 못했습니다. 선택한 파일을 확인한 후 다시 시도하세요", - "clickOrDragToImport": "클릭하거나 여기로 끌어와 가져오기", + "clickOrDragToImport": "모델 폴더를 클릭하거나 끌어오세요", + "supportedImportFormats": "Mver 및 Tauri 형식 지원", "openDirectoryFailed": "모델 폴더를 열 수 없습니다" }, "tooltips": { diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index ac0b9f0b..cd6b3ad6 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -95,7 +95,8 @@ "importOutOfMemory": "Não há memória suficiente para importar este modelo", "importLoadFailed": "O modelo foi importado, mas não pôde ser exibido", "importFailed": "Falha ao importar o modelo. Verifique os arquivos selecionados e tente novamente", - "clickOrDragToImport": "Clique ou arraste para importar", + "clickOrDragToImport": "Clique ou arraste uma pasta", + "supportedImportFormats": "Compatível com Mver e Tauri", "openDirectoryFailed": "Não foi possível abrir a pasta do modelo" }, "tooltips": { diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index be076a0f..6882499f 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -95,7 +95,8 @@ "importOutOfMemory": "Недостаточно памяти для импорта этой модели", "importLoadFailed": "Модель импортирована, но её не удалось отобразить", "importFailed": "Не удалось импортировать модель. Проверьте выбранные файлы и повторите попытку", - "clickOrDragToImport": "Щёлкните или перетащите сюда для импорта", + "clickOrDragToImport": "Выберите или перетащите папку", + "supportedImportFormats": "Поддержка форматов Mver и Tauri", "openDirectoryFailed": "Не удалось открыть папку модели" }, "tooltips": { diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index aecff08c..0917da65 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -95,7 +95,8 @@ "importOutOfMemory": "内存不足,无法导入此模型", "importLoadFailed": "模型已导入,但无法正常显示", "importFailed": "模型导入失败,请检查所选文件后重试", - "clickOrDragToImport": "点击或拖动至此区域导入", + "clickOrDragToImport": "点击或拖入模型文件夹", + "supportedImportFormats": "同时支持 Mver 和 Tauri 格式", "openDirectoryFailed": "无法打开模型目录" }, "tooltips": { diff --git a/resources/assets/locales/zh-Hant.json b/resources/assets/locales/zh-Hant.json index ce05226e..d3f3709e 100644 --- a/resources/assets/locales/zh-Hant.json +++ b/resources/assets/locales/zh-Hant.json @@ -95,7 +95,8 @@ "importOutOfMemory": "記憶體不足,無法匯入此模型", "importLoadFailed": "模型已匯入,但無法正常顯示", "importFailed": "模型匯入失敗,請檢查所選檔案後再試一次", - "clickOrDragToImport": "點擊或拖曳至此區域匯入", + "clickOrDragToImport": "點擊或拖入模型資料夾", + "supportedImportFormats": "支援 Mver 與 Tauri 格式", "openDirectoryFailed": "無法開啟模型目錄" }, "tooltips": { diff --git a/src/ui/preferences/preferences_model_card.c b/src/ui/preferences/preferences_model_card.c index 82f55c1e..8feb0ddf 100644 --- a/src/ui/preferences/preferences_model_card.c +++ b/src/ui/preferences/preferences_model_card.c @@ -64,12 +64,21 @@ bool bongo_cat_preferences_model_import_card(BongoCatPreferences *value, bongo_cat_preferences_icon_draw(value, canvas, BONGO_CAT_UI_ICON_UPLOAD, nk_rect(cx - 20, cy - 20, 40, 40), p.pink); const char *label = tr(value->app, - "pages.preference.model.hints.clickOrDragToImport", "Import model directory"); + "pages.preference.model.hints.clickOrDragToImport", + "Click or drag a model folder"); float width = value->ui.caption_font->width(value->ui.caption_font->userdata, value->ui.caption_font->height, label, nk_strlen(label)); - text(context, canvas, nk_rect(cx - width * .5f, cy + 30, + text(context, canvas, nk_rect(cx - width * .5f, cy + 25, NK_MIN(width + 1, bounds.w - 20), 24), label, p.accent, value->ui.caption_font); + const char *support = tr(value->app, + "pages.preference.model.hints.supportedImportFormats", + "Supports Mver and Tauri formats"); + width = value->ui.caption_font->width(value->ui.caption_font->userdata, + value->ui.caption_font->height, support, nk_strlen(support)); + text(context, canvas, nk_rect(cx - width * .5f, cy + 47, + NK_MIN(width + 1, bounds.w - 20), 24), support, p.muted, + value->ui.caption_font); if (pointer_hover) bongo_cat_ui_cursor_hover_rect(context, bounds, BONGO_CAT_UI_CURSOR_POINTER); return pointer_hover && nk_input_is_mouse_click_in_rect(&context->input, From 78aead5b76ca286081187c9aeceebae38e775f91 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 29 Aug 2026 15:45:28 +0800 Subject: [PATCH 146/334] Polish model import and about page copy --- resources/assets/locales/de-DE.json | 2 +- resources/assets/locales/en-US.json | 2 +- resources/assets/locales/es-ES.json | 2 +- resources/assets/locales/fr-FR.json | 2 +- resources/assets/locales/ja-JP.json | 2 +- resources/assets/locales/ko-KR.json | 2 +- resources/assets/locales/pt-BR.json | 2 +- resources/assets/locales/ru-RU.json | 2 +- resources/assets/locales/zh-CN.json | 2 +- resources/assets/locales/zh-Hant.json | 2 +- src/ui/preferences/preferences_about.c | 2 -- src/ui/preferences/preferences_model_card.c | 2 +- 12 files changed, 11 insertions(+), 13 deletions(-) diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index 68d3f2ce..147420d2 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -214,7 +214,7 @@ "viewReleases": "Versionen anzeigen", "openUpdateFailed": "Die Update-Seite konnte nicht geöffnet werden", "works": "Weitere Anwendungen", - "worksText": "Weitere Software von vladelaina", + "worksText": "Weitere Werke von vladelaina, die einen Blick wert sind", "refactorDeveloper": "Entwickler, der BongoCat in C neu geschrieben hat und zugleich aktueller Maintainer ist", "community": "Community", "qqGroup": "QQ-Gruppe", diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index 1dc73fe1..52dc7a0b 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -214,7 +214,7 @@ "viewReleases": "View releases", "openUpdateFailed": "Unable to open the update page", "works": "More apps", - "worksText": "More software from vladelaina", + "worksText": "More from vladelaina, well worth a try", "refactorDeveloper": "Developer who rewrote BongoCat in C and is also its current maintainer", "community": "Community", "qqGroup": "QQ Group", diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index f896096b..687b4d80 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -214,7 +214,7 @@ "viewReleases": "Ver versiones", "openUpdateFailed": "No se pudo abrir la página de actualización", "works": "Más aplicaciones", - "worksText": "Más software de vladelaina", + "worksText": "Más creaciones de vladelaina que vale la pena probar", "refactorDeveloper": "Desarrollador que reescribió BongoCat en C y que también es su mantenedor actual", "community": "Comunidad", "qqGroup": "Grupo de QQ", diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index bbe53c63..3433687e 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -214,7 +214,7 @@ "viewReleases": "Voir les versions", "openUpdateFailed": "Impossible d'ouvrir la page de mise à jour", "works": "Plus d'applications", - "worksText": "Plus de logiciels de vladelaina", + "worksText": "D’autres créations de vladelaina à découvrir", "refactorDeveloper": "Développeur qui a réécrit BongoCat en C et qui en est aussi le mainteneur actuel", "community": "Communauté", "qqGroup": "Groupe QQ", diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index d2a4abe5..255dd8db 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -214,7 +214,7 @@ "viewReleases": "リリースを表示", "openUpdateFailed": "更新ページを開けませんでした", "works": "その他のアプリ", - "worksText": "vladelainaのその他のソフトウェア", + "worksText": "vladelainaのほかの作品も、ぜひお試しください", "refactorDeveloper": "BongoCatをC言語で書き直した開発者であり、現在のメンテナーでもある", "community": "コミュニティ", "qqGroup": "QQグループ", diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index 6aa5f54b..e8d881bf 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -214,7 +214,7 @@ "viewReleases": "릴리스 보기", "openUpdateFailed": "업데이트 페이지를 열 수 없습니다", "works": "더 많은 앱", - "worksText": "vladelaina의 다른 소프트웨어", + "worksText": "vladelaina의 다른 작품도 만나보세요", "refactorDeveloper": "BongoCat을 C 언어로 재작성한 개발자이자 현재 유지보수 담당자", "community": "커뮤니티", "qqGroup": "QQ 그룹", diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index cd6b3ad6..3ecc8bb6 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -214,7 +214,7 @@ "viewReleases": "Ver versões", "openUpdateFailed": "Não foi possível abrir a página de atualização", "works": "Mais aplicativos", - "worksText": "Outros softwares de vladelaina", + "worksText": "Outras criações de vladelaina que valem a pena conhecer", "refactorDeveloper": "Desenvolvedor que reescreveu o BongoCat em C e também é seu mantenedor atual", "community": "Comunidade", "qqGroup": "Grupo QQ", diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index 6882499f..3072710a 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -214,7 +214,7 @@ "viewReleases": "Посмотреть выпуски", "openUpdateFailed": "Не удалось открыть страницу обновления", "works": "Другие приложения", - "worksText": "Другие программы от vladelaina", + "worksText": "Другие работы vladelaina тоже стоит попробовать", "refactorDeveloper": "Разработчик, переписавший BongoCat на C, и его нынешний мейнтейнер", "community": "Сообщество", "qqGroup": "Группа QQ", diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index 0917da65..846e82df 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -214,7 +214,7 @@ "viewReleases": "查看发布版本", "openUpdateFailed": "无法打开更新页面", "works": "更多作品", - "worksText": "来自 vladelaina 的其他软件", + "worksText": "来自 vladelaina 的其他作品,同样值得一试", "refactorDeveloper": "将BongoCat用C语言重构的开发者,同时也是现任维护者", "community": "社区", "qqGroup": "QQ 群", diff --git a/resources/assets/locales/zh-Hant.json b/resources/assets/locales/zh-Hant.json index d3f3709e..d8bd1f19 100644 --- a/resources/assets/locales/zh-Hant.json +++ b/resources/assets/locales/zh-Hant.json @@ -214,7 +214,7 @@ "viewReleases": "查看發佈版本", "openUpdateFailed": "無法開啟更新頁面", "works": "更多作品", - "worksText": "來自 vladelaina 的其他軟體", + "worksText": "來自 vladelaina 的其他作品,同樣值得一試", "refactorDeveloper": "將BongoCat用C語言重構的開發者,同時也是現任維護者", "community": "社群", "qqGroup": "QQ 群", diff --git a/src/ui/preferences/preferences_about.c b/src/ui/preferences/preferences_about.c index ca736cc6..9c31e617 100644 --- a/src/ui/preferences/preferences_about.c +++ b/src/ui/preferences/preferences_about.c @@ -222,8 +222,6 @@ void bongo_cat_preferences_about_projects(BongoCatPreferences *value, if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return; BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); struct nk_command_buffer *canvas = nk_window_get_canvas(context); - nk_stroke_line(canvas, bounds.x + 8, bounds.y - 5, bounds.x + bounds.w - 8, - bounds.y - 5, 1, p.border_subtle); bongo_cat_preferences_about_projects_heading(value, context, bounds); float card_width = 220, center = bounds.x + bounds.w * .5f + 3; project(value, context, canvas, nk_rect(center - 262, bounds.y + 112, diff --git a/src/ui/preferences/preferences_model_card.c b/src/ui/preferences/preferences_model_card.c index 8feb0ddf..f021c22f 100644 --- a/src/ui/preferences/preferences_model_card.c +++ b/src/ui/preferences/preferences_model_card.c @@ -77,7 +77,7 @@ bool bongo_cat_preferences_model_import_card(BongoCatPreferences *value, width = value->ui.caption_font->width(value->ui.caption_font->userdata, value->ui.caption_font->height, support, nk_strlen(support)); text(context, canvas, nk_rect(cx - width * .5f, cy + 47, - NK_MIN(width + 1, bounds.w - 20), 24), support, p.muted, + NK_MIN(width + 1, bounds.w - 20), 24), support, p.pink, value->ui.caption_font); if (pointer_hover) bongo_cat_ui_cursor_hover_rect(context, bounds, BONGO_CAT_UI_CURSOR_POINTER); From 1966f76fcb29968090120b221c0989131ef96bda Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 29 Aug 2026 17:01:45 +0800 Subject: [PATCH 147/334] Fix Mver model candidate filtering --- cmake/RuntimeSources.cmake | 1 + cmake/Tests.cmake | 1 + .../model/import/model_import_digest.c | 8 +- .../model/import/model_import_discover.c | 11 +- .../model/import/model_import_identity.c | 2 + .../model/import/model_import_installed.c | 2 + .../import/mver/model_import_mver_policy.c | 137 ++++++++++++++++++ .../import/mver/model_import_mver_policy.h | 15 ++ .../model/import/nearby/model_import_nearby.c | 9 ++ .../nearby/model_import_nearby_internal.h | 2 +- tests/model_import/test_mver_container.c | 17 ++- tests/model_import/test_mver_import.c | 2 + tests/model_import/test_mver_policy.c | 74 ++++++++++ 13 files changed, 276 insertions(+), 5 deletions(-) create mode 100644 src/runtime/model/import/mver/model_import_mver_policy.c create mode 100644 src/runtime/model/import/mver/model_import_mver_policy.h create mode 100644 tests/model_import/test_mver_policy.c diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 0f1d06b8..55a04c4d 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -108,6 +108,7 @@ set(BONGO_CAT_RUNTIME_IMPORT_SOURCES src/runtime/model/import/mver/model_import_mver_metadata.c src/runtime/model/import/mver/model_import_mver_motion.c src/runtime/model/import/mver/model_import_mver_patch.c + src/runtime/model/import/mver/model_import_mver_policy.c src/runtime/model/import/mver/model_import_mver_shortcut.c src/runtime/model/import/nearby/model_import_nearby.c src/runtime/model/import/nearby/model_import_nearby_cache.c diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index cc63e136..cbfe1b92 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -53,6 +53,7 @@ if(BUILD_TESTING) tests/model_import/test_mver_pointer_import.c tests/ui/test_preferences_text.c tests/model_import/test_mver_nearby_identity.c + tests/model_import/test_mver_policy.c tests/model_import/test_model_import_identity.c tests/model_import/test_slim_package.c tests/model_import/test_mver_support.c) diff --git a/src/runtime/model/import/model_import_digest.c b/src/runtime/model/import/model_import_digest.c index 8f67fb81..d7f04428 100644 --- a/src/runtime/model/import/model_import_digest.c +++ b/src/runtime/model/import/model_import_digest.c @@ -1,4 +1,5 @@ #include "model_import.h" +#include "model_import_mver_policy.h" #include "bongo_cat/path.h" #include "bongo_cat/sha256.h" @@ -214,11 +215,13 @@ static bool stamp_equal(const DigestStamp *value, uint64_t sum, uint64_t exclusi } static bool candidate_placeholder(const BongoCatImportCandidate *candidate, - const DigestStamp *content, const DigestStamp *overrides) { + const DigestStamp *content, const DigestStamp *overrides, + BongoCatImportDigestCache *cache) { /* These fingerprints are the stock mode and input-image templates shipped by the Mver 0.1.6 runtime; package configuration is intentionally not part of the placeholder decision. */ if (candidate->format == BONGO_CAT_IMPORT_TAURI) return false; + if (bongo_cat_import_mver_stock_model(candidate, cache)) return true; if (candidate->format == BONGO_CAT_IMPORT_MVER_PATCH) { if (!overrides->files) return true; if (candidate->mode != BONGO_CAT_MODE_GAMEPAD) return false; @@ -284,6 +287,7 @@ bool bongo_cat_import_candidate_inspect_cached( if (length < 0 || (size_t)length >= sizeof(summary)) return false; bongo_cat_sha256_bytes(summary, (size_t)length, output); if (placeholder) - *placeholder = candidate_placeholder(candidate, &content, &overrides); + *placeholder = candidate_placeholder(candidate, &content, &overrides, + cache); return true; } diff --git a/src/runtime/model/import/model_import_discover.c b/src/runtime/model/import/model_import_discover.c index 2c31734f..43747f8b 100644 --- a/src/runtime/model/import/model_import_discover.c +++ b/src/runtime/model/import/model_import_discover.c @@ -48,10 +48,19 @@ static int rank(const BongoCatImportCandidate *candidate) { candidate->mode == BONGO_CAT_MODE_KEYBOARD ? 1 : 2; } +static int format_rank(const BongoCatImportCandidate *candidate) { + return candidate->format == BONGO_CAT_IMPORT_MVER ? 0 : + candidate->format == BONGO_CAT_IMPORT_TAURI ? 1 : 2; +} + static int compare_candidates(const void *left, const void *right) { const BongoCatImportCandidate *a = left, *b = right; int difference = rank(a) - rank(b); - return difference ? difference : strcmp(a->directory, b->directory); + if (difference) return difference; + difference = format_rank(a) - format_rank(b); + if (difference) return difference; + difference = strcmp(a->directory, b->directory); + return difference ? difference : strcmp(a->patch_root, b->patch_root); } typedef struct ContainerDiscovery { diff --git a/src/runtime/model/import/model_import_identity.c b/src/runtime/model/import/model_import_identity.c index 942414b5..568cc5db 100644 --- a/src/runtime/model/import/model_import_identity.c +++ b/src/runtime/model/import/model_import_identity.c @@ -1,4 +1,5 @@ #include "model_import.h" +#include "model_import_mver_policy.h" #include "bongo_cat/path.h" #include "bongo_cat/sha256.h" #include "bongo_cat/utf8.h" @@ -176,6 +177,7 @@ bool bongo_cat_import_prepare_package_metadata_cached( size_t used = 0, output = 0; for (size_t i = 0; i < discovery->count; ++i) { BongoCatImportCandidate candidate = discovery->candidates[i]; + if (bongo_cat_import_patch_has_full_base(discovery, i)) continue; BongoCatPackageMetadata *item = &metadata[output]; bool placeholder = false; if (!bongo_cat_import_candidate_inspect_cached(&candidate, diff --git a/src/runtime/model/import/model_import_installed.c b/src/runtime/model/import/model_import_installed.c index ce698bea..6ae5f493 100644 --- a/src/runtime/model/import/model_import_installed.c +++ b/src/runtime/model/import/model_import_installed.c @@ -1,4 +1,5 @@ #include "model_import.h" +#include "model_import_mver_policy.h" #include "model_import_nearby_internal.h" #include "runtime.h" #include "bongo_cat/path.h" @@ -91,6 +92,7 @@ static bool add_package(InstalledModelScan *scan, const char *directory, BongoCatModelMode modes[BONGO_CAT_IMPORT_CANDIDATE_CAP] = {0}; size_t identity_count = 0; for (size_t i = 0; i < discovery->count; ++i) { + if (bongo_cat_import_patch_has_full_base(discovery, i)) continue; BongoCatImportCandidate *candidate = &discovery->candidates[i]; char cache_id[BONGO_CAT_ID_CAP], adapter[BONGO_CAT_PATH_CAP]; char signature[65], identity[65]; diff --git a/src/runtime/model/import/mver/model_import_mver_policy.c b/src/runtime/model/import/mver/model_import_mver_policy.c new file mode 100644 index 00000000..d7932f77 --- /dev/null +++ b/src/runtime/model/import/mver/model_import_mver_policy.c @@ -0,0 +1,137 @@ +#include "model_import_mver_policy.h" +#include "bongo_cat/file.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +typedef struct StockLive2DProfile { + BongoCatModelMode mode; + const char *moc; + const char *textures[3]; + size_t texture_count; +} StockLive2DProfile; + +bool bongo_cat_import_patch_base_inspect( + const BongoCatImportCandidate *candidate, char output[65], + bool *placeholder, BongoCatError *error) { + if (!candidate || candidate->format != BONGO_CAT_IMPORT_MVER_PATCH) + return false; + BongoCatImportCandidate base = *candidate; + base.format = BONGO_CAT_IMPORT_MVER; + base.patch_root[0] = '\0'; + base.overrides[0] = '\0'; + return bongo_cat_import_candidate_inspect(&base, output, placeholder, error); +} + +bool bongo_cat_import_patch_has_full_base( + const BongoCatImportDiscovery *discovery, size_t candidate_index) { + if (!discovery || candidate_index >= discovery->count) return false; + const BongoCatImportCandidate *patch = + &discovery->candidates[candidate_index]; + if (patch->format != BONGO_CAT_IMPORT_MVER_PATCH) return false; + for (size_t i = 0; i < discovery->count; ++i) { + const BongoCatImportCandidate *base = &discovery->candidates[i]; + if (base->format == BONGO_CAT_IMPORT_MVER && + base->mode == patch->mode && + strcmp(base->directory, patch->directory) == 0 && + strcmp(base->setting, patch->setting) == 0) return true; + } + return false; +} + +static bool safe_model_reference(const char *value) { + if (!value || !value[0] || value[0] == '/' || value[0] == '\\' || + strchr(value, ':')) return false; + const char *part = value; + while (*part) { + while (*part == '/' || *part == '\\') part++; + if (part[0] == '.' && part[1] == '.' && + (!part[2] || part[2] == '/' || part[2] == '\\')) return false; + part = strpbrk(part, "/\\"); + if (!part) break; + } + return true; +} + +static bool reference_digest(const char *root, const char *relative, + BongoCatImportDigestCache *cache, char digest[65]) { + char path[BONGO_CAT_PATH_CAP]; + uint64_t size = 0, modified = 0; + return safe_model_reference(relative) && + bongo_cat_path_join(path, sizeof(path), root, relative) && + bongo_cat_path_file_info(path, &size, &modified) && + bongo_cat_import_digest_file_cached(cache, path, size, modified, digest); +} + +static bool profile_matches(const StockLive2DProfile *profile, + const BongoCatImportCandidate *candidate, yyjson_val *refs, + BongoCatImportDigestCache *cache) { + if (profile->mode != candidate->mode) return false; + const char *moc = yyjson_get_str(yyjson_obj_get(refs, "Moc")); + yyjson_val *textures = yyjson_obj_get(refs, "Textures"); + if (!yyjson_is_arr(textures) || + yyjson_arr_size(textures) != profile->texture_count) return false; + char digest[65]; + if (!reference_digest(candidate->directory, moc, cache, digest) || + strcmp(digest, profile->moc) != 0) return false; + for (size_t i = 0; i < profile->texture_count; ++i) { + const char *texture = yyjson_get_str(yyjson_arr_get(textures, i)); + if (!reference_digest(candidate->directory, texture, cache, digest) || + strcmp(digest, profile->textures[i]) != 0) return false; + } + return true; +} + +bool bongo_cat_import_mver_stock_model( + const BongoCatImportCandidate *candidate, + BongoCatImportDigestCache *cache) { + /* Mver skins commonly retain these runtime models while replacing only + mode input images. Match the model's rendered core independently from + those surrounding images so the bundled model cannot become a variant. */ + static const StockLive2DProfile profiles[] = { + {BONGO_CAT_MODE_STANDARD, + "59f0969de5ddfb530c52c661e549e274e6af34cb59b4274399b9e9f1b37ede51", + {"1b5092cf3b1b54f69d8986d6ce7dfec324b36a13fcd10f129acc0053997dfd3e", + "c98d6db3a0a38ef312a38bacb5328e4787c079fa044e6faa85ed44e5b2fbb601"}, 2}, + {BONGO_CAT_MODE_KEYBOARD, + "03ed67f3ee2ea612aba4da0d42874f8879853d69043c9aae98af440d1f66965e", + {"3153891ec9c6b89ce9b33a610fb081d276ff356c5e19407d5ff0690d7ac7d97a", + "3332efb5349cdf54ea70f1ba491791d3690d48876d8abea19cb7c3f3ef383d93", + "681fcc7634d723cb10cfe4efa4eccb16c2b90d51647fff2f9904aaa298f030c5"}, 3}, + {BONGO_CAT_MODE_GAMEPAD, + "e7f11d627011bb2c65d8b0882ce4545115d2256672dca256b674a713e3e5f3d6", + {"9e593239d867b90966b0f3df81ac98aad7e74eb35cba81f3cf961dfb9bf495ea", + "3332efb5349cdf54ea70f1ba491791d3690d48876d8abea19cb7c3f3ef383d93", + "681fcc7634d723cb10cfe4efa4eccb16c2b90d51647fff2f9904aaa298f030c5"}, 3}, + {BONGO_CAT_MODE_KEYBOARD, + "03ed67f3ee2ea612aba4da0d42874f8879853d69043c9aae98af440d1f66965e", + {"0a17e9c74b0269e0d8c4291da03825fe81d20c09da520537127140fbd0900400", + "bf32b89f328160ac8bd7cd4e527646eedfae98294506c597f381001ae6a83eb4", + "93c3213355e4743b38e8dd2e14a24693f2d24f10dc8b51c4ca986a749ee24682"}, 3}, + {BONGO_CAT_MODE_GAMEPAD, + "e7f11d627011bb2c65d8b0882ce4545115d2256672dca256b674a713e3e5f3d6", + {"8238890702166583db122e9d4f0a3c3d1717e3188ab8bfd4a264a2a4e13a053d", + "bf32b89f328160ac8bd7cd4e527646eedfae98294506c597f381001ae6a83eb4", + "93c3213355e4743b38e8dd2e14a24693f2d24f10dc8b51c4ca986a749ee24682"}, 3} + }; + char manifest[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(manifest, sizeof(manifest), candidate->directory, + candidate->setting)) return false; + FILE *file = bongo_cat_file_open(manifest, "rb"); + yyjson_doc *document = file ? yyjson_read_fp(file, 0, NULL, NULL) : NULL; + if (file) fclose(file); + yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; + yyjson_val *refs = yyjson_is_obj(root) + ? yyjson_obj_get(root, "FileReferences") : NULL; + bool matched = false; + for (size_t i = 0; yyjson_is_obj(refs) && + i < sizeof(profiles) / sizeof(profiles[0]); ++i) + if (profile_matches(&profiles[i], candidate, refs, cache)) { + matched = true; + break; + } + yyjson_doc_free(document); + return matched; +} diff --git a/src/runtime/model/import/mver/model_import_mver_policy.h b/src/runtime/model/import/mver/model_import_mver_policy.h new file mode 100644 index 00000000..a481ca09 --- /dev/null +++ b/src/runtime/model/import/mver/model_import_mver_policy.h @@ -0,0 +1,15 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_MVER_POLICY_H +#define BONGO_CAT_MODEL_IMPORT_MVER_POLICY_H + +#include "model_import.h" + +bool bongo_cat_import_mver_stock_model( + const BongoCatImportCandidate *candidate, + BongoCatImportDigestCache *cache); +bool bongo_cat_import_patch_base_inspect( + const BongoCatImportCandidate *candidate, char output[65], + bool *placeholder, BongoCatError *error); +bool bongo_cat_import_patch_has_full_base( + const BongoCatImportDiscovery *discovery, size_t candidate_index); + +#endif diff --git a/src/runtime/model/import/nearby/model_import_nearby.c b/src/runtime/model/import/nearby/model_import_nearby.c index 70838290..375f770b 100644 --- a/src/runtime/model/import/nearby/model_import_nearby.c +++ b/src/runtime/model/import/nearby/model_import_nearby.c @@ -1,4 +1,5 @@ #include "model_import.h" +#include "model_import_mver_policy.h" #include "model_import_nearby_internal.h" #include "runtime.h" #include "bongo_cat/path.h" @@ -117,6 +118,14 @@ static bool add_candidate(BongoCatApp *app, const char *cache_root, source, signature, identity, true, candidate->mode); return true; } + if (candidate->format == BONGO_CAT_IMPORT_MVER_PATCH) { + char base_identity[65]; + bool base_placeholder = false; + if (!bongo_cat_import_patch_base_inspect(candidate, base_identity, + &base_placeholder, error)) return false; + if (base_placeholder || existing_identity(&app->models, base_identity, + candidate->mode, same_id)) return true; + } if (existing_identity(&app->models, identity, candidate->mode, same_id)) { if (!identity_cached) bongo_cat_nearby_remember_inspection(adapter, source, signature, identity, false, candidate->mode); diff --git a/src/runtime/model/import/nearby/model_import_nearby_internal.h b/src/runtime/model/import/nearby/model_import_nearby_internal.h index fb308394..c250122a 100644 --- a/src/runtime/model/import/nearby/model_import_nearby_internal.h +++ b/src/runtime/model/import/nearby/model_import_nearby_internal.h @@ -4,7 +4,7 @@ #include "model_import.h" #define BONGO_CAT_NEARBY_CACHE_MARKER ".bongo-cat-cache.json" -#define BONGO_CAT_NEARBY_CACHE_SCHEMA 4 +#define BONGO_CAT_NEARBY_CACHE_SCHEMA 5 #define BONGO_CAT_ADAPTER_CACHE_DIRECTORY "model-adapters" void bongo_cat_import_model_scan_lock(void); diff --git a/tests/model_import/test_mver_container.c b/tests/model_import/test_mver_container.c index eaa387e6..60a7ce01 100644 --- a/tests/model_import/test_mver_container.c +++ b/tests/model_import/test_mver_container.c @@ -92,6 +92,14 @@ void test_mver_container_discovery(void) { contained_patch = &selected_container.candidates[i]; } CHECK(patch_count == 1); + BongoCatImportDiscovery filtered = selected_container; + BongoCatPackageMetadata filtered_metadata[ + BONGO_CAT_IMPORT_CANDIDATE_CAP] = {0}; + CHECK(bongo_cat_import_prepare_package_metadata(&filtered, + filtered_metadata, &error)); + CHECK(filtered.count == 2); + for (size_t i = 0; i < filtered.count; ++i) + CHECK(filtered.candidates[i].format == BONGO_CAT_IMPORT_MVER); char installed_root[BONGO_CAT_PATH_CAP]; snprintf(installed_root, sizeof(installed_root), "%s/bongocat-container-installed-%llu", temporary, stamp); @@ -109,6 +117,13 @@ void test_mver_container_discovery(void) { BongoCatImportDiscovery external_patch = {0}; CHECK(bongo_cat_import_mver_patch_discover(variant, &external_patch, &error) == 1); + BongoCatImportDiscovery standalone_patch = external_patch; + BongoCatPackageMetadata standalone_metadata[ + BONGO_CAT_IMPORT_CANDIDATE_CAP] = {0}; + CHECK(bongo_cat_import_prepare_package_metadata(&standalone_patch, + standalone_metadata, &error)); + CHECK(standalone_patch.count == 1 && + standalone_patch.candidates[0].format == BONGO_CAT_IMPORT_MVER_PATCH); char external_root[BONGO_CAT_PATH_CAP]; snprintf(external_root, sizeof(external_root), "%s/bongocat-container-external-%llu", temporary, stamp); @@ -133,7 +148,7 @@ void test_mver_container_discovery(void) { BongoCatImportReceipt receipt = {0}; CHECK(bongo_cat_import_install(root, models_root, &receipt, &error) == BONGO_CAT_OK); - CHECK(receipt.count > 1 && receipt.installed_count == receipt.count); + CHECK(receipt.count == 2 && receipt.installed_count == 2); char stored[BONGO_CAT_PATH_CAP], duplicate_directory[BONGO_CAT_PATH_CAP]; CHECK(child(stored, sizeof(stored), models_root, receipt.ids[0], false) && bongo_cat_path_is_dir(stored)); diff --git a/tests/model_import/test_mver_import.c b/tests/model_import/test_mver_import.c index 802ab0b2..a940f3fa 100644 --- a/tests/model_import/test_mver_import.c +++ b/tests/model_import/test_mver_import.c @@ -18,6 +18,7 @@ int failures; int test_preferences_text(void); int test_mver_nearby_identity(void); int test_mver_nearby_refresh(void); int test_mver_missing_motion_groups(void); +int test_mver_policy(void); int test_model_import_identity(void); int test_slim_package(void); static bool chord(const char *json, bool gamepad, const char *expected) { @@ -214,6 +215,7 @@ int main(void) { model_visual_curve(); failures += test_mver_nearby_identity(); failures += test_mver_nearby_refresh(); + failures += test_mver_policy(); failures += test_model_import_identity(); test_mver_container_discovery(); test_tauri_portable(); diff --git a/tests/model_import/test_mver_policy.c b/tests/model_import/test_mver_policy.c new file mode 100644 index 00000000..b7ec8731 --- /dev/null +++ b/tests/model_import/test_mver_policy.c @@ -0,0 +1,74 @@ +#include "model_import.h" +#include "model_storage.h" +#include "test_mver_support.h" + +#include +#include + +static int failures; +#define CHECK(value) do { if (!(value)) { \ + fprintf(stderr, "%s:%d: check failed: %s\n", __FILE__, __LINE__, #value); \ + failures++; \ +} } while (0) + +int test_mver_policy(void) { + failures = 0; + char *temporary = SDL_GetCurrentDirectory(); + CHECK(temporary != NULL); + if (!temporary) return failures; + static const struct { + const char *name; + BongoCatModelMode mode; + } fixtures[] = { + {"keyboard", BONGO_CAT_MODE_KEYBOARD}, + {"gamepad", BONGO_CAT_MODE_GAMEPAD} + }; + for (size_t i = 0; i < sizeof(fixtures) / sizeof(fixtures[0]); ++i) { + BongoCatImportCandidate candidate = {0}; + snprintf(candidate.directory, sizeof(candidate.directory), + "%s/resources/assets/models/%s", BONGO_CAT_NATIVE_SOURCE_DIR, + fixtures[i].name); + snprintf(candidate.assets, sizeof(candidate.assets), "%s", + candidate.directory); + snprintf(candidate.package_root, sizeof(candidate.package_root), "%s", + candidate.directory); + snprintf(candidate.setting, sizeof(candidate.setting), + "cat.model3.json"); + candidate.mode = fixtures[i].mode; + candidate.format = BONGO_CAT_IMPORT_MVER; + char digest[65]; + bool placeholder = false; + BongoCatError error = {0}; + CHECK(bongo_cat_import_candidate_inspect(&candidate, digest, + &placeholder, &error) && placeholder); + } + + char source[BONGO_CAT_PATH_CAP], custom[BONGO_CAT_PATH_CAP]; + char texture[BONGO_CAT_PATH_CAP], replacement[BONGO_CAT_PATH_CAP]; + snprintf(source, sizeof(source), "%s/resources/assets/models/keyboard", + BONGO_CAT_NATIVE_SOURCE_DIR); + snprintf(custom, sizeof(custom), "%s/bongocat-retextured-stock-%llu", + temporary, (unsigned long long)SDL_GetTicksNS()); + BongoCatError error = {0}; + CHECK(bongo_cat_model_copy_directory(source, custom, &error) == BONGO_CAT_OK); + CHECK(child(texture, sizeof(texture), custom, + "demomodel2.1024/texture_00.png", false)); + snprintf(replacement, sizeof(replacement), + "%s/resources/assets/bongocat.png", BONGO_CAT_NATIVE_SOURCE_DIR); + CHECK(SDL_CopyFile(replacement, texture)); + BongoCatImportCandidate candidate = {0}; + snprintf(candidate.directory, sizeof(candidate.directory), "%s", custom); + snprintf(candidate.assets, sizeof(candidate.assets), "%s", custom); + snprintf(candidate.package_root, sizeof(candidate.package_root), "%s", + custom); + snprintf(candidate.setting, sizeof(candidate.setting), "cat.model3.json"); + candidate.mode = BONGO_CAT_MODE_KEYBOARD; + candidate.format = BONGO_CAT_IMPORT_MVER; + char digest[65]; + bool placeholder = true; + CHECK(bongo_cat_import_candidate_inspect(&candidate, digest, &placeholder, + &error) && !placeholder); + CHECK(bongo_cat_model_remove_tree(custom, NULL)); + SDL_free(temporary); + return failures; +} From a0c6045620129f712da765e1391741798025a2d1 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 29 Aug 2026 18:39:03 +0800 Subject: [PATCH 148/334] Stabilize model switching and GL lifecycle --- CMakeLists.txt | 4 +- cmake/Cubism.cmake | 2 + cmake/CubismUserModelSafety.cmake | 57 ++++++ cmake/RuntimeSources.cmake | 1 + include/bongo_cat/gl_api.h | 8 + src/live2d/cubism_bridge.cpp | 70 +++---- src/live2d/cubism_bridge_load.cpp | 89 +++++++++ src/live2d/cubism_model.hpp | 2 + src/live2d/cubism_model_load.cpp | 6 +- src/live2d/cubism_model_renderer.cpp | 31 ++++ src/live2d/cubism_model_update.cpp | 8 +- src/live2d/cubism_runtime.hpp | 8 + src/platform/windows/windows_dialog.c | 13 +- src/runtime/lifecycle/runtime_flow.c | 15 +- src/runtime/lifecycle/shutdown.c | 2 +- src/runtime/model/model_files.c | 35 +++- src/ui/backend/ui_backend.c | 29 ++- src/ui/backend/ui_backend.h | 1 + src/ui/preferences/preferences_assets.c | 11 ++ src/ui/preferences/preferences_gl.c | 75 ++++++++ src/ui/preferences/preferences_gl.h | 11 ++ src/ui/preferences/preferences_import.c | 4 +- src/ui/preferences/preferences_lifecycle.c | 25 ++- src/ui/preferences/preferences_model.c | 3 - src/ui/preferences/preferences_model_cover.c | 181 +------------------ src/ui/preferences/preferences_model_cover.h | 2 +- src/ui/preferences/preferences_scale.c | 22 +-- src/ui/preferences/preferences_state.h | 1 + src/ui/rendering/ui_paint_cache.c | 8 + src/ui/rendering/ui_paint_cache.h | 1 + 30 files changed, 447 insertions(+), 278 deletions(-) create mode 100644 cmake/CubismUserModelSafety.cmake create mode 100644 src/live2d/cubism_bridge_load.cpp create mode 100644 src/live2d/cubism_model_renderer.cpp create mode 100644 src/ui/preferences/preferences_gl.c create mode 100644 src/ui/preferences/preferences_gl.h diff --git a/CMakeLists.txt b/CMakeLists.txt index e0b3f933..984bb4a9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -43,9 +43,11 @@ if(EXISTS "${CUBISM_CORE_HEADER}") else() include(cmake/Cubism.cmake) set(BONGO_CAT_LIVE2D_SOURCES - src/live2d/cubism_bridge.cpp src/live2d/cubism_bridge_visual.cpp + src/live2d/cubism_bridge.cpp src/live2d/cubism_bridge_load.cpp + src/live2d/cubism_bridge_visual.cpp src/live2d/cubism_expression.cpp src/live2d/cubism_model_load.cpp + src/live2d/cubism_model_renderer.cpp src/live2d/cubism_model_idle.cpp src/live2d/cubism_model_update.cpp src/live2d/cubism_parameter_overrides.cpp diff --git a/cmake/Cubism.cmake b/cmake/Cubism.cmake index 0a99b1c5..0b05753a 100644 --- a/cmake/Cubism.cmake +++ b/cmake/Cubism.cmake @@ -48,6 +48,8 @@ add_subdirectory("${CUBISM_GLEW_PATH}/build/cmake" "${CMAKE_BINARY_DIR}/cubism-g EXCLUDE_FROM_ALL) set(FRAMEWORK_SOURCE OpenGL) add_subdirectory("${CUBISM_FRAMEWORK_PATH}" "${CMAKE_BINARY_DIR}/cubism-framework") +include(cmake/CubismUserModelSafety.cmake) +bongo_cat_harden_cubism_user_model(Framework) include(cmake/CubismShaderOptimize.cmake) bongo_cat_optimize_cubism_shaders(Framework) diff --git a/cmake/CubismUserModelSafety.cmake b/cmake/CubismUserModelSafety.cmake new file mode 100644 index 00000000..d7bbd49a --- /dev/null +++ b/cmake/CubismUserModelSafety.cmake @@ -0,0 +1,57 @@ +function(bongo_cat_harden_cubism_user_model target) + set(model_dir "${CUBISM_FRAMEWORK_PATH}/src/Model") + set(source_path "${model_dir}/CubismUserModel.cpp") + set(output_dir "${CMAKE_CURRENT_BINARY_DIR}/generated/cubism-model") + set(output_source "${output_dir}/CubismUserModel.cpp") + + file(READ "${source_path}" source) + string(REPLACE "\r\n" "\n" source "${source}") + + set(resize_unsafe [=[ _renderer->SetRenderTargetSize(width, height);]=]) + set(resize_safe [=[ if (_renderer) + { + _renderer->SetRenderTargetSize(width, height); + }]=]) + if(source MATCHES "void CubismUserModel::SetRenderTargetSize") + string(FIND "${source}" "${resize_safe}" resize_safe_position) + if(resize_safe_position EQUAL -1) + string(FIND "${source}" "${resize_unsafe}" resize_unsafe_position) + if(resize_unsafe_position EQUAL -1) + message(FATAL_ERROR + "Cubism 5 r.5 user-model patch mismatch: render target resize") + endif() + string(REPLACE "${resize_unsafe}" "${resize_safe}" source "${source}") + endif() + else() + message(FATAL_ERROR + "Cubism 5 r.5 user-model patch mismatch: resize function") + endif() + + set(create_unsafe [=[ _renderer = Rendering::CubismRenderer::Create(width, height); + _renderer->Initialize(_model, maskBufferCount);]=]) + set(create_safe [=[ _renderer = Rendering::CubismRenderer::Create(width, height); + + if (_renderer) + { + _renderer->Initialize(_model, maskBufferCount); + }]=]) + string(FIND "${source}" "${create_safe}" create_safe_position) + if(create_safe_position EQUAL -1) + string(FIND "${source}" "${create_unsafe}" create_unsafe_position) + if(create_unsafe_position EQUAL -1) + message(FATAL_ERROR + "Cubism 5 r.5 user-model patch mismatch: renderer creation") + endif() + string(REPLACE "${create_unsafe}" "${create_safe}" source "${source}") + endif() + + file(MAKE_DIRECTORY "${output_dir}") + file(WRITE "${output_source}" "${source}") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + "${source_path}") + get_target_property(framework_sources ${target} SOURCES) + list(REMOVE_ITEM framework_sources "${source_path}") + set_property(TARGET ${target} PROPERTY SOURCES "${framework_sources}") + target_sources(${target} PRIVATE "${output_source}") + target_include_directories(${target} PRIVATE "${model_dir}") +endfunction() diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 55a04c4d..9f57e2c6 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -173,6 +173,7 @@ set(BONGO_CAT_UI_PREFERENCES_SOURCES src/ui/preferences/preferences_dialog.c src/ui/preferences/preferences_form_label.c src/ui/preferences/preferences_fonts.c + src/ui/preferences/preferences_gl.c src/ui/preferences/preferences_import.c src/ui/preferences/preferences_import_dialog.c src/ui/preferences/preferences_icons.c diff --git a/include/bongo_cat/gl_api.h b/include/bongo_cat/gl_api.h index a2f6e91a..778b5b7e 100644 --- a/include/bongo_cat/gl_api.h +++ b/include/bongo_cat/gl_api.h @@ -4,6 +4,10 @@ #include "bongo_cat/common.h" #include +#ifdef __cplusplus +extern "C" { +#endif + typedef struct BongoCatGL { PFNGLCREATESHADERPROC create_shader; PFNGLSHADERSOURCEPROC shader_source; @@ -40,4 +44,8 @@ bool bongo_cat_gl_clear_errors(void); unsigned int bongo_cat_gl_program(BongoCatGL *gl, const char *vertex, const char *fragment, BongoCatError *error); +#ifdef __cplusplus +} +#endif + #endif diff --git a/src/live2d/cubism_bridge.cpp b/src/live2d/cubism_bridge.cpp index 57c043fd..705167c9 100644 --- a/src/live2d/cubism_bridge.cpp +++ b/src/live2d/cubism_bridge.cpp @@ -7,9 +7,10 @@ #include #include +#include +#include #include #include -#include #include #ifdef _WIN32 @@ -139,56 +140,16 @@ extern "C" BongoCatLive2D *bongo_cat_live2d_create_cover_runtime( extern "C" void bongo_cat_live2d_destroy(BongoCatLive2D *runtime) { if (!runtime) return; + if (runtime->retired_count) SDL_Log("[runtime] Live2D resource handoff: " + "stage=retirement-flush queue=%u current_context=%p", + runtime->retired_count, (void *)SDL_GL_GetCurrentContext()); + for (unsigned i = 0; i < runtime->retired_count; ++i) + delete runtime->retired[i].model; delete runtime->model; delete runtime; stop_framework(); } -extern "C" BongoCatResult bongo_cat_live2d_load(BongoCatLive2D *runtime, - const char *directory, const char *setting, bool preset, - const BongoCatLive2DRenderOptions *render_options, - BongoCatLive2DLoadProgress progress, void *userdata, - BongoCatError *error) { - if (!runtime) return BONGO_CAT_ERROR_ARGUMENT; - bongo_cat::NativeModel *previous = runtime->model; - bongo_cat::NativeModel *model = nullptr; - try { - model = new(std::nothrow) bongo_cat::NativeModel(); - if (!model) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, - "Cannot allocate Live2D model"); - return BONGO_CAT_ERROR_MEMORY; - } - if (render_options) model->set_render_options(*render_options); - if (!model->load(directory, setting, preset, progress, userdata, error)) { - delete model; - return error ? error->code : BONGO_CAT_ERROR_CUBISM; - } - /* Keep the previous renderer alive until the replacement is complete. */ - model->reshape(runtime->width, runtime->height); - if (!model->load_textures(error, progress, userdata)) { - BongoCatResult result = error ? error->code : BONGO_CAT_ERROR_CUBISM; - delete model; - return result; - } - if (progress) progress(userdata, 1.0f); - runtime->model = model; - delete previous; - return BONGO_CAT_OK; - } catch (const std::bad_alloc &) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, - "Out of memory while loading the Live2D model"); - } catch (const std::exception &exception) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, - "Live2D model load failed: %s", exception.what()); - } catch (...) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, - "Live2D model load failed with an unknown exception"); - } - delete model; - return error ? error->code : BONGO_CAT_ERROR_CUBISM; -} - extern "C" bool bongo_cat_live2d_ready(const BongoCatLive2D *runtime) { return runtime && runtime->model; } @@ -230,7 +191,22 @@ extern "C" bool bongo_cat_live2d_update(BongoCatLive2D *runtime, float elapsed) return runtime && runtime->model && runtime->model->update(elapsed); } extern "C" void bongo_cat_live2d_draw(BongoCatLive2D *runtime) { - if (runtime && runtime->model) runtime->model->draw(); + if (!runtime) return; + unsigned keep = 0, released = 0; + for (unsigned i = 0; i < runtime->retired_count; ++i) { + BongoCatRetiredModel item = runtime->retired[i]; + if (item.frames_remaining) item.frames_remaining--; + if (!item.frames_remaining) { + delete item.model; + released++; + } else runtime->retired[keep++] = item; + } + runtime->retired_count = keep; + if (released) SDL_Log("[runtime] Live2D resource handoff: " + "stage=retirement-complete released=%u queue=%u current_context=%p " + "gl_error=0x%x", released, keep, (void *)SDL_GL_GetCurrentContext(), + (unsigned)glGetError()); + if (runtime->model) runtime->model->draw(); } extern "C" void bongo_cat_live2d_set_mirror(BongoCatLive2D *runtime, bool mirror) { if (runtime && runtime->model) runtime->model->set_mirror(mirror); } diff --git a/src/live2d/cubism_bridge_load.cpp b/src/live2d/cubism_bridge_load.cpp new file mode 100644 index 00000000..b5f5967f --- /dev/null +++ b/src/live2d/cubism_bridge_load.cpp @@ -0,0 +1,89 @@ +#include "bongo_cat/model.h" +#if defined(CSM_TARGET_WIN_GL) || defined(CSM_TARGET_LINUX_GL) +#include +#endif +#include "cubism_runtime.hpp" + +#include +#include +#include +#include + +static void retire_previous(BongoCatLive2D *runtime, + bongo_cat::NativeModel *previous) { + if (!runtime || !previous) return; + if (runtime->retired_count == runtime->retired_capacity) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Live2D retirement queue reached its limit; releasing the oldest model"); + delete runtime->retired[0].model; + for (unsigned i = 1; i < runtime->retired_count; ++i) + runtime->retired[i - 1] = runtime->retired[i]; + runtime->retired_count--; + } + BongoCatRetiredModel *slot = &runtime->retired[runtime->retired_count++]; + slot->model = previous; + slot->frames_remaining = 3; +} + +extern "C" BongoCatResult bongo_cat_live2d_load(BongoCatLive2D *runtime, + const char *directory, const char *setting, bool preset, + const BongoCatLive2DRenderOptions *render_options, + BongoCatLive2DLoadProgress progress, void *userdata, + BongoCatError *error) { + if (!runtime) return BONGO_CAT_ERROR_ARGUMENT; + bongo_cat::NativeModel *previous = runtime->model; + bongo_cat::NativeModel *model = nullptr; + try { + model = new(std::nothrow) bongo_cat::NativeModel(); + if (!model) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Cannot allocate Live2D model"); + return BONGO_CAT_ERROR_MEMORY; + } + if (render_options) model->set_render_options(*render_options); + if (!model->load(directory, setting, preset, progress, userdata, error)) { + delete model; + return error ? error->code : BONGO_CAT_ERROR_CUBISM; + } + /* Keep the previous renderer alive until the replacement is complete. */ + model->reshape(runtime->width, runtime->height); + if (!model->load_textures(error, progress, userdata)) { + BongoCatResult result = error ? error->code : BONGO_CAT_ERROR_CUBISM; + delete model; + return result; + } + GLenum ready_error = glGetError(); + SDL_Log("[runtime] Live2D resource handoff: stage=replacement-ready " + "cover=%d new_textures=%zu previous_textures=%zu current_window=%p " + "current_context=%p gl_error=0x%x", runtime->cover_runtime, + model->texture_count(), previous ? previous->texture_count() : 0, + (void *)SDL_GL_GetCurrentWindow(), + (void *)SDL_GL_GetCurrentContext(), (unsigned)ready_error); + if (progress) progress(userdata, 1.0f); + runtime->model = model; + retire_previous(runtime, previous); + SDL_Log("[runtime] Live2D resource handoff: stage=retirement-queued " + "cover=%d previous=%d previous_textures=%zu queue=%u frames=%u", + runtime->cover_runtime, previous != nullptr, + previous ? previous->texture_count() : 0, runtime->retired_count, + previous ? 3u : 0u); + GLenum retired_error = glGetError(); + SDL_Log("[runtime] Live2D resource handoff: stage=complete cover=%d " + "new_textures=%zu current_window=%p current_context=%p gl_error=0x%x", + runtime->cover_runtime, model->texture_count(), + (void *)SDL_GL_GetCurrentWindow(), + (void *)SDL_GL_GetCurrentContext(), (unsigned)retired_error); + return BONGO_CAT_OK; + } catch (const std::bad_alloc &) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Out of memory while loading the Live2D model"); + } catch (const std::exception &exception) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, + "Live2D model load failed: %s", exception.what()); + } catch (...) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, + "Live2D model load failed with an unknown exception"); + } + delete model; + return error ? error->code : BONGO_CAT_ERROR_CUBISM; +} diff --git a/src/live2d/cubism_model.hpp b/src/live2d/cubism_model.hpp index b20fac6f..de57bbed 100644 --- a/src/live2d/cubism_model.hpp +++ b/src/live2d/cubism_model.hpp @@ -31,6 +31,7 @@ class NativeModel final : public Csm::CubismUserModel { BongoCatError *error); bool load_textures(BongoCatError *error, BongoCatLive2DLoadProgress progress, void *userdata); + size_t texture_count() const { return textures_.size(); } void release_render_resources(); bool canvas_size(int *width, int *height) const; bool frame(BongoCatLive2DFrame *frame) const; @@ -128,6 +129,7 @@ class NativeModel final : public Csm::CubismUserModel { void select_motion(const std::string &key, bool selected); void release_textures(); void release_renderer(); + bool create_renderer(BongoCatError *error); void bind_textures(); std::vector read(const std::string &path, size_t maximum = (size_t)-1) const; diff --git a/src/live2d/cubism_model_load.cpp b/src/live2d/cubism_model_load.cpp index 1d7150b5..df4639d8 100644 --- a/src/live2d/cubism_model_load.cpp +++ b/src/live2d/cubism_model_load.cpp @@ -256,10 +256,12 @@ bool NativeModel::load_textures(BongoCatError *error, } prepare_expression_frame(); release_renderer(); - CreateRenderer((Csm::csmUint32)width_, (Csm::csmUint32)height_); + if (!create_renderer(error)) { + release_textures(); + return false; + } renderer_width_ = width_; renderer_height_ = height_; - bind_textures(); return true; } void NativeModel::release_textures() { diff --git a/src/live2d/cubism_model_renderer.cpp b/src/live2d/cubism_model_renderer.cpp new file mode 100644 index 00000000..a322744b --- /dev/null +++ b/src/live2d/cubism_model_renderer.cpp @@ -0,0 +1,31 @@ +#include "cubism_model.hpp" +#include "bongo_cat/gl_api.h" + +#include + +namespace bongo_cat { + +bool NativeModel::create_renderer(BongoCatError *error) { + if (!SDL_GL_GetCurrentContext()) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, + "Cannot create the Live2D renderer without an OpenGL context"); + return false; + } + if (!bongo_cat_gl_clear_errors()) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, + "Cannot clear the OpenGL error state before creating the Live2D renderer"); + return false; + } + CreateRenderer((Csm::csmUint32)width_, (Csm::csmUint32)height_); + auto *renderer = GetRenderer(); + if (renderer) bind_textures(); + GLenum renderer_error = glGetError(); + if (renderer && renderer_error == GL_NO_ERROR) return true; + bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, + "Cannot create the Live2D renderer (OpenGL 0x%x)", + (unsigned)renderer_error); + release_renderer(); + return false; +} + +} // namespace bongo_cat diff --git a/src/live2d/cubism_model_update.cpp b/src/live2d/cubism_model_update.cpp index 37188024..e27b77d7 100644 --- a/src/live2d/cubism_model_update.cpp +++ b/src/live2d/cubism_model_update.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include namespace bongo_cat { @@ -23,11 +24,12 @@ void NativeModel::resize(int width, int height) { renderer_height_ = height_; return; } - DeleteRenderer(); - CreateRenderer((Csm::csmUint32)width_, (Csm::csmUint32)height_); + SDL_LogDebug(SDL_LOG_CATEGORY_VIDEO, + "Live2D render target resized in place: %dx%d -> %dx%d", + renderer_width_, renderer_height_, width_, height_); + SetRenderTargetSize((Csm::csmUint32)width_, (Csm::csmUint32)height_); renderer_width_ = width_; renderer_height_ = height_; - bind_textures(); } void NativeModel::reshape(int width, int height) { diff --git a/src/live2d/cubism_runtime.hpp b/src/live2d/cubism_runtime.hpp index d0c277dc..9f9eed70 100644 --- a/src/live2d/cubism_runtime.hpp +++ b/src/live2d/cubism_runtime.hpp @@ -3,8 +3,16 @@ #include "cubism_model.hpp" +struct BongoCatRetiredModel { + bongo_cat::NativeModel *model = nullptr; + unsigned frames_remaining = 0; +}; + struct BongoCatLive2D { + static constexpr unsigned retired_capacity = 4; bongo_cat::NativeModel *model; + BongoCatRetiredModel retired[retired_capacity]{}; + unsigned retired_count = 0; int width = 612; int height = 354; bool cover_runtime = false; diff --git a/src/platform/windows/windows_dialog.c b/src/platform/windows/windows_dialog.c index c8b92ffe..cac7d1b6 100644 --- a/src/platform/windows/windows_dialog.c +++ b/src/platform/windows/windows_dialog.c @@ -13,9 +13,10 @@ typedef struct BongoCatWindowsFolderDialogArgs { SDL_DialogFileCallback callback; void *userdata; - SDL_Window *window; + HWND owner; char *default_location; bool allow_many; + Uint64 started_ns; } BongoCatWindowsFolderDialogArgs; static void notify_cancel(BongoCatWindowsFolderDialogArgs *args) { @@ -94,7 +95,7 @@ static bool show_folder_dialog(BongoCatWindowsFolderDialogArgs *args) { } } - result = dialog->lpVtbl->Show(dialog, owner_window(args->window)); + result = dialog->lpVtbl->Show(dialog, args->owner); if (result == HRESULT_FROM_WIN32(ERROR_CANCELLED)) { notify_cancel(args); callback_called = true; @@ -162,6 +163,9 @@ static bool show_folder_dialog(BongoCatWindowsFolderDialogArgs *args) { static int SDLCALL folder_dialog_thread(void *userdata) { BongoCatWindowsFolderDialogArgs *args = userdata; show_folder_dialog(args); + SDL_Log("[runtime] Folder dialog: stage=completed owner=%p elapsed_ms=%.2f", + (void *)args->owner, + (double)(SDL_GetTicksNS() - args->started_ns) / 1000000.0); SDL_free(args->default_location); SDL_free(args); return 0; @@ -179,8 +183,9 @@ void bongo_cat_windows_show_open_folder_dialog( } args->callback = callback; args->userdata = userdata; - args->window = window; + args->owner = owner_window(window); args->allow_many = allow_many; + args->started_ns = SDL_GetTicksNS(); if (default_location) { args->default_location = SDL_strdup(default_location); if (!args->default_location) { @@ -199,6 +204,8 @@ void bongo_cat_windows_show_open_folder_dialog( callback(userdata, files, -1); return; } + SDL_Log("[runtime] Folder dialog: stage=opened owner=%p allow_many=%d", + (void *)args->owner, allow_many); SDL_DetachThread(thread); } diff --git a/src/runtime/lifecycle/runtime_flow.c b/src/runtime/lifecycle/runtime_flow.c index a434267c..47698e89 100644 --- a/src/runtime/lifecycle/runtime_flow.c +++ b/src/runtime/lifecycle/runtime_flow.c @@ -53,10 +53,19 @@ void bongo_cat_runtime_flow_update(BongoCatApp *app, uint64_t now) { case 6: model_mode(app, BONGO_CAT_MODE_KEYBOARD); mark(app, "model-keyboard"); break; case 7: model_mode(app, BONGO_CAT_MODE_STANDARD); mark(app, "model-standard"); break; case 8: model_mode(app, BONGO_CAT_MODE_GAMEPAD); mark(app, "model-gamepad"); break; - case 9: bongo_cat_preferences_show(app->preferences); mark(app, "settings-first"); break; + case 9: + app->smoke_preference_model_select = true; + bongo_cat_preferences_show(app->preferences); + mark(app, "settings-first-model-select"); break; case 10: mark(app, "settings-idle"); break; - case 11: bongo_cat_preferences_close(app->preferences); mark(app, "settings-closed"); break; - case 12: bongo_cat_preferences_show(app->preferences); mark(app, "settings-reopen"); break; + case 11: + bongo_cat_preferences_close(app->preferences); + model_mode(app, BONGO_CAT_MODE_STANDARD); + mark(app, "settings-closed-model-restored"); break; + case 12: + app->smoke_preference_model_select = true; + bongo_cat_preferences_show(app->preferences); + mark(app, "settings-reopen-model-select"); break; case 13: bongo_cat_preferences_close(app->preferences); bongo_cat_app_select_model(app, app->smoke_runtime_model); diff --git a/src/runtime/lifecycle/shutdown.c b/src/runtime/lifecycle/shutdown.c index a0caecc5..28d187db 100644 --- a/src/runtime/lifecycle/shutdown.c +++ b/src/runtime/lifecycle/shutdown.c @@ -16,10 +16,10 @@ void bongo_cat_app_shutdown(BongoCatApp *app, const char *stage, bongo_cat_config_store_flush(app); bongo_cat_multi_pet_shutdown(app); bongo_cat_model_refresh_shutdown(app); - bongo_cat_import_nearby_shutdown(); bongo_cat_update_destroy(app->update); app->update = NULL; bongo_cat_preferences_destroy(app->preferences); + bongo_cat_import_nearby_shutdown(); bongo_cat_i18n_destroy(app->i18n); bongo_cat_tray_destroy(app->tray); bongo_cat_gamepads_set_enabled(app, false); diff --git a/src/runtime/model/model_files.c b/src/runtime/model/model_files.c index 3cb51908..efb8ef26 100644 --- a/src/runtime/model/model_files.c +++ b/src/runtime/model/model_files.c @@ -6,6 +6,7 @@ #include "bongo_cat/preferences.h" #include +#include #include #include #include @@ -42,6 +43,13 @@ static void model_runtime_stage(BongoCatApp *app, const char *stage, SDL_Log("[runtime] Model load %s: id=%s", stage, id); } +static void model_running_stage(BongoCatApp *app, const char *model_id) { + char state[BONGO_CAT_ID_CAP + 16]; + snprintf(state, sizeof(state), "running:model:%s", + model_id && model_id[0] ? model_id : "none"); + bongo_cat_runtime_stage(app, state); +} + static bool behavior_cacheable(const BongoCatModelEntry *entry) { /* Installed Mver trees keep a stable content digest. Nearby models are managed by their source directory, so do not retain stale data. */ @@ -186,6 +194,16 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, free(behaviors); return false; } + char previous_model[BONGO_CAT_ID_CAP]; + snprintf(previous_model, sizeof(previous_model), "%s", + app->loaded_model[0] ? app->loaded_model : "none"); + uint64_t load_started_ns = SDL_GetTicksNS(); + GLenum initial_gl_error = glGetError(); + SDL_Log("[runtime] Model switch transaction: stage=begin previous=%s next=%s " + "main_window=%p main_context=%p current_window=%p current_context=%p " + "gl_error=0x%x", previous_model, entry->id, (void *)app->window, + (void *)app->gl_context, (void *)SDL_GL_GetCurrentWindow(), + (void *)SDL_GL_GetCurrentContext(), (unsigned)initial_gl_error); bongo_cat_live2d_reshape(app->live2d, pixel_width, pixel_height); snprintf(app->loading_model, sizeof(app->loading_model), "%s", entry->id); app->model_load_runtime_stage = 0; @@ -197,8 +215,11 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, app->model_load_last_frame_ns = 0; if (replacing_model) app->last_frame_ns = SDL_GetTicksNS(); if (load_result != BONGO_CAT_OK) { - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, - "Model load failed: id=%s error=%s", entry->id, failure->message); + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Model switch transaction: " + "stage=failed previous=%s next=%s elapsed_ms=%.2f gl_error=0x%x " + "error=%s", previous_model, entry->id, + (double)(SDL_GetTicksNS() - load_started_ns) / 1000000.0, + (unsigned)glGetError(), failure->message); if (restore_context && previous_window && previous_context && !SDL_GL_MakeCurrent(previous_window, previous_context)) SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, @@ -208,7 +229,7 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, request_model_frame(app, false); app->loading_model[0] = '\0'; app->model_load_runtime_stage = 0; - bongo_cat_runtime_stage(app, "running"); + model_running_stage(app, app->loaded_model); return false; } model_runtime_stage(app, "committing", entry); @@ -260,10 +281,16 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Cannot restore the previous OpenGL context: %s", SDL_GetError()); bongo_cat_memory_policy_model_loaded(); + SDL_Log("[runtime] Model switch transaction: stage=complete previous=%s " + "next=%s elapsed_ms=%.2f current_window=%p current_context=%p " + "gl_error=0x%x", previous_model, entry->id, + (double)(SDL_GetTicksNS() - load_started_ns) / 1000000.0, + (void *)SDL_GL_GetCurrentWindow(), (void *)SDL_GL_GetCurrentContext(), + (unsigned)glGetError()); model_runtime_stage(app, "completed", entry); app->loading_model[0] = '\0'; app->model_load_runtime_stage = 0; - bongo_cat_runtime_stage(app, "running"); + model_running_stage(app, entry->id); return true; } diff --git a/src/ui/backend/ui_backend.c b/src/ui/backend/ui_backend.c index 0e709cb1..421d9606 100644 --- a/src/ui/backend/ui_backend.c +++ b/src/ui/backend/ui_backend.c @@ -1,6 +1,7 @@ #include "ui_backend.h" #include "ui_font_atlas.h" #include "ui_paint.h" +#include "ui_paint_cache.h" #include #include @@ -108,24 +109,36 @@ bool bongo_cat_ui_init(BongoCatUIBackend *ui, SDL_Window *window, return true; } -void bongo_cat_ui_destroy(BongoCatUIBackend *ui) { +static void release(BongoCatUIBackend *ui, bool release_gl) { if (!ui) return; if (context_backend == ui) context_backend = NULL; - bongo_cat_ui_paint_destroy(ui); - bongo_cat_ui_resize_cache_destroy(ui); + if (release_gl) { + bongo_cat_ui_paint_destroy(ui); + bongo_cat_ui_resize_cache_destroy(ui); + } else { + bongo_cat_ui_paint_cache_abandon(ui); + ui->resize_cache_texture = 0; + ui->resize_cache_width = ui->resize_cache_height = 0; + } if (ui->atlas.permanent.alloc) bongo_cat_ui_font_atlas_destroy(ui); nk_buffer_free(&ui->commands); nk_free(&ui->context); - if (ui->font_texture) glDeleteTextures(1, &ui->font_texture); - if (ui->vbo) ui->gl.delete_buffers(1, &ui->vbo); - if (ui->ebo) ui->gl.delete_buffers(1, &ui->ebo); - if (ui->vao) ui->gl.delete_vertex_arrays(1, &ui->vao); - if (ui->program) ui->gl.delete_program(ui->program); + if (release_gl) { + if (ui->font_texture) glDeleteTextures(1, &ui->font_texture); + if (ui->vbo) ui->gl.delete_buffers(1, &ui->vbo); + if (ui->ebo) ui->gl.delete_buffers(1, &ui->ebo); + if (ui->vao) ui->gl.delete_vertex_arrays(1, &ui->vao); + if (ui->program) ui->gl.delete_program(ui->program); + } free(ui->vertices); free(ui->elements); memset(ui, 0, sizeof(*ui)); } +void bongo_cat_ui_destroy(BongoCatUIBackend *ui) { release(ui, true); } + +void bongo_cat_ui_abandon(BongoCatUIBackend *ui) { release(ui, false); } + static void convert(BongoCatUIBackend *ui) { static const struct nk_draw_vertex_layout_element layout[] = { {NK_VERTEX_POSITION, NK_FORMAT_FLOAT, offsetof(UIVertex, position)}, diff --git a/src/ui/backend/ui_backend.h b/src/ui/backend/ui_backend.h index b85392d7..266ee514 100644 --- a/src/ui/backend/ui_backend.h +++ b/src/ui/backend/ui_backend.h @@ -83,6 +83,7 @@ bool bongo_cat_ui_init(BongoCatUIBackend *ui, SDL_Window *window, const nk_rune *glyph_ranges, float layout_scale, float raster_scale, BongoCatError *error); void bongo_cat_ui_destroy(BongoCatUIBackend *ui); +void bongo_cat_ui_abandon(BongoCatUIBackend *ui); void bongo_cat_ui_input_begin(BongoCatUIBackend *ui); void bongo_cat_ui_input_end(BongoCatUIBackend *ui); void bongo_cat_ui_input_reset(BongoCatUIBackend *ui); diff --git a/src/ui/preferences/preferences_assets.c b/src/ui/preferences/preferences_assets.c index 6a262121..75eb09fd 100644 --- a/src/ui/preferences/preferences_assets.c +++ b/src/ui/preferences/preferences_assets.c @@ -103,3 +103,14 @@ void bongo_cat_preferences_assets_clear(BongoCatPreferences *value) { value->icon_hidpi_attempted = false; bongo_cat_preferences_support_assets_clear(value); } + +void bongo_cat_preferences_assets_abandon(BongoCatPreferences *value) { + if (!value) return; + value->logo_texture = 0; + value->icon_texture = 0; + value->icon_texture_hidpi = 0; + value->catime_texture = 0; + value->vlaina_texture = 0; + value->icon_hidpi_attempted = false; + value->support_assets_loaded = false; +} diff --git a/src/ui/preferences/preferences_gl.c b/src/ui/preferences/preferences_gl.c new file mode 100644 index 00000000..cce0c05c --- /dev/null +++ b/src/ui/preferences/preferences_gl.c @@ -0,0 +1,75 @@ +#include "preferences_gl.h" +#include "preferences_state.h" + +#include +#include + +static bool fail(BongoCatPreferences *value, const char *message) { + char detail[256]; + snprintf(detail, sizeof(detail), "%s", + message && message[0] ? message : "unknown OpenGL error"); + SDL_LogError(SDL_LOG_CATEGORY_VIDEO, + "Preferences dedicated OpenGL context failed: %s " + "main_window=%p settings_window=%p main_context=%p", + detail, (void *)value->app->window, (void *)value->window, + (void *)value->app->gl_context); + SDL_GL_MakeCurrent(value->app->window, value->app->gl_context); + SDL_SetError("%s", detail); + return false; +} + +bool bongo_cat_preferences_gl_create(BongoCatPreferences *value) { + if (!value || !value->app || !value->window || !value->app->window || + !value->app->gl_context) + return false; + if (!SDL_GL_MakeCurrent(value->app->window, value->app->gl_context)) + return fail(value, SDL_GetError()); + if (!SDL_GL_SetAttribute(SDL_GL_SHARE_WITH_CURRENT_CONTEXT, 1)) + return fail(value, SDL_GetError()); + SDL_GLContext context = SDL_GL_CreateContext(value->window); + char creation_error[256] = {0}; + if (!context) snprintf(creation_error, sizeof(creation_error), "%s", + SDL_GetError()); + bool reset = SDL_GL_SetAttribute(SDL_GL_SHARE_WITH_CURRENT_CONTEXT, 0); + if (!context) + return fail(value, creation_error[0] ? creation_error : + "SDL_GL_CreateContext returned no context"); + if (!reset) { + snprintf(creation_error, sizeof(creation_error), "%s", SDL_GetError()); + SDL_GL_DestroyContext(context); + return fail(value, creation_error); + } + if (!SDL_GL_MakeCurrent(value->window, context)) { + snprintf(creation_error, sizeof(creation_error), "%s", SDL_GetError()); + SDL_GL_DestroyContext(context); + return fail(value, creation_error); + } + value->gl_context = context; + value->owns_gl_context = true; + const GLubyte *vendor = glGetString(GL_VENDOR); + const GLubyte *renderer = glGetString(GL_RENDERER); + const GLubyte *version = glGetString(GL_VERSION); + SDL_Log("[runtime] Preferences OpenGL context: shared=1 dedicated=1 " + "main_window=%p settings_window=%p main_context=%p settings_context=%p " + "vendor=%s renderer=%s version=%s", + (void *)value->app->window, (void *)value->window, + (void *)value->app->gl_context, (void *)value->gl_context, + vendor ? (const char *)vendor : "unknown", + renderer ? (const char *)renderer : "unknown", + version ? (const char *)version : "unknown"); + return true; +} + +void bongo_cat_preferences_gl_destroy(BongoCatPreferences *value) { + if (!value || !value->gl_context) return; + SDL_Log("[runtime] Preferences OpenGL context release: " + "settings_window=%p settings_context=%p current_window=%p " + "current_context=%p", (void *)value->window, + (void *)value->gl_context, (void *)SDL_GL_GetCurrentWindow(), + (void *)SDL_GL_GetCurrentContext()); + if (SDL_GL_GetCurrentContext() == value->gl_context) + SDL_GL_MakeCurrent(value->app->window, value->app->gl_context); + if (value->owns_gl_context) SDL_GL_DestroyContext(value->gl_context); + value->gl_context = NULL; + value->owns_gl_context = false; +} diff --git a/src/ui/preferences/preferences_gl.h b/src/ui/preferences/preferences_gl.h new file mode 100644 index 00000000..b2c89fb2 --- /dev/null +++ b/src/ui/preferences/preferences_gl.h @@ -0,0 +1,11 @@ +#ifndef BONGO_CAT_PREFERENCES_GL_H +#define BONGO_CAT_PREFERENCES_GL_H + +#include + +typedef struct BongoCatPreferences BongoCatPreferences; + +bool bongo_cat_preferences_gl_create(BongoCatPreferences *value); +void bongo_cat_preferences_gl_destroy(BongoCatPreferences *value); + +#endif diff --git a/src/ui/preferences/preferences_import.c b/src/ui/preferences/preferences_import.c index 104e8384..b83e425f 100644 --- a/src/ui/preferences/preferences_import.c +++ b/src/ui/preferences/preferences_import.c @@ -66,7 +66,6 @@ static BongoCatImportJob *copy_job(const char *const *files) { } return job; } - static BongoCatImportJob *copy_path(const char *path) { const char *files[] = {path, NULL}; return path && path[0] ? copy_job(files) : NULL; @@ -181,7 +180,6 @@ static bool start_job(BongoCatImportDialog *dialog, BongoCatApp *app, SDL_UnlockMutex(dialog->mutex); return dialog->worker != NULL; } - static void SDLCALL import_callback(void *userdata, const char *const *files, int filter) { (void)filter; @@ -196,6 +194,8 @@ static void SDLCALL import_callback(void *userdata, const char *const *files, event.user.data1 = job; event.user.data2 = dialog; if (dialog->active && !dialog->busy && job) pushed = SDL_PushEvent(&event); + if (!dialog->active) + SDL_Log("[runtime] Folder dialog result ignored during shutdown"); if (!pushed) dialog->open = false; SDL_UnlockMutex(dialog->mutex); if (!pushed) free_job(job); diff --git a/src/ui/preferences/preferences_lifecycle.c b/src/ui/preferences/preferences_lifecycle.c index 7fe863c4..9935ff98 100644 --- a/src/ui/preferences/preferences_lifecycle.c +++ b/src/ui/preferences/preferences_lifecycle.c @@ -1,4 +1,6 @@ #include "preferences_controls.h" +#include "preferences_gl.h" +#include "preferences_model_cover.h" #include "preferences_state.h" #include "ui_animation.h" #include "bongo_cat/memory.h" @@ -12,28 +14,32 @@ static void release_window(BongoCatPreferences *value) { if (!value || !value->window) return; bongo_cat_preferences_live_resize_uninstall(value); - if (value->gl_context) SDL_GL_MakeCurrent(value->window, value->gl_context); + bool context_ready = !value->gl_context || + SDL_GL_MakeCurrent(value->window, value->gl_context); + if (!context_ready) SDL_LogError(SDL_LOG_CATEGORY_VIDEO, + "Preferences GL cleanup skipped because its context could not be " + "activated: %s", SDL_GetError()); if (value->ui_initialized && value->input_active) bongo_cat_preferences_input_end(value); SDL_StopTextInput(value->window); - bongo_cat_preferences_model_cache_clear(value->app); + if (context_ready) bongo_cat_preferences_model_cache_clear(value->app); + else bongo_cat_preferences_model_cache_abandon(value->app); if (value->ui_initialized) { bongo_cat_pref_controls_reset(&value->ui.context); bongo_cat_ui_animations_reset(&value->ui.context); } - bongo_cat_preferences_assets_clear(value); + if (context_ready) bongo_cat_preferences_assets_clear(value); + else bongo_cat_preferences_assets_abandon(value); if (value->ui_initialized) { bongo_cat_ui_cursor_destroy(&value->ui); - bongo_cat_ui_destroy(&value->ui); + if (context_ready) bongo_cat_ui_destroy(&value->ui); + else bongo_cat_ui_abandon(&value->ui); } if (value->chrome_dragging) SDL_CaptureMouse(false); - if (value->owns_gl_context && value->gl_context) - SDL_GL_DestroyContext(value->gl_context); + bongo_cat_preferences_gl_destroy(value); SDL_DestroyWindow(value->window); value->window = NULL; - value->gl_context = NULL; value->visible = false; - value->owns_gl_context = false; value->transparent_window = false; value->ui_initialized = false; value->font_reload_pending = false; @@ -112,8 +118,9 @@ void bongo_cat_preferences_close(BongoCatPreferences *value) { void bongo_cat_preferences_destroy(BongoCatPreferences *value) { if (!value) return; + bongo_cat_preferences_import_destroy(value->import_dialog); + value->import_dialog = NULL; bongo_cat_preferences_close(value); release_window(value); - bongo_cat_preferences_import_destroy(value->import_dialog); free(value); } diff --git a/src/ui/preferences/preferences_model.c b/src/ui/preferences/preferences_model.c index 5ee5c84b..517de255 100644 --- a/src/ui/preferences/preferences_model.c +++ b/src/ui/preferences/preferences_model.c @@ -267,7 +267,4 @@ void bongo_cat_preferences_page_model(BongoCatPreferences *value, } context->style.window.spacing = old_spacing; bongo_cat_preferences_model_covers_prune(app); - if (!value->model_loading && - bongo_cat_preferences_model_cover_generate_current(app)) - bongo_cat_preferences_invalidate(value); } diff --git a/src/ui/preferences/preferences_model_cover.c b/src/ui/preferences/preferences_model_cover.c index c470926f..e7e40759 100644 --- a/src/ui/preferences/preferences_model_cover.c +++ b/src/ui/preferences/preferences_model_cover.c @@ -1,7 +1,5 @@ #include "preferences_model_cover.h" #include "preferences_internal.h" -#include "runtime.h" -#include "bongo_cat/file.h" #include "bongo_cat/image.h" #include "bongo_cat/path.h" #include "bongo_cat/preferences.h" @@ -19,34 +17,11 @@ typedef struct ModelCoverSlot { uint64_t generation; } ModelCoverSlot; -typedef struct ModelCoverAttempt { - BongoCatApp *app; - char model_id[BONGO_CAT_ID_CAP]; - bool attempted; -} ModelCoverAttempt; - static ModelCoverSlot cover_cache[BONGO_CAT_MODEL_CAP]; -static ModelCoverAttempt cover_attempts[BONGO_CAT_MODEL_CAP]; static uint64_t cover_generation; static unsigned cover_load_budget; static bool cover_load_deferred; -static void clear_attempts(BongoCatApp *app) { - for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) - if (!app || cover_attempts[i].app == app) - memset(&cover_attempts[i], 0, sizeof(cover_attempts[i])); -} - -static void prune_attempts(BongoCatApp *app) { - if (!app) return; - for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) { - ModelCoverAttempt *attempt = &cover_attempts[i]; - if (attempt->app == app && !bongo_cat_models_find( - &app->models, attempt->model_id)) - memset(attempt, 0, sizeof(*attempt)); - } -} - void bongo_cat_preferences_model_cover_cache_clear(BongoCatApp *app) { for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) { ModelCoverSlot *slot = &cover_cache[i]; @@ -59,7 +34,13 @@ void bongo_cat_preferences_model_cover_cache_clear(BongoCatApp *app) { void bongo_cat_preferences_model_cache_clear(BongoCatApp *app) { bongo_cat_preferences_remove_dialog_clear(app); bongo_cat_preferences_model_cover_cache_clear(app); - clear_attempts(app); +} + +void bongo_cat_preferences_model_cache_abandon(BongoCatApp *app) { + bongo_cat_preferences_remove_dialog_clear(app); + for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) + if (!app || cover_cache[i].app == app) + memset(&cover_cache[i], 0, sizeof(cover_cache[i])); } void bongo_cat_preferences_model_covers_begin(BongoCatApp *app) { @@ -67,7 +48,7 @@ void bongo_cat_preferences_model_covers_begin(BongoCatApp *app) { if (!cover_generation) cover_generation++; cover_load_budget = 1; cover_load_deferred = false; - prune_attempts(app); + (void)app; } const BongoCatModelCover *bongo_cat_preferences_model_cover( @@ -109,152 +90,6 @@ const BongoCatModelCover *bongo_cat_preferences_model_cover( return &empty->image; } -static bool capture_model_cover(BongoCatApp *app, - const BongoCatModelEntry *entry) { - if (!app || !entry || !app->window || !app->gl_context || - app->window_minimized) return false; - char path[BONGO_CAT_PATH_CAP], resources[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(resources, sizeof(resources), - entry->adapter_directory, "resources") || - !bongo_cat_path_join(path, sizeof(path), resources, "cover.png")) return false; - char marker[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(marker, sizeof(marker), resources, - ".bongo-cat-cover-fallback")) return false; - bool fallback = bongo_cat_path_is_file(marker); - if (bongo_cat_path_is_file(path) && !fallback) return true; - if (!bongo_cat_path_create_directory(resources)) return false; - SDL_Window *previous_window = SDL_GL_GetCurrentWindow(); - SDL_GLContext previous_context = SDL_GL_GetCurrentContext(); - char temporary[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(temporary, sizeof(temporary), resources, - ".bongo-cat-runtime-cover.png")) return false; - bongo_cat_file_remove(temporary); - int width = 0, height = 0; - SDL_GetWindowSizeInPixels(app->window, &width, &height); - BongoCatLive2D *cover = NULL; - BongoCatError ignored = {0}; - if (width > 1 && height > 1 && - SDL_GL_MakeCurrent(app->window, app->gl_context)) { - /* Generated-cover state is destructive, so it requires its own model. */ - cover = bongo_cat_live2d_create_cover_runtime( - app->asset_root, &ignored); - if (cover) { - bongo_cat_live2d_reshape(cover, width, height); - if (bongo_cat_live2d_load(cover, entry->directory, - entry->setting_file, entry->preset, - &app->model_render_options, NULL, NULL, - &ignored) != BONGO_CAT_OK) { - bongo_cat_live2d_destroy(cover); - cover = NULL; - } - } - } - if (cover && cover != app->live2d && - bongo_cat_live2d_prepare_cover(cover)) { - bongo_cat_live2d_resize(cover, width, height); - glViewport(0, 0, width, height); - glEnable(GL_MULTISAMPLE); - glDisable(GL_SCISSOR_TEST); - glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); - glClearColor(0.0f, 0.0f, 0.0f, 0.0f); - glClear(GL_COLOR_BUFFER_BIT); - int x = 0, y = 0, content_width = width, content_height = height; - if (bongo_cat_live2d_viewport(cover, &x, &y, - &content_width, &content_height) && - content_width > 0 && content_height > 0) - glViewport(x, y, content_width, content_height); - bongo_cat_live2d_set_mirror(cover, app->settings.model.mirror); - bongo_cat_live2d_draw(cover); - glViewport(0, 0, width, height); - snprintf(app->pending_model_cover_path, - sizeof(app->pending_model_cover_path), "%s", temporary); - bongo_cat_frame_capture_pending(app, width, height); - app->dirty = true; - } - app->pending_model_cover_path[0] = '\0'; - if (cover) bongo_cat_live2d_destroy(cover); - if (previous_window && previous_context && - (previous_window != SDL_GL_GetCurrentWindow() || - previous_context != SDL_GL_GetCurrentContext())) - SDL_GL_MakeCurrent(previous_window, previous_context); - if (!bongo_cat_path_is_file(temporary)) return false; - if (!bongo_cat_file_replace(temporary, path, true)) { - bongo_cat_file_remove(temporary); - return false; - } - if (fallback) bongo_cat_file_remove(marker); - return true; -} - -static bool cover_needs_capture(const BongoCatModelEntry *entry) { - char resources[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; - char marker[BONGO_CAT_PATH_CAP]; - return entry && bongo_cat_path_join(resources, sizeof(resources), - entry->adapter_directory, "resources") && - bongo_cat_path_join(path, sizeof(path), resources, "cover.png") && - bongo_cat_path_join(marker, sizeof(marker), resources, - ".bongo-cat-cover-fallback") && - (!bongo_cat_path_is_file(path) || bongo_cat_path_is_file(marker)); -} - -static ModelCoverAttempt *find_cover_attempt(BongoCatApp *app, - const char *model_id) { - for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) - if (cover_attempts[i].app == app && - !strcmp(cover_attempts[i].model_id, model_id)) - return &cover_attempts[i]; - return NULL; -} - -static ModelCoverAttempt *create_cover_attempt(BongoCatApp *app, - const char *model_id) { - for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) { - ModelCoverAttempt *attempt = &cover_attempts[i]; - if (attempt->app) continue; - attempt->app = app; - snprintf(attempt->model_id, sizeof(attempt->model_id), "%s", model_id); - return attempt; - } - return NULL; -} - -static void forget_cached_cover(BongoCatApp *app, - const BongoCatModelEntry *entry) { - char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), entry->adapter_directory, - "resources/cover.png")) return; - for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) { - ModelCoverSlot *slot = &cover_cache[i]; - if (slot->app != app || strcmp(slot->path, path)) continue; - if (slot->image.texture) glDeleteTextures(1, &slot->image.texture); - memset(slot, 0, sizeof(*slot)); - return; - } -} - -bool bongo_cat_preferences_model_cover_generate_current(BongoCatApp *app) { - if (!app || !app->window || !app->gl_context || - app->window_minimized) return false; - const BongoCatModelEntry *entry = bongo_cat_models_find( - &app->models, app->loaded_model); - if (!entry || strcmp(app->session.active_model_id, entry->id) || - !cover_needs_capture(entry)) - return false; - ModelCoverAttempt *attempt = find_cover_attempt(app, entry->id); - if (!attempt) { - attempt = create_cover_attempt(app, entry->id); - if (!attempt) return false; - return true; - } - if (attempt->attempted) return false; - attempt->attempted = true; - bool captured = capture_model_cover(app, entry); - if (captured) forget_cached_cover(app, entry); - else SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "Cannot generate cover for model %s", entry->id); - return true; -} - void bongo_cat_preferences_model_covers_prune(BongoCatApp *app) { for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) { ModelCoverSlot *slot = &cover_cache[i]; diff --git a/src/ui/preferences/preferences_model_cover.h b/src/ui/preferences/preferences_model_cover.h index 1e6664e4..f4515cfd 100644 --- a/src/ui/preferences/preferences_model_cover.h +++ b/src/ui/preferences/preferences_model_cover.h @@ -13,10 +13,10 @@ typedef struct BongoCatModelCover { void bongo_cat_preferences_model_covers_begin(BongoCatApp *app); void bongo_cat_preferences_model_cover_cache_clear(BongoCatApp *app); +void bongo_cat_preferences_model_cache_abandon(BongoCatApp *app); const BongoCatModelCover *bongo_cat_preferences_model_cover( BongoCatApp *app, const BongoCatModelEntry *entry, int pixel_width, int pixel_height); -bool bongo_cat_preferences_model_cover_generate_current(BongoCatApp *app); void bongo_cat_preferences_model_covers_prune(BongoCatApp *app); #endif diff --git a/src/ui/preferences/preferences_scale.c b/src/ui/preferences/preferences_scale.c index 343ad6b8..6b6b280e 100644 --- a/src/ui/preferences/preferences_scale.c +++ b/src/ui/preferences/preferences_scale.c @@ -1,5 +1,6 @@ #include "preferences_state.h" #include "preferences_fonts.h" +#include "preferences_gl.h" #include "ui_catime.h" #include "bongo_cat/memory_policy.h" #include @@ -83,25 +84,10 @@ static SDL_HitTestResult SDLCALL preference_hit_test(SDL_Window *window, return SDL_HITTEST_NORMAL; } -static bool attach_gl_context(BongoCatPreferences *value) { - value->gl_context = value->app->gl_context; - if (SDL_GL_MakeCurrent(value->window, value->gl_context)) return true; - SDL_GL_MakeCurrent(value->app->window, value->app->gl_context); - SDL_GL_SetAttribute(SDL_GL_SHARE_WITH_CURRENT_CONTEXT, 1); - value->gl_context = SDL_GL_CreateContext(value->window); - SDL_GL_SetAttribute(SDL_GL_SHARE_WITH_CURRENT_CONTEXT, 0); - value->owns_gl_context = value->gl_context != NULL; - return value->gl_context && - SDL_GL_MakeCurrent(value->window, value->gl_context); -} - static void discard_window(BongoCatPreferences *value) { - if (value->owns_gl_context && value->gl_context) - SDL_GL_DestroyContext(value->gl_context); + bongo_cat_preferences_gl_destroy(value); if (value->window) SDL_DestroyWindow(value->window); value->window = NULL; - value->gl_context = NULL; - value->owns_gl_context = false; value->transparent_window = false; SDL_GL_MakeCurrent(value->app->window, value->app->gl_context); } @@ -148,7 +134,7 @@ bool bongo_cat_preferences_open_window(BongoCatPreferences *value) { SDL_SetWindowPosition(value->window, SDL_WINDOWPOS_CENTERED_DISPLAY(display), SDL_WINDOWPOS_CENTERED_DISPLAY(display)); SDL_SyncWindow(value->window); - bool context_ready = attach_gl_context(value); + bool context_ready = bongo_cat_preferences_gl_create(value); if (!context_ready && value->transparent_window) { SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, "Transparent preferences OpenGL context unavailable: %s", @@ -160,7 +146,7 @@ bool bongo_cat_preferences_open_window(BongoCatPreferences *value) { SDL_WINDOWPOS_CENTERED_DISPLAY(display), SDL_WINDOWPOS_CENTERED_DISPLAY(display)); SDL_SyncWindow(value->window); - context_ready = attach_gl_context(value); + context_ready = bongo_cat_preferences_gl_create(value); } if (!context_ready) return false; BongoCatPreferenceFonts fonts; diff --git a/src/ui/preferences/preferences_state.h b/src/ui/preferences/preferences_state.h index 7e5f5a50..02db3810 100644 --- a/src/ui/preferences/preferences_state.h +++ b/src/ui/preferences/preferences_state.h @@ -136,6 +136,7 @@ void bongo_cat_preferences_model_load_progress(BongoCatPreferences *value, float bongo_cat_preferences_model_visual_progress(BongoCatPreferences *value, const char *model_id); void bongo_cat_preferences_assets_clear(BongoCatPreferences *value); +void bongo_cat_preferences_assets_abandon(BongoCatPreferences *value); void bongo_cat_preferences_model_cover_cache_clear(BongoCatApp *app); void bongo_cat_preferences_page_cache_clear(BongoCatPreferences *value, int previous_page, int next_page); diff --git a/src/ui/rendering/ui_paint_cache.c b/src/ui/rendering/ui_paint_cache.c index f605d999..65b12c39 100644 --- a/src/ui/rendering/ui_paint_cache.c +++ b/src/ui/rendering/ui_paint_cache.c @@ -162,3 +162,11 @@ void bongo_cat_ui_paint_cache_destroy(BongoCatUIBackend *backend) { } if (backend) backend->paint_frame_marker = 0; } + +void bongo_cat_ui_paint_cache_abandon(BongoCatUIBackend *backend) { + for (size_t i = 0; i < sizeof(textures) / sizeof(textures[0]); ++i) { + BongoCatUIPaintTexture *item = &textures[i]; + if (item->backend == backend) memset(item, 0, sizeof(*item)); + } + if (backend) backend->paint_frame_marker = 0; +} diff --git a/src/ui/rendering/ui_paint_cache.h b/src/ui/rendering/ui_paint_cache.h index df4475ad..e4a6a1d0 100644 --- a/src/ui/rendering/ui_paint_cache.h +++ b/src/ui/rendering/ui_paint_cache.h @@ -38,5 +38,6 @@ void bongo_cat_ui_paint_cache_begin_frame(BongoCatUIBackend *backend); size_t bongo_cat_ui_paint_cache_usage(BongoCatUIBackend *backend, size_t *texture_count); void bongo_cat_ui_paint_cache_destroy(BongoCatUIBackend *backend); +void bongo_cat_ui_paint_cache_abandon(BongoCatUIBackend *backend); #endif From 16fa8e98d88c5f94cdd3a2f16e328fc5fb886148 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 29 Aug 2026 22:12:53 +0800 Subject: [PATCH 149/334] feat(models): improve progressive import and catalog refresh --- CMakeLists.txt | 2 +- cmake/RuntimeSources.cmake | 6 + include/bongo_cat/app.h | 3 + include/bongo_cat/preferences.h | 2 + src/runtime/lifecycle/app.c | 4 +- src/runtime/model/import/model_import.c | 107 +++++------- src/runtime/model/import/model_import.h | 11 ++ .../model/import/model_import_identity.c | 13 ++ .../model/import/model_import_installed.c | 68 +++++++- .../model/import/model_import_package_index.c | 165 ++++++++++++++++++ .../model/import/model_import_progressive.c | 156 +++++++++++++++++ .../import/model_import_session_internal.h | 31 ++++ src/runtime/model/model_catalog_commit.c | 19 ++ src/runtime/model/model_catalog_refresh.c | 97 ++++++++-- src/runtime/model/model_catalog_runtime.c | 125 ++++++++----- src/runtime/model/model_catalog_selection.c | 106 +++++++++++ src/runtime/model/model_catalog_selection.h | 17 ++ src/runtime/runtime.h | 4 +- src/ui/preferences/preferences.c | 3 + src/ui/preferences/preferences_import.c | 88 +++++----- .../preferences/preferences_import_internal.h | 20 ++- .../preferences/preferences_import_progress.c | 59 +++++++ src/ui/preferences/preferences_model_watch.c | 90 ++++++++++ src/ui/preferences/preferences_state.h | 8 + tests/model_import/test_mver_container.c | 34 ++++ .../model_import/test_mver_nearby_identity.c | 58 ++++++ 26 files changed, 1119 insertions(+), 177 deletions(-) create mode 100644 src/runtime/model/import/model_import_package_index.c create mode 100644 src/runtime/model/import/model_import_progressive.c create mode 100644 src/runtime/model/import/model_import_session_internal.h create mode 100644 src/runtime/model/model_catalog_commit.c create mode 100644 src/runtime/model/model_catalog_selection.c create mode 100644 src/runtime/model/model_catalog_selection.h create mode 100644 src/ui/preferences/preferences_import_progress.c create mode 100644 src/ui/preferences/preferences_model_watch.c diff --git a/CMakeLists.txt b/CMakeLists.txt index 984bb4a9..aab92f05 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -project(BongoCat VERSION 1.1.0 DESCRIPTION "BongoCat" HOMEPAGE_URL "https://github.com/vladelaina/BongoCat" LANGUAGES C CXX) +project(BongoCat VERSION 1.2.0 DESCRIPTION "BongoCat" HOMEPAGE_URL "https://github.com/vladelaina/BongoCat" LANGUAGES C CXX) include(cmake/Version.cmake) include(FetchContent) include(CTest) diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 9f57e2c6..f3d7c575 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -69,7 +69,9 @@ set(BONGO_CAT_RUNTIME_MODEL_SOURCES src/runtime/model/model_behavior_state.c src/runtime/model/model_catalog_runtime.c src/runtime/model/model_catalog_builtins.c + src/runtime/model/model_catalog_commit.c src/runtime/model/model_catalog_refresh.c + src/runtime/model/model_catalog_selection.c src/runtime/model/model_files.c src/runtime/model/model_geometry.c src/runtime/model/multi_pet.c @@ -93,6 +95,8 @@ set(BONGO_CAT_RUNTIME_IMPORT_SOURCES src/runtime/model/import/model_import_installed.c src/runtime/model/import/model_import_metadata.c src/runtime/model/import/model_import_package.c + src/runtime/model/import/model_import_package_index.c + src/runtime/model/import/model_import_progressive.c src/runtime/model/import/model_import_report.c src/runtime/model/import/model_import_scan.c src/runtime/model/import/model_import_source.c @@ -176,6 +180,7 @@ set(BONGO_CAT_UI_PREFERENCES_SOURCES src/ui/preferences/preferences_gl.c src/ui/preferences/preferences_import.c src/ui/preferences/preferences_import_dialog.c + src/ui/preferences/preferences_import_progress.c src/ui/preferences/preferences_icons.c src/ui/preferences/preferences_language.c src/ui/preferences/preferences_lifecycle.c @@ -185,6 +190,7 @@ set(BONGO_CAT_UI_PREFERENCES_SOURCES src/ui/preferences/preferences_model_card_paint.c src/ui/preferences/preferences_model_cover.c src/ui/preferences/preferences_model_section.c + src/ui/preferences/preferences_model_watch.c src/ui/preferences/preferences_model_glyphs.c src/ui/preferences/preferences_model_rename.c src/ui/preferences/preferences_notice.c diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index da99b9c4..ea1d258a 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -196,6 +196,9 @@ BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, Bong void bongo_cat_app_rescan_models(BongoCatApp *app); void bongo_cat_app_refresh_installed_models(BongoCatApp *app); void bongo_cat_app_refresh_nearby_models(BongoCatApp *app); +void bongo_cat_app_request_model_refresh(BongoCatApp *app); +void bongo_cat_app_request_model_package_refresh(BongoCatApp *app, + const char *package_id); void bongo_cat_app_request_nearby_model_refresh(BongoCatApp *app); void bongo_cat_config_store_flush(BongoCatApp *app); diff --git a/include/bongo_cat/preferences.h b/include/bongo_cat/preferences.h index 00014de4..1df98f4b 100644 --- a/include/bongo_cat/preferences.h +++ b/include/bongo_cat/preferences.h @@ -14,6 +14,8 @@ void bongo_cat_preferences_show(BongoCatPreferences *preferences); void bongo_cat_preferences_close(BongoCatPreferences *preferences); bool bongo_cat_preferences_visible(const BongoCatPreferences *preferences); bool bongo_cat_preferences_needs_frame(BongoCatPreferences *preferences); +void bongo_cat_preferences_model_watch(BongoCatPreferences *preferences, + uint64_t now); void bongo_cat_preferences_input_begin(BongoCatPreferences *preferences); void bongo_cat_preferences_input_end(BongoCatPreferences *preferences); bool bongo_cat_preferences_event(BongoCatPreferences *preferences, const SDL_Event *event); diff --git a/src/runtime/lifecycle/app.c b/src/runtime/lifecycle/app.c index 459b5424..95492c02 100644 --- a/src/runtime/lifecycle/app.c +++ b/src/runtime/lifecycle/app.c @@ -263,10 +263,12 @@ void bongo_cat_app_loop(BongoCatApp *app) { handle_event(app, &event); } bongo_cat_preferences_input_end(app->preferences); + uint64_t now = SDL_GetTicksNS(); + bongo_cat_preferences_model_watch(app->preferences, now); bongo_cat_model_refresh_update(app); take_instance_wake(app); if (take_update_shutdown(app)) continue; - uint64_t now = SDL_GetTicksNS(); bongo_cat_window_update_wheel_animation(app, now); + now = SDL_GetTicksNS(); bongo_cat_window_update_wheel_animation(app, now); bongo_cat_multi_pet_update(app, now); bongo_cat_random_expression_update(app, now); bongo_cat_window_update_display_recovery(app, now); diff --git a/src/runtime/model/import/model_import.c b/src/runtime/model/import/model_import.c index fbd77436..5b3b2ad6 100644 --- a/src/runtime/model/import/model_import.c +++ b/src/runtime/model/import/model_import.c @@ -1,4 +1,5 @@ #include "model_import.h" +#include "model_import_session_internal.h" #include "model_storage.h" #include "bongo_cat/path.h" @@ -14,72 +15,19 @@ typedef struct ImportInstall { bool committed; } ImportInstall; -struct BongoCatImportSession { - char root[BONGO_CAT_PATH_CAP]; - BongoCatImportDigestCache *digests; -}; - static void cleanup(ImportInstall *install) { if (!install) return; bongo_cat_model_remove_tree(install->committed ? install->target : install->temporary, NULL); } -static bool same_package(const BongoCatImportDiscovery *left, - const BongoCatPackageMetadata *left_metadata, - const BongoCatImportDiscovery *right, - const BongoCatPackageMetadata *right_metadata) { - if (!left || !right || left->count != right->count) return false; - for (size_t i = 0; i < left->count; ++i) - if (left->candidates[i].mode != right->candidates[i].mode || - strcmp(left_metadata[i].content_digest, - right_metadata[i].content_digest) != 0) return false; - return true; -} - -typedef struct ExistingPackageSearch { - const BongoCatImportDiscovery *expected; - const BongoCatPackageMetadata *metadata; - BongoCatImportDigestCache *digests; - char id[BONGO_CAT_ID_CAP]; -} ExistingPackageSearch; - -static BongoCatPathVisit find_existing_package(void *userdata, - const char *dirname, const char *name) { - ExistingPackageSearch *search = userdata; - if (!name || name[0] == '.') return BONGO_CAT_PATH_CONTINUE; - char directory[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(directory, sizeof(directory), dirname, name) || - !bongo_cat_import_authored_package(directory)) - return BONGO_CAT_PATH_CONTINUE; - BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); - BongoCatPackageMetadata *metadata = calloc(BONGO_CAT_IMPORT_CANDIDATE_CAP, - sizeof(*metadata)); - if (!discovery || !metadata) { - free(discovery); - free(metadata); - return BONGO_CAT_PATH_CONTINUE; - } - BongoCatError ignored = {0}; - bool matches = bongo_cat_import_discover(directory, discovery, &ignored) && - bongo_cat_import_prepare_package_metadata_cached(discovery, metadata, - search->digests, &ignored) && - same_package(search->expected, search->metadata, discovery, metadata); - free(discovery); - free(metadata); - if (!matches) return BONGO_CAT_PATH_CONTINUE; - return bongo_cat_import_package_id(search->id, sizeof(search->id), name) - ? BONGO_CAT_PATH_SUCCESS : BONGO_CAT_PATH_CONTINUE; -} - static bool existing_package(BongoCatImportSession *session, const BongoCatImportDiscovery *discovery, - const BongoCatPackageMetadata *metadata, char id[BONGO_CAT_ID_CAP]) { - ExistingPackageSearch search = {discovery, metadata, session->digests, {0}}; - if (!bongo_cat_path_enumerate(session->root, find_existing_package, - &search) || !search.id[0]) return false; - snprintf(id, BONGO_CAT_ID_CAP, "%s", search.id); - return true; + const BongoCatPackageMetadata *metadata, + char ids[BONGO_CAT_IMPORT_CANDIDATE_CAP][BONGO_CAT_ID_CAP], + size_t *count) { + return bongo_cat_import_package_index_find(session->packages, + discovery, metadata, ids, count); } static bool resolve_install_id(const char *base, const char *root, @@ -115,6 +63,18 @@ static void fill_receipt(BongoCatImportReceipt *receipt, const char *id, } } +static void fill_receipt_ids(BongoCatImportReceipt *receipt, + const char ids[BONGO_CAT_IMPORT_CANDIDATE_CAP][BONGO_CAT_ID_CAP], + size_t count, bool installed) { + if (!receipt) return; + receipt->count = count; + receipt->installed_count = installed ? count : 0; + for (size_t i = 0; i < count; ++i) { + snprintf(receipt->ids[i], sizeof(receipt->ids[i]), "%s", ids[i]); + receipt->installed[i] = installed; + } +} + static BongoCatResult install_discovery(BongoCatImportSession *session, BongoCatImportDiscovery *source_discovery, BongoCatPackageMetadata *source_metadata, @@ -155,14 +115,25 @@ static BongoCatResult install_discovery(BongoCatImportSession *session, return error && error->code ? error->code : BONGO_CAT_ERROR_FORMAT; } - char existing[BONGO_CAT_ID_CAP]; - if (existing_package(session, normalized, metadata, existing)) { - fill_receipt(receipt, existing, normalized->count, false); + char existing[BONGO_CAT_IMPORT_CANDIDATE_CAP][BONGO_CAT_ID_CAP]; + size_t existing_count = 0; + if (existing_package(session, normalized, metadata, existing, + &existing_count)) { + fill_receipt_ids(receipt, existing, existing_count, false); free(normalized); free(metadata); cleanup(&install); return BONGO_CAT_OK; } + if (!bongo_cat_import_package_index_has_capacity(session->packages, + normalized->count)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Too many installed model variants"); + free(normalized); + free(metadata); + cleanup(&install); + return BONGO_CAT_ERROR_FORMAT; + } if (!resolve_install_id(source_metadata[0].package_id, session->root, install.id, error) || !bongo_cat_path_join(install.target, sizeof(install.target), @@ -181,6 +152,13 @@ static BongoCatResult install_discovery(BongoCatImportSession *session, return BONGO_CAT_ERROR_IO; } install.committed = true; + if (!bongo_cat_import_package_index_add(session->packages, install.id, + normalized, metadata, error)) { + free(normalized); + free(metadata); + cleanup(&install); + return error && error->code ? error->code : BONGO_CAT_ERROR_FORMAT; + } fill_receipt(receipt, install.id, normalized->count, true); free(normalized); free(metadata); @@ -206,11 +184,18 @@ BongoCatImportSession *bongo_cat_import_session_create(const char *models_root, bongo_cat_import_session_destroy(session); return NULL; } + session->packages = bongo_cat_import_package_index_create(session->root, + session->digests, error); + if (!session->packages) { + bongo_cat_import_session_destroy(session); + return NULL; + } return session; } void bongo_cat_import_session_destroy(BongoCatImportSession *session) { if (!session) return; + bongo_cat_import_package_index_destroy(session->packages); bongo_cat_import_digest_cache_destroy(session->digests); free(session); } diff --git a/src/runtime/model/import/model_import.h b/src/runtime/model/import/model_import.h index 28927726..6ee9b469 100644 --- a/src/runtime/model/import/model_import.h +++ b/src/runtime/model/import/model_import.h @@ -42,6 +42,9 @@ typedef struct BongoCatImportReceipt { size_t installed_count; } BongoCatImportReceipt; +typedef void (*BongoCatImportReceiptCallback)(void *userdata, + const BongoCatImportReceipt *receipt); + typedef struct BongoCatPackageMetadata { char package_id[BONGO_CAT_ID_CAP]; char content_digest[65]; @@ -116,6 +119,8 @@ bool bongo_cat_import_package_id(char *output, size_t capacity, const char *name); bool bongo_cat_import_variant_id(char *output, size_t capacity, const char *package_id, size_t variant_index); +bool bongo_cat_import_package_base_id(char *output, size_t capacity, + const char *model_id); uint32_t bongo_cat_import_candidate_capabilities( const BongoCatImportCandidate *candidate); void bongo_cat_import_describe_nearby_entry(BongoCatModelEntry *entry, @@ -141,6 +146,8 @@ BongoCatResult bongo_cat_import_nearby(BongoCatApp *app, const char *root, BongoCatError *error); BongoCatResult bongo_cat_import_installed_models(BongoCatApp *app, const char *root, BongoCatError *error); +BongoCatResult bongo_cat_import_installed_package(BongoCatApp *app, + const char *root, const char *package_id, BongoCatError *error); BongoCatResult bongo_cat_import_install(const char *source, const char *models_root, BongoCatImportReceipt *receipt, BongoCatError *error); @@ -150,5 +157,9 @@ void bongo_cat_import_session_destroy(BongoCatImportSession *session); BongoCatResult bongo_cat_import_session_install( BongoCatImportSession *session, const char *source, BongoCatImportReceipt *receipt, BongoCatError *error); +BongoCatResult bongo_cat_import_session_install_progressive( + BongoCatImportSession *session, const char *source, + BongoCatImportReceiptCallback callback, void *userdata, + BongoCatError *error); #endif diff --git a/src/runtime/model/import/model_import_identity.c b/src/runtime/model/import/model_import_identity.c index 568cc5db..7ad32fc1 100644 --- a/src/runtime/model/import/model_import_identity.c +++ b/src/runtime/model/import/model_import_identity.c @@ -169,6 +169,19 @@ bool bongo_cat_import_variant_id(char *output, size_t capacity, return written >= 0 && (size_t)written < capacity; } +bool bongo_cat_import_package_base_id(char *output, size_t capacity, + const char *model_id) { + if (!output || capacity < 2 || !model_id || !model_id[0]) return false; + int written = snprintf(output, capacity, "%s", model_id); + if (written < 0 || (size_t)written >= capacity) return false; + char *separator = strrchr(output, '~'); + if (!separator || !separator[1]) return true; + for (char *cursor = separator + 1; *cursor; ++cursor) + if (*cursor < '0' || *cursor > '9') return true; + *separator = '\0'; + return output[0] != '\0'; +} + bool bongo_cat_import_prepare_package_metadata_cached( BongoCatImportDiscovery *discovery, BongoCatPackageMetadata *metadata, BongoCatImportDigestCache *cache, diff --git a/src/runtime/model/import/model_import_installed.c b/src/runtime/model/import/model_import_installed.c index 6ae5f493..bb2edf95 100644 --- a/src/runtime/model/import/model_import_installed.c +++ b/src/runtime/model/import/model_import_installed.c @@ -194,18 +194,72 @@ static BongoCatPathVisit scan_package(void *userdata, return BONGO_CAT_PATH_FAILURE; } +static bool installed_cache_root(const BongoCatApp *app, + char cache_root[BONGO_CAT_PATH_CAP], BongoCatError *error) { + if (bongo_cat_path_join(cache_root, BONGO_CAT_PATH_CAP, app->cache_root, + BONGO_CAT_ADAPTER_CACHE_DIRECTORY) && + bongo_cat_path_create_directory(cache_root)) return true; + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot create installed model adapter cache"); + return false; +} + +static void remove_storage_models(BongoCatModelCatalog *models, + const char *directory) { + size_t output = 0; + for (size_t i = 0; i < models->count; ++i) { + BongoCatModelEntry entry = models->entries[i]; + if (!strcmp(entry.storage_directory, directory)) continue; + models->entries[output++] = entry; + } + if (output < BONGO_CAT_MODEL_CAP) + memset(&models->entries[output], 0, + (BONGO_CAT_MODEL_CAP - output) * sizeof(models->entries[0])); + models->count = output; +} + +BongoCatResult bongo_cat_import_installed_package(BongoCatApp *app, + const char *root, const char *package_id, BongoCatError *error) { + if (!app || !root || !package_id || !package_id[0] || + !app->cache_root[0]) return BONGO_CAT_ERROR_ARGUMENT; + char normalized_id[BONGO_CAT_ID_CAP]; + if (!bongo_cat_import_package_id(normalized_id, sizeof(normalized_id), + package_id) || strcmp(normalized_id, package_id)) + return BONGO_CAT_ERROR_ARGUMENT; + char directory[BONGO_CAT_PATH_CAP], cache_root[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(directory, sizeof(directory), root, package_id) || + !bongo_cat_import_authored_package(directory) || + !installed_cache_root(app, cache_root, error)) { + if (error && !error->code) + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Installed model package is unavailable: %s", package_id); + return error && error->code ? error->code : BONGO_CAT_ERROR_FORMAT; + } + BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); + if (!discovery) return BONGO_CAT_ERROR_MEMORY; + bongo_cat_import_model_scan_lock(); + bool found = bongo_cat_import_discover(directory, discovery, error); + InstalledModelScan scan = {app, cache_root, error, BONGO_CAT_OK}; + if (found) { + remove_storage_models(&app->models, directory); + if (!add_package(&scan, directory, discovery)) + scan.result = error && error->code ? error->code : + BONGO_CAT_ERROR_IO; + } + bongo_cat_import_model_scan_unlock(); + free(discovery); + if (!found && scan.result == BONGO_CAT_OK) + scan.result = error && error->code ? error->code : + BONGO_CAT_ERROR_FORMAT; + return scan.result; +} + BongoCatResult bongo_cat_import_installed_models(BongoCatApp *app, const char *root, BongoCatError *error) { if (!app || !root || !app->cache_root[0]) return BONGO_CAT_ERROR_ARGUMENT; if (!bongo_cat_path_is_dir(root)) return BONGO_CAT_OK; char cache_root[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(cache_root, sizeof(cache_root), app->cache_root, - BONGO_CAT_ADAPTER_CACHE_DIRECTORY) || - !bongo_cat_path_create_directory(cache_root)) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot create installed model adapter cache"); - return BONGO_CAT_ERROR_IO; - } + if (!installed_cache_root(app, cache_root, error)) return BONGO_CAT_ERROR_IO; InstalledModelScan scan = {app, cache_root, error, BONGO_CAT_OK}; bongo_cat_import_model_scan_lock(); bool ok = bongo_cat_path_enumerate(root, scan_package, &scan); diff --git a/src/runtime/model/import/model_import_package_index.c b/src/runtime/model/import/model_import_package_index.c new file mode 100644 index 00000000..f831d1d9 --- /dev/null +++ b/src/runtime/model/import/model_import_package_index.c @@ -0,0 +1,165 @@ +#include "model_import_session_internal.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +typedef struct IndexedPackage { + char id[BONGO_CAT_ID_CAP]; + char digests[BONGO_CAT_IMPORT_CANDIDATE_CAP][65]; + BongoCatModelMode modes[BONGO_CAT_IMPORT_CANDIDATE_CAP]; + size_t count; +} IndexedPackage; + +struct BongoCatImportPackageIndex { + IndexedPackage entries[BONGO_CAT_MODEL_CAP]; + size_t count; + size_t model_count; +}; + +/* Build once for a batch; successful atomic installs extend this snapshot. */ + +static bool match_package(const IndexedPackage *known, + const BongoCatImportDiscovery *discovery, + const BongoCatPackageMetadata *metadata, + size_t matches[BONGO_CAT_IMPORT_CANDIDATE_CAP]) { + if (!known || !discovery || !metadata || discovery->count > known->count) + return false; + bool used[BONGO_CAT_IMPORT_CANDIDATE_CAP] = {0}; + for (size_t i = 0; i < discovery->count; ++i) { + bool found = false; + for (size_t j = 0; j < known->count; ++j) { + if (used[j] || known->modes[j] != discovery->candidates[i].mode || + strcmp(known->digests[j], metadata[i].content_digest) != 0) + continue; + used[j] = true; + matches[i] = j; + found = true; + break; + } + if (!found) return false; + } + return true; +} + +bool bongo_cat_import_package_index_add(BongoCatImportPackageIndex *index, + const char *id, const BongoCatImportDiscovery *discovery, + const BongoCatPackageMetadata *metadata, BongoCatError *error) { + if (!index || !id || !id[0] || !discovery || !metadata || + discovery->count > BONGO_CAT_IMPORT_CANDIDATE_CAP) return false; + if (index->count >= BONGO_CAT_MODEL_CAP || + discovery->count > BONGO_CAT_MODEL_CAP - index->model_count) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Too many installed model variants"); + return false; + } + IndexedPackage *entry = &index->entries[index->count++]; + entry->count = discovery->count; + index->model_count += discovery->count; + snprintf(entry->id, sizeof(entry->id), "%s", id); + for (size_t i = 0; i < entry->count; ++i) { + entry->modes[i] = discovery->candidates[i].mode; + snprintf(entry->digests[i], sizeof(entry->digests[i]), "%s", + metadata[i].content_digest); + } + return true; +} + +bool bongo_cat_import_package_index_find( + const BongoCatImportPackageIndex *index, + const BongoCatImportDiscovery *discovery, + const BongoCatPackageMetadata *metadata, + char ids[BONGO_CAT_IMPORT_CANDIDATE_CAP][BONGO_CAT_ID_CAP], + size_t *count) { + if (!index || !discovery || !metadata || !ids || !count || + discovery->count > BONGO_CAT_IMPORT_CANDIDATE_CAP) return false; + *count = 0; + for (size_t i = 0; i < index->count; ++i) { + size_t matches[BONGO_CAT_IMPORT_CANDIDATE_CAP] = {0}; + if (!match_package(&index->entries[i], discovery, metadata, matches)) + continue; + for (size_t j = 0; j < discovery->count; ++j) + if (!bongo_cat_import_variant_id(ids[j], BONGO_CAT_ID_CAP, + index->entries[i].id, matches[j])) { + *count = 0; + return false; + } + *count = discovery->count; + return true; + } + return false; +} + +bool bongo_cat_import_package_index_has_capacity( + const BongoCatImportPackageIndex *index, size_t model_count) { + return index && index->count < BONGO_CAT_MODEL_CAP && + model_count <= BONGO_CAT_MODEL_CAP - index->model_count; +} + +typedef struct IndexBuild { + BongoCatImportPackageIndex *index; + BongoCatImportDigestCache *digests; + BongoCatError *error; + bool failed; +} IndexBuild; + +static BongoCatPathVisit index_package(void *userdata, + const char *dirname, const char *name) { + IndexBuild *build = userdata; + if (!name || name[0] == '.') return BONGO_CAT_PATH_CONTINUE; + char directory[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(directory, sizeof(directory), dirname, name) || + !bongo_cat_import_authored_package(directory)) + return BONGO_CAT_PATH_CONTINUE; + BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); + BongoCatPackageMetadata *metadata = calloc( + BONGO_CAT_IMPORT_CANDIDATE_CAP, sizeof(*metadata)); + if (!discovery || !metadata) { + free(discovery); + free(metadata); + bongo_cat_error_set(build->error, BONGO_CAT_ERROR_MEMORY, + "Cannot index installed model packages"); + build->failed = true; + return BONGO_CAT_PATH_FAILURE; + } + BongoCatError ignored = {0}; + bool valid = bongo_cat_import_discover(directory, discovery, &ignored) && + bongo_cat_import_prepare_package_metadata_cached(discovery, metadata, + build->digests, &ignored); + char id[BONGO_CAT_ID_CAP]; + bool added = !valid || + (bongo_cat_import_package_id(id, sizeof(id), name) && + bongo_cat_import_package_index_add(build->index, id, discovery, + metadata, build->error)); + free(discovery); + free(metadata); + if (added) return BONGO_CAT_PATH_CONTINUE; + build->failed = true; + return BONGO_CAT_PATH_FAILURE; +} + +BongoCatImportPackageIndex *bongo_cat_import_package_index_create( + const char *root, BongoCatImportDigestCache *digests, + BongoCatError *error) { + if (!root || !digests) return NULL; + BongoCatImportPackageIndex *index = calloc(1, sizeof(*index)); + if (!index) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Cannot allocate installed model index"); + return NULL; + } + IndexBuild build = {index, digests, error, false}; + bool scanned = bongo_cat_path_enumerate(root, index_package, &build); + if (scanned && !build.failed) return index; + if (!build.failed) + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot index installed model packages"); + free(index); + return NULL; +} + +void bongo_cat_import_package_index_destroy( + BongoCatImportPackageIndex *index) { + free(index); +} diff --git a/src/runtime/model/import/model_import_progressive.c b/src/runtime/model/import/model_import_progressive.c new file mode 100644 index 00000000..fe90e217 --- /dev/null +++ b/src/runtime/model/import/model_import_progressive.c @@ -0,0 +1,156 @@ +#include "model_import.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +typedef struct ProgressiveBase { + char directory[BONGO_CAT_PATH_CAP]; + char setting[BONGO_CAT_PATH_CAP]; + BongoCatModelMode mode; +} ProgressiveBase; + +typedef struct ProgressiveUnit { + char source[BONGO_CAT_PATH_CAP]; + bool patch_only; +} ProgressiveUnit; + +typedef struct ProgressiveScan { + ProgressiveUnit units[BONGO_CAT_MODEL_CAP]; + ProgressiveBase bases[BONGO_CAT_MODEL_CAP]; + size_t unit_count; + size_t base_count; + size_t model_count; +} ProgressiveScan; + +static int discover_exact(const char *source, + BongoCatImportDiscovery *discovery, BongoCatError *error) { + memset(discovery, 0, sizeof(*discovery)); + int found = bongo_cat_import_mver_discover_exact(source, discovery, error); + if (found) return found; + memset(discovery, 0, sizeof(*discovery)); + found = bongo_cat_import_mver_patch_discover_exact(source, discovery, + error); + if (found) return found; + memset(discovery, 0, sizeof(*discovery)); + return bongo_cat_import_tauri_discover_exact(source, discovery, error); +} + +static BongoCatResult collect_unit(void *userdata, const char *source, + BongoCatImportDiscovery *discovery, BongoCatError *error) { + ProgressiveScan *scan = userdata; + if (scan->unit_count >= BONGO_CAT_MODEL_CAP || + discovery->count > BONGO_CAT_MODEL_CAP - scan->model_count) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Selected folder contains too many model variants"); + return BONGO_CAT_ERROR_FORMAT; + } + ProgressiveUnit *unit = &scan->units[scan->unit_count++]; + snprintf(unit->source, sizeof(unit->source), "%s", source); + unit->patch_only = discovery->count > 0; + scan->model_count += discovery->count; + for (size_t i = 0; i < discovery->count; ++i) { + const BongoCatImportCandidate *candidate = &discovery->candidates[i]; + if (candidate->format != BONGO_CAT_IMPORT_MVER_PATCH) + unit->patch_only = false; + if (candidate->format != BONGO_CAT_IMPORT_MVER) continue; + if (scan->base_count >= BONGO_CAT_MODEL_CAP) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Selected folder contains too many model variants"); + return BONGO_CAT_ERROR_FORMAT; + } + ProgressiveBase *base = &scan->bases[scan->base_count++]; + snprintf(base->directory, sizeof(base->directory), "%s", + candidate->directory); + snprintf(base->setting, sizeof(base->setting), "%s", + candidate->setting); + base->mode = candidate->mode; + } + return BONGO_CAT_OK; +} + +static bool has_base(const ProgressiveScan *scan, + const BongoCatImportCandidate *patch) { + for (size_t i = 0; i < scan->base_count; ++i) { + const ProgressiveBase *base = &scan->bases[i]; + if (base->mode == patch->mode && + strcmp(base->directory, patch->directory) == 0 && + strcmp(base->setting, patch->setting) == 0) return true; + } + return false; +} + +static bool redundant_patch(const ProgressiveScan *scan, + const ProgressiveUnit *unit) { + if (!unit->patch_only) return false; + BongoCatImportDiscovery discovery; + BongoCatError ignored = {0}; + if (discover_exact(unit->source, &discovery, &ignored) <= 0 || + !discovery.count) return false; + for (size_t i = 0; i < discovery.count; ++i) + if (!has_base(scan, &discovery.candidates[i])) return false; + return true; +} + +static BongoCatResult install_one(BongoCatImportSession *session, + const char *source, BongoCatImportReceiptCallback callback, + void *userdata, BongoCatError *error) { + BongoCatImportReceipt receipt = {0}; + BongoCatResult result = bongo_cat_import_session_install(session, source, + &receipt, error); + if (result == BONGO_CAT_OK && receipt.count && callback) + callback(userdata, &receipt); + return result; +} + +BongoCatResult bongo_cat_import_session_install_progressive( + BongoCatImportSession *session, const char *source, + BongoCatImportReceiptCallback callback, void *userdata, + BongoCatError *error) { + if (!session || !source) return BONGO_CAT_ERROR_ARGUMENT; + char directory[BONGO_CAT_PATH_CAP]; + BongoCatResult normalized = bongo_cat_import_source_directory(source, + directory, sizeof(directory), error); + if (normalized != BONGO_CAT_OK) return normalized; + + BongoCatImportDiscovery exact; + BongoCatError exact_error = {0}; + int exact_result = discover_exact(directory, &exact, &exact_error); + if (exact_result > 0) + return install_one(session, directory, callback, userdata, error); + if (exact_result < 0) { + if (error) *error = exact_error; + return exact_error.code ? exact_error.code : BONGO_CAT_ERROR_FORMAT; + } + + ProgressiveScan *scan = calloc(1, sizeof(*scan)); + if (!scan) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Cannot allocate progressive model import scan"); + return BONGO_CAT_ERROR_MEMORY; + } + BongoCatResult scanned = bongo_cat_import_scan(directory, collect_unit, + scan, error); + if (scanned != BONGO_CAT_OK || !scan->unit_count) { + free(scan); + return scanned != BONGO_CAT_OK ? scanned : install_one(session, + directory, callback, userdata, error); + } + + BongoCatResult first_failure = BONGO_CAT_OK; + BongoCatError first_error = {0}; + for (size_t i = 0; i < scan->unit_count; ++i) { + if (redundant_patch(scan, &scan->units[i])) continue; + BongoCatError local = {0}; + BongoCatResult result = install_one(session, scan->units[i].source, + callback, userdata, &local); + if (result != BONGO_CAT_OK && first_failure == BONGO_CAT_OK) { + first_failure = result; + first_error = local; + } + } + free(scan); + if (first_failure != BONGO_CAT_OK && error) *error = first_error; + return first_failure; +} diff --git a/src/runtime/model/import/model_import_session_internal.h b/src/runtime/model/import/model_import_session_internal.h new file mode 100644 index 00000000..4efb5516 --- /dev/null +++ b/src/runtime/model/import/model_import_session_internal.h @@ -0,0 +1,31 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_SESSION_INTERNAL_H +#define BONGO_CAT_MODEL_IMPORT_SESSION_INTERNAL_H + +#include "model_import.h" + +typedef struct BongoCatImportPackageIndex BongoCatImportPackageIndex; + +struct BongoCatImportSession { + char root[BONGO_CAT_PATH_CAP]; + BongoCatImportDigestCache *digests; + BongoCatImportPackageIndex *packages; +}; + +BongoCatImportPackageIndex *bongo_cat_import_package_index_create( + const char *root, BongoCatImportDigestCache *digests, + BongoCatError *error); +void bongo_cat_import_package_index_destroy( + BongoCatImportPackageIndex *index); +bool bongo_cat_import_package_index_find( + const BongoCatImportPackageIndex *index, + const BongoCatImportDiscovery *discovery, + const BongoCatPackageMetadata *metadata, + char ids[BONGO_CAT_IMPORT_CANDIDATE_CAP][BONGO_CAT_ID_CAP], + size_t *count); +bool bongo_cat_import_package_index_has_capacity( + const BongoCatImportPackageIndex *index, size_t model_count); +bool bongo_cat_import_package_index_add(BongoCatImportPackageIndex *index, + const char *id, const BongoCatImportDiscovery *discovery, + const BongoCatPackageMetadata *metadata, BongoCatError *error); + +#endif diff --git a/src/runtime/model/model_catalog_commit.c b/src/runtime/model/model_catalog_commit.c new file mode 100644 index 00000000..28bfe3f5 --- /dev/null +++ b/src/runtime/model/model_catalog_commit.c @@ -0,0 +1,19 @@ +#include "runtime.h" +#include "model_import.h" +#include "bongo_cat/path.h" + +#include + +void bongo_cat_model_catalog_finish_package(BongoCatApp *app, + const char *package_id) { + if (!app || !package_id || !package_id[0]) return; + char storage[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(storage, sizeof(storage), app->models_root, + package_id)) return; + for (size_t i = 0; i < app->models.count; ++i) { + BongoCatModelEntry *entry = &app->models.entries[i]; + if (!strcmp(entry->storage_directory, storage)) + bongo_cat_import_apply_metadata(app, entry->id, + entry->adapter_directory); + } +} diff --git a/src/runtime/model/model_catalog_refresh.c b/src/runtime/model/model_catalog_refresh.c index 85345dd8..8cf8deb0 100644 --- a/src/runtime/model/model_catalog_refresh.c +++ b/src/runtime/model/model_catalog_refresh.c @@ -1,4 +1,5 @@ #include "runtime.h" +#include "model_import.h" #include "bongo_cat/preferences.h" #include @@ -14,8 +15,12 @@ typedef struct BongoCatModelRefreshJob { char cache_root[BONGO_CAT_PATH_CAP]; char nearby_root[BONGO_CAT_PATH_CAP]; char active_model_id[BONGO_CAT_ID_CAP]; + char package_id[BONGO_CAT_ID_CAP]; + BongoCatRemovedModel removed_models[BONGO_CAT_MODEL_CAP]; + size_t removed_model_count; uint64_t started_ns; uint64_t revision; + BongoCatError error; bool success; } BongoCatModelRefreshJob; @@ -27,6 +32,9 @@ struct BongoCatModelRefresh { uint64_t revision; bool busy; bool rerun; + bool active_nearby; + bool rerun_nearby; + char rerun_package_id[BONGO_CAT_ID_CAP]; }; static BongoCatModelRefresh *create_refresh(void) { @@ -44,6 +52,11 @@ static BongoCatModelRefresh *create_refresh(void) { static int SDLCALL refresh_worker(void *userdata) { BongoCatModelRefreshJob *job = userdata; + if (!SDL_SetCurrentThreadPriority(SDL_THREAD_PRIORITY_LOW)) { + SDL_LogDebug(SDL_LOG_CATEGORY_APPLICATION, + "Cannot lower model catalog thread priority: %s", SDL_GetError()); + SDL_ClearError(); + } BongoCatApp *scan = calloc(1, sizeof(*scan)); if (scan) { snprintf(scan->asset_root, sizeof(scan->asset_root), "%s", @@ -55,9 +68,18 @@ static int SDLCALL refresh_worker(void *userdata) { snprintf(scan->session.active_model_id, sizeof(scan->session.active_model_id), "%s", job->active_model_id); - bongo_cat_model_catalog_scan(scan, false, job->nearby_root); + scan->settings.removed_model_count = job->removed_model_count; + memcpy(scan->settings.removed_models, job->removed_models, + job->removed_model_count * sizeof(job->removed_models[0])); + scan->models = job->models; + BongoCatResult result = BONGO_CAT_OK; + if (job->package_id[0]) + result = bongo_cat_import_installed_package(scan, + job->models_root, job->package_id, &job->error); + else result = bongo_cat_model_catalog_scan(scan, false, + job->nearby_root); job->models = scan->models; - job->success = true; + job->success = result == BONGO_CAT_OK; free(scan); } @@ -73,22 +95,35 @@ static int SDLCALL refresh_worker(void *userdata) { return 0; } -static bool start_refresh(BongoCatApp *app) { +static bool start_refresh(BongoCatApp *app, bool include_nearby, + const char *package_id) { BongoCatModelRefresh *refresh = app->model_refresh; BongoCatModelRefreshJob *job = calloc(1, sizeof(*job)); if (!job) return false; job->owner = refresh; job->started_ns = SDL_GetTicksNS(); job->revision = refresh->revision; + if (package_id) job->models = app->models; snprintf(job->asset_root, sizeof(job->asset_root), "%s", app->asset_root); snprintf(job->models_root, sizeof(job->models_root), "%s", app->models_root); snprintf(job->cache_root, sizeof(job->cache_root), "%s", app->cache_root); - snprintf(job->nearby_root, sizeof(job->nearby_root), "%s", - app->nearby_root); + if (include_nearby) + snprintf(job->nearby_root, sizeof(job->nearby_root), "%s", + app->nearby_root); + if (package_id) + snprintf(job->package_id, sizeof(job->package_id), "%s", package_id); snprintf(job->active_model_id, sizeof(job->active_model_id), "%s", app->session.active_model_id); + job->removed_model_count = app->settings.removed_model_count; + if (job->removed_model_count > BONGO_CAT_MODEL_CAP) + job->removed_model_count = BONGO_CAT_MODEL_CAP; + memcpy(job->removed_models, app->settings.removed_models, + job->removed_model_count * sizeof(job->removed_models[0])); refresh->busy = true; refresh->rerun = false; + refresh->active_nearby = include_nearby; + refresh->rerun_nearby = false; + refresh->rerun_package_id[0] = '\0'; refresh->worker = SDL_CreateThread(refresh_worker, BONGO_CAT_SLUG "-model-catalog", job); if (refresh->worker) return true; @@ -97,8 +132,9 @@ static bool start_refresh(BongoCatApp *app) { return false; } -void bongo_cat_app_request_nearby_model_refresh(BongoCatApp *app) { - if (!app || SDL_getenv("BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN")) return; +static void request_refresh(BongoCatApp *app, bool include_nearby, + const char *package_id) { + if (!app) return; if (!app->model_refresh) app->model_refresh = create_refresh(); BongoCatModelRefresh *refresh = app->model_refresh; if (!refresh) { @@ -107,19 +143,47 @@ void bongo_cat_app_request_nearby_model_refresh(BongoCatApp *app) { return; } if (refresh->busy) { + if (!refresh->rerun) + snprintf(refresh->rerun_package_id, + sizeof(refresh->rerun_package_id), "%s", + package_id ? package_id : ""); + else if (!package_id || !refresh->rerun_package_id[0] || + strcmp(refresh->rerun_package_id, package_id)) + refresh->rerun_package_id[0] = '\0'; refresh->rerun = true; + refresh->rerun_nearby = refresh->rerun_nearby || include_nearby; return; } - if (!start_refresh(app)) + if (!start_refresh(app, include_nearby, package_id)) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Cannot start background model refresh: %s", SDL_GetError()); } +void bongo_cat_app_request_model_refresh(BongoCatApp *app) { + request_refresh(app, false, NULL); +} + +void bongo_cat_app_request_model_package_refresh(BongoCatApp *app, + const char *package_id) { + if (!package_id || !package_id[0]) return; + request_refresh(app, false, package_id); +} + +void bongo_cat_app_request_nearby_model_refresh(BongoCatApp *app) { + if (SDL_getenv("BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN")) return; + request_refresh(app, true, NULL); +} + void bongo_cat_model_refresh_invalidate(BongoCatApp *app) { BongoCatModelRefresh *refresh = app ? app->model_refresh : NULL; if (!refresh) return; refresh->revision++; - if (refresh->busy) refresh->rerun = true; + if (refresh->busy) { + refresh->rerun = true; + refresh->rerun_nearby = refresh->rerun_nearby || + refresh->active_nearby; + refresh->rerun_package_id[0] = '\0'; + } } void bongo_cat_model_refresh_update(BongoCatApp *app) { @@ -140,7 +204,9 @@ void bongo_cat_model_refresh_update(BongoCatApp *app) { job->models.count * sizeof(job->models.entries[0])) != 0; if (changed) { app->models = job->models; - bongo_cat_model_catalog_finish(app); + if (job->package_id[0]) + bongo_cat_model_catalog_finish_package(app, job->package_id); + else bongo_cat_model_catalog_finish(app); bongo_cat_preferences_models_changed(app->preferences); } SDL_Log("Background model refresh completed: models=%llu changed=%d " @@ -148,9 +214,16 @@ void bongo_cat_model_refresh_update(BongoCatApp *app) { changed, (SDL_GetTicksNS() - job->started_ns) / 1000000.0); } else if (!job->success) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "Background model refresh failed: not enough memory"); + "Background model refresh failed: %s", + job->error.message[0] ? job->error.message : "not enough memory"); free(job); - if (refresh->rerun && !start_refresh(app)) + bool rerun = refresh->rerun; + bool include_nearby = refresh->rerun_nearby; + char package_id[BONGO_CAT_ID_CAP]; + snprintf(package_id, sizeof(package_id), "%s", + refresh->rerun_package_id); + if (rerun && !start_refresh(app, include_nearby, + package_id[0] ? package_id : NULL)) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Cannot restart background model refresh: %s", SDL_GetError()); } diff --git a/src/runtime/model/model_catalog_runtime.c b/src/runtime/model/model_catalog_runtime.c index b396fca5..782a1cf6 100644 --- a/src/runtime/model/model_catalog_runtime.c +++ b/src/runtime/model/model_catalog_runtime.c @@ -1,38 +1,15 @@ #include "runtime.h" +#include "model_catalog_selection.h" #include "model_import.h" #include "model_storage.h" #include "bongo_cat/path.h" +#include "bongo_cat/preferences.h" #include #include #include #include -typedef struct ModelSelection { - char additional[BONGO_CAT_ADDITIONAL_MODEL_CAP][BONGO_CAT_ID_CAP]; - size_t count; -} ModelSelection; - -static ModelSelection capture_model_selection(const BongoCatApp *app) { - ModelSelection selection = {0}; - selection.count = app->session.additional_model_count; - if (selection.count > BONGO_CAT_ADDITIONAL_MODEL_CAP) - selection.count = BONGO_CAT_ADDITIONAL_MODEL_CAP; - memcpy(selection.additional, app->session.additional_model_ids, - selection.count * sizeof(selection.additional[0])); - return selection; -} - -static void restore_model_selection(BongoCatApp *app, - const ModelSelection *selection) { - bongo_cat_session_clear_additional_models(&app->session); - for (size_t i = 0; i < selection->count; ++i) - if (bongo_cat_models_find(&app->models, selection->additional[i])) - bongo_cat_session_add_model(&app->session, - selection->additional[i]); - bongo_cat_multi_pet_primary_update(app, SDL_GetTicksNS()); -} - static size_t models_using_storage(const BongoCatModelCatalog *models, const char *directory) { size_t count = 0; @@ -73,8 +50,9 @@ static void prune_behavior_shortcuts(BongoCatApp *app) { app->settings.behavior_shortcut_count = output; } -static void scan_nearby_root(BongoCatApp *app, const char *root) { - if (!root || !root[0] || !bongo_cat_path_is_dir(root)) return; +static BongoCatResult scan_nearby_root(BongoCatApp *app, const char *root) { + if (!root || !root[0] || !bongo_cat_path_is_dir(root)) + return BONGO_CAT_OK; size_t before = app->models.count; BongoCatError error = {0}; BongoCatResult result = bongo_cat_import_nearby_scan(app, root, @@ -84,41 +62,77 @@ static void scan_nearby_root(BongoCatApp *app, const char *root) { (unsigned long long)added, root); if (result != BONGO_CAT_OK && error.message[0]) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", error.message); + return result; } -static void scan_owned_models(BongoCatApp *app, bool cleanup) { +static BongoCatResult scan_owned_models(BongoCatApp *app, bool cleanup) { BongoCatError error = {0}; bongo_cat_models_init(&app->models); - if (bongo_cat_model_install_builtins(app->asset_root, app->models_root, - &error) != BONGO_CAT_OK && error.message[0]) - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", error.message); - if (cleanup) bongo_cat_model_cleanup_imports(app->models_root, &error); - bongo_cat_models_scan(&app->models, app->models_root, false, &error); + BongoCatResult result = bongo_cat_model_install_builtins(app->asset_root, + app->models_root, &error); + if (result != BONGO_CAT_OK) { + if (error.message[0]) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "%s", error.message); + return result; + } + if (cleanup) { + if (!bongo_cat_model_cleanup_imports(app->models_root, &error)) { + if (error.message[0]) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "%s", error.message); + return error.code ? error.code : BONGO_CAT_ERROR_IO; + } + } + result = bongo_cat_models_scan(&app->models, app->models_root, false, + &error); + if (result != BONGO_CAT_OK) { + if (error.message[0]) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "%s", error.message); + return result; + } error = (BongoCatError){0}; - if (bongo_cat_import_installed_models(app, app->models_root, &error) != - BONGO_CAT_OK && error.message[0]) + result = bongo_cat_import_installed_models(app, app->models_root, &error); + if (result != BONGO_CAT_OK && error.message[0]) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", error.message); + return result; } void bongo_cat_model_catalog_finish(BongoCatApp *app) { + if (!app) return; prune_behavior_shortcuts(app); + bool selection_changed = bongo_cat_model_catalog_reconcile(app); for (size_t i = 0; i < app->models.count; ++i) bongo_cat_import_apply_metadata(app, app->models.entries[i].id, app->models.entries[i].adapter_directory); + if (selection_changed && app->preferences) bongo_cat_preferences_models_changed(app->preferences); } -void bongo_cat_model_catalog_scan(BongoCatApp *app, bool cleanup, +BongoCatResult bongo_cat_model_catalog_scan(BongoCatApp *app, bool cleanup, const char *nearby_root) { - if (!app) return; - scan_owned_models(app, cleanup); + if (!app) return BONGO_CAT_ERROR_ARGUMENT; + BongoCatResult result = scan_owned_models(app, cleanup); + if (result != BONGO_CAT_OK) return result; if (nearby_root && !SDL_getenv("BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN")) - scan_nearby_root(app, nearby_root); + return scan_nearby_root(app, nearby_root); + return BONGO_CAT_OK; } void bongo_cat_app_rescan_models(BongoCatApp *app) { if (!app) return; bongo_cat_model_refresh_invalidate(app); - bongo_cat_model_catalog_scan(app, true, app->nearby_root); + BongoCatModelCatalog previous = app->models; + char active_model_id[BONGO_CAT_ID_CAP]; + snprintf(active_model_id, sizeof(active_model_id), "%s", + app->session.active_model_id); + BongoCatResult result = bongo_cat_model_catalog_scan(app, true, + app->nearby_root); + if (result != BONGO_CAT_OK) { + app->models = previous; + snprintf(app->session.active_model_id, + sizeof(app->session.active_model_id), "%s", active_model_id); + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Model catalog rescan failed; keeping previous catalog"); + return; + } bongo_cat_model_catalog_finish(app); } @@ -131,7 +145,14 @@ void bongo_cat_app_refresh_installed_models(BongoCatApp *app) { return; } *previous = app->models; - scan_owned_models(app, false); + BongoCatResult result = scan_owned_models(app, false); + if (result != BONGO_CAT_OK) { + app->models = *previous; + free(previous); + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Installed model refresh failed; keeping previous catalog"); + return; + } for (size_t i = 0; i < previous->count && app->models.count < BONGO_CAT_MODEL_CAP; ++i) { const BongoCatModelEntry *entry = &previous->entries[i]; @@ -149,8 +170,18 @@ void bongo_cat_app_refresh_nearby_models(BongoCatApp *app) { char active_model_id[BONGO_CAT_ID_CAP]; snprintf(active_model_id, sizeof(active_model_id), "%s", app->session.active_model_id); - scan_owned_models(app, false); - scan_nearby_root(app, app->nearby_root); + BongoCatModelCatalog previous = app->models; + BongoCatResult result = scan_owned_models(app, false); + if (result == BONGO_CAT_OK) + result = scan_nearby_root(app, app->nearby_root); + if (result != BONGO_CAT_OK) { + app->models = previous; + snprintf(app->session.active_model_id, + sizeof(app->session.active_model_id), "%s", active_model_id); + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Nearby model refresh failed; keeping previous catalog"); + return; + } snprintf(app->session.active_model_id, sizeof(app->session.active_model_id), "%s", active_model_id); bongo_cat_model_catalog_finish(app); @@ -178,7 +209,8 @@ BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, bool primary = !strcmp(id, app->session.active_model_id) || !strcmp(id, app->loaded_model); bool additional = !primary && bongo_cat_app_model_active(app, id); - ModelSelection previous_selection = capture_model_selection(app); + BongoCatModelSelection previous_selection = + bongo_cat_model_selection_capture(app); char directory[BONGO_CAT_PATH_CAP]; snprintf(directory, sizeof(directory), "%s", entry->storage_directory); char package_id[BONGO_CAT_ID_CAP]; @@ -235,7 +267,8 @@ BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, restore_error.message[0] ? restore_error.message : "unknown error"); } - if (additional) restore_model_selection(app, &previous_selection); + if (additional) + bongo_cat_model_selection_restore(app, &previous_selection); bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, "Cannot record removed model version: %s", id); return BONGO_CAT_ERROR_FORMAT; @@ -254,7 +287,7 @@ BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, "unknown error"); } if (restore_selection) - restore_model_selection(app, &previous_selection); + bongo_cat_model_selection_restore(app, &previous_selection); return BONGO_CAT_ERROR_IO; } if (!shared_storage) diff --git a/src/runtime/model/model_catalog_selection.c b/src/runtime/model/model_catalog_selection.c new file mode 100644 index 00000000..ee6e49b2 --- /dev/null +++ b/src/runtime/model/model_catalog_selection.c @@ -0,0 +1,106 @@ +#include "model_catalog_selection.h" + +#include +#include +#include + +BongoCatModelSelection bongo_cat_model_selection_capture( + const BongoCatApp *app) { + BongoCatModelSelection selection = {0}; + if (!app) return selection; + selection.count = app->session.additional_model_count; + if (selection.count > BONGO_CAT_ADDITIONAL_MODEL_CAP) + selection.count = BONGO_CAT_ADDITIONAL_MODEL_CAP; + memcpy(selection.additional, app->session.additional_model_ids, + selection.count * sizeof(selection.additional[0])); + return selection; +} + +void bongo_cat_model_selection_restore(BongoCatApp *app, + const BongoCatModelSelection *selection) { + if (!app || !selection) return; + bongo_cat_session_clear_additional_models(&app->session); + for (size_t i = 0; i < selection->count; ++i) + if (bongo_cat_models_find(&app->models, selection->additional[i])) + bongo_cat_session_add_model(&app->session, + selection->additional[i]); + bongo_cat_multi_pet_primary_update(app, SDL_GetTicksNS()); +} + +static const BongoCatModelEntry *fallback_model( + const BongoCatModelCatalog *models) { + static const char *const ids[] = {"standard", "keyboard", "gamepad"}; + for (size_t i = 0; i < sizeof(ids) / sizeof(ids[0]); ++i) { + const BongoCatModelEntry *entry = bongo_cat_models_find(models, ids[i]); + if (entry) return entry; + } + return models && models->count ? &models->entries[0] : NULL; +} + +static bool selection_matches(const BongoCatApp *app, + const BongoCatModelSelection *selection) { + if (!app || !selection || app->session.additional_model_count != + selection->count) return false; + return !memcmp(app->session.additional_model_ids, selection->additional, + selection->count * sizeof(selection->additional[0])); +} + +static void clear_loaded_model(BongoCatApp *app) { + if (!app) return; + app->loaded_model[0] = '\0'; + app->loading_model[0] = '\0'; + app->loaded_mode = BONGO_CAT_MODE_STANDARD; + bongo_cat_gamepads_set_enabled(app, false); +} + +bool bongo_cat_model_catalog_reconcile(BongoCatApp *app) { + if (!app) return false; + BongoCatModelSelection selection = + bongo_cat_model_selection_capture(app); + const BongoCatModelEntry *active = bongo_cat_models_find(&app->models, + app->session.active_model_id); + const BongoCatModelEntry *loaded = bongo_cat_models_find(&app->models, + app->loaded_model); + bool changed = false; + if (!active) { + active = loaded ? loaded : fallback_model(&app->models); + if (active) { + if (strcmp(app->session.active_model_id, active->id) != 0) { + snprintf(app->session.active_model_id, + sizeof(app->session.active_model_id), "%s", active->id); + changed = true; + } + } else if (app->session.active_model_id[0]) { + app->session.active_model_id[0] = '\0'; + changed = true; + } + } + if (app->settings.model.multiple_pets) { + bongo_cat_model_selection_restore(app, &selection); + if (!selection_matches(app, &selection)) changed = true; + } else if (app->session.additional_model_count) { + bongo_cat_session_clear_additional_models(&app->session); + changed = true; + } + if (app->loaded_model[0] && !loaded) { + if (active && app->live2d) { + BongoCatError error = {0}; + if (!bongo_cat_app_select_model_with_error(app, active->id, + &error)) { + if (error.message[0]) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Unable to restore an available model after refresh: %s", + error.message); + clear_loaded_model(app); + changed = true; + } + } else { + clear_loaded_model(app); + changed = true; + } + } + if (!active && app->session.additional_model_count) { + bongo_cat_session_clear_additional_models(&app->session); + changed = true; + } + return changed; +} diff --git a/src/runtime/model/model_catalog_selection.h b/src/runtime/model/model_catalog_selection.h new file mode 100644 index 00000000..5d1edf00 --- /dev/null +++ b/src/runtime/model/model_catalog_selection.h @@ -0,0 +1,17 @@ +#ifndef BONGO_CAT_MODEL_CATALOG_SELECTION_H +#define BONGO_CAT_MODEL_CATALOG_SELECTION_H + +#include "runtime.h" + +typedef struct BongoCatModelSelection { + char additional[BONGO_CAT_ADDITIONAL_MODEL_CAP][BONGO_CAT_ID_CAP]; + size_t count; +} BongoCatModelSelection; + +BongoCatModelSelection bongo_cat_model_selection_capture( + const BongoCatApp *app); +void bongo_cat_model_selection_restore(BongoCatApp *app, + const BongoCatModelSelection *selection); +bool bongo_cat_model_catalog_reconcile(BongoCatApp *app); + +#endif diff --git a/src/runtime/runtime.h b/src/runtime/runtime.h index 789aa571..7066396d 100644 --- a/src/runtime/runtime.h +++ b/src/runtime/runtime.h @@ -10,9 +10,11 @@ BongoCatResult bongo_cat_window_create(BongoCatApp *app, BongoCatError *error); bool bongo_cat_app_initialize(BongoCatApp *app, int argc, char **argv, BongoCatError *error); void bongo_cat_app_loop(BongoCatApp *app); -void bongo_cat_model_catalog_scan(BongoCatApp *app, bool cleanup, +BongoCatResult bongo_cat_model_catalog_scan(BongoCatApp *app, bool cleanup, const char *nearby_root); void bongo_cat_model_catalog_finish(BongoCatApp *app); +void bongo_cat_model_catalog_finish_package(BongoCatApp *app, + const char *package_id); void bongo_cat_model_refresh_invalidate(BongoCatApp *app); bool bongo_cat_model_refresh_event(BongoCatApp *app, const SDL_Event *event); diff --git a/src/ui/preferences/preferences.c b/src/ui/preferences/preferences.c index c1eb710e..8836c2dc 100644 --- a/src/ui/preferences/preferences.c +++ b/src/ui/preferences/preferences.c @@ -251,11 +251,14 @@ void bongo_cat_preferences_invalidate(BongoCatPreferences *value) { void bongo_cat_preferences_models_changed(BongoCatPreferences *value) { if (!value) return; value->render_dirty = true; + value->model_directory_watch_known = false; if (value->page != 1) { value->model_glyphs_loaded = false; return; } value->model_glyphs_loaded = true; + if (bongo_cat_preferences_import_status(value->import_dialog, + NULL, NULL, NULL)) return; value->font_reload_pending = value->ui_initialized; value->font_reload_defer_once = value->font_reload_pending; } diff --git a/src/ui/preferences/preferences_import.c b/src/ui/preferences/preferences_import.c index b83e425f..ddff0c08 100644 --- a/src/ui/preferences/preferences_import.c +++ b/src/ui/preferences/preferences_import.c @@ -14,6 +14,18 @@ static void free_job(BongoCatImportJob *job) { SDL_free(job->paths); SDL_free(job); } + +static void free_import_event(const SDL_Event *event, bool *release_worker) { + if (!event) return; + if (event->user.code == BONGO_CAT_IMPORT_PROGRESS_CODE) { + SDL_free(event->user.data1); + return; + } + BongoCatImportJob *job = (BongoCatImportJob *)event->user.data1; + if (release_worker && event->user.code == BONGO_CAT_IMPORT_COMPLETE_CODE) + *release_worker = true; + free_job(job); +} static void release_dialog(BongoCatImportDialog *dialog) { bool destroy = false; SDL_LockMutex(dialog->mutex); @@ -39,9 +51,7 @@ void bongo_cat_preferences_import_destroy(BongoCatImportDialog *dialog) { while (SDL_PeepEvents(&event, 1, SDL_GETEVENT, dialog->event_type, dialog->event_type) > 0) { if (event.user.data2 != dialog) continue; - BongoCatImportJob *job = (BongoCatImportJob *)event.user.data1; - release_worker = release_worker || (job && job->completion); - free_job(job); + free_import_event(&event, &release_worker); } dialog->worker_job = NULL; dialog->busy = false; @@ -80,6 +90,23 @@ static void remember_package(BongoCatImportJob *job, const char *id) { "%s", id); } +typedef struct ImportProgressContext { + BongoCatImportJob *job; + size_t completed; +} ImportProgressContext; + +static void receive_import(void *userdata, + const BongoCatImportReceipt *receipt) { + ImportProgressContext *progress = userdata; + BongoCatImportJob *job = progress->job; + if (receipt->count) remember_package(job, receipt->ids[0]); + job->resolved_count += receipt->count; + job->installed_count += receipt->installed_count; + job->result = BONGO_CAT_OK; + bongo_cat_preferences_import_report_progress(job, receipt, + ++progress->completed); +} + static int SDLCALL import_worker(void *userdata) { BongoCatImportJob *job = userdata; if (!SDL_SetCurrentThreadPriority(SDL_THREAD_PRIORITY_LOW)) { @@ -92,32 +119,19 @@ static int SDLCALL import_worker(void *userdata) { job->models_root, &job->error); if (!session) job->result = job->error.code ? job->error.code : BONGO_CAT_ERROR_IO; + ImportProgressContext progress = {job, 0}; for (size_t i = 0; session && i < job->count; ++i) { - BongoCatImportReceipt receipt; + size_t before = progress.completed; BongoCatError error = {0}; - BongoCatResult result = bongo_cat_import_session_install(session, - job->paths[i], &receipt, &error); - if (result == BONGO_CAT_OK) { - if (receipt.count) remember_package(job, receipt.ids[0]); - for (size_t j = 0; j < receipt.count; ++j) { - if (!job->first_id[0] || - (receipt.installed[j] && !job->first_id_installed)) { - snprintf(job->first_id, sizeof(job->first_id), "%s", - receipt.ids[j]); - job->first_id_installed = receipt.installed[j]; - } - } - job->resolved_count += receipt.count; - job->installed_count += receipt.installed_count; - job->result = BONGO_CAT_OK; - } else if (!job->error.message[0]) { + BongoCatResult result = bongo_cat_import_session_install_progressive( + session, job->paths[i], receive_import, &progress, &error); + if (result != BONGO_CAT_OK && !job->error.message[0]) { job->result = result; job->error = error; } - SDL_LockMutex(job->dialog->mutex); - if (job->dialog->worker_job == job) - job->dialog->completed = i + 1; - SDL_UnlockMutex(job->dialog->mutex); + if (progress.completed == before) + bongo_cat_preferences_import_report_progress(job, NULL, + ++progress.completed); } bongo_cat_import_session_destroy(session); if (job->resolved_count) job->result = BONGO_CAT_OK; @@ -128,7 +142,6 @@ static int SDLCALL import_worker(void *userdata) { SDL_LockMutex(job->dialog->mutex); bool notify = job->dialog->active; if (notify) { - job->completion = true; event.type = job->dialog->event_type; event.user.windowID = job->dialog->window_id; event.user.code = BONGO_CAT_IMPORT_COMPLETE_CODE; @@ -237,29 +250,14 @@ static void complete_job(BongoCatImportDialog *dialog, BongoCatApp *app, } SDL_UnlockMutex(dialog->mutex); if (worker) SDL_WaitThread(worker, NULL); - char restored_id[BONGO_CAT_ID_CAP] = {0}; - size_t restored_count = 0; + size_t restored_count = job->restored_count; if (job->result == BONGO_CAT_OK) for (size_t i = 0; i < job->package_id_count; ++i) if (bongo_cat_settings_restore_model_package(&app->settings, - job->package_ids[i])) { - restored_count++; - if (!restored_id[0]) - snprintf(restored_id, sizeof(restored_id), "%s", - job->package_ids[i]); - } + job->package_ids[i])) restored_count++; bool catalog_changed = job->installed_count > 0 || restored_count > 0; - if (job->result == BONGO_CAT_OK && job->first_id[0] && catalog_changed) { - SDL_GL_MakeCurrent(app->window, app->gl_context); - bongo_cat_app_refresh_installed_models(app); - const char *selected = job->first_id_installed || !restored_id[0] - ? job->first_id : restored_id; - if (!bongo_cat_app_select_model(app, selected)) { - job->result = BONGO_CAT_ERROR_CUBISM; - bongo_cat_error_set(&job->error, job->result, - "Model imported but could not be loaded"); - } - } + if (job->result == BONGO_CAT_OK && catalog_changed) + bongo_cat_app_request_model_refresh(app); bongo_cat_preferences_import_complete(app, job->result, &job->error, job->resolved_count, job->installed_count + restored_count); free_job(job); @@ -282,6 +280,8 @@ bool bongo_cat_preferences_import_event(BongoCatImportDialog *dialog, else if (!accept) free_job(job); return true; } + if (bongo_cat_preferences_import_progress_event(dialog, app, event)) + return true; if (event->user.code == BONGO_CAT_IMPORT_COMPLETE_CODE && app && job) { complete_job(dialog, app, job); return true; diff --git a/src/ui/preferences/preferences_import_internal.h b/src/ui/preferences/preferences_import_internal.h index 4d4408fa..64e34318 100644 --- a/src/ui/preferences/preferences_import_internal.h +++ b/src/ui/preferences/preferences_import_internal.h @@ -3,25 +3,32 @@ #include "preferences_internal.h" +typedef struct BongoCatImportReceipt BongoCatImportReceipt; + typedef struct BongoCatImportJob { size_t count; char **paths; char models_root[BONGO_CAT_PATH_CAP]; - char first_id[BONGO_CAT_ID_CAP]; - bool first_id_installed; char package_ids[BONGO_CAT_MODEL_CAP][BONGO_CAT_ID_CAP]; size_t package_id_count; size_t resolved_count; size_t installed_count; + size_t restored_count; BongoCatResult result; BongoCatError error; BongoCatImportDialog *dialog; - bool completion; } BongoCatImportJob; +typedef struct BongoCatImportProgress { + BongoCatImportJob *job; + char package_id[BONGO_CAT_ID_CAP]; + size_t installed_count; +} BongoCatImportProgress; + enum { BONGO_CAT_IMPORT_EVENT_CODE = 0x42434e49, - BONGO_CAT_IMPORT_COMPLETE_CODE = 0x42434e4a + BONGO_CAT_IMPORT_PROGRESS_CODE = 0x42434e4a, + BONGO_CAT_IMPORT_COMPLETE_CODE = 0x42434e4b }; struct BongoCatImportDialog { @@ -39,4 +46,9 @@ struct BongoCatImportDialog { bool busy; }; +void bongo_cat_preferences_import_report_progress(BongoCatImportJob *job, + const BongoCatImportReceipt *receipt, size_t completed); +bool bongo_cat_preferences_import_progress_event( + BongoCatImportDialog *dialog, BongoCatApp *app, const SDL_Event *event); + #endif diff --git a/src/ui/preferences/preferences_import_progress.c b/src/ui/preferences/preferences_import_progress.c new file mode 100644 index 00000000..2be047ab --- /dev/null +++ b/src/ui/preferences/preferences_import_progress.c @@ -0,0 +1,59 @@ +#include "preferences_import_internal.h" +#include "model_import.h" + +#include +#include + +void bongo_cat_preferences_import_report_progress(BongoCatImportJob *job, + const BongoCatImportReceipt *receipt, size_t completed) { + BongoCatImportProgress *progress = receipt && receipt->count + ? SDL_calloc(1, sizeof(*progress)) : NULL; + if (progress) { + progress->job = job; + progress->installed_count = receipt->installed_count; + bongo_cat_import_package_base_id(progress->package_id, + sizeof(progress->package_id), receipt->ids[0]); + } + SDL_Event event = {0}; + SDL_LockMutex(job->dialog->mutex); + bool current = job->dialog->active && + job->dialog->worker_job == job; + if (current) { + job->dialog->completed = completed; + if (job->dialog->total < completed) + job->dialog->total = completed; + } + bool pushed = false; + if (current && progress) { + event.type = job->dialog->event_type; + event.user.windowID = job->dialog->window_id; + event.user.code = BONGO_CAT_IMPORT_PROGRESS_CODE; + event.user.data1 = progress; + event.user.data2 = job->dialog; + pushed = SDL_PushEvent(&event); + } + SDL_UnlockMutex(job->dialog->mutex); + if (!pushed) SDL_free(progress); +} + +bool bongo_cat_preferences_import_progress_event( + BongoCatImportDialog *dialog, BongoCatApp *app, const SDL_Event *event) { + if (!dialog || !event || + event->user.code != BONGO_CAT_IMPORT_PROGRESS_CODE) return false; + BongoCatImportProgress *progress = + (BongoCatImportProgress *)event->user.data1; + SDL_LockMutex(dialog->mutex); + bool current = progress && dialog->active && + dialog->worker_job == progress->job; + SDL_UnlockMutex(dialog->mutex); + if (current && app) { + bool restored = bongo_cat_settings_restore_model_package( + &app->settings, progress->package_id); + if (restored) progress->job->restored_count++; + if (progress->installed_count || restored) + bongo_cat_app_request_model_package_refresh(app, + progress->package_id); + } + SDL_free(progress); + return true; +} diff --git a/src/ui/preferences/preferences_model_watch.c b/src/ui/preferences/preferences_model_watch.c new file mode 100644 index 00000000..b96a0032 --- /dev/null +++ b/src/ui/preferences/preferences_model_watch.c @@ -0,0 +1,90 @@ +#include "preferences_state.h" +#include "bongo_cat/path.h" + +#include + +#define MODEL_DIRECTORY_WATCH_INTERVAL_NS 750000000ull + +typedef struct ModelDirectorySnapshot { + uint64_t sum; + uint64_t xor_value; + size_t count; + bool available; +} ModelDirectorySnapshot; + +static uint64_t mix_hash(uint64_t value) { + value ^= value >> 30; + value *= 0xbf58476d1ce4e5b9ull; + value ^= value >> 27; + value *= 0x94d049bb133111ebull; + return value ^ (value >> 31); +} + +static uint64_t name_hash(const char *name) { + uint64_t value = 1469598103934665603ull; + for (const unsigned char *cursor = (const unsigned char *)name; + cursor && *cursor; ++cursor) { + value ^= *cursor; + value *= 1099511628211ull; + } + return value; +} + +static BongoCatPathVisit snapshot_item(void *userdata, + const char *directory, const char *name) { + ModelDirectorySnapshot *snapshot = userdata; + if (!name || name[0] == '.') return BONGO_CAT_PATH_CONTINUE; + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), directory, name) || + !bongo_cat_path_is_dir(path)) return BONGO_CAT_PATH_CONTINUE; + uint64_t hash = name_hash(name); + snapshot->sum += mix_hash(hash); + snapshot->xor_value ^= mix_hash(hash ^ 0x9e3779b97f4a7c15ull); + snapshot->count++; + return BONGO_CAT_PATH_CONTINUE; +} + +static ModelDirectorySnapshot directory_snapshot(const char *root) { + ModelDirectorySnapshot snapshot = {0}; + snapshot.available = root && bongo_cat_path_is_dir(root) && + bongo_cat_path_enumerate(root, snapshot_item, &snapshot); + return snapshot; +} + +void bongo_cat_preferences_model_watch(BongoCatPreferences *value, + uint64_t now) { + if (!value) return; + bool active = value->visible && value->page == 1 && value->window; + if (!active) { + value->model_directory_watch_active = false; + return; + } + if (!value->model_directory_watch_active) { + value->model_directory_watch_active = true; + if (!value->model_directory_watch_known) + value->model_directory_watch_refresh = true; + value->model_directory_watch_due_ns = 0; + } + if (value->model_directory_watch_due_ns > now) return; + value->model_directory_watch_due_ns = now + + MODEL_DIRECTORY_WATCH_INTERVAL_NS; + if (bongo_cat_preferences_import_status(value->import_dialog, + NULL, NULL, NULL)) return; + + ModelDirectorySnapshot current = directory_snapshot( + value->app->models_root); + bool changed = value->model_directory_watch_known && + (value->model_directory_watch_available != current.available || + value->model_directory_watch_count != current.count || + value->model_directory_watch_sum != current.sum || + value->model_directory_watch_xor != current.xor_value); + value->model_directory_watch_known = true; + value->model_directory_watch_available = current.available; + value->model_directory_watch_count = current.count; + value->model_directory_watch_sum = current.sum; + value->model_directory_watch_xor = current.xor_value; + if (changed || value->model_directory_watch_refresh) { + value->model_directory_watch_refresh = false; + bongo_cat_app_request_model_refresh(value->app); + } +} diff --git a/src/ui/preferences/preferences_state.h b/src/ui/preferences/preferences_state.h index 02db3810..521a64ac 100644 --- a/src/ui/preferences/preferences_state.h +++ b/src/ui/preferences/preferences_state.h @@ -55,6 +55,14 @@ struct BongoCatPreferences { bool font_reload_pending; bool font_reload_defer_once; bool model_glyphs_loaded; + bool model_directory_watch_active; + bool model_directory_watch_known; + bool model_directory_watch_refresh; + bool model_directory_watch_available; + size_t model_directory_watch_count; + uint64_t model_directory_watch_sum; + uint64_t model_directory_watch_xor; + uint64_t model_directory_watch_due_ns; bool smoke_behavior_open_pending; bool model_selection_pending; bool pending_model_multiple; diff --git a/tests/model_import/test_mver_container.c b/tests/model_import/test_mver_container.c index 60a7ce01..d945e418 100644 --- a/tests/model_import/test_mver_container.c +++ b/tests/model_import/test_mver_container.c @@ -10,6 +10,20 @@ #include #include +typedef struct ProgressiveImportLog { + size_t callbacks; + size_t resolved; + size_t installed; +} ProgressiveImportLog; + +static void progressive_receipt(void *userdata, + const BongoCatImportReceipt *receipt) { + ProgressiveImportLog *log = userdata; + log->callbacks++; + log->resolved += receipt->count; + log->installed += receipt->installed_count; +} + void test_mver_container_discovery(void) { char root[BONGO_CAT_PATH_CAP], package[BONGO_CAT_PATH_CAP]; char backup[BONGO_CAT_PATH_CAP]; @@ -138,6 +152,20 @@ void test_mver_container_discovery(void) { CHECK(child(backup, sizeof(backup), installed.overrides, "override_bak", false) && !bongo_cat_path_is_file(backup)); + char progressive_root[BONGO_CAT_PATH_CAP]; + snprintf(progressive_root, sizeof(progressive_root), + "%s/bongocat-container-progressive-%llu", temporary, stamp); + CHECK(SDL_CreateDirectory(progressive_root)); + ProgressiveImportLog progressive = {0}; + BongoCatImportSession *session = bongo_cat_import_session_create( + progressive_root, &error); + CHECK(session != NULL); + CHECK(session && bongo_cat_import_session_install_progressive(session, + root, progressive_receipt, &progressive, &error) == BONGO_CAT_OK); + CHECK(progressive.callbacks == 2 && progressive.resolved == 2 && + progressive.installed == 2); + bongo_cat_import_session_destroy(session); + char models_root[BONGO_CAT_PATH_CAP], cache_root[BONGO_CAT_PATH_CAP]; snprintf(models_root, sizeof(models_root), "%s/bongocat-container-models-%llu", temporary, stamp); @@ -149,6 +177,11 @@ void test_mver_container_discovery(void) { CHECK(bongo_cat_import_install(root, models_root, &receipt, &error) == BONGO_CAT_OK); CHECK(receipt.count == 2 && receipt.installed_count == 2); + BongoCatImportReceipt subset_receipt = {0}; + CHECK(bongo_cat_import_install(package, models_root, &subset_receipt, + &error) == BONGO_CAT_OK); + CHECK(subset_receipt.count == 1 && subset_receipt.installed_count == 0 && + strcmp(subset_receipt.ids[0], receipt.ids[0]) == 0); char stored[BONGO_CAT_PATH_CAP], duplicate_directory[BONGO_CAT_PATH_CAP]; CHECK(child(stored, sizeof(stored), models_root, receipt.ids[0], false) && bongo_cat_path_is_dir(stored)); @@ -197,6 +230,7 @@ void test_mver_container_discovery(void) { } CHECK(bongo_cat_model_remove_tree(models_root, NULL)); CHECK(bongo_cat_model_remove_tree(cache_root, NULL)); + CHECK(bongo_cat_model_remove_tree(progressive_root, NULL)); CHECK(bongo_cat_model_remove_tree(installed_root, NULL)); CHECK(bongo_cat_model_remove_tree(external_root, NULL)); CHECK(bongo_cat_model_remove_tree(root, NULL)); diff --git a/tests/model_import/test_mver_nearby_identity.c b/tests/model_import/test_mver_nearby_identity.c index 1a8849fc..510873b5 100644 --- a/tests/model_import/test_mver_nearby_identity.c +++ b/tests/model_import/test_mver_nearby_identity.c @@ -23,6 +23,63 @@ static bool model_displayed(const BongoCatApp *app, const char *name) { return false; } +static bool wait_for_model(BongoCatApp *app, const char *name, bool visible) { + uint64_t deadline = SDL_GetTicksNS() + 5000000000ull; + while (model_displayed(app, name) != visible && + SDL_GetTicksNS() < deadline) { + bongo_cat_model_refresh_update(app); + SDL_Delay(2); + } + return model_displayed(app, name) == visible; +} + +static void background_installed_refresh(const char *temporary) { + unsigned long long nonce = (unsigned long long)SDL_GetTicksNS(); + char root[BONGO_CAT_PATH_CAP], data[BONGO_CAT_PATH_CAP]; + char source[BONGO_CAT_PATH_CAP], models[BONGO_CAT_PATH_CAP]; + char cache[BONGO_CAT_PATH_CAP]; + snprintf(root, sizeof(root), "%s/bongocat-owned-source-%llu", + temporary, nonce); + snprintf(data, sizeof(data), "%s/bongocat-owned-data-%llu", + temporary, nonce); + CHECK(SDL_CreateDirectory(data)); + CHECK(mver_fixture(root)); + CHECK(child(source, sizeof(source), root, "config.json", false)); + CHECK(child(models, sizeof(models), data, "models", true)); + CHECK(child(cache, sizeof(cache), data, "cache", true)); + BongoCatImportReceipt receipt = {0}; + BongoCatError error = {0}; + CHECK(bongo_cat_import_install(source, models, &receipt, &error) == + BONGO_CAT_OK && receipt.count == 1); + + BongoCatApp *app = calloc(1, sizeof(*app)); + CHECK(app != NULL); + if (app) { + bongo_cat_settings_defaults(&app->settings); + bongo_cat_session_defaults(&app->session); + snprintf(app->asset_root, sizeof(app->asset_root), + "%s/resources/assets", BONGO_CAT_NATIVE_SOURCE_DIR); + snprintf(app->models_root, sizeof(app->models_root), "%s", models); + snprintf(app->cache_root, sizeof(app->cache_root), "%s", cache); + snprintf(app->models.entries[0].id, + sizeof(app->models.entries[0].id), "unrelated-model"); + app->models.entries[0].managed = true; + app->models.count = 1; + bongo_cat_app_request_model_package_refresh(app, receipt.ids[0]); + CHECK(wait_for_model(app, bongo_cat_path_name(root), true)); + CHECK(bongo_cat_models_find(&app->models, "unrelated-model") != NULL); + CHECK(bongo_cat_settings_set_model_removed(&app->settings, + receipt.ids[0], true)); + bongo_cat_app_request_model_package_refresh(app, receipt.ids[0]); + CHECK(wait_for_model(app, bongo_cat_path_name(root), false)); + CHECK(bongo_cat_models_find(&app->models, "unrelated-model") != NULL); + bongo_cat_model_refresh_shutdown(app); + free(app); + } + CHECK(bongo_cat_model_remove_tree(root, NULL)); + CHECK(bongo_cat_model_remove_tree(data, NULL)); +} + int test_mver_nearby_identity(void) { failures = 0; char *temporary = SDL_GetCurrentDirectory(); @@ -108,6 +165,7 @@ int test_mver_nearby_refresh(void) { SDL_free(temporary); return failures; } + background_installed_refresh(temporary); BongoCatApp *parsed = calloc(1, sizeof(*parsed)); CHECK(parsed != NULL); From 6c5558670120618f9a4b96cac77d7bb0d756f24d Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 29 Aug 2026 22:50:25 +0800 Subject: [PATCH 150/334] fix githbu action --- CMakeLists.txt | 4 ++ cmake/CheckCubismUserModelSafety.cmake | 59 +++++++++++++++++ cmake/Cubism.cmake | 6 +- cmake/CubismUserModelSafety.cmake | 89 +++++++++++++++++++++----- src/ui/preferences/preferences.c | 6 +- src/ui/preferences/preferences_model.c | 4 -- tests/model_import/test_mver_import.c | 11 ++++ 7 files changed, 152 insertions(+), 27 deletions(-) create mode 100644 cmake/CheckCubismUserModelSafety.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index aab92f05..f8cffe96 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -144,5 +144,9 @@ add_custom_target(check-platform-runtime-safety ALL COMMAND ${CMAKE_COMMAND} -DROOT=${CMAKE_CURRENT_SOURCE_DIR} -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/CheckPlatformRuntimeSafety.cmake VERBATIM) +add_custom_target(check-cubism-user-model-safety ALL + COMMAND ${CMAKE_COMMAND} + -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/CheckCubismUserModelSafety.cmake + VERBATIM) include(cmake/Tests.cmake) include(cmake/Packaging.cmake) diff --git a/cmake/CheckCubismUserModelSafety.cmake b/cmake/CheckCubismUserModelSafety.cmake new file mode 100644 index 00000000..5d270c66 --- /dev/null +++ b/cmake/CheckCubismUserModelSafety.cmake @@ -0,0 +1,59 @@ +cmake_minimum_required(VERSION 3.24) + +include("${CMAKE_CURRENT_LIST_DIR}/CubismUserModelSafety.cmake") + +function(assert_patched variable label) + set(source "${${variable}}") + bongo_cat_patch_cubism_renderer_creation(source) + set(expected [=[if (_renderer) + { + _renderer->Initialize(_model, maskBufferCount); + }]=]) + string(FIND "${source}" "${expected}" guard_position) + if(guard_position EQUAL -1) + message(FATAL_ERROR "Cubism user-model safety test failed: ${label}") + endif() +endfunction() + +set(official_unsafe [=[void CubismUserModel::CreateRenderer( + csmUint32 width, csmUint32 height, csmInt32 maskBufferCount) +{ + _renderer = Rendering::CubismRenderer::Create(width, height); + + _renderer->Initialize(_model, maskBufferCount); +} + +void CubismUserModel::DeleteRenderer() +{ +}]=]) +assert_patched(official_unsafe "official r.5 layout") + +set(alternate_unsafe [=[void CubismUserModel::CreateRenderer(csmInt32 maskBufferCount) +{ + _renderer=Rendering::CubismRenderer::Create(); + _renderer -> Initialize ( _model , maskBufferCount ) ; +} +void CubismUserModel::DeleteRenderer() +{ +}]=]) +assert_patched(alternate_unsafe "alternate renderer signature") + +set(already_safe [=[void CubismUserModel::CreateRenderer(csmInt32 maskBufferCount) +{ + _renderer = Rendering::CubismRenderer::Create(); + if (_renderer != nullptr) + { + _renderer->Initialize(_model, maskBufferCount); + } +} +void CubismUserModel::DeleteRenderer() +{ +}]=]) +set(expected_safe "${already_safe}") +bongo_cat_patch_cubism_renderer_creation(already_safe) +if(NOT already_safe STREQUAL expected_safe) + message(FATAL_ERROR + "Cubism user-model safety test failed: safe source was modified") +endif() + +message(STATUS "Cubism user-model safety patch policy passed") diff --git a/cmake/Cubism.cmake b/cmake/Cubism.cmake index 0b05753a..3b8bdb07 100644 --- a/cmake/Cubism.cmake +++ b/cmake/Cubism.cmake @@ -2,9 +2,9 @@ set(CUBISM_CORE_PATH "${BONGO_CAT_CUBISM_SDK}/Core") set(CUBISM_FRAMEWORK_PATH "${BONGO_CAT_CUBISM_SDK}/Framework") set(CUBISM_GLEW_PATH "${BONGO_CAT_CUBISM_SDK}/Samples/OpenGL/thirdParty/glew") -# GLEW 2.2.0 still declares compatibility with pre-3.5 CMake. CMake 4 removes -# that compatibility unless the parent project supplies an explicit floor. -set(CMAKE_POLICY_VERSION_MINIMUM 3.5) +# GLEW 2.2.0 declares an obsolete policy floor. Keep its unchanged upstream +# project compatible with current CMake releases without patching vendor files. +set(CMAKE_POLICY_VERSION_MINIMUM 3.10) foreach(PATH IN ITEMS CUBISM_FRAMEWORK_PATH CUBISM_GLEW_PATH) if(NOT EXISTS "${${PATH}}") diff --git a/cmake/CubismUserModelSafety.cmake b/cmake/CubismUserModelSafety.cmake index d7bbd49a..5269cd64 100644 --- a/cmake/CubismUserModelSafety.cmake +++ b/cmake/CubismUserModelSafety.cmake @@ -1,3 +1,74 @@ +function(bongo_cat_patch_cubism_renderer_creation variable) + set(source "${${variable}}") + set(create_anchor "void CubismUserModel::CreateRenderer") + set(delete_anchor "void CubismUserModel::DeleteRenderer") + string(FIND "${source}" "${create_anchor}" create_position) + string(FIND "${source}" "${delete_anchor}" delete_position) + if(create_position EQUAL -1 OR delete_position LESS create_position) + message(FATAL_ERROR + "Cubism 5 r.5 user-model patch mismatch: renderer functions") + endif() + + math(EXPR function_length "${delete_position} - ${create_position}") + string(SUBSTRING "${source}" ${create_position} ${function_length} + create_function) + set(create_pattern + [=[_renderer[ \t]*=[ \t]*Rendering::CubismRenderer::Create[ \t]*\([^;]*\)[ \t]*;]=]) + string(REGEX MATCH "${create_pattern}" create_statement "${create_function}") + if(NOT create_statement) + message(FATAL_ERROR + "Cubism 5 r.5 user-model patch mismatch: renderer creation") + endif() + string(FIND "${create_function}" "${create_statement}" statement_position) + string(LENGTH "${create_statement}" statement_length) + math(EXPR statement_end "${statement_position} + ${statement_length}") + string(SUBSTRING "${create_function}" ${statement_end} -1 create_tail) + string(REGEX MATCH "${create_pattern}" duplicate_create "${create_tail}") + if(duplicate_create) + message(FATAL_ERROR + "Cubism 5 r.5 user-model patch mismatch: multiple renderer creators") + endif() + + set(initialize_pattern + [=[_renderer[ \t]*->[ \t]*Initialize[ \t]*\([ \t]*_model[ \t]*,[ \t]*maskBufferCount[ \t]*\)[ \t]*;]=]) + string(REGEX MATCH "${initialize_pattern}" initialize_statement + "${create_function}") + if(NOT initialize_statement) + message(FATAL_ERROR + "Cubism 5 r.5 user-model patch mismatch: renderer initialization") + endif() + string(FIND "${create_function}" "${initialize_statement}" + initialize_position) + string(LENGTH "${initialize_statement}" initialize_length) + math(EXPR initialize_end "${initialize_position} + ${initialize_length}") + string(SUBSTRING "${create_function}" ${initialize_end} -1 initialize_tail) + string(REGEX MATCH "${initialize_pattern}" duplicate_initialize + "${initialize_tail}") + if(duplicate_initialize) + message(FATAL_ERROR + "Cubism 5 r.5 user-model patch mismatch: multiple renderer initializers") + endif() + + set(renderer_condition + [=[if[ \t]*\([ \t]*_renderer([ \t]*!=[ \t]*(NULL|nullptr))?[ \t]*\)]=]) + set(guard_pattern + "${renderer_condition}[ \t\n]*(\\{[ \t\n]*)?${initialize_pattern}") + string(REGEX MATCH "${guard_pattern}" guarded_initialize + "${create_function}") + if(NOT guarded_initialize) + set(safe_initialize [=[if (_renderer) + { + _renderer->Initialize(_model, maskBufferCount); + }]=]) + string(REPLACE "${initialize_statement}" "${safe_initialize}" + create_function "${create_function}") + string(SUBSTRING "${source}" 0 ${create_position} prefix) + string(SUBSTRING "${source}" ${delete_position} -1 suffix) + set(source "${prefix}${create_function}${suffix}") + endif() + set(${variable} "${source}" PARENT_SCOPE) +endfunction() + function(bongo_cat_harden_cubism_user_model target) set(model_dir "${CUBISM_FRAMEWORK_PATH}/src/Model") set(source_path "${model_dir}/CubismUserModel.cpp") @@ -27,23 +98,7 @@ function(bongo_cat_harden_cubism_user_model target) "Cubism 5 r.5 user-model patch mismatch: resize function") endif() - set(create_unsafe [=[ _renderer = Rendering::CubismRenderer::Create(width, height); - _renderer->Initialize(_model, maskBufferCount);]=]) - set(create_safe [=[ _renderer = Rendering::CubismRenderer::Create(width, height); - - if (_renderer) - { - _renderer->Initialize(_model, maskBufferCount); - }]=]) - string(FIND "${source}" "${create_safe}" create_safe_position) - if(create_safe_position EQUAL -1) - string(FIND "${source}" "${create_unsafe}" create_unsafe_position) - if(create_unsafe_position EQUAL -1) - message(FATAL_ERROR - "Cubism 5 r.5 user-model patch mismatch: renderer creation") - endif() - string(REPLACE "${create_unsafe}" "${create_safe}" source "${source}") - endif() + bongo_cat_patch_cubism_renderer_creation(source) file(MAKE_DIRECTORY "${output_dir}") file(WRITE "${output_source}" "${source}") diff --git a/src/ui/preferences/preferences.c b/src/ui/preferences/preferences.c index 8836c2dc..3c88555d 100644 --- a/src/ui/preferences/preferences.c +++ b/src/ui/preferences/preferences.c @@ -257,8 +257,8 @@ void bongo_cat_preferences_models_changed(BongoCatPreferences *value) { return; } value->model_glyphs_loaded = true; - if (bongo_cat_preferences_import_status(value->import_dialog, - NULL, NULL, NULL)) return; value->font_reload_pending = value->ui_initialized; - value->font_reload_defer_once = value->font_reload_pending; + /* Catalog refreshes finish between UI frames. Rebuild before drawing the + new cards so names never appear with missing-glyph placeholders. */ + value->font_reload_defer_once = false; } diff --git a/src/ui/preferences/preferences_model.c b/src/ui/preferences/preferences_model.c index 517de255..cae872f6 100644 --- a/src/ui/preferences/preferences_model.c +++ b/src/ui/preferences/preferences_model.c @@ -177,10 +177,6 @@ void bongo_cat_preferences_import_complete(BongoCatApp *app, } else bongo_cat_preferences_notice_show(app, message, result != BONGO_CAT_OK || partial); if (result == BONGO_CAT_OK) { - if (installed_count && app->preferences->page == 1) { - app->preferences->font_reload_pending = true; - app->preferences->font_reload_defer_once = true; - } SDL_Log("Resolved %zu model package(s); installed %zu new package(s)", resolved_count, installed_count); } diff --git a/tests/model_import/test_mver_import.c b/tests/model_import/test_mver_import.c index a940f3fa..34f37b6a 100644 --- a/tests/model_import/test_mver_import.c +++ b/tests/model_import/test_mver_import.c @@ -160,6 +160,16 @@ static void font_reload_defers_during_frame(void) { NULL, NULL, NULL, NULL, NULL, 1.0f)); } +static void model_change_reloads_glyphs_before_drawing(void) { + BongoCatPreferences value = {0}; + value.page = 1; + value.ui_initialized = true; + value.font_reload_defer_once = true; + bongo_cat_preferences_models_changed(&value); + CHECK(value.render_dirty && value.model_glyphs_loaded); + CHECK(value.font_reload_pending && !value.font_reload_defer_once); +} + static void model_visual_expires_without_window(void) { BongoCatPreferences value = {0}; value.model_load_visual_active = true; @@ -211,6 +221,7 @@ int main(void) { failures += test_mver_missing_motion_groups(); behavior_labels_add_font_glyphs(); font_reload_defers_during_frame(); + model_change_reloads_glyphs_before_drawing(); model_visual_expires_without_window(); model_visual_curve(); failures += test_mver_nearby_identity(); From d21cdb9158cb27e2b6a1073895977de4fefeb972 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 30 Aug 2026 00:37:17 +0800 Subject: [PATCH 151/334] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1d930863..6fbc3786 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ + -tip > [!TIP] > The model featured in this demonstration is from [宇痕冫](https://www.bilibili.com/video/BV1ZVK56HECF). From e73a5412c73b144ba3e219f9765cfc6eedf2f5a1 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 30 Aug 2026 00:40:19 +0800 Subject: [PATCH 152/334] Update README.md --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index 6fbc3786..edefeebf 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,17 @@ > [!TIP] > The model featured in this demonstration is from [宇痕冫](https://www.bilibili.com/video/BV1ZVK56HECF). +

+ +

From fd1e7dee59852071eb8f87985e25037b1da67ef6 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 30 Aug 2026 00:42:29 +0800 Subject: [PATCH 153/334] Update README.md --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index edefeebf..5c0fd756 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,5 @@ - -> [!TIP] -> The model featured in this demonstration is from [宇痕冫](https://www.bilibili.com/video/BV1ZVK56HECF). - + + + +> [!TIP] +> The model featured in this demonstration is from [宇痕冫](https://www.bilibili.com/video/BV1ZVK56HECF). +

From e16de1ab6530671f3d06c7ef6d7caa557f00c1ef Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 30 Aug 2026 00:52:29 +0800 Subject: [PATCH 154/334] Update README.md --- README.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5c0fd756..10cb4145 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,22 @@ - + + +

+ 💘C/C++ × SDL3 × OpenGL, stir it up, mash it together! Bong~ Bongocat!!! +

+ + +
+ +
+ + + + + > [!TIP] From c744a6f5d452c0dec91e3042d3333d51cd54ba8d Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 30 Aug 2026 00:59:24 +0800 Subject: [PATCH 155/334] Update README.md --- README.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 10cb4145..a5d10f1c 100644 --- a/README.md +++ b/README.md @@ -23,13 +23,10 @@ - - - - - > [!TIP] > The model featured in this demonstration is from [宇痕冫](https://www.bilibili.com/video/BV1ZVK56HECF). +> +> 🎁Looking for **free** models? Visit our official website: [bongocat.pet](https://bongocat.pet/models)

From 41b939608f003ef32dad67e70c9b714cb95e3010 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 30 Aug 2026 01:25:28 +0800 Subject: [PATCH 156/334] Update README.md --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a5d10f1c..a126b787 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ + + +

Catime @@ -18,7 +21,7 @@
-
From 63bdee966c74f541ee015a4f3a901af135df4e2a Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 30 Aug 2026 09:32:34 +0800 Subject: [PATCH 157/334] Improve batch model import failure reporting --- resources/assets/locales/de-DE.json | 4 +- resources/assets/locales/en-US.json | 4 +- resources/assets/locales/es-ES.json | 4 +- resources/assets/locales/fr-FR.json | 4 +- resources/assets/locales/ja-JP.json | 4 +- resources/assets/locales/ko-KR.json | 4 +- resources/assets/locales/pt-BR.json | 4 +- resources/assets/locales/ru-RU.json | 4 +- resources/assets/locales/zh-CN.json | 4 +- resources/assets/locales/zh-Hant.json | 4 +- src/runtime/model/import/model_import.h | 10 ++- .../model/import/model_import_progressive.c | 50 +++++++++++---- src/ui/preferences/preferences_import.c | 62 ++++++++----------- .../preferences/preferences_import_internal.h | 18 +++++- .../preferences/preferences_import_progress.c | 41 ++++++++++++ src/ui/preferences/preferences_internal.h | 3 +- src/ui/preferences/preferences_model.c | 54 ++++++++++++---- tests/model_import/test_mver_container.c | 14 ++++- 18 files changed, 219 insertions(+), 73 deletions(-) diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index 147420d2..dda84da6 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -88,7 +88,9 @@ "deleteFailed": "Dieses Modell konnte nicht gelöscht werden", "importSuccess": "Erfolgreich importiert", "importExists": "Modell ist bereits vorhanden", - "importPartial": "Einige Modelle wurden importiert, aber mindestens ein Element ist fehlgeschlagen", + "importBatchResult": "Import erfolgreich: %zu; fehlgeschlagen: %zu. Fehlende Modelle: %s%s. Erster Fehler: %s", + "importMoreFailures": " und %zu weitere", + "importUnknownSource": "unbekannte Quelle", "importInvalidSource": "Die ausgewählte Quelle ist nicht mehr vorhanden oder kann nicht verwendet werden", "importInvalidFormat": "Es wurde kein unterstütztes Modell gefunden oder das Modellpaket ist unvollständig", "importFileAccess": "Die Modelldateien konnten nicht gelesen oder gespeichert werden. Prüfen Sie die Dateiberechtigungen und den verfügbaren Speicherplatz", diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index 52dc7a0b..93cef631 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -88,7 +88,9 @@ "deleteFailed": "Unable to delete this model", "importSuccess": "Imported Successfully", "importExists": "Model already exists", - "importPartial": "Some models were imported, but one or more items failed", + "importBatchResult": "Import succeeded: %zu; failed: %zu. Failed models: %s%s. First failure: %s", + "importMoreFailures": " and %zu more", + "importUnknownSource": "unknown source", "importInvalidSource": "The selected source no longer exists or cannot be used", "importInvalidFormat": "No supported model was found, or the model package is incomplete", "importFileAccess": "The model files could not be read or saved. Check file permissions and available disk space", diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index 687b4d80..0715f9f1 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -88,7 +88,9 @@ "deleteFailed": "No se pudo eliminar este modelo", "importSuccess": "Importado correctamente", "importExists": "El modelo ya existe", - "importPartial": "Se importaron algunos modelos, pero uno o más elementos fallaron", + "importBatchResult": "Importación correcta: %zu; fallida: %zu. Modelos fallidos: %s%s. Primer error: %s", + "importMoreFailures": " y %zu más", + "importUnknownSource": "fuente desconocida", "importInvalidSource": "La fuente seleccionada ya no existe o no se puede utilizar", "importInvalidFormat": "No se encontró ningún modelo compatible o el paquete del modelo está incompleto", "importFileAccess": "No se pudieron leer o guardar los archivos del modelo. Compruebe los permisos y el espacio disponible en el disco", diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index 3433687e..eef2b523 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -88,7 +88,9 @@ "deleteFailed": "Impossible de supprimer ce modèle", "importSuccess": "Importation réussie", "importExists": "Le modèle existe déjà", - "importPartial": "Certains modèles ont été importés, mais un ou plusieurs éléments ont échoué", + "importBatchResult": "Importations réussies : %zu ; échecs : %zu. Modèles en échec : %s%s. Premier échec : %s", + "importMoreFailures": " et %zu autre(s)", + "importUnknownSource": "source inconnue", "importInvalidSource": "La source sélectionnée n'existe plus ou ne peut pas être utilisée", "importInvalidFormat": "Aucun modèle pris en charge n'a été trouvé, ou le paquet du modèle est incomplet", "importFileAccess": "Impossible de lire ou d'enregistrer les fichiers du modèle. Vérifiez les autorisations et l'espace disque disponible", diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index 255dd8db..35d85f69 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -88,7 +88,9 @@ "deleteFailed": "このモデルを削除できません", "importSuccess": "インポートしました", "importExists": "モデルは既に存在します", - "importPartial": "一部のモデルをインポートしましたが、1件以上の項目で失敗しました", + "importBatchResult": "インポート成功: %zu件、失敗: %zu件。失敗したモデル: %s%s。最初の失敗理由: %s", + "importMoreFailures": "、他%zu件", + "importUnknownSource": "不明なソース", "importInvalidSource": "選択したインポート元は存在しないか、使用できません", "importInvalidFormat": "対応するモデルが見つからないか、モデルパッケージが不完全です", "importFileAccess": "モデルファイルを読み込みまたは保存できません。ファイルの権限とディスクの空き容量を確認してください", diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index e8d881bf..d38f0762 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -88,7 +88,9 @@ "deleteFailed": "이 모델을 삭제할 수 없습니다", "importSuccess": "가져오기 완료", "importExists": "모델이 이미 존재합니다", - "importPartial": "일부 모델을 가져왔지만 하나 이상의 항목을 가져오지 못했습니다", + "importBatchResult": "가져오기 성공 %zu개, 실패 %zu개. 실패한 모델: %s%s. 첫 번째 실패 원인: %s", + "importMoreFailures": " 외 %zu개", + "importUnknownSource": "알 수 없는 원본", "importInvalidSource": "선택한 원본이 더 이상 존재하지 않거나 사용할 수 없습니다", "importInvalidFormat": "지원되는 모델을 찾을 수 없거나 모델 패키지가 완전하지 않습니다", "importFileAccess": "모델 파일을 읽거나 저장할 수 없습니다. 파일 권한과 사용 가능한 디스크 공간을 확인하세요", diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index 3ecc8bb6..4ef90a1e 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -88,7 +88,9 @@ "deleteFailed": "Não foi possível excluir este modelo", "importSuccess": "Importação bem-sucedida", "importExists": "O modelo já existe", - "importPartial": "Alguns modelos foram importados, mas ocorreu uma falha em um ou mais itens", + "importBatchResult": "Importados com sucesso: %zu; falhas: %zu. Modelos com falha: %s%s. Primeira falha: %s", + "importMoreFailures": " e mais %zu", + "importUnknownSource": "origem desconhecida", "importInvalidSource": "A origem selecionada não existe mais ou não pode ser usada", "importInvalidFormat": "Nenhum modelo compatível foi encontrado ou o pacote do modelo está incompleto", "importFileAccess": "Não foi possível ler ou salvar os arquivos do modelo. Verifique as permissões e o espaço disponível em disco", diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index 3072710a..83e212b2 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -88,7 +88,9 @@ "deleteFailed": "Не удалось удалить эту модель", "importSuccess": "Успешно импортировано", "importExists": "Модель уже существует", - "importPartial": "Некоторые модели импортированы, но один или несколько элементов импортировать не удалось", + "importBatchResult": "Успешно импортировано: %zu; ошибок: %zu. Ошибки моделей: %s%s. Первая ошибка: %s", + "importMoreFailures": " и ещё %zu", + "importUnknownSource": "неизвестный источник", "importInvalidSource": "Выбранный источник больше не существует или не может быть использован", "importInvalidFormat": "Поддерживаемая модель не найдена или пакет модели неполон", "importFileAccess": "Не удалось прочитать или сохранить файлы модели. Проверьте права доступа и свободное место на диске", diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index 846e82df..38984751 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -88,7 +88,9 @@ "deleteFailed": "无法删除此模型", "importSuccess": "导入成功", "importExists": "模型已存在,无需重复导入", - "importPartial": "部分模型已导入,但仍有一个或多个项目导入失败", + "importBatchResult": "导入成功 %zu 个,导入失败 %zu 个。失败模型:%s%s。首个失败原因:%s", + "importMoreFailures": "等其余 %zu 个", + "importUnknownSource": "未知来源", "importInvalidSource": "所选来源已不存在或无法使用", "importInvalidFormat": "未找到支持的模型,或模型包文件不完整", "importFileAccess": "无法读取或保存模型文件,请检查文件权限和可用磁盘空间", diff --git a/resources/assets/locales/zh-Hant.json b/resources/assets/locales/zh-Hant.json index d8bd1f19..e905659a 100644 --- a/resources/assets/locales/zh-Hant.json +++ b/resources/assets/locales/zh-Hant.json @@ -88,7 +88,9 @@ "deleteFailed": "無法刪除此模型", "importSuccess": "匯入成功", "importExists": "模型已存在,無需重複匯入", - "importPartial": "部分模型已匯入,但仍有一個或多個項目匯入失敗", + "importBatchResult": "匯入成功 %zu 個,匯入失敗 %zu 個。失敗模型:%s%s。首個失敗原因:%s", + "importMoreFailures": "等其餘 %zu 個", + "importUnknownSource": "未知來源", "importInvalidSource": "所選來源已不存在或無法使用", "importInvalidFormat": "找不到支援的模型,或模型套件檔案不完整", "importFileAccess": "無法讀取或儲存模型檔案,請檢查檔案權限和可用磁碟空間", diff --git a/src/runtime/model/import/model_import.h b/src/runtime/model/import/model_import.h index 6ee9b469..4632d8aa 100644 --- a/src/runtime/model/import/model_import.h +++ b/src/runtime/model/import/model_import.h @@ -5,6 +5,7 @@ #include "bongo_cat/model.h" #define BONGO_CAT_IMPORT_CANDIDATE_CAP 16 +#define BONGO_CAT_IMPORT_FAILURE_NAME_CAP 3 #define BONGO_CAT_SKIN_CONFIG_FILE "bongocat.skin.json" typedef enum BongoCatImportFormat { @@ -45,6 +46,13 @@ typedef struct BongoCatImportReceipt { typedef void (*BongoCatImportReceiptCallback)(void *userdata, const BongoCatImportReceipt *receipt); +typedef struct BongoCatImportBatchStats { + size_t succeeded_count; + size_t failed_count; + size_t failure_name_count; + char failure_names[BONGO_CAT_IMPORT_FAILURE_NAME_CAP][BONGO_CAT_ID_CAP]; +} BongoCatImportBatchStats; + typedef struct BongoCatPackageMetadata { char package_id[BONGO_CAT_ID_CAP]; char content_digest[65]; @@ -160,6 +168,6 @@ BongoCatResult bongo_cat_import_session_install( BongoCatResult bongo_cat_import_session_install_progressive( BongoCatImportSession *session, const char *source, BongoCatImportReceiptCallback callback, void *userdata, - BongoCatError *error); + BongoCatImportBatchStats *stats, BongoCatError *error); #endif diff --git a/src/runtime/model/import/model_import_progressive.c b/src/runtime/model/import/model_import_progressive.c index fe90e217..24fb2074 100644 --- a/src/runtime/model/import/model_import_progressive.c +++ b/src/runtime/model/import/model_import_progressive.c @@ -93,49 +93,77 @@ static bool redundant_patch(const ProgressiveScan *scan, return true; } +static void record_failure_name(BongoCatImportBatchStats *stats, + const char *source) { + if (!stats || stats->failure_name_count >= + BONGO_CAT_IMPORT_FAILURE_NAME_CAP) return; + const char *name = bongo_cat_path_name(source); + if (!name || !name[0]) return; + snprintf(stats->failure_names[stats->failure_name_count++], + BONGO_CAT_ID_CAP, "%s", name); +} + static BongoCatResult install_one(BongoCatImportSession *session, const char *source, BongoCatImportReceiptCallback callback, - void *userdata, BongoCatError *error) { + void *userdata, BongoCatImportBatchStats *stats, BongoCatError *error) { BongoCatImportReceipt receipt = {0}; BongoCatResult result = bongo_cat_import_session_install(session, source, &receipt, error); - if (result == BONGO_CAT_OK && receipt.count && callback) + if (result == BONGO_CAT_OK) stats->succeeded_count++; + else { + stats->failed_count++; + record_failure_name(stats, source); + } + if (result == BONGO_CAT_OK && callback) callback(userdata, &receipt); return result; } +static BongoCatResult fail_source(BongoCatImportBatchStats *stats, + const char *source, BongoCatResult result) { + stats->failed_count++; + record_failure_name(stats, source); + return result; +} + BongoCatResult bongo_cat_import_session_install_progressive( BongoCatImportSession *session, const char *source, BongoCatImportReceiptCallback callback, void *userdata, - BongoCatError *error) { - if (!session || !source) return BONGO_CAT_ERROR_ARGUMENT; + BongoCatImportBatchStats *stats, BongoCatError *error) { + if (!stats) return BONGO_CAT_ERROR_ARGUMENT; + *stats = (BongoCatImportBatchStats){0}; + if (!session || !source) + return fail_source(stats, source, BONGO_CAT_ERROR_ARGUMENT); char directory[BONGO_CAT_PATH_CAP]; BongoCatResult normalized = bongo_cat_import_source_directory(source, directory, sizeof(directory), error); - if (normalized != BONGO_CAT_OK) return normalized; + if (normalized != BONGO_CAT_OK) + return fail_source(stats, source, normalized); BongoCatImportDiscovery exact; BongoCatError exact_error = {0}; int exact_result = discover_exact(directory, &exact, &exact_error); if (exact_result > 0) - return install_one(session, directory, callback, userdata, error); + return install_one(session, directory, callback, userdata, stats, + error); if (exact_result < 0) { if (error) *error = exact_error; - return exact_error.code ? exact_error.code : BONGO_CAT_ERROR_FORMAT; + return fail_source(stats, directory, exact_error.code + ? exact_error.code : BONGO_CAT_ERROR_FORMAT); } ProgressiveScan *scan = calloc(1, sizeof(*scan)); if (!scan) { bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, "Cannot allocate progressive model import scan"); - return BONGO_CAT_ERROR_MEMORY; + return fail_source(stats, directory, BONGO_CAT_ERROR_MEMORY); } BongoCatResult scanned = bongo_cat_import_scan(directory, collect_unit, scan, error); if (scanned != BONGO_CAT_OK || !scan->unit_count) { free(scan); - return scanned != BONGO_CAT_OK ? scanned : install_one(session, - directory, callback, userdata, error); + return scanned != BONGO_CAT_OK ? fail_source(stats, directory, scanned) : + install_one(session, directory, callback, userdata, stats, error); } BongoCatResult first_failure = BONGO_CAT_OK; @@ -144,7 +172,7 @@ BongoCatResult bongo_cat_import_session_install_progressive( if (redundant_patch(scan, &scan->units[i])) continue; BongoCatError local = {0}; BongoCatResult result = install_one(session, scan->units[i].source, - callback, userdata, &local); + callback, userdata, stats, &local); if (result != BONGO_CAT_OK && first_failure == BONGO_CAT_OK) { first_failure = result; first_error = local; diff --git a/src/ui/preferences/preferences_import.c b/src/ui/preferences/preferences_import.c index ddff0c08..11653aad 100644 --- a/src/ui/preferences/preferences_import.c +++ b/src/ui/preferences/preferences_import.c @@ -81,32 +81,6 @@ static BongoCatImportJob *copy_path(const char *path) { return path && path[0] ? copy_job(files) : NULL; } -static void remember_package(BongoCatImportJob *job, const char *id) { - if (!job || !id || !id[0]) return; - for (size_t i = 0; i < job->package_id_count; ++i) - if (!strcmp(job->package_ids[i], id)) return; - if (job->package_id_count >= BONGO_CAT_MODEL_CAP) return; - snprintf(job->package_ids[job->package_id_count++], BONGO_CAT_ID_CAP, - "%s", id); -} - -typedef struct ImportProgressContext { - BongoCatImportJob *job; - size_t completed; -} ImportProgressContext; - -static void receive_import(void *userdata, - const BongoCatImportReceipt *receipt) { - ImportProgressContext *progress = userdata; - BongoCatImportJob *job = progress->job; - if (receipt->count) remember_package(job, receipt->ids[0]); - job->resolved_count += receipt->count; - job->installed_count += receipt->installed_count; - job->result = BONGO_CAT_OK; - bongo_cat_preferences_import_report_progress(job, receipt, - ++progress->completed); -} - static int SDLCALL import_worker(void *userdata) { BongoCatImportJob *job = userdata; if (!SDL_SetCurrentThreadPriority(SDL_THREAD_PRIORITY_LOW)) { @@ -117,25 +91,41 @@ static int SDLCALL import_worker(void *userdata) { job->result = BONGO_CAT_ERROR_FORMAT; BongoCatImportSession *session = bongo_cat_import_session_create( job->models_root, &job->error); - if (!session) job->result = job->error.code ? job->error.code : - BONGO_CAT_ERROR_IO; - ImportProgressContext progress = {job, 0}; + if (!session) { + job->result = job->error.code ? job->error.code : BONGO_CAT_ERROR_IO; + if (job->error.code == BONGO_CAT_OK) job->error.code = job->result; + job->failed_count = job->count; + for (size_t i = 0; i < job->count; ++i) + bongo_cat_preferences_import_record_failure(job, job->paths[i]); + } + BongoCatImportProgressContext progress = {job, 0}; for (size_t i = 0; session && i < job->count; ++i) { size_t before = progress.completed; + BongoCatImportBatchStats stats = {0}; BongoCatError error = {0}; BongoCatResult result = bongo_cat_import_session_install_progressive( - session, job->paths[i], receive_import, &progress, &error); - if (result != BONGO_CAT_OK && !job->error.message[0]) { + session, job->paths[i], bongo_cat_preferences_import_receive, + &progress, &stats, &error); + job->succeeded_count += stats.succeeded_count; + job->failed_count += stats.failed_count; + bongo_cat_preferences_import_merge_failures(job, &stats); + if (result != BONGO_CAT_OK && job->error.code == BONGO_CAT_OK) { job->result = result; job->error = error; + if (job->error.code == BONGO_CAT_OK) job->error.code = result; } - if (progress.completed == before) + if (stats.failed_count) { + progress.completed += stats.failed_count; + bongo_cat_preferences_import_report_progress(job, NULL, + progress.completed); + } else if (progress.completed == before) { bongo_cat_preferences_import_report_progress(job, NULL, ++progress.completed); + } } bongo_cat_import_session_destroy(session); - if (job->resolved_count) job->result = BONGO_CAT_OK; - if (!job->resolved_count && !job->error.message[0]) + if (job->succeeded_count) job->result = BONGO_CAT_OK; + if (!job->succeeded_count && job->error.code == BONGO_CAT_OK) bongo_cat_error_set(&job->error, BONGO_CAT_ERROR_FORMAT, "No model could be imported"); SDL_Event event = {0}; @@ -259,7 +249,9 @@ static void complete_job(BongoCatImportDialog *dialog, BongoCatApp *app, if (job->result == BONGO_CAT_OK && catalog_changed) bongo_cat_app_request_model_refresh(app); bongo_cat_preferences_import_complete(app, job->result, &job->error, - job->resolved_count, job->installed_count + restored_count); + job->resolved_count, job->installed_count + restored_count, + job->succeeded_count, job->failed_count, job->failed_names, + job->failed_name_count); free_job(job); release_dialog(dialog); } diff --git a/src/ui/preferences/preferences_import_internal.h b/src/ui/preferences/preferences_import_internal.h index 64e34318..a919dc26 100644 --- a/src/ui/preferences/preferences_import_internal.h +++ b/src/ui/preferences/preferences_import_internal.h @@ -2,8 +2,7 @@ #define BONGO_CAT_PREFERENCES_IMPORT_INTERNAL_H #include "preferences_internal.h" - -typedef struct BongoCatImportReceipt BongoCatImportReceipt; +#include "model_import.h" typedef struct BongoCatImportJob { size_t count; @@ -14,11 +13,20 @@ typedef struct BongoCatImportJob { size_t resolved_count; size_t installed_count; size_t restored_count; + size_t succeeded_count; + size_t failed_count; + size_t failed_name_count; + char failed_names[BONGO_CAT_IMPORT_FAILURE_NAME_CAP][BONGO_CAT_ID_CAP]; BongoCatResult result; BongoCatError error; BongoCatImportDialog *dialog; } BongoCatImportJob; +typedef struct BongoCatImportProgressContext { + BongoCatImportJob *job; + size_t completed; +} BongoCatImportProgressContext; + typedef struct BongoCatImportProgress { BongoCatImportJob *job; char package_id[BONGO_CAT_ID_CAP]; @@ -48,6 +56,12 @@ struct BongoCatImportDialog { void bongo_cat_preferences_import_report_progress(BongoCatImportJob *job, const BongoCatImportReceipt *receipt, size_t completed); +void bongo_cat_preferences_import_receive(void *userdata, + const BongoCatImportReceipt *receipt); +void bongo_cat_preferences_import_record_failure(BongoCatImportJob *job, + const char *source); +void bongo_cat_preferences_import_merge_failures(BongoCatImportJob *job, + const BongoCatImportBatchStats *stats); bool bongo_cat_preferences_import_progress_event( BongoCatImportDialog *dialog, BongoCatApp *app, const SDL_Event *event); diff --git a/src/ui/preferences/preferences_import_progress.c b/src/ui/preferences/preferences_import_progress.c index 2be047ab..4cd53d70 100644 --- a/src/ui/preferences/preferences_import_progress.c +++ b/src/ui/preferences/preferences_import_progress.c @@ -1,8 +1,49 @@ #include "preferences_import_internal.h" #include "model_import.h" +#include "bongo_cat/path.h" #include #include +#include + +void bongo_cat_preferences_import_record_failure(BongoCatImportJob *job, + const char *source) { + if (!job || job->failed_name_count >= + BONGO_CAT_IMPORT_FAILURE_NAME_CAP) return; + const char *name = bongo_cat_path_name(source); + if (!name || !name[0]) return; + snprintf(job->failed_names[job->failed_name_count++], BONGO_CAT_ID_CAP, + "%s", name); +} + +void bongo_cat_preferences_import_merge_failures(BongoCatImportJob *job, + const BongoCatImportBatchStats *stats) { + if (!job || !stats) return; + for (size_t i = 0; i < stats->failure_name_count; ++i) + bongo_cat_preferences_import_record_failure(job, + stats->failure_names[i]); +} + +static void remember_package(BongoCatImportJob *job, const char *id) { + if (!job || !id || !id[0]) return; + for (size_t i = 0; i < job->package_id_count; ++i) + if (!strcmp(job->package_ids[i], id)) return; + if (job->package_id_count >= BONGO_CAT_MODEL_CAP) return; + snprintf(job->package_ids[job->package_id_count++], BONGO_CAT_ID_CAP, + "%s", id); +} + +void bongo_cat_preferences_import_receive(void *userdata, + const BongoCatImportReceipt *receipt) { + BongoCatImportProgressContext *progress = userdata; + BongoCatImportJob *job = progress->job; + if (receipt->count) remember_package(job, receipt->ids[0]); + job->resolved_count += receipt->count; + job->installed_count += receipt->installed_count; + job->result = BONGO_CAT_OK; + bongo_cat_preferences_import_report_progress(job, receipt, + ++progress->completed); +} void bongo_cat_preferences_import_report_progress(BongoCatImportJob *job, const BongoCatImportReceipt *receipt, size_t completed) { diff --git a/src/ui/preferences/preferences_internal.h b/src/ui/preferences/preferences_internal.h index 1c6c8dd6..77bdc5b9 100644 --- a/src/ui/preferences/preferences_internal.h +++ b/src/ui/preferences/preferences_internal.h @@ -55,7 +55,8 @@ void bongo_cat_preferences_import_path(BongoCatApp *app, SDL_Window *window, const char *path); void bongo_cat_preferences_import_complete(BongoCatApp *app, BongoCatResult result, const BongoCatError *error, size_t resolved_count, - size_t installed_count); + size_t installed_count, size_t succeeded_count, size_t failed_count, + const char (*failed_names)[BONGO_CAT_ID_CAP], size_t failed_name_count); void bongo_cat_preferences_model_cache_clear(BongoCatApp *app); bool bongo_cat_preferences_remove_dialog_active(const BongoCatApp *app); void bongo_cat_preferences_remove_dialog_open(BongoCatApp *app, const char *id); diff --git a/src/ui/preferences/preferences_model.c b/src/ui/preferences/preferences_model.c index cae872f6..585aa3c1 100644 --- a/src/ui/preferences/preferences_model.c +++ b/src/ui/preferences/preferences_model.c @@ -3,6 +3,7 @@ #include "preferences_model_cover.h" #include "preferences_notice.h" #include "preferences_widgets.h" +#include "model_import.h" #include "bongo_cat/i18n.h" #include "bongo_cat/preferences.h" @@ -132,10 +133,7 @@ void bongo_cat_preferences_process_model_selection(BongoCatPreferences *value) { } static const char *import_failure_message(BongoCatApp *app, - BongoCatResult result, bool partial) { - if (partial) return tr(app, - "pages.preference.model.hints.importPartial", - "Some models were imported, but one or more items failed"); + BongoCatResult result) { switch (result) { case BONGO_CAT_ERROR_ARGUMENT: return tr(app, "pages.preference.model.hints.importInvalidSource", @@ -160,22 +158,54 @@ static const char *import_failure_message(BongoCatApp *app, void bongo_cat_preferences_import_complete(BongoCatApp *app, BongoCatResult result, const BongoCatError *error, size_t resolved_count, - size_t installed_count) { + size_t installed_count, size_t succeeded_count, size_t failed_count, + const char (*failed_names)[BONGO_CAT_ID_CAP], size_t failed_name_count) { if (!app || !app->preferences) return; - bool partial = result == BONGO_CAT_OK && error && error->message[0]; - const char *message = result != BONGO_CAT_OK || partial - ? import_failure_message(app, result, partial) - : installed_count ? tr(app, + bool failed = failed_count > 0 || result != BONGO_CAT_OK; + char batch_message[384]; + char failure_list[220] = ""; + char more_failures[64] = ""; + size_t listed_count; + size_t shown_names = failed_names && failed_name_count > + BONGO_CAT_IMPORT_FAILURE_NAME_CAP ? BONGO_CAT_IMPORT_FAILURE_NAME_CAP + : failed_names ? failed_name_count : 0; + for (size_t i = 0; i < shown_names; ++i) { + size_t used = strlen(failure_list); + snprintf(failure_list + used, sizeof(failure_list) - used, + "%s%s", i ? ", " : "", failed_names[i]); + } + listed_count = shown_names; + if (!failure_list[0]) { + snprintf(failure_list, sizeof(failure_list), "%s", + tr(app, "pages.preference.model.hints.importUnknownSource", + "unknown source")); + listed_count = failed_count ? 1 : 0; + } + if (failed_count > listed_count) snprintf(more_failures, + sizeof(more_failures), tr(app, + "pages.preference.model.hints.importMoreFailures", + " and %zu more"), failed_count - listed_count); + const char *message = installed_count ? tr(app, "pages.preference.model.hints.importSuccess", "Model imported") : tr(app, "pages.preference.model.hints.importExists", "Model already exists"); - if ((result != BONGO_CAT_OK || partial) && error && error->message[0]) + if (failed) { + BongoCatResult failure = error && error->code != BONGO_CAT_OK + ? error->code : result; + snprintf(batch_message, sizeof(batch_message), tr(app, + "pages.preference.model.hints.importBatchResult", + "Import succeeded: %zu; failed: %zu. Failed models: %s%s. First failure: %s"), + succeeded_count, failed_count, failure_list, more_failures, + import_failure_message(app, failure)); + message = batch_message; + } + if (failed && error && error->message[0]) SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Model import failed: %s", error->message); if (app->smoke) { - if (result != BONGO_CAT_OK || partial) app->exit_code = 1; + if (failed) app->exit_code = 1; } else bongo_cat_preferences_notice_show(app, message, - result != BONGO_CAT_OK || partial); + failed); if (result == BONGO_CAT_OK) { SDL_Log("Resolved %zu model package(s); installed %zu new package(s)", resolved_count, installed_count); diff --git a/tests/model_import/test_mver_container.c b/tests/model_import/test_mver_container.c index d945e418..43a572c6 100644 --- a/tests/model_import/test_mver_container.c +++ b/tests/model_import/test_mver_container.c @@ -159,11 +159,23 @@ void test_mver_container_discovery(void) { ProgressiveImportLog progressive = {0}; BongoCatImportSession *session = bongo_cat_import_session_create( progressive_root, &error); + BongoCatImportBatchStats stats = {0}; CHECK(session != NULL); CHECK(session && bongo_cat_import_session_install_progressive(session, - root, progressive_receipt, &progressive, &error) == BONGO_CAT_OK); + root, progressive_receipt, &progressive, &stats, &error) == + BONGO_CAT_OK); CHECK(progressive.callbacks == 2 && progressive.resolved == 2 && progressive.installed == 2); + CHECK(stats.succeeded_count == 2 && stats.failed_count == 0); + char missing_source[BONGO_CAT_PATH_CAP]; + CHECK(child(missing_source, sizeof(missing_source), progressive_root, + "missing", false)); + CHECK(bongo_cat_import_session_install_progressive(session, + missing_source, progressive_receipt, &progressive, &stats, &error) == + BONGO_CAT_ERROR_ARGUMENT); + CHECK(stats.succeeded_count == 0 && stats.failed_count == 1 && + stats.failure_name_count == 1 && strcmp(stats.failure_names[0], + "missing") == 0); bongo_cat_import_session_destroy(session); char models_root[BONGO_CAT_PATH_CAP], cache_root[BONGO_CAT_PATH_CAP]; From 31e99f7fa91de5b5dfb47062b447906bb0eb00d3 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 30 Aug 2026 10:23:44 +0800 Subject: [PATCH 158/334] Fix Mver model import compatibility regressions --- src/runtime/model/import/model_import_tauri_mver.c | 5 +++++ src/runtime/model/import/mver/model_import_mver_policy.c | 7 ++++--- tests/model_import/test_mver_policy.c | 1 + tests/model_import/test_tauri_portable.c | 5 ++++- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/runtime/model/import/model_import_tauri_mver.c b/src/runtime/model/import/model_import_tauri_mver.c index c2b10900..c77aabbf 100644 --- a/src/runtime/model/import/model_import_tauri_mver.c +++ b/src/runtime/model/import/model_import_tauri_mver.c @@ -139,6 +139,11 @@ static bool config_write(const char *path, BongoCatModelMode mode, bool ok = root && decoration && mode_object && window_size && yyjson_mut_arr_add_int(document, window_size, width) && yyjson_mut_arr_add_int(document, window_size, height) && + /* Tauri resources are authored in the same pixel canvas as the + generated Mver input images. Make the projection explicit so a + missing legacy l2d_correct value cannot apply the 1.1 default. */ + yyjson_mut_obj_add_real(document, decoration, "l2d_correct", 1.0) && + yyjson_mut_obj_add_arr(document, decoration, "l2d_offset") && yyjson_mut_obj_add_int(document, root, "mode", mode == BONGO_CAT_MODE_STANDARD ? 1 : mode == BONGO_CAT_MODE_KEYBOARD ? 2 : 3) && diff --git a/src/runtime/model/import/mver/model_import_mver_policy.c b/src/runtime/model/import/mver/model_import_mver_policy.c index d7932f77..47583f18 100644 --- a/src/runtime/model/import/mver/model_import_mver_policy.c +++ b/src/runtime/model/import/mver/model_import_mver_policy.c @@ -92,9 +92,10 @@ bool bongo_cat_import_mver_stock_model( those surrounding images so the bundled model cannot become a variant. */ static const StockLive2DProfile profiles[] = { {BONGO_CAT_MODE_STANDARD, - "59f0969de5ddfb530c52c661e549e274e6af34cb59b4274399b9e9f1b37ede51", - {"1b5092cf3b1b54f69d8986d6ce7dfec324b36a13fcd10f129acc0053997dfd3e", - "c98d6db3a0a38ef312a38bacb5328e4787c079fa044e6faa85ed44e5b2fbb601"}, 2}, + "7bbcdb3df4fe085b0cbd9dc3a1cf32d351bd56787d0ddd1c238e50a5dcb6729a", + {"f20955f2779f52dddefae131e6c7c5506ce4261d60cf020d594e095e5a2193b2", + "bf32b89f328160ac8bd7cd4e527646eedfae98294506c597f381001ae6a83eb4", + "93c3213355e4743b38e8dd2e14a24693f2d24f10dc8b51c4ca986a749ee24682"}, 3}, {BONGO_CAT_MODE_KEYBOARD, "03ed67f3ee2ea612aba4da0d42874f8879853d69043c9aae98af440d1f66965e", {"3153891ec9c6b89ce9b33a610fb081d276ff356c5e19407d5ff0690d7ac7d97a", diff --git a/tests/model_import/test_mver_policy.c b/tests/model_import/test_mver_policy.c index b7ec8731..7211add0 100644 --- a/tests/model_import/test_mver_policy.c +++ b/tests/model_import/test_mver_policy.c @@ -20,6 +20,7 @@ int test_mver_policy(void) { const char *name; BongoCatModelMode mode; } fixtures[] = { + {"standard", BONGO_CAT_MODE_STANDARD}, {"keyboard", BONGO_CAT_MODE_KEYBOARD}, {"gamepad", BONGO_CAT_MODE_GAMEPAD} }; diff --git a/tests/model_import/test_tauri_portable.c b/tests/model_import/test_tauri_portable.c index a033226c..e1d3ba2e 100644 --- a/tests/model_import/test_tauri_portable.c +++ b/tests/model_import/test_tauri_portable.c @@ -99,6 +99,8 @@ void test_tauri_portable(void) { yyjson_val *normalized_left = yyjson_obj_get(normalized_mode, "lefthand"); yyjson_val *window_size = yyjson_obj_get( yyjson_obj_get(normalized_root, "decoration"), "window_size"); + yyjson_val *l2d_correct = yyjson_obj_get( + yyjson_obj_get(normalized_root, "decoration"), "l2d_correct"); int expected_width = 0, expected_height = 0; CHECK(bongo_cat_image_info(tray, &expected_width, &expected_height)); CHECK(yyjson_get_int(yyjson_obj_get(normalized_root, "mode")) == 2 && @@ -107,7 +109,8 @@ void test_tauri_portable(void) { yyjson_get_int(yyjson_arr_get(yyjson_arr_get(normalized_left, 0), 0)) == 24 && yyjson_get_int(yyjson_arr_get(window_size, 0)) == expected_width && - yyjson_get_int(yyjson_arr_get(window_size, 1)) == expected_height); + yyjson_get_int(yyjson_arr_get(window_size, 1)) == expected_height && + yyjson_is_num(l2d_correct) && yyjson_get_num(l2d_correct) == 1.0); yyjson_doc_free(normalized); CHECK(child(path, sizeof(path), package, "img/keyboard/cat_model/cat.model3.json", false) && From 9269e46f1862ad1d397811feff60fc738ad4e5f7 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 30 Aug 2026 15:08:34 +0800 Subject: [PATCH 159/334] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a126b787..9329b971 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ > [!TIP] -> The model featured in this demonstration is from [宇痕冫](https://www.bilibili.com/video/BV1ZVK56HECF). +> The model featured in this demonstration is from [宇痕冫](https://space.bilibili.com/348616056). > > 🎁Looking for **free** models? Visit our official website: [bongocat.pet](https://bongocat.pet/models) From 735224b2e76d057d4840ee831a483c0f414338ec Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 30 Aug 2026 15:37:47 +0800 Subject: [PATCH 160/334] Refactor model import format handling --- cmake/RuntimeSources.cmake | 29 +- src/live2d/live2d_stub.c | 2 +- src/runtime/model/import/README.md | 29 ++ src/runtime/model/import/model_import.h | 34 +- .../model/import/model_import_adapter.c | 1 + .../model/import/model_import_digest.c | 20 +- .../model/import/model_import_discover.c | 54 +--- .../model/import/model_import_identity.c | 13 +- .../model/import/model_import_manifest.c | 93 ++++++ .../model/import/model_import_manifest.h | 10 + .../model/import/model_import_package.c | 1 + src/runtime/model/import/model_import_path.c | 34 ++ src/runtime/model/import/model_import_path.h | 13 + .../model/import/model_import_progressive.c | 2 + src/runtime/model/import/model_import_scan.c | 2 + ...{model_import.c => model_import_session.c} | 0 .../model/import/model_import_source.c | 37 +-- src/runtime/model/import/model_import_tauri.c | 45 --- .../model/import/model_import_tauri_mver.c | 304 ------------------ .../import/model_import_tauri_mver_internal.h | 26 -- .../import/model_import_tauri_mver_keys.c | 86 ----- .../model/import/mver/model_import_mver.h | 20 ++ ...port_mver.c => model_import_mver_assets.c} | 1 - .../import/mver/model_import_mver_copy.h | 2 +- .../model_import_mver_discovery.c} | 16 +- .../import/mver/model_import_mver_internal.h | 2 +- .../import/mver/model_import_mver_patch.c | 21 +- .../import/mver/model_import_mver_policy.c | 1 + .../import/mver/model_import_mver_policy.h | 2 +- .../model/import/nearby/model_import_nearby.c | 22 +- .../model/import/tauri/model_import_tauri.h | 15 + .../model_import_tauri_candidate.c} | 127 ++------ .../import/tauri/model_import_tauri_config.c | 70 ++++ .../import/tauri/model_import_tauri_convert.c | 57 ++++ .../tauri/model_import_tauri_discovery.c | 111 +++++++ .../import/tauri/model_import_tauri_images.c | 110 +++++++ .../tauri/model_import_tauri_internal.h | 69 ++++ .../import/tauri/model_import_tauri_keys.c | 157 +++++++++ .../tauri/model_import_tauri_metadata.c | 114 +++++++ .../tauri/model_import_tauri_resources.c | 64 ++++ .../model_import_tauri_tree.c} | 8 +- .../model_import/test_model_import_identity.c | 1 + tests/model_import/test_mver_container.c | 8 + tests/model_import/test_tauri_portable.c | 16 +- 44 files changed, 1104 insertions(+), 745 deletions(-) create mode 100644 src/runtime/model/import/README.md create mode 100644 src/runtime/model/import/model_import_manifest.c create mode 100644 src/runtime/model/import/model_import_manifest.h create mode 100644 src/runtime/model/import/model_import_path.c create mode 100644 src/runtime/model/import/model_import_path.h rename src/runtime/model/import/{model_import.c => model_import_session.c} (100%) delete mode 100644 src/runtime/model/import/model_import_tauri.c delete mode 100644 src/runtime/model/import/model_import_tauri_mver.c delete mode 100644 src/runtime/model/import/model_import_tauri_mver_internal.h delete mode 100644 src/runtime/model/import/model_import_tauri_mver_keys.c create mode 100644 src/runtime/model/import/mver/model_import_mver.h rename src/runtime/model/import/mver/{model_import_mver.c => model_import_mver_assets.c} (99%) rename src/runtime/model/import/{model_import_format.c => mver/model_import_mver_discovery.c} (93%) create mode 100644 src/runtime/model/import/tauri/model_import_tauri.h rename src/runtime/model/import/{model_import_discovery.c => tauri/model_import_tauri_candidate.c} (59%) create mode 100644 src/runtime/model/import/tauri/model_import_tauri_config.c create mode 100644 src/runtime/model/import/tauri/model_import_tauri_convert.c create mode 100644 src/runtime/model/import/tauri/model_import_tauri_discovery.c create mode 100644 src/runtime/model/import/tauri/model_import_tauri_images.c create mode 100644 src/runtime/model/import/tauri/model_import_tauri_internal.h create mode 100644 src/runtime/model/import/tauri/model_import_tauri_keys.c create mode 100644 src/runtime/model/import/tauri/model_import_tauri_metadata.c create mode 100644 src/runtime/model/import/tauri/model_import_tauri_resources.c rename src/runtime/model/import/{model_import_tauri_mver_tree.c => tauri/model_import_tauri_tree.c} (93%) diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index f3d7c575..8e132cbc 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -82,30 +82,30 @@ set(BONGO_CAT_RUNTIME_MODEL_SOURCES src/runtime/model/model_update.c) set(BONGO_CAT_RUNTIME_IMPORT_SOURCES - src/runtime/model/import/model_import.c + # Shared import workflow. src/runtime/model/import/model_import_app.c src/runtime/model/import/model_import_adapter.c src/runtime/model/import/model_import_digest.c src/runtime/model/import/model_import_digest_api.c src/runtime/model/import/model_import_digest_cache.c src/runtime/model/import/model_import_discover.c - src/runtime/model/import/model_import_discovery.c - src/runtime/model/import/model_import_format.c + src/runtime/model/import/model_import_manifest.c src/runtime/model/import/model_import_identity.c src/runtime/model/import/model_import_installed.c src/runtime/model/import/model_import_metadata.c src/runtime/model/import/model_import_package.c src/runtime/model/import/model_import_package_index.c + src/runtime/model/import/model_import_path.c src/runtime/model/import/model_import_progressive.c src/runtime/model/import/model_import_report.c src/runtime/model/import/model_import_scan.c + src/runtime/model/import/model_import_session.c src/runtime/model/import/model_import_source.c - src/runtime/model/import/model_import_tauri.c - src/runtime/model/import/model_import_tauri_mver.c - src/runtime/model/import/model_import_tauri_mver_keys.c - src/runtime/model/import/model_import_tauri_mver_tree.c - src/runtime/model/import/mver/model_import_mver.c + + # Mver is the canonical package and runtime-adapter format. + src/runtime/model/import/mver/model_import_mver_assets.c src/runtime/model/import/mver/model_import_mver_copy.c + src/runtime/model/import/mver/model_import_mver_discovery.c src/runtime/model/import/mver/model_import_mver_effect.c src/runtime/model/import/mver/model_import_mver_image.c src/runtime/model/import/mver/model_import_mver_labels.c @@ -114,6 +114,19 @@ set(BONGO_CAT_RUNTIME_IMPORT_SOURCES src/runtime/model/import/mver/model_import_mver_patch.c src/runtime/model/import/mver/model_import_mver_policy.c src/runtime/model/import/mver/model_import_mver_shortcut.c + + # Tauri packages are converted to canonical Mver packages before loading. + src/runtime/model/import/tauri/model_import_tauri_candidate.c + src/runtime/model/import/tauri/model_import_tauri_config.c + src/runtime/model/import/tauri/model_import_tauri_convert.c + src/runtime/model/import/tauri/model_import_tauri_discovery.c + src/runtime/model/import/tauri/model_import_tauri_images.c + src/runtime/model/import/tauri/model_import_tauri_keys.c + src/runtime/model/import/tauri/model_import_tauri_metadata.c + src/runtime/model/import/tauri/model_import_tauri_resources.c + src/runtime/model/import/tauri/model_import_tauri_tree.c + + # Nearby discovery builds adapters without installing source packages. src/runtime/model/import/nearby/model_import_nearby.c src/runtime/model/import/nearby/model_import_nearby_cache.c src/runtime/model/import/nearby/model_import_nearby_signature.c) diff --git a/src/live2d/live2d_stub.c b/src/live2d/live2d_stub.c index 22662d5e..01099a4d 100644 --- a/src/live2d/live2d_stub.c +++ b/src/live2d/live2d_stub.c @@ -1,5 +1,5 @@ #include "bongo_cat/model.h" -#include "model_import.h" +#include "model_import_manifest.h" #include #include diff --git a/src/runtime/model/import/README.md b/src/runtime/model/import/README.md new file mode 100644 index 00000000..1d1a26a0 --- /dev/null +++ b/src/runtime/model/import/README.md @@ -0,0 +1,29 @@ +# Model import architecture + +The installed package format is Mver. Other source formats must be converted +to an Mver package before they enter the installed-model catalog. + +## Pipeline + +1. `model_import_source.c` resolves the selected file or directory. +2. `model_import_discover.c` selects the source-format discovery module. +3. Identity and digest modules decide whether the package is already known. +4. Mver sources are copied while distribution-only files are filtered out. +5. Tauri sources are converted by the isolated `tauri/` module. +6. The prepared package is discovered again through the Mver module. +7. Mver metadata and assets are converted into the runtime adapter. + +## Ownership + +- Root files: shared workflow, storage, identity, scanning, and validation. +- `mver/`: canonical package discovery, copying, and runtime adaptation. +- `tauri/`: Tauri discovery and conversion to canonical Mver structure. +- `nearby/`: non-installing discovery and adapter caching. + +## Invariants + +- Native Mver imports never call Tauri conversion code. +- Tauri conversion ends by producing a valid Mver directory tree. +- Installed packages are validated through Mver discovery after preparation. +- Format-specific APIs stay in their format directories. +- Shared path and manifest helpers stay format-neutral. diff --git a/src/runtime/model/import/model_import.h b/src/runtime/model/import/model_import.h index 4632d8aa..bfa955d5 100644 --- a/src/runtime/model/import/model_import.h +++ b/src/runtime/model/import/model_import.h @@ -70,29 +70,9 @@ typedef BongoCatResult (*BongoCatImportVisitor)(void *userdata, typedef struct BongoCatApp BongoCatApp; -bool bongo_cat_import_discover(const char *source, BongoCatImportDiscovery *discovery, - BongoCatError *error); -bool bongo_cat_import_mver_config_path(const char *root, - char *path, size_t capacity); -int bongo_cat_import_mver_discover(const char *source, - BongoCatImportDiscovery *discovery, BongoCatError *error); -int bongo_cat_import_mver_discover_exact(const char *source, - BongoCatImportDiscovery *discovery, BongoCatError *error); -int bongo_cat_import_mver_patch_discover(const char *source, +/* Shared discovery and package preparation. */ +bool bongo_cat_import_discover(const char *source, BongoCatImportDiscovery *discovery, BongoCatError *error); -int bongo_cat_import_mver_patch_discover_exact(const char *source, - BongoCatImportDiscovery *discovery, BongoCatError *error); -int bongo_cat_import_tauri_discover_exact(const char *source, - BongoCatImportDiscovery *discovery, BongoCatError *error); -bool bongo_cat_import_manifest_valid(const char *root, const char *setting, - BongoCatError *error); -bool bongo_cat_import_tauri_add_candidate(BongoCatImportDiscovery *discovery, - const char *directory, const char *setting); -bool bongo_cat_import_tauri_convert_to_mver( - const BongoCatImportCandidate *candidate, const char *target, - BongoCatImportCandidate *installed, BongoCatError *error); -bool bongo_cat_import_mver_assets(const BongoCatImportCandidate *candidate, - const char *target, BongoCatError *error); bool bongo_cat_import_adapter_metadata(const BongoCatImportCandidate *candidate, const char *target, BongoCatError *error); bool bongo_cat_import_write_report(const BongoCatImportCandidate *candidate, @@ -105,6 +85,8 @@ bool bongo_cat_import_prepare_storage( const BongoCatImportDiscovery *discovery, const char *target, BongoCatError *error); bool bongo_cat_import_authored_package(const char *directory); + +/* Content identity and package metadata. */ bool bongo_cat_import_candidate_digest(const BongoCatImportCandidate *candidate, char output[65], BongoCatError *error); bool bongo_cat_import_candidate_inspect(const BongoCatImportCandidate *candidate, @@ -131,6 +113,8 @@ bool bongo_cat_import_package_base_id(char *output, size_t capacity, const char *model_id); uint32_t bongo_cat_import_candidate_capabilities( const BongoCatImportCandidate *candidate); + +/* Runtime adapter and catalog integration. */ void bongo_cat_import_describe_nearby_entry(BongoCatModelEntry *entry, const BongoCatImportCandidate *candidate, const char *id, const char *identity, const char *source_hash, const char *source, @@ -139,6 +123,8 @@ void bongo_cat_import_apply_metadata(BongoCatApp *app, const char *model_id, const char *directory); bool bongo_cat_import_render_options(const char *directory, BongoCatLive2DRenderOptions *options); + +/* Recursive source scanning. */ BongoCatResult bongo_cat_import_scan(const char *root, BongoCatImportVisitor visitor, void *userdata, BongoCatError *error); BongoCatResult bongo_cat_import_scan_budget(const char *root, @@ -146,6 +132,8 @@ BongoCatResult bongo_cat_import_scan_budget(const char *root, BongoCatError *error); BongoCatResult bongo_cat_import_source_directory(const char *source, char *directory, size_t capacity, BongoCatError *error); + +/* Nearby and installed catalog discovery. */ BongoCatResult bongo_cat_import_nearby_root(BongoCatApp *app, const char *root, BongoCatError *error); BongoCatResult bongo_cat_import_nearby_scan(BongoCatApp *app, @@ -156,6 +144,8 @@ BongoCatResult bongo_cat_import_installed_models(BongoCatApp *app, const char *root, BongoCatError *error); BongoCatResult bongo_cat_import_installed_package(BongoCatApp *app, const char *root, const char *package_id, BongoCatError *error); + +/* Installation sessions and progressive batch import. */ BongoCatResult bongo_cat_import_install(const char *source, const char *models_root, BongoCatImportReceipt *receipt, BongoCatError *error); diff --git a/src/runtime/model/import/model_import_adapter.c b/src/runtime/model/import/model_import_adapter.c index 78921e44..f04695df 100644 --- a/src/runtime/model/import/model_import_adapter.c +++ b/src/runtime/model/import/model_import_adapter.c @@ -1,4 +1,5 @@ #include "model_import.h" +#include "mver/model_import_mver.h" #include "model_storage.h" #include "runtime.h" #include "bongo_cat/file.h" diff --git a/src/runtime/model/import/model_import_digest.c b/src/runtime/model/import/model_import_digest.c index d7f04428..7184d21c 100644 --- a/src/runtime/model/import/model_import_digest.c +++ b/src/runtime/model/import/model_import_digest.c @@ -215,11 +215,11 @@ static bool stamp_equal(const DigestStamp *value, uint64_t sum, uint64_t exclusi } static bool candidate_placeholder(const BongoCatImportCandidate *candidate, - const DigestStamp *content, const DigestStamp *overrides, + const DigestStamp *overrides, BongoCatImportDigestCache *cache) { - /* These fingerprints are the stock mode and input-image templates shipped - by the Mver 0.1.6 runtime; package configuration is intentionally not - part of the placeholder decision. */ + /* Identify bundled runtime templates by their rendered Live2D core. Do + not use aggregate asset stamps here: older releases shipped a popular + authored skin with the same file counts and byte totals. */ if (candidate->format == BONGO_CAT_IMPORT_TAURI) return false; if (bongo_cat_import_mver_stock_model(candidate, cache)) return true; if (candidate->format == BONGO_CAT_IMPORT_MVER_PATCH) { @@ -230,14 +230,7 @@ static bool candidate_placeholder(const BongoCatImportCandidate *candidate, stamp_equal(overrides, 0xe35c6e634128d62bull, 0x17e2f6d0ad5746bfull, 1297880ull, 12ull); } - if (candidate->mode == BONGO_CAT_MODE_STANDARD) - return stamp_equal(content, 0x354b60da1c319aa4ull, - 0x9c9cfce9a7c63fe4ull, 38273435ull, 153ull); - if (candidate->mode == BONGO_CAT_MODE_KEYBOARD) - return stamp_equal(content, 0x65afe356f5b20130ull, - 0x45cc631ac72ef8c6ull, 1005234ull, 35ull); - return stamp_equal(content, 0x91e8cfcd07fb9ae8ull, - 0x83fd63c53a241538ull, 1711338ull, 51ull); + return false; } bool bongo_cat_import_candidate_inspect_cached( @@ -287,7 +280,6 @@ bool bongo_cat_import_candidate_inspect_cached( if (length < 0 || (size_t)length >= sizeof(summary)) return false; bongo_cat_sha256_bytes(summary, (size_t)length, output); if (placeholder) - *placeholder = candidate_placeholder(candidate, &content, &overrides, - cache); + *placeholder = candidate_placeholder(candidate, &overrides, cache); return true; } diff --git a/src/runtime/model/import/model_import_discover.c b/src/runtime/model/import/model_import_discover.c index 43747f8b..8ed9f862 100644 --- a/src/runtime/model/import/model_import_discover.c +++ b/src/runtime/model/import/model_import_discover.c @@ -1,4 +1,6 @@ #include "model_import.h" +#include "mver/model_import_mver.h" +#include "tauri/model_import_tauri.h" #include "bongo_cat/path.h" #include @@ -18,31 +20,6 @@ static void seed_source_name(const char *source, sizeof(discovery->source_name), "%s", name); } -static bool suffix(const char *name, const char *ending) { - size_t a = name ? strlen(name) : 0, b = ending ? strlen(ending) : 0; - return a >= b && strcmp(name + a - b, ending) == 0; -} - -static BongoCatPathVisit discover_item(void *userdata, - const char *dirname, const char *name) { - BongoCatImportDiscovery *discovery = userdata; - char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), dirname, name)) - return BONGO_CAT_PATH_FAILURE; - if (bongo_cat_path_is_file(path) && suffix(name, ".model3.json")) { - if (bongo_cat_import_manifest_valid(dirname, name, NULL) && - !bongo_cat_import_tauri_add_candidate(discovery, dirname, name)) - return BONGO_CAT_PATH_FAILURE; - return BONGO_CAT_PATH_CONTINUE; - } - if (!bongo_cat_path_is_dir(path) || discovery->depth >= 8 || name[0] == '.') - return BONGO_CAT_PATH_CONTINUE; - discovery->depth++; - bool ok = bongo_cat_path_enumerate(path, discover_item, discovery); - discovery->depth--; - return ok ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; -} - static int rank(const BongoCatImportCandidate *candidate) { return candidate->mode == BONGO_CAT_MODE_STANDARD ? 0 : candidate->mode == BONGO_CAT_MODE_KEYBOARD ? 1 : 2; @@ -106,13 +83,9 @@ bool bongo_cat_import_discover(const char *source, &container, error); if (result != BONGO_CAT_OK) return false; if (discovery->count) { - char config[BONGO_CAT_PATH_CAP]; - bool is_container = !bongo_cat_import_mver_config_path(source, - config, sizeof(config)); - if (is_container) for (size_t i = 0; i < discovery->count; ++i) - if (discovery->candidates[i].format != BONGO_CAT_IMPORT_TAURI) - snprintf(discovery->candidates[i].package_root, - sizeof(discovery->candidates[i].package_root), "%s", source); + /* Recursive discovery returns the owning package root on each + candidate. Re-rooting Mver candidates at the selected container + would copy unrelated sibling folders and break normalization. */ qsort(discovery->candidates, discovery->count, sizeof(discovery->candidates[0]), compare_candidates); return true; @@ -122,23 +95,14 @@ bool bongo_cat_import_discover(const char *source, return false; } if (error) *error = (BongoCatError){0}; - if (!bongo_cat_path_enumerate(source, discover_item, discovery)) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - discovery->ambiguous - ? "A model directory contains multiple model3 manifests" - : "Cannot scan model directory or it contains too many models"); - return false; - } - if (!discovery->count) { + int recursive = bongo_cat_import_tauri_discover_recursive(source, + discovery, error); + if (recursive < 0) return false; + if (!recursive) { bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, "Selected directory contains no valid Live2D model3 JSON"); return false; } - for (size_t i = 0; i < discovery->count; ++i) - if (discovery->candidates[i].format == BONGO_CAT_IMPORT_TAURI && - !discovery->candidates[i].package_root[0]) - snprintf(discovery->candidates[i].package_root, - sizeof(discovery->candidates[i].package_root), "%s", source); qsort(discovery->candidates, discovery->count, sizeof(discovery->candidates[0]), compare_candidates); return true; diff --git a/src/runtime/model/import/model_import_identity.c b/src/runtime/model/import/model_import_identity.c index 7ad32fc1..34593b28 100644 --- a/src/runtime/model/import/model_import_identity.c +++ b/src/runtime/model/import/model_import_identity.c @@ -1,4 +1,5 @@ #include "model_import.h" +#include "model_import_path.h" #include "model_import_mver_policy.h" #include "bongo_cat/path.h" #include "bongo_cat/sha256.h" @@ -35,21 +36,11 @@ static const char *source_root(const BongoCatImportCandidate *candidate) { candidate->patch_root[0] ? candidate->patch_root : candidate->package_root; } -static bool parent_path(const char *path, char *parent, size_t capacity) { - size_t length = path ? strlen(path) : 0; - while (length && (path[length - 1] == '/' || path[length - 1] == '\\')) length--; - while (length && path[length - 1] != '/' && path[length - 1] != '\\') length--; - while (length && (path[length - 1] == '/' || path[length - 1] == '\\')) length--; - if (!length || length >= capacity) return false; - memcpy(parent, path, length); parent[length] = '\0'; - return true; -} - static const char *display_source(const BongoCatImportCandidate *candidate, char parent[BONGO_CAT_PATH_CAP]) { const char *source = source_root(candidate); if (candidate->format == BONGO_CAT_IMPORT_MVER_PATCH && - parent_path(source, parent, BONGO_CAT_PATH_CAP) && + bongo_cat_import_parent_path(source, parent, BONGO_CAT_PATH_CAP) && !strcmp(bongo_cat_path_name(source), bongo_cat_path_name(candidate->package_root))) return parent; return source; diff --git a/src/runtime/model/import/model_import_manifest.c b/src/runtime/model/import/model_import_manifest.c new file mode 100644 index 00000000..7b0487c1 --- /dev/null +++ b/src/runtime/model/import/model_import_manifest.c @@ -0,0 +1,93 @@ +#include "model_import_manifest.h" +#include "runtime.h" +#include "bongo_cat/file.h" +#include "bongo_cat/image.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +static bool safe_reference(const char *value) { + if (!value || !value[0] || value[0] == '/' || value[0] == '\\' || + strchr(value, ':')) return false; + const char *part = value; + while (*part) { + while (*part == '/' || *part == '\\') part++; + if (part[0] == '.' && part[1] == '.' && + (!part[2] || part[2] == '/' || part[2] == '\\')) return false; + part = strpbrk(part, "/\\"); + if (!part) break; + } + return true; +} +static bool referenced_file(const char *root, const char *relative) { + char path[BONGO_CAT_PATH_CAP]; + return safe_reference(relative) && + bongo_cat_path_join(path, sizeof(path), root, relative) && + bongo_cat_path_is_file(path); +} + +static bool referenced_texture(const char *root, const char *relative) { + char path[BONGO_CAT_PATH_CAP]; + if (!safe_reference(relative) || + !bongo_cat_path_join(path, sizeof(path), root, relative)) return false; + return bongo_cat_image_info(path, NULL, NULL); +} + +static bool optional_reference(const char *root, yyjson_val *refs, + const char *name) { + yyjson_val *value = yyjson_obj_get(refs, name); + if (!value) return true; + const char *relative = yyjson_get_str(value); + return relative && referenced_file(root, relative); +} + +static bool behavior_references(const char *root, yyjson_val *refs) { + yyjson_val *expressions = yyjson_obj_get(refs, "Expressions"); + if (expressions && !yyjson_is_arr(expressions)) return false; + size_t index, count; yyjson_val *item; + yyjson_arr_foreach(expressions, index, count, item) + if (!referenced_file(root, yyjson_get_str(yyjson_obj_get(item, "File")))) + return false; + yyjson_val *motions = yyjson_obj_get(refs, "Motions"); + if (motions && !yyjson_is_obj(motions)) return false; + size_t group_index, group_count; yyjson_val *key, *group; + yyjson_obj_foreach(motions, group_index, group_count, key, group) { + if (!yyjson_is_arr(group)) return false; + yyjson_arr_foreach(group, index, count, item) { + if (!referenced_file(root, yyjson_get_str(yyjson_obj_get(item, "File")))) + return false; + const char *sound = yyjson_get_str(yyjson_obj_get(item, "Sound")); + if (sound && !safe_reference(sound)) return false; + } + } + return true; +} + +bool bongo_cat_import_manifest_valid(const char *root, const char *setting, + BongoCatError *error) { + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), root, setting)) return false; + FILE *file = bongo_cat_file_open(path, "rb"); + yyjson_doc *document = file ? yyjson_read_fp(file, 0, NULL, NULL) : NULL; + if (file) fclose(file); + yyjson_val *manifest = document ? yyjson_doc_get_root(document) : NULL; + yyjson_val *refs = yyjson_is_obj(manifest) + ? yyjson_obj_get(manifest, "FileReferences") : NULL; + const char *moc = yyjson_get_str(yyjson_obj_get(refs, "Moc")); + yyjson_val *textures = yyjson_obj_get(refs, "Textures"); + bool valid = yyjson_get_int(yyjson_obj_get(manifest, "Version")) == 3 && + yyjson_is_obj(refs) && referenced_file(root, moc) && yyjson_is_arr(textures) && + yyjson_arr_size(textures) > 0; + size_t index, maximum; yyjson_val *texture; + yyjson_arr_foreach(textures, index, maximum, texture) + valid = valid && referenced_texture(root, yyjson_get_str(texture)); + valid = valid && optional_reference(root, refs, "Physics") && + optional_reference(root, refs, "Pose") && + optional_reference(root, refs, "DisplayInfo") && behavior_references(root, refs); + yyjson_doc_free(document); + if (!valid && error) bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Model manifest or referenced assets are invalid: %s", path); + return valid; +} diff --git a/src/runtime/model/import/model_import_manifest.h b/src/runtime/model/import/model_import_manifest.h new file mode 100644 index 00000000..cfc196f4 --- /dev/null +++ b/src/runtime/model/import/model_import_manifest.h @@ -0,0 +1,10 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_MANIFEST_H +#define BONGO_CAT_MODEL_IMPORT_MANIFEST_H + +#include "bongo_cat/common.h" + +/* Validates a Live2D manifest and every referenced local asset. */ +bool bongo_cat_import_manifest_valid(const char *root, const char *setting, + BongoCatError *error); + +#endif diff --git a/src/runtime/model/import/model_import_package.c b/src/runtime/model/import/model_import_package.c index e88d7cb6..52fa5e1c 100644 --- a/src/runtime/model/import/model_import_package.c +++ b/src/runtime/model/import/model_import_package.c @@ -1,5 +1,6 @@ #include "model_import.h" #include "model_import_mver_copy.h" +#include "tauri/model_import_tauri.h" #include "bongo_cat/path.h" #include diff --git a/src/runtime/model/import/model_import_path.c b/src/runtime/model/import/model_import_path.c new file mode 100644 index 00000000..8bd2a031 --- /dev/null +++ b/src/runtime/model/import/model_import_path.c @@ -0,0 +1,34 @@ +#include "model_import_path.h" + +#include +#include + +static bool separator(char value) { + return value == '/' || value == '\\'; +} + +bool bongo_cat_import_parent_path(const char *path, char *parent, + size_t capacity) { + size_t length = path ? strlen(path) : 0; + while (length && separator(path[length - 1])) length--; + while (length && !separator(path[length - 1])) length--; + while (length > 1 && separator(path[length - 1])) length--; + if (!length || length >= capacity) return false; + memcpy(parent, path, length); + parent[length] = '\0'; + return true; +} + +bool bongo_cat_import_has_suffix(const char *value, const char *suffix) { + size_t value_length = value ? strlen(value) : 0; + size_t suffix_length = suffix ? strlen(suffix) : 0; + return value_length >= suffix_length && suffix_length > 0 && + memcmp(value + value_length - suffix_length, suffix, suffix_length) == 0; +} + +bool bongo_cat_import_has_suffix_ci(const char *value, const char *suffix) { + size_t value_length = value ? strlen(value) : 0; + size_t suffix_length = suffix ? strlen(suffix) : 0; + return value_length >= suffix_length && suffix_length > 0 && + SDL_strcasecmp(value + value_length - suffix_length, suffix) == 0; +} diff --git a/src/runtime/model/import/model_import_path.h b/src/runtime/model/import/model_import_path.h new file mode 100644 index 00000000..9266cd82 --- /dev/null +++ b/src/runtime/model/import/model_import_path.h @@ -0,0 +1,13 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_PATH_H +#define BONGO_CAT_MODEL_IMPORT_PATH_H + +#include +#include + +/* Small path operations shared by source-format modules. */ +bool bongo_cat_import_parent_path(const char *path, char *parent, + size_t capacity); +bool bongo_cat_import_has_suffix(const char *value, const char *suffix); +bool bongo_cat_import_has_suffix_ci(const char *value, const char *suffix); + +#endif diff --git a/src/runtime/model/import/model_import_progressive.c b/src/runtime/model/import/model_import_progressive.c index 24fb2074..5aabbe0a 100644 --- a/src/runtime/model/import/model_import_progressive.c +++ b/src/runtime/model/import/model_import_progressive.c @@ -1,4 +1,6 @@ #include "model_import.h" +#include "mver/model_import_mver.h" +#include "tauri/model_import_tauri.h" #include "bongo_cat/path.h" #include diff --git a/src/runtime/model/import/model_import_scan.c b/src/runtime/model/import/model_import_scan.c index 42bd3ca3..79923514 100644 --- a/src/runtime/model/import/model_import_scan.c +++ b/src/runtime/model/import/model_import_scan.c @@ -1,4 +1,6 @@ #include "model_import.h" +#include "mver/model_import_mver.h" +#include "tauri/model_import_tauri.h" #include "bongo_cat/path.h" #include diff --git a/src/runtime/model/import/model_import.c b/src/runtime/model/import/model_import_session.c similarity index 100% rename from src/runtime/model/import/model_import.c rename to src/runtime/model/import/model_import_session.c diff --git a/src/runtime/model/import/model_import_source.c b/src/runtime/model/import/model_import_source.c index d60716ee..6f489655 100644 --- a/src/runtime/model/import/model_import_source.c +++ b/src/runtime/model/import/model_import_source.c @@ -1,40 +1,22 @@ #include "model_import.h" +#include "model_import_path.h" #include "bongo_cat/path.h" #include #include #include -static bool suffix_matches(const char *value, const char *suffix) { - size_t value_length = value ? strlen(value) : 0; - size_t suffix_length = suffix ? strlen(suffix) : 0; - return value_length >= suffix_length && SDL_strcasecmp( - value + value_length - suffix_length, suffix) == 0; -} - -static bool parent_path(const char *path, char *parent, size_t capacity) { - size_t length = path ? strlen(path) : 0; - while (length && (path[length - 1] == '/' || path[length - 1] == '\\')) - length--; - while (length && path[length - 1] != '/' && path[length - 1] != '\\') - length--; - while (length > 1 && (path[length - 1] == '/' || path[length - 1] == '\\')) - length--; - if (!length || length >= capacity) return false; - memcpy(parent, path, length); - parent[length] = '\0'; - return true; -} - static bool image_package_root(const char *source, char *directory, size_t capacity) { char current[BONGO_CAT_PATH_CAP]; - if (!parent_path(source, current, sizeof(current))) return false; + if (!bongo_cat_import_parent_path(source, current, sizeof(current))) + return false; for (int depth = 0; depth < 12; ++depth) { if (SDL_strcasecmp(bongo_cat_path_name(current), "img") == 0) - return parent_path(current, directory, capacity); + return bongo_cat_import_parent_path(current, directory, capacity); char parent[BONGO_CAT_PATH_CAP]; - if (!parent_path(current, parent, sizeof(parent))) return false; + if (!bongo_cat_import_parent_path(current, parent, sizeof(parent))) + return false; snprintf(current, sizeof(current), "%s", parent); } return false; @@ -60,17 +42,18 @@ BongoCatResult bongo_cat_import_source_directory(const char *source, return BONGO_CAT_ERROR_ARGUMENT; } const char *name = bongo_cat_path_name(source); - if (name && suffix_matches(name, ".png") && + if (name && bongo_cat_import_has_suffix_ci(name, ".png") && image_package_root(source, directory, capacity)) return BONGO_CAT_OK; if (!name || (SDL_strcasecmp(name, "config.json") != 0 && SDL_strcasecmp(name, BONGO_CAT_SKIN_CONFIG_FILE) != 0 && - !suffix_matches(name, ".model3.json"))) { + !bongo_cat_import_has_suffix_ci(name, ".model3.json"))) { bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, "Select a BongoCat skin file, Mver config.json, image-patch PNG, " "or Live2D .model3.json file"); return BONGO_CAT_ERROR_FORMAT; } - if (parent_path(source, directory, capacity)) return BONGO_CAT_OK; + if (bongo_cat_import_parent_path(source, directory, capacity)) + return BONGO_CAT_OK; bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, "Cannot determine the selected model file directory: %s", source); return BONGO_CAT_ERROR_ARGUMENT; diff --git a/src/runtime/model/import/model_import_tauri.c b/src/runtime/model/import/model_import_tauri.c deleted file mode 100644 index 6c81f837..00000000 --- a/src/runtime/model/import/model_import_tauri.c +++ /dev/null @@ -1,45 +0,0 @@ -#include "model_import.h" -#include "bongo_cat/path.h" - -#include -#include - -static bool suffix(const char *name, const char *ending) { - size_t name_length = name ? strlen(name) : 0; - size_t ending_length = ending ? strlen(ending) : 0; - return name_length >= ending_length && - strcmp(name + name_length - ending_length, ending) == 0; -} - -static BongoCatPathVisit discover_item(void *userdata, - const char *dirname, const char *name) { - BongoCatImportDiscovery *discovery = userdata; - char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), dirname, name)) - return BONGO_CAT_PATH_FAILURE; - if (!bongo_cat_path_is_file(path) || !suffix(name, ".model3.json")) - return BONGO_CAT_PATH_CONTINUE; - if (bongo_cat_import_manifest_valid(dirname, name, NULL) && - !bongo_cat_import_tauri_add_candidate(discovery, dirname, name)) - return BONGO_CAT_PATH_FAILURE; - return BONGO_CAT_PATH_CONTINUE; -} - -int bongo_cat_import_tauri_discover_exact(const char *source, - BongoCatImportDiscovery *discovery, BongoCatError *error) { - if (!source || !discovery || !bongo_cat_path_is_dir(source)) return 0; - if (!discovery->source_name[0]) snprintf(discovery->source_name, - sizeof(discovery->source_name), "%s", bongo_cat_path_name(source)); - size_t before = discovery->count; - if (!bongo_cat_path_enumerate(source, discover_item, discovery)) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - discovery->ambiguous - ? "A model directory contains multiple model3 manifests" - : "Cannot inspect nearby model directory"); - return -1; - } - for (size_t i = before; i < discovery->count; ++i) - snprintf(discovery->candidates[i].package_root, - sizeof(discovery->candidates[i].package_root), "%s", source); - return discovery->count > before ? 1 : 0; -} diff --git a/src/runtime/model/import/model_import_tauri_mver.c b/src/runtime/model/import/model_import_tauri_mver.c deleted file mode 100644 index c77aabbf..00000000 --- a/src/runtime/model/import/model_import_tauri_mver.c +++ /dev/null @@ -1,304 +0,0 @@ -#include "model_import.h" -#include "model_import_tauri_mver_internal.h" -#include "bongo_cat/file.h" -#include "bongo_cat/image.h" -#include "bongo_cat/json.h" -#include "bongo_cat/path.h" -#include -#include -#include -#include -#include -static bool resource_file(const BongoCatImportCandidate *candidate, const char *name, - char *path, size_t capacity) { - const char *roots[3] = {candidate->assets, candidate->directory, - candidate->package_root}; - for (size_t i = 0; i < sizeof(roots) / sizeof(roots[0]); ++i) { - if (!roots[i][0]) continue; - char resources[BONGO_CAT_PATH_CAP]; - if (bongo_cat_path_join(resources, sizeof(resources), roots[i], - "resources") && bongo_cat_path_join(path, capacity, - resources, name) && bongo_cat_path_is_file(path)) return true; - if (bongo_cat_path_join(path, capacity, roots[i], name) && - bongo_cat_path_is_file(path)) return true; - } - return false; -} -static bool write_placeholder(const char *path) { - static const unsigned char png[] = { - 0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a,0x00,0x00,0x00,0x0d, - 0x49,0x48,0x44,0x52,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x01, - 0x08,0x06,0x00,0x00,0x00,0x1f,0x15,0xc4,0x89,0x00,0x00,0x00, - 0x0d,0x49,0x44,0x41,0x54,0x08,0xd7,0x63,0xf8,0xcf,0xc0,0xf0, - 0x1f,0x00,0x05,0x00,0x01,0xff,0x89,0x99,0x3d,0x1d,0x00,0x00, - 0x00,0x00,0x49,0x45,0x4e,0x44,0xae,0x42,0x60,0x82 - }; - FILE *file = bongo_cat_file_open(path, "wb"); - if (!file) return false; - bool ok = fwrite(png, 1, sizeof(png), file) == sizeof(png); - if (fclose(file) != 0) ok = false; - return ok; -} -static bool copy_image_or_placeholder(const char *source, const char *target, - BongoCatError *error) { - bool ok = source && bongo_cat_path_is_file(source) - ? bongo_cat_path_copy_file(source, target) : write_placeholder(target); - if (!ok && error) bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot create normalized Mver image: %s", target); - return ok; -} -static bool copy_mver_image(const BongoCatImportCandidate *candidate, - const char *mode_root, const char *source_name, const char *target_name, - const char *fallback, BongoCatError *error) { - char source[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; - const char *selected = fallback; - if (source_name && resource_file(candidate, source_name, source, - sizeof(source))) selected = source; - if (!bongo_cat_path_join(target, sizeof(target), mode_root, target_name)) - return false; - return copy_image_or_placeholder(selected, target, error); -} -static bool copy_mver_runtime_images( - const BongoCatImportCandidate *candidate, const char *mode_root, - BongoCatError *error) { - char background[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(background, sizeof(background), mode_root, - candidate->mode == BONGO_CAT_MODE_STANDARD ? "mousebg.png" : - "bg.png")) return false; - /* Tauri omits these Mver-only layers. Preserve real files when supplied; - otherwise use transparent PNGs so the portable package still loads. */ - if (candidate->mode == BONGO_CAT_MODE_STANDARD) { - static const char *const backgrounds[] = { - "tabletbg.png", "l2dmousebg.png", "l2dtabletbg.png" - }; - static const char *const layers[] = { - "arm.png", "up.png", "mouse.png", "mouse_left.png", - "mouse_right.png", "mouse_side.png", "tablet.png", - "tablet_left.png", "tablet_right.png" - }; - for (size_t i = 0; i < sizeof(backgrounds) / sizeof(backgrounds[0]); - ++i) - if (!copy_mver_image(candidate, mode_root, backgrounds[i], - backgrounds[i], background, error)) return false; - for (size_t i = 0; i < sizeof(layers) / sizeof(layers[0]); ++i) - if (!copy_mver_image(candidate, mode_root, layers[i], layers[i], - NULL, error)) return false; - return true; - } - static const char *const idle_hands[] = { - "lefthand/leftup.png", "righthand/rightup.png" - }; - for (size_t i = 0; i < sizeof(idle_hands) / sizeof(idle_hands[0]); ++i) - if (!copy_mver_image(candidate, mode_root, idle_hands[i], - idle_hands[i], NULL, error)) return false; - if (candidate->mode != BONGO_CAT_MODE_GAMEPAD) return true; - static const char *const gamepad[] = { - "arm_L.png", "arm_R.png", "left_stick.png", "left_stick_down.png", - "right_stick.png", "right_stick_down.png" - }; - for (size_t i = 0; i < sizeof(gamepad) / sizeof(gamepad[0]); ++i) - if (!copy_mver_image(candidate, mode_root, gamepad[i], gamepad[i], - NULL, error)) return false; - return true; -} -static void mver_window_size(const BongoCatImportCandidate *candidate, int *width, - int *height) { - *width = 612; - *height = 352; - char source[BONGO_CAT_PATH_CAP]; - if ((resource_file(candidate, "background.png", source, - sizeof(source)) || - resource_file(candidate, "cover.png", source, sizeof(source))) && - !bongo_cat_image_info(source, width, height)) { - *width = 612; - *height = 352; - } -} -static bool add_matrix(yyjson_mut_doc *document, yyjson_mut_val *mode, - const char *name, const TauriKeyFiles *files) { - yyjson_mut_val *matrix = yyjson_mut_obj_add_arr(document, mode, name); - if (!matrix) return false; - for (size_t i = 0; i < files->count; ++i) { - yyjson_mut_val *row = yyjson_mut_arr_add_arr(document, matrix); - if (!row || !yyjson_mut_arr_add_int(document, row, files->values[i].code)) - return false; - } - return true; -} -static bool config_write(const char *path, BongoCatModelMode mode, - const TauriKeyFiles *left, const TauriKeyFiles *right, int width, int height, BongoCatError *error) { - yyjson_mut_doc *document = yyjson_mut_doc_new(NULL); - yyjson_mut_val *root = document ? yyjson_mut_obj(document) : NULL; - if (document) yyjson_mut_doc_set_root(document, root); - yyjson_mut_val *decoration = root ? yyjson_mut_obj_add_obj(document, root, - "decoration") : NULL; - yyjson_mut_val *mode_object = root ? yyjson_mut_obj_add_obj(document, root, - bongo_cat_mode_name(mode)) : NULL; - yyjson_mut_val *window_size = decoration ? yyjson_mut_obj_add_arr(document, - decoration, "window_size") : NULL; - bool ok = root && decoration && mode_object && window_size && - yyjson_mut_arr_add_int(document, window_size, width) && - yyjson_mut_arr_add_int(document, window_size, height) && - /* Tauri resources are authored in the same pixel canvas as the - generated Mver input images. Make the projection explicit so a - missing legacy l2d_correct value cannot apply the 1.1 default. */ - yyjson_mut_obj_add_real(document, decoration, "l2d_correct", 1.0) && - yyjson_mut_obj_add_arr(document, decoration, "l2d_offset") && - yyjson_mut_obj_add_int(document, root, "mode", - mode == BONGO_CAT_MODE_STANDARD ? 1 : - mode == BONGO_CAT_MODE_KEYBOARD ? 2 : 3) && - yyjson_mut_obj_add_bool(document, mode_object, "l2d", true); - if (ok && mode == BONGO_CAT_MODE_STANDARD) - ok = add_matrix(document, mode_object, "hand", left) && - yyjson_mut_obj_add_bool(document, mode_object, "mouse", false) && - yyjson_mut_obj_add_arr(document, mode_object, "keyboard"); - if (ok && mode != BONGO_CAT_MODE_STANDARD) - ok = add_matrix(document, mode_object, "lefthand", left) && - add_matrix(document, mode_object, "righthand", right) && - yyjson_mut_obj_add_arr(document, mode_object, "keyboard"); - if (ok && mode == BONGO_CAT_MODE_GAMEPAD) - ok = yyjson_mut_obj_add_int(document, mode_object, "input_mode", 1); - if (ok) ok = yyjson_mut_obj_add_arr(document, mode_object, "face") && - yyjson_mut_obj_add_arr(document, mode_object, "sounds") && - yyjson_mut_obj_add_arr(document, mode_object, "l2d_expression") && - yyjson_mut_obj_add_arr(document, mode_object, "l2d_motion") && - yyjson_mut_obj_add_arr(document, mode_object, "l2d_motion_lockhand"); - if (ok) ok = bongo_cat_json_write_file(path, document, YYJSON_WRITE_PRETTY, - NULL); - yyjson_mut_doc_free(document); - if (!ok && error) bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot write normalized Mver configuration: %s", path); - return ok; -} -static bool copy_preview(const BongoCatImportCandidate *candidate, const char *mode_root, BongoCatError *error) { - char source[BONGO_CAT_PATH_CAP], cat[BONGO_CAT_PATH_CAP]; - char target[BONGO_CAT_PATH_CAP]; - bool have_cover = resource_file(candidate, "cover.png", source, - sizeof(source)); - if (!bongo_cat_path_join(cat, sizeof(cat), mode_root, "cat.png") || - !copy_image_or_placeholder(have_cover ? source : NULL, cat, error)) - return false; - bool have_background = resource_file(candidate, "background.png", source, - sizeof(source)); - if (!have_background && have_cover) { - /* A cover is a better fallback than an absent preview. */ - snprintf(source, sizeof(source), "%s", cat); - have_background = true; - } - const char *background_name = candidate->mode == BONGO_CAT_MODE_STANDARD - ? "mousebg.png" : "bg.png"; - if (!bongo_cat_path_join(target, sizeof(target), mode_root, - background_name)) - return false; - return copy_image_or_placeholder(have_background ? source : NULL, target, - error); -} -static bool copy_keys(const BongoCatImportCandidate *candidate, - const char *resource_directory, const char *mode_root, const char *name, - const char *mver_group, TauriKeyFiles *files, BongoCatError *error) { - char source[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(source, sizeof(source), resource_directory, name)) - return false; - if (!bongo_cat_path_is_dir(source)) { - char parent[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(parent, sizeof(parent), candidate->directory, - "resources") || !bongo_cat_path_join(source, sizeof(source), - parent, name) || !bongo_cat_path_is_dir(source)) return true; - } - if (!bongo_cat_path_enumerate(source, bongo_cat_tauri_collect_keys, files)) - return false; - qsort(files->values, files->count, sizeof(files->values[0]), - bongo_cat_tauri_compare_keys); - char destination[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(destination, sizeof(destination), mode_root, - mver_group) || !bongo_cat_path_create_directory(destination)) - return false; - for (size_t i = 0; i < files->count; ++i) { - char filename[32], path[BONGO_CAT_PATH_CAP]; - snprintf(filename, sizeof(filename), "%zu.png", i); - if (!bongo_cat_path_join(path, sizeof(path), destination, filename) || - !bongo_cat_path_copy_file(files->values[i].path, path)) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot copy tauri input image: %s", files->values[i].path); - return false; - } - } - return true; -} -bool bongo_cat_import_tauri_convert_to_mver(const BongoCatImportCandidate *candidate, - const char *target, - BongoCatImportCandidate *installed, BongoCatError *error) { - if (!candidate || !target || !installed || - candidate->format != BONGO_CAT_IMPORT_TAURI) return false; - char img[BONGO_CAT_PATH_CAP], mode_root[BONGO_CAT_PATH_CAP], model_root[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_create_directory(target) || - !bongo_cat_path_join(img, sizeof(img), target, "img") || - !bongo_cat_path_join(mode_root, sizeof(mode_root), img, - bongo_cat_mode_name(candidate->mode)) || - !bongo_cat_path_join(model_root, sizeof(model_root), mode_root, - "cat_model") || !bongo_cat_path_create_directory(mode_root) || - !bongo_cat_path_create_directory(model_root)) return false; - if (!bongo_cat_tauri_copy_model_tree(candidate->directory, model_root, 0, - error)) - return false; - char resource_root[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(resource_root, sizeof(resource_root), - candidate->assets, "resources") || !bongo_cat_path_is_dir(resource_root)) { - if (!bongo_cat_path_join(resource_root, sizeof(resource_root), - candidate->directory, "resources") || - !bongo_cat_path_is_dir(resource_root)) - snprintf(resource_root, sizeof(resource_root), "%s", - candidate->assets); - } - TauriKeyFiles left = {0}, right = {0}; - if (candidate->mode == BONGO_CAT_MODE_STANDARD) { - if (!copy_keys(candidate, resource_root, mode_root, "left-keys", "hand", - &left, error)) return false; - } else if (!copy_keys(candidate, resource_root, mode_root, "left-keys", - "lefthand", &left, error) || - !copy_keys(candidate, resource_root, mode_root, "right-keys", "righthand", - &right, error)) return false; - char source[BONGO_CAT_PATH_CAP], fallback[BONGO_CAT_PATH_CAP]; - bool have_fallback = resource_file(candidate, "cover.png", source, - sizeof(source)) || resource_file(candidate, "background.png", source, - sizeof(source)); - if (!left.count) { - char hand[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(hand, sizeof(hand), mode_root, - candidate->mode == BONGO_CAT_MODE_STANDARD ? "hand" : "lefthand") || - !bongo_cat_path_create_directory(hand) || - !bongo_cat_path_join(fallback, sizeof(fallback), hand, "0.png") || - !copy_image_or_placeholder(have_fallback ? source : NULL, fallback, - error)) return false; - left.values[0].code = candidate->mode == BONGO_CAT_MODE_GAMEPAD ? 0 : 65; - left.count = 1; - } - if (candidate->mode != BONGO_CAT_MODE_STANDARD && !right.count) { - char hand[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(hand, sizeof(hand), mode_root, "righthand") || - !bongo_cat_path_create_directory(hand) || - !bongo_cat_path_join(fallback, sizeof(fallback), hand, "0.png") || - !copy_image_or_placeholder(have_fallback ? source : NULL, fallback, - error)) return false; - right.values[0].code = candidate->mode == BONGO_CAT_MODE_GAMEPAD ? 1 : 65; - right.count = 1; - } - int width, height; - mver_window_size(candidate, &width, &height); - char config[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(config, sizeof(config), target, "config.json") || - !config_write(config, candidate->mode, &left, &right, width, height, - error) || - !copy_preview(candidate, mode_root, error) || - !copy_mver_runtime_images(candidate, mode_root, error)) return false; - *installed = *candidate; - snprintf(installed->directory, sizeof(installed->directory), "%s", model_root); - snprintf(installed->assets, sizeof(installed->assets), "%s", mode_root); - snprintf(installed->package_root, sizeof(installed->package_root), "%s", target); - snprintf(installed->config, sizeof(installed->config), "%s", config); - installed->overrides[0] = '\0'; - installed->patch_root[0] = '\0'; - installed->format = BONGO_CAT_IMPORT_MVER; - installed->gamepad_buttons = candidate->mode == BONGO_CAT_MODE_GAMEPAD; - return true; -} diff --git a/src/runtime/model/import/model_import_tauri_mver_internal.h b/src/runtime/model/import/model_import_tauri_mver_internal.h deleted file mode 100644 index e6270a54..00000000 --- a/src/runtime/model/import/model_import_tauri_mver_internal.h +++ /dev/null @@ -1,26 +0,0 @@ -#ifndef BONGO_CAT_MODEL_IMPORT_TAURI_MVER_INTERNAL_H -#define BONGO_CAT_MODEL_IMPORT_TAURI_MVER_INTERNAL_H - -#include "model_import.h" -#include "bongo_cat/path.h" - -#define TAURI_KEY_CAP 256 - -typedef struct TauriKeyFile { - char name[BONGO_CAT_ID_CAP]; - char path[BONGO_CAT_PATH_CAP]; - int code; -} TauriKeyFile; - -typedef struct TauriKeyFiles { - TauriKeyFile values[TAURI_KEY_CAP]; - size_t count; -} TauriKeyFiles; - -BongoCatPathVisit bongo_cat_tauri_collect_keys(void *userdata, - const char *dirname, const char *name); -int bongo_cat_tauri_compare_keys(const void *left, const void *right); -bool bongo_cat_tauri_copy_model_tree(const char *source, const char *target, - unsigned depth, BongoCatError *error); - -#endif diff --git a/src/runtime/model/import/model_import_tauri_mver_keys.c b/src/runtime/model/import/model_import_tauri_mver_keys.c deleted file mode 100644 index 627ea9b0..00000000 --- a/src/runtime/model/import/model_import_tauri_mver_keys.c +++ /dev/null @@ -1,86 +0,0 @@ -#include "model_import_tauri_mver_internal.h" - -#include -#include -#include -#include - -static bool suffix(const char *name, const char *ending) { - size_t a = name ? strlen(name) : 0, b = ending ? strlen(ending) : 0; - return a >= b && SDL_strcasecmp(name + a - b, ending) == 0; -} - -static int key_code(const char *filename) { - char name[BONGO_CAT_ID_CAP]; - snprintf(name, sizeof(name), "%s", filename ? filename : ""); - char *dot = strrchr(name, '.'); - if (!dot || SDL_strcasecmp(dot, ".png") != 0) return -1; - *dot = '\0'; - char *end = NULL; - long numeric = strtol(name, &end, 10); - if (name[0] && end && !end[0] && numeric >= 0 && numeric <= 255) - return (int)numeric; - if (strlen(name) == 4 && SDL_strncasecmp(name, "Key", 3) == 0 && - name[3] >= 'A' && name[3] <= 'Z') return name[3]; - if (strlen(name) == 4 && SDL_strncasecmp(name, "Num", 3) == 0 && - name[3] >= '0' && name[3] <= '9') return name[3]; - if ((name[0] == 'F' || name[0] == 'f') && strlen(name) <= 3) { - int value = atoi(name + 1); - if (value >= 1 && value <= 12) return 111 + value; - } - static const struct { const char *name; int code; } map[] = { - {"Backspace", 8}, {"BackSpace", 8}, {"Tab", 9}, {"Return", 13}, - {"Pause", 19}, {"CapsLock", 20}, {"Escape", 27}, {"Space", 32}, - {"PageUp", 33}, {"PageDown", 34}, {"End", 35}, {"Home", 36}, - {"LeftArrow", 37}, {"UpArrow", 38}, {"RightArrow", 39}, - {"DownArrow", 40}, {"PrintScreen", 44}, {"Insert", 45}, - {"Delete", 46}, {"Meta", 91}, {"MetaLeft", 91}, {"MetaRight", 92}, - {"Apps", 93}, {"Kp0", 96}, {"Kp1", 97}, {"Kp2", 98}, - {"Kp3", 99}, {"Kp4", 100}, {"Kp5", 101}, {"Kp6", 102}, - {"Kp7", 103}, {"Kp8", 104}, {"Kp9", 105}, - {"KpMultiply", 106}, {"KpPlus", 107}, {"KpMinus", 109}, - {"KpDecimal", 110}, {"KpDivide", 111}, {"NumLock", 144}, - {"ScrollLock", 145}, {"Semicolon", 186}, {"SemiColon", 186}, - {"Equal", 187}, {"Comma", 188}, {"Minus", 189}, - {"Period", 190}, {"Dot", 190}, {"Slash", 191}, - {"BackQuote", 192}, {"BracketLeft", 219}, {"LeftBracket", 219}, - {"Backslash", 220}, {"BracketRight", 221}, {"RightBracket", 221}, - {"Quote", 222}, {"Shift", 16}, {"ShiftLeft", 16}, - {"ShiftRight", 16}, {"Control", 17}, {"ControlLeft", 17}, - {"ControlRight", 17}, {"Alt", 18}, {"AltGr", 18} - }; - for (size_t i = 0; i < sizeof(map) / sizeof(map[0]); ++i) - if (SDL_strcasecmp(name, map[i].name) == 0) return map[i].code; - static const struct { const char *name; int code; } gamepad[] = { - {"South", 0}, {"East", 1}, {"West", 2}, {"North", 3}, - {"LeftTrigger", 4}, {"RightTrigger", 5}, {"LeftTrigger2", 6}, - {"RightTrigger2", 7}, {"LeftThumb", 8}, {"RightThumb", 9}, - {"DPadLeft", 10}, {"DPadRight", 11}, {"DPadUp", 12}, - {"DPadDown", 13}, {"Start", 14}, {"Select", 15} - }; - for (size_t i = 0; i < sizeof(gamepad) / sizeof(gamepad[0]); ++i) - if (SDL_strcasecmp(name, gamepad[i].name) == 0) return gamepad[i].code; - return -1; -} - -BongoCatPathVisit bongo_cat_tauri_collect_keys(void *userdata, - const char *dirname, const char *name) { - TauriKeyFiles *files = userdata; - if (files->count >= TAURI_KEY_CAP || !suffix(name, ".png")) - return BONGO_CAT_PATH_CONTINUE; - int code = key_code(name); - if (code < 0) return BONGO_CAT_PATH_CONTINUE; - TauriKeyFile *item = &files->values[files->count]; - if (!bongo_cat_path_join(item->path, sizeof(item->path), dirname, name)) - return BONGO_CAT_PATH_FAILURE; - if (!bongo_cat_path_is_file(item->path)) return BONGO_CAT_PATH_CONTINUE; - snprintf(item->name, sizeof(item->name), "%s", name); - item->code = code; - files->count++; - return BONGO_CAT_PATH_CONTINUE; -} - -int bongo_cat_tauri_compare_keys(const void *left, const void *right) { - const TauriKeyFile *a = left, *b = right; - return SDL_strcasecmp(a->name, b->name); -} diff --git a/src/runtime/model/import/mver/model_import_mver.h b/src/runtime/model/import/mver/model_import_mver.h new file mode 100644 index 00000000..58ed4157 --- /dev/null +++ b/src/runtime/model/import/mver/model_import_mver.h @@ -0,0 +1,20 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_MVER_H +#define BONGO_CAT_MODEL_IMPORT_MVER_H + +#include "../model_import.h" + +/* Canonical package discovery and runtime-adapter generation. */ +bool bongo_cat_import_mver_config_path(const char *root, + char *path, size_t capacity); +int bongo_cat_import_mver_discover(const char *source, + BongoCatImportDiscovery *discovery, BongoCatError *error); +int bongo_cat_import_mver_discover_exact(const char *source, + BongoCatImportDiscovery *discovery, BongoCatError *error); +int bongo_cat_import_mver_patch_discover(const char *source, + BongoCatImportDiscovery *discovery, BongoCatError *error); +int bongo_cat_import_mver_patch_discover_exact(const char *source, + BongoCatImportDiscovery *discovery, BongoCatError *error); +bool bongo_cat_import_mver_assets(const BongoCatImportCandidate *candidate, + const char *target, BongoCatError *error); + +#endif diff --git a/src/runtime/model/import/mver/model_import_mver.c b/src/runtime/model/import/mver/model_import_mver_assets.c similarity index 99% rename from src/runtime/model/import/mver/model_import_mver.c rename to src/runtime/model/import/mver/model_import_mver_assets.c index 4e51a0c4..b05fd47c 100644 --- a/src/runtime/model/import/mver/model_import_mver.c +++ b/src/runtime/model/import/mver/model_import_mver_assets.c @@ -1,4 +1,3 @@ -#include "model_import.h" #include "model_import_mver_internal.h" #include "runtime.h" #include "bongo_cat/file.h" diff --git a/src/runtime/model/import/mver/model_import_mver_copy.h b/src/runtime/model/import/mver/model_import_mver_copy.h index 53f64629..f8a50ef0 100644 --- a/src/runtime/model/import/mver/model_import_mver_copy.h +++ b/src/runtime/model/import/mver/model_import_mver_copy.h @@ -1,7 +1,7 @@ #ifndef BONGO_CAT_MODEL_IMPORT_MVER_COPY_H #define BONGO_CAT_MODEL_IMPORT_MVER_COPY_H -#include "model_import.h" +#include "model_import_mver.h" bool bongo_cat_import_mver_copy_package( const BongoCatImportCandidate *candidate, const char *target, diff --git a/src/runtime/model/import/model_import_format.c b/src/runtime/model/import/mver/model_import_mver_discovery.c similarity index 93% rename from src/runtime/model/import/model_import_format.c rename to src/runtime/model/import/mver/model_import_mver_discovery.c index 0d87c257..aa9bd261 100644 --- a/src/runtime/model/import/model_import_format.c +++ b/src/runtime/model/import/mver/model_import_mver_discovery.c @@ -1,4 +1,7 @@ #include "model_import.h" +#include "../model_import_manifest.h" +#include "model_import_mver.h" +#include "model_import_path.h" #include "runtime.h" #include "bongo_cat/file.h" #include "bongo_cat/path.h" @@ -31,16 +34,6 @@ static bool mver_shape(const char *image_root) { return false; } -static bool parent_path(const char *path, char *parent, size_t capacity) { - size_t length = path ? strlen(path) : 0; - while (length && (path[length - 1] == '/' || path[length - 1] == '\\')) length--; - while (length && path[length - 1] != '/' && path[length - 1] != '\\') length--; - while (length > 1 && (path[length - 1] == '/' || path[length - 1] == '\\')) length--; - if (!length || length >= capacity) return false; - memcpy(parent, path, length); parent[length] = '\0'; - return true; -} - bool bongo_cat_import_mver_config_path(const char *root, char *path, size_t capacity) { static const char *names[] = {BONGO_CAT_SKIN_CONFIG_FILE, "config.json"}; @@ -63,7 +56,8 @@ static bool find_package(const char *source, char *package, size_t capacity, for (int depth = 0; depth < 4; ++depth) { if (package_at(package, config, image_root)) return true; char parent[BONGO_CAT_PATH_CAP]; - if (!parent_path(package, parent, sizeof(parent))) break; + if (!bongo_cat_import_parent_path(package, parent, sizeof(parent))) + break; snprintf(package, capacity, "%s", parent); } return false; diff --git a/src/runtime/model/import/mver/model_import_mver_internal.h b/src/runtime/model/import/mver/model_import_mver_internal.h index c10b7764..547f70c8 100644 --- a/src/runtime/model/import/mver/model_import_mver_internal.h +++ b/src/runtime/model/import/mver/model_import_mver_internal.h @@ -1,7 +1,7 @@ #ifndef BONGO_CAT_MODEL_IMPORT_MVER_INTERNAL_H #define BONGO_CAT_MODEL_IMPORT_MVER_INTERNAL_H -#include "model_import.h" +#include "model_import_mver.h" typedef struct BongoCatMverKeyNames { const char *items[2]; diff --git a/src/runtime/model/import/mver/model_import_mver_patch.c b/src/runtime/model/import/mver/model_import_mver_patch.c index cfda9bef..9e1c54cd 100644 --- a/src/runtime/model/import/mver/model_import_mver_patch.c +++ b/src/runtime/model/import/mver/model_import_mver_patch.c @@ -1,4 +1,6 @@ #include "model_import.h" +#include "model_import_mver.h" +#include "../model_import_path.h" #include "runtime.h" #include "bongo_cat/path.h" @@ -48,16 +50,6 @@ static BongoCatPathVisit find_image_root(void *userdata, return ok ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; } -static bool parent_path(const char *path, char *parent, size_t capacity) { - size_t length = path ? strlen(path) : 0; - while (length && (path[length - 1] == '/' || path[length - 1] == '\\')) length--; - while (length && path[length - 1] != '/' && path[length - 1] != '\\') length--; - while (length > 1 && (path[length - 1] == '/' || path[length - 1] == '\\')) length--; - if (!length || length >= capacity) return false; - memcpy(parent, path, length); parent[length] = '\0'; - return true; -} - static bool full_package(const char *path) { char config[BONGO_CAT_PATH_CAP], image[BONGO_CAT_PATH_CAP]; return bongo_cat_import_mver_config_path(path, config, sizeof(config)) && @@ -73,7 +65,8 @@ static int find_base(const char *source, const char *model_name, snprintf(current, sizeof(current), "%s", source); for (int depth = 0; depth < 6; ++depth) { char parent[BONGO_CAT_PATH_CAP], candidate[BONGO_CAT_PATH_CAP]; - if (!parent_path(current, parent, sizeof(parent))) break; + if (!bongo_cat_import_parent_path(current, parent, sizeof(parent))) + break; if (bongo_cat_path_join(candidate, sizeof(candidate), parent, model_name) && strcmp(candidate, source) != 0 && full_package(candidate)) { if (!matches) snprintf(base, capacity, "%s", candidate); @@ -87,7 +80,8 @@ static int find_base(const char *source, const char *model_name, static int finish_patch(const char *source, const char *image_root, BongoCatImportDiscovery *discovery, BongoCatError *error) { char model_root[BONGO_CAT_PATH_CAP], base[BONGO_CAT_PATH_CAP]; - if (!parent_path(image_root, model_root, sizeof(model_root))) return -1; + if (!bongo_cat_import_parent_path(image_root, model_root, + sizeof(model_root))) return -1; int bases = find_base(source, bongo_cat_path_name(model_root), base, sizeof(base)); if (bases != 1) { bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, @@ -117,7 +111,8 @@ int bongo_cat_import_mver_patch_discover_exact(const char *source, BongoCatImportDiscovery *discovery, BongoCatError *error) { char root[BONGO_CAT_PATH_CAP], image[BONGO_CAT_PATH_CAP]; if (strcmp(bongo_cat_path_name(source), "img") == 0 && patch_shape(source)) { - if (!parent_path(source, root, sizeof(root))) return 0; + if (!bongo_cat_import_parent_path(source, root, sizeof(root))) + return 0; return finish_patch(root, source, discovery, error); } if (!bongo_cat_path_join(image, sizeof(image), source, "img") || diff --git a/src/runtime/model/import/mver/model_import_mver_policy.c b/src/runtime/model/import/mver/model_import_mver_policy.c index 47583f18..bfc711e5 100644 --- a/src/runtime/model/import/mver/model_import_mver_policy.c +++ b/src/runtime/model/import/mver/model_import_mver_policy.c @@ -87,6 +87,7 @@ static bool profile_matches(const StockLive2DProfile *profile, bool bongo_cat_import_mver_stock_model( const BongoCatImportCandidate *candidate, BongoCatImportDigestCache *cache) { + if (!candidate) return false; /* Mver skins commonly retain these runtime models while replacing only mode input images. Match the model's rendered core independently from those surrounding images so the bundled model cannot become a variant. */ diff --git a/src/runtime/model/import/mver/model_import_mver_policy.h b/src/runtime/model/import/mver/model_import_mver_policy.h index a481ca09..8a0e5de4 100644 --- a/src/runtime/model/import/mver/model_import_mver_policy.h +++ b/src/runtime/model/import/mver/model_import_mver_policy.h @@ -1,7 +1,7 @@ #ifndef BONGO_CAT_MODEL_IMPORT_MVER_POLICY_H #define BONGO_CAT_MODEL_IMPORT_MVER_POLICY_H -#include "model_import.h" +#include "model_import_mver.h" bool bongo_cat_import_mver_stock_model( const BongoCatImportCandidate *candidate, diff --git a/src/runtime/model/import/nearby/model_import_nearby.c b/src/runtime/model/import/nearby/model_import_nearby.c index 375f770b..d9de4f72 100644 --- a/src/runtime/model/import/nearby/model_import_nearby.c +++ b/src/runtime/model/import/nearby/model_import_nearby.c @@ -1,6 +1,9 @@ #include "model_import.h" #include "model_import_mver_policy.h" +#include "model_import_mver.h" #include "model_import_nearby_internal.h" +#include "../model_import_path.h" +#include "../tauri/model_import_tauri.h" #include "runtime.h" #include "bongo_cat/path.h" #include "bongo_cat/sha256.h" @@ -41,20 +44,6 @@ void bongo_cat_import_nearby_shutdown(void) { SDL_DestroyMutex(mutex); } -static bool parent_path(const char *path, char *parent, size_t capacity) { - size_t length = path ? strlen(path) : 0; - while (length && (path[length - 1] == '/' || path[length - 1] == '\\')) - length--; - while (length && path[length - 1] != '/' && path[length - 1] != '\\') - length--; - while (length > 1 && (path[length - 1] == '/' || - path[length - 1] == '\\')) length--; - if (!length || length >= capacity) return false; - memcpy(parent, path, length); - parent[length] = '\0'; - return true; -} - static bool existing_identity(const BongoCatModelCatalog *models, const char *identity, BongoCatModelMode mode, const BongoCatModelEntry *ignored) { @@ -88,7 +77,7 @@ static void candidate_name(const char *source, if (value && candidate->format == BONGO_CAT_IMPORT_MVER_PATCH && candidate->package_root[0] && !strcmp(value, bongo_cat_path_name(candidate->package_root)) && - parent_path(source, parent, sizeof(parent))) + bongo_cat_import_parent_path(source, parent, sizeof(parent))) value = bongo_cat_path_name(parent); snprintf(name, capacity, "%s", value && value[0] ? value : "Nearby model"); } @@ -256,7 +245,8 @@ BongoCatResult bongo_cat_import_nearby(BongoCatApp *app, BongoCatResult result = bongo_cat_import_nearby_root(app, root, error); if (result != BONGO_CAT_OK || app->models.count != before) return result; char parent[BONGO_CAT_PATH_CAP]; - if (!parent_path(root, parent, sizeof(parent)) || !strcmp(parent, root)) + if (!bongo_cat_import_parent_path(root, parent, sizeof(parent)) || + !strcmp(parent, root)) return BONGO_CAT_OK; if (error) *error = (BongoCatError){0}; return bongo_cat_import_nearby_root(app, parent, error); diff --git a/src/runtime/model/import/tauri/model_import_tauri.h b/src/runtime/model/import/tauri/model_import_tauri.h new file mode 100644 index 00000000..838adbaa --- /dev/null +++ b/src/runtime/model/import/tauri/model_import_tauri.h @@ -0,0 +1,15 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_TAURI_H +#define BONGO_CAT_MODEL_IMPORT_TAURI_H + +#include "../model_import.h" + +/* Source-format adapter. Conversion output must be a canonical Mver package. */ +int bongo_cat_import_tauri_discover_exact(const char *source, + BongoCatImportDiscovery *discovery, BongoCatError *error); +int bongo_cat_import_tauri_discover_recursive(const char *source, + BongoCatImportDiscovery *discovery, BongoCatError *error); +bool bongo_cat_import_tauri_convert_to_mver( + const BongoCatImportCandidate *candidate, const char *target, + BongoCatImportCandidate *installed, BongoCatError *error); + +#endif diff --git a/src/runtime/model/import/model_import_discovery.c b/src/runtime/model/import/tauri/model_import_tauri_candidate.c similarity index 59% rename from src/runtime/model/import/model_import_discovery.c rename to src/runtime/model/import/tauri/model_import_tauri_candidate.c index dc44bd1c..a6fbb1ea 100644 --- a/src/runtime/model/import/model_import_discovery.c +++ b/src/runtime/model/import/tauri/model_import_tauri_candidate.c @@ -1,106 +1,8 @@ -#include "model_import.h" -#include "runtime.h" -#include "bongo_cat/file.h" -#include "bongo_cat/image.h" -#include "bongo_cat/path.h" +#include "model_import_tauri_internal.h" +#include #include -#include #include -#include - -static bool safe_reference(const char *value) { - if (!value || !value[0] || value[0] == '/' || value[0] == '\\' || - strchr(value, ':')) return false; - const char *part = value; - while (*part) { - while (*part == '/' || *part == '\\') part++; - if (part[0] == '.' && part[1] == '.' && - (!part[2] || part[2] == '/' || part[2] == '\\')) return false; - part = strpbrk(part, "/\\"); - if (!part) break; - } - return true; -} -static bool referenced_file(const char *root, const char *relative) { - char path[BONGO_CAT_PATH_CAP]; - return safe_reference(relative) && - bongo_cat_path_join(path, sizeof(path), root, relative) && bongo_cat_path_is_file(path); -} - -static bool referenced_texture(const char *root, const char *relative) { - char path[BONGO_CAT_PATH_CAP]; - if (!safe_reference(relative) || - !bongo_cat_path_join(path, sizeof(path), root, relative)) return false; - return bongo_cat_image_info(path, NULL, NULL); -} - -static bool optional_reference(const char *root, yyjson_val *refs, - const char *name) { - yyjson_val *value = yyjson_obj_get(refs, name); - if (!value) return true; - const char *relative = yyjson_get_str(value); - return relative && referenced_file(root, relative); -} - -static bool behavior_references(const char *root, yyjson_val *refs) { - yyjson_val *expressions = yyjson_obj_get(refs, "Expressions"); - if (expressions && !yyjson_is_arr(expressions)) return false; - size_t index, count; yyjson_val *item; - yyjson_arr_foreach(expressions, index, count, item) - if (!referenced_file(root, yyjson_get_str(yyjson_obj_get(item, "File")))) - return false; - yyjson_val *motions = yyjson_obj_get(refs, "Motions"); - if (motions && !yyjson_is_obj(motions)) return false; - size_t group_index, group_count; yyjson_val *key, *group; - yyjson_obj_foreach(motions, group_index, group_count, key, group) { - if (!yyjson_is_arr(group)) return false; - yyjson_arr_foreach(group, index, count, item) { - if (!referenced_file(root, yyjson_get_str(yyjson_obj_get(item, "File")))) - return false; - const char *sound = yyjson_get_str(yyjson_obj_get(item, "Sound")); - if (sound && !safe_reference(sound)) return false; - } - } - return true; -} -bool bongo_cat_import_manifest_valid(const char *root, const char *setting, - BongoCatError *error) { - char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), root, setting)) return false; - FILE *file = bongo_cat_file_open(path, "rb"); - yyjson_doc *document = file ? yyjson_read_fp(file, 0, NULL, NULL) : NULL; - if (file) fclose(file); - yyjson_val *manifest = document ? yyjson_doc_get_root(document) : NULL; - yyjson_val *refs = yyjson_is_obj(manifest) - ? yyjson_obj_get(manifest, "FileReferences") : NULL; - const char *moc = yyjson_get_str(yyjson_obj_get(refs, "Moc")); - yyjson_val *textures = yyjson_obj_get(refs, "Textures"); - bool valid = yyjson_get_int(yyjson_obj_get(manifest, "Version")) == 3 && - yyjson_is_obj(refs) && referenced_file(root, moc) && yyjson_is_arr(textures) && - yyjson_arr_size(textures) > 0; - size_t index, maximum; yyjson_val *texture; - yyjson_arr_foreach(textures, index, maximum, texture) - valid = valid && referenced_texture(root, yyjson_get_str(texture)); - valid = valid && optional_reference(root, refs, "Physics") && - optional_reference(root, refs, "Pose") && - optional_reference(root, refs, "DisplayInfo") && behavior_references(root, refs); - yyjson_doc_free(document); - if (!valid && error) bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Model manifest or referenced assets are invalid: %s", path); - return valid; -} - -static bool path_parent(const char *path, char *parent, size_t capacity) { - size_t length = path ? strlen(path) : 0; - while (length && (path[length - 1] == '/' || path[length - 1] == '\\')) length--; - while (length && path[length - 1] != '/' && path[length - 1] != '\\') length--; - while (length > 1 && (path[length - 1] == '/' || path[length - 1] == '\\')) length--; - if (!length || length >= capacity) return false; - memcpy(parent, path, length); - parent[length] = '\0'; - return true; -} static bool ascii_contains_ci(const char *value, const char *needle) { if (!value || !needle || !needle[0]) return false; @@ -159,14 +61,18 @@ static bool has_preview_assets(const char *directory) { "mousebg.png", "tabletbg.png"}; for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) { char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), directory, names[i])) continue; - if (bongo_cat_path_is_file(path) || bongo_cat_path_is_dir(path)) return true; + if (!bongo_cat_path_join(path, sizeof(path), directory, names[i])) + continue; + if (bongo_cat_path_is_file(path) || bongo_cat_path_is_dir(path)) + return true; } return false; } static bool has_cover_asset(const char *directory) { - const char *names[] = {"resources/cover.png", "cover.png", "cat.png", "bg.png"}; + const char *names[] = { + "resources/cover.png", "cover.png", "cat.png", "bg.png" + }; for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) { char path[BONGO_CAT_PATH_CAP]; if (bongo_cat_path_join(path, sizeof(path), directory, names[i]) && @@ -249,19 +155,24 @@ bool bongo_cat_import_tauri_add_candidate(BongoCatImportDiscovery *discovery, if (discovery->count >= BONGO_CAT_IMPORT_CANDIDATE_CAP) return false; for (size_t i = 0; i < discovery->count; ++i) if (strcmp(discovery->candidates[i].directory, directory) == 0) { - if (strcmp(discovery->candidates[i].setting, setting) == 0) return true; + if (strcmp(discovery->candidates[i].setting, setting) == 0) + return true; discovery->ambiguous = true; return false; } - BongoCatImportCandidate *candidate = &discovery->candidates[discovery->count++]; - snprintf(candidate->directory, sizeof(candidate->directory), "%s", directory); + BongoCatImportCandidate *candidate = + &discovery->candidates[discovery->count++]; + snprintf(candidate->directory, sizeof(candidate->directory), "%s", + directory); snprintf(candidate->setting, sizeof(candidate->setting), "%s", setting); snprintf(candidate->assets, sizeof(candidate->assets), "%s", directory); - snprintf(candidate->package_root, sizeof(candidate->package_root), "%s", directory); + snprintf(candidate->package_root, sizeof(candidate->package_root), "%s", + directory); candidate->format = BONGO_CAT_IMPORT_TAURI; char parent[BONGO_CAT_PATH_CAP]; if ((!has_cover_asset(directory) || !has_background_asset(directory)) && - path_parent(directory, parent, sizeof(parent)) && has_preview_assets(parent)) + bongo_cat_import_parent_path(directory, parent, sizeof(parent)) && + has_preview_assets(parent)) snprintf(candidate->assets, sizeof(candidate->assets), "%s", parent); if (!tauri_resource_mode(candidate->directory, candidate->assets, &candidate->mode, &candidate->gamepad_buttons)) { diff --git a/src/runtime/model/import/tauri/model_import_tauri_config.c b/src/runtime/model/import/tauri/model_import_tauri_config.c new file mode 100644 index 00000000..f0d30efe --- /dev/null +++ b/src/runtime/model/import/tauri/model_import_tauri_config.c @@ -0,0 +1,70 @@ +#include "model_import_tauri_internal.h" + +#include "bongo_cat/json.h" + +#include + +static bool add_matrix(yyjson_mut_doc *document, yyjson_mut_val *mode, + const char *name, const TauriKeyFiles *files) { + yyjson_mut_val *matrix = yyjson_mut_obj_add_arr(document, mode, name); + if (!matrix) return false; + for (size_t i = 0; i < files->count; ++i) { + yyjson_mut_val *row = yyjson_mut_arr_add_arr(document, matrix); + if (!row || !yyjson_mut_arr_add_int(document, row, + files->values[i].code)) return false; + } + return true; +} + +bool bongo_cat_tauri_write_config(const char *path, BongoCatModelMode mode, + const TauriKeyFiles *left, const TauriKeyFiles *right, + const TauriMverCalibration *calibration, BongoCatError *error) { + if (!path || !left || !right || !calibration) return false; + yyjson_mut_doc *document = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root = document ? yyjson_mut_obj(document) : NULL; + if (document) yyjson_mut_doc_set_root(document, root); + yyjson_mut_val *decoration = root ? yyjson_mut_obj_add_obj(document, root, + "decoration") : NULL; + yyjson_mut_val *mode_object = root ? yyjson_mut_obj_add_obj(document, root, + bongo_cat_mode_name(mode)) : NULL; + yyjson_mut_val *window_size = decoration ? yyjson_mut_obj_add_arr(document, + decoration, "window_size") : NULL; + yyjson_mut_val *offset = decoration ? yyjson_mut_obj_add_arr(document, + decoration, "l2d_offset") : NULL; + bool ok = root && decoration && mode_object && window_size && offset && + yyjson_mut_arr_add_int(document, window_size, + calibration->window_width) && + yyjson_mut_arr_add_int(document, window_size, + calibration->window_height) && + yyjson_mut_obj_add_real(document, decoration, "l2d_correct", + calibration->l2d_correct) && + yyjson_mut_arr_add_real(document, offset, calibration->l2d_offset_x) && + yyjson_mut_arr_add_real(document, offset, calibration->l2d_offset_y) && + yyjson_mut_obj_add_bool(document, decoration, "l2d_horizontal_flip", + calibration->mirror) && + yyjson_mut_obj_add_int(document, root, "mode", + mode == BONGO_CAT_MODE_STANDARD ? 1 : + mode == BONGO_CAT_MODE_KEYBOARD ? 2 : 3) && + yyjson_mut_obj_add_bool(document, mode_object, "l2d", true); + if (ok && mode == BONGO_CAT_MODE_STANDARD) + ok = add_matrix(document, mode_object, "hand", left) && + yyjson_mut_obj_add_bool(document, mode_object, "mouse", false) && + yyjson_mut_obj_add_arr(document, mode_object, "keyboard"); + if (ok && mode != BONGO_CAT_MODE_STANDARD) + ok = add_matrix(document, mode_object, "lefthand", left) && + add_matrix(document, mode_object, "righthand", right) && + yyjson_mut_obj_add_arr(document, mode_object, "keyboard"); + if (ok && mode == BONGO_CAT_MODE_GAMEPAD) + ok = yyjson_mut_obj_add_int(document, mode_object, "input_mode", 1); + if (ok) ok = yyjson_mut_obj_add_arr(document, mode_object, "face") && + yyjson_mut_obj_add_arr(document, mode_object, "sounds") && + yyjson_mut_obj_add_arr(document, mode_object, "l2d_expression") && + yyjson_mut_obj_add_arr(document, mode_object, "l2d_motion") && + yyjson_mut_obj_add_arr(document, mode_object, "l2d_motion_lockhand"); + if (ok) ok = bongo_cat_json_write_file(path, document, + YYJSON_WRITE_PRETTY, NULL); + yyjson_mut_doc_free(document); + if (!ok && error) bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot write normalized Mver configuration: %s", path); + return ok; +} diff --git a/src/runtime/model/import/tauri/model_import_tauri_convert.c b/src/runtime/model/import/tauri/model_import_tauri_convert.c new file mode 100644 index 00000000..ee70e526 --- /dev/null +++ b/src/runtime/model/import/tauri/model_import_tauri_convert.c @@ -0,0 +1,57 @@ +#include "model_import_tauri_internal.h" +#include "bongo_cat/path.h" + +#include + +static bool create_target_tree(const char *target, BongoCatModelMode mode, + char *mode_root, size_t mode_capacity, char *model_root, + size_t model_capacity) { + char image_root[BONGO_CAT_PATH_CAP]; + return bongo_cat_path_create_directory(target) && + bongo_cat_path_join(image_root, sizeof(image_root), target, "img") && + bongo_cat_path_join(mode_root, mode_capacity, image_root, + bongo_cat_mode_name(mode)) && + bongo_cat_path_join(model_root, model_capacity, mode_root, + "cat_model") && bongo_cat_path_create_directory(mode_root) && + bongo_cat_path_create_directory(model_root); +} + +bool bongo_cat_import_tauri_convert_to_mver( + const BongoCatImportCandidate *candidate, const char *target, + BongoCatImportCandidate *installed, BongoCatError *error) { + if (!candidate || !target || !installed || + candidate->format != BONGO_CAT_IMPORT_TAURI) return false; + char mode_root[BONGO_CAT_PATH_CAP], model_root[BONGO_CAT_PATH_CAP]; + if (!create_target_tree(target, candidate->mode, mode_root, + sizeof(mode_root), model_root, sizeof(model_root)) || + !bongo_cat_tauri_copy_model_tree(candidate->directory, model_root, 0, + error)) return false; + char resource_directory[BONGO_CAT_PATH_CAP]; + TauriKeyFiles left, right; + if (!bongo_cat_tauri_resource_root(candidate, resource_directory, + sizeof(resource_directory)) || + !bongo_cat_tauri_copy_input_images(candidate, resource_directory, + mode_root, &left, &right, error)) + return false; + TauriMverCalibration calibration; + char config[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_tauri_read_calibration(candidate, &calibration) || + !bongo_cat_path_join(config, sizeof(config), target, "config.json") || + !bongo_cat_tauri_write_config(config, candidate->mode, &left, &right, + &calibration, error) || + !bongo_cat_tauri_copy_preview(candidate, mode_root, error) || + !bongo_cat_tauri_copy_runtime_images(candidate, mode_root, error)) + return false; + *installed = *candidate; + snprintf(installed->directory, sizeof(installed->directory), "%s", + model_root); + snprintf(installed->assets, sizeof(installed->assets), "%s", mode_root); + snprintf(installed->package_root, sizeof(installed->package_root), "%s", + target); + snprintf(installed->config, sizeof(installed->config), "%s", config); + installed->overrides[0] = '\0'; + installed->patch_root[0] = '\0'; + installed->format = BONGO_CAT_IMPORT_MVER; + installed->gamepad_buttons = candidate->mode == BONGO_CAT_MODE_GAMEPAD; + return true; +} diff --git a/src/runtime/model/import/tauri/model_import_tauri_discovery.c b/src/runtime/model/import/tauri/model_import_tauri_discovery.c new file mode 100644 index 00000000..4413abf2 --- /dev/null +++ b/src/runtime/model/import/tauri/model_import_tauri_discovery.c @@ -0,0 +1,111 @@ +#include "model_import_tauri_internal.h" +#include "../model_import_manifest.h" + +#include +#include +#include + +static bool mver_input_assets(const char *directory) { + char path[BONGO_CAT_PATH_CAP], group[BONGO_CAT_PATH_CAP]; + static const char *const groups[] = {"hand", "lefthand", "righthand"}; + for (size_t i = 0; i < sizeof(groups) / sizeof(groups[0]); ++i) + if (bongo_cat_path_join(group, sizeof(group), directory, groups[i]) && + bongo_cat_path_join(path, sizeof(path), group, "0.png") && + bongo_cat_path_is_file(path)) return true; + return false; +} + +static bool mver_mode_directory(const char *directory) { + const char *name = bongo_cat_path_name(directory); + if (!name || (SDL_strcasecmp(name, "standard") != 0 && + SDL_strcasecmp(name, "keyboard") != 0 && + SDL_strcasecmp(name, "gamepad") != 0)) return false; + char image_root[BONGO_CAT_PATH_CAP]; + return bongo_cat_import_parent_path(directory, image_root, + sizeof(image_root)) && + SDL_strcasecmp(bongo_cat_path_name(image_root), "img") == 0 && + mver_input_assets(directory); +} + +static bool inside_mver_model(const char *directory) { + char current[BONGO_CAT_PATH_CAP]; + int written = snprintf(current, sizeof(current), "%s", + directory ? directory : ""); + if (written < 0 || (size_t)written >= sizeof(current)) return false; + for (int depth = 0; depth < 4; ++depth) { + if (mver_mode_directory(current)) return true; + char parent[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_import_parent_path(current, parent, sizeof(parent))) + break; + snprintf(current, sizeof(current), "%s", parent); + } + return false; +} + +static BongoCatPathVisit discover_model(void *userdata, + const char *dirname, const char *name) { + BongoCatImportDiscovery *discovery = userdata; + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), dirname, name)) + return BONGO_CAT_PATH_FAILURE; + if (!bongo_cat_path_is_file(path) || + !bongo_cat_import_has_suffix(name, ".model3.json")) + return BONGO_CAT_PATH_CONTINUE; + if (inside_mver_model(dirname)) return BONGO_CAT_PATH_CONTINUE; + if (bongo_cat_import_manifest_valid(dirname, name, NULL) && + !bongo_cat_import_tauri_add_candidate(discovery, dirname, name)) + return BONGO_CAT_PATH_FAILURE; + return BONGO_CAT_PATH_CONTINUE; +} + +static BongoCatPathVisit discover_recursive_item(void *userdata, + const char *dirname, const char *name) { + BongoCatImportDiscovery *discovery = userdata; + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), dirname, name)) + return BONGO_CAT_PATH_FAILURE; + if (bongo_cat_path_is_file(path)) + return discover_model(userdata, dirname, name); + if (!bongo_cat_path_is_dir(path) || discovery->depth >= 8 || name[0] == '.') + return BONGO_CAT_PATH_CONTINUE; + discovery->depth++; + bool ok = bongo_cat_path_enumerate(path, discover_recursive_item, + discovery); + discovery->depth--; + return ok ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; +} + +int bongo_cat_import_tauri_discover_exact(const char *source, + BongoCatImportDiscovery *discovery, BongoCatError *error) { + if (!source || !discovery || !bongo_cat_path_is_dir(source)) return 0; + if (!discovery->source_name[0]) snprintf(discovery->source_name, + sizeof(discovery->source_name), "%s", bongo_cat_path_name(source)); + size_t before = discovery->count; + if (!bongo_cat_path_enumerate(source, discover_model, discovery)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + discovery->ambiguous + ? "A model directory contains multiple model3 manifests" + : "Cannot inspect nearby model directory"); + return -1; + } + for (size_t i = before; i < discovery->count; ++i) + snprintf(discovery->candidates[i].package_root, + sizeof(discovery->candidates[i].package_root), "%s", source); + return discovery->count > before ? 1 : 0; +} + +int bongo_cat_import_tauri_discover_recursive(const char *source, + BongoCatImportDiscovery *discovery, BongoCatError *error) { + if (!source || !discovery || !bongo_cat_path_is_dir(source)) return 0; + if (!discovery->source_name[0]) snprintf(discovery->source_name, + sizeof(discovery->source_name), "%s", bongo_cat_path_name(source)); + size_t before = discovery->count; + if (!bongo_cat_path_enumerate(source, discover_recursive_item, discovery)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + discovery->ambiguous + ? "A model directory contains multiple model3 manifests" + : "Cannot scan model directory or it contains too many models"); + return -1; + } + return discovery->count > before ? 1 : 0; +} diff --git a/src/runtime/model/import/tauri/model_import_tauri_images.c b/src/runtime/model/import/tauri/model_import_tauri_images.c new file mode 100644 index 00000000..3fbdd9bc --- /dev/null +++ b/src/runtime/model/import/tauri/model_import_tauri_images.c @@ -0,0 +1,110 @@ +#include "model_import_tauri_internal.h" + +#include "bongo_cat/file.h" +#include "bongo_cat/path.h" + +#include + +static bool write_placeholder(const char *path) { + static const unsigned char png[] = { + 0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a,0x00,0x00,0x00,0x0d, + 0x49,0x48,0x44,0x52,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x01, + 0x08,0x06,0x00,0x00,0x00,0x1f,0x15,0xc4,0x89,0x00,0x00,0x00, + 0x0d,0x49,0x44,0x41,0x54,0x08,0xd7,0x63,0xf8,0xcf,0xc0,0xf0, + 0x1f,0x00,0x05,0x00,0x01,0xff,0x89,0x99,0x3d,0x1d,0x00,0x00, + 0x00,0x00,0x49,0x45,0x4e,0x44,0xae,0x42,0x60,0x82 + }; + FILE *file = bongo_cat_file_open(path, "wb"); + if (!file) return false; + bool ok = fwrite(png, 1, sizeof(png), file) == sizeof(png); + if (fclose(file) != 0) ok = false; + return ok; +} + +bool bongo_cat_tauri_copy_image_or_placeholder(const char *source, + const char *target, BongoCatError *error) { + bool ok = source && bongo_cat_path_is_file(source) + ? bongo_cat_path_copy_file(source, target) : write_placeholder(target); + if (!ok && error) bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot create normalized Mver image: %s", target); + return ok; +} + +static bool copy_mver_image(const BongoCatImportCandidate *candidate, + const char *mode_root, const char *source_name, const char *target_name, + const char *fallback, BongoCatError *error) { + char source[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; + const char *selected = fallback; + if (source_name && bongo_cat_tauri_find_resource_file(candidate, + source_name, source, sizeof(source))) selected = source; + if (!bongo_cat_path_join(target, sizeof(target), mode_root, target_name)) + return false; + return bongo_cat_tauri_copy_image_or_placeholder(selected, target, error); +} + +bool bongo_cat_tauri_copy_runtime_images( + const BongoCatImportCandidate *candidate, const char *mode_root, + BongoCatError *error) { + char background[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(background, sizeof(background), mode_root, + candidate->mode == BONGO_CAT_MODE_STANDARD ? "mousebg.png" : + "bg.png")) return false; + /* Tauri omits these Mver-only layers. Preserve real files when supplied; + otherwise use transparent PNGs so the normalized package still loads. */ + if (candidate->mode == BONGO_CAT_MODE_STANDARD) { + static const char *const backgrounds[] = { + "tabletbg.png", "l2dmousebg.png", "l2dtabletbg.png" + }; + static const char *const layers[] = { + "arm.png", "up.png", "mouse.png", "mouse_left.png", + "mouse_right.png", "mouse_side.png", "tablet.png", + "tablet_left.png", "tablet_right.png" + }; + for (size_t i = 0; i < sizeof(backgrounds) / sizeof(backgrounds[0]); + ++i) + if (!copy_mver_image(candidate, mode_root, backgrounds[i], + backgrounds[i], background, error)) return false; + for (size_t i = 0; i < sizeof(layers) / sizeof(layers[0]); ++i) + if (!copy_mver_image(candidate, mode_root, layers[i], layers[i], + NULL, error)) return false; + return true; + } + static const char *const idle_hands[] = { + "lefthand/leftup.png", "righthand/rightup.png" + }; + for (size_t i = 0; i < sizeof(idle_hands) / sizeof(idle_hands[0]); ++i) + if (!copy_mver_image(candidate, mode_root, idle_hands[i], + idle_hands[i], NULL, error)) return false; + if (candidate->mode != BONGO_CAT_MODE_GAMEPAD) return true; + static const char *const gamepad[] = { + "arm_L.png", "arm_R.png", "left_stick.png", "left_stick_down.png", + "right_stick.png", "right_stick_down.png" + }; + for (size_t i = 0; i < sizeof(gamepad) / sizeof(gamepad[0]); ++i) + if (!copy_mver_image(candidate, mode_root, gamepad[i], gamepad[i], + NULL, error)) return false; + return true; +} + +bool bongo_cat_tauri_copy_preview(const BongoCatImportCandidate *candidate, + const char *mode_root, BongoCatError *error) { + char source[BONGO_CAT_PATH_CAP], cat[BONGO_CAT_PATH_CAP]; + char target[BONGO_CAT_PATH_CAP]; + bool have_cover = bongo_cat_tauri_find_resource_file(candidate, + "cover.png", source, sizeof(source)); + if (!bongo_cat_path_join(cat, sizeof(cat), mode_root, "cat.png") || + !bongo_cat_tauri_copy_image_or_placeholder( + have_cover ? source : NULL, cat, error)) return false; + bool have_background = bongo_cat_tauri_find_resource_file(candidate, + "background.png", source, sizeof(source)); + if (!have_background && have_cover) { + snprintf(source, sizeof(source), "%s", cat); + have_background = true; + } + const char *background_name = candidate->mode == BONGO_CAT_MODE_STANDARD + ? "mousebg.png" : "bg.png"; + if (!bongo_cat_path_join(target, sizeof(target), mode_root, + background_name)) return false; + return bongo_cat_tauri_copy_image_or_placeholder( + have_background ? source : NULL, target, error); +} diff --git a/src/runtime/model/import/tauri/model_import_tauri_internal.h b/src/runtime/model/import/tauri/model_import_tauri_internal.h new file mode 100644 index 00000000..343b5836 --- /dev/null +++ b/src/runtime/model/import/tauri/model_import_tauri_internal.h @@ -0,0 +1,69 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_TAURI_INTERNAL_H +#define BONGO_CAT_MODEL_IMPORT_TAURI_INTERNAL_H + +#include "model_import_tauri.h" +#include "../model_import_path.h" +#include "bongo_cat/path.h" + +#define TAURI_KEY_CAP 256 +#define BONGO_CAT_TAURI_SOURCE_FILE "bongocat.mver.json" + +typedef struct TauriMverCalibration { + double l2d_correct; + double l2d_offset_x; + double l2d_offset_y; + int window_width; + int window_height; + bool mirror; +} TauriMverCalibration; + +typedef struct TauriKeyFile { + char name[BONGO_CAT_ID_CAP]; + char path[BONGO_CAT_PATH_CAP]; + int code; +} TauriKeyFile; + +typedef struct TauriKeyFiles { + TauriKeyFile values[TAURI_KEY_CAP]; + size_t count; +} TauriKeyFiles; + +/* Candidate construction is private to Tauri discovery. */ +bool bongo_cat_import_tauri_add_candidate(BongoCatImportDiscovery *discovery, + const char *directory, const char *setting); + +/* Source package lookup. */ +bool bongo_cat_tauri_find_resource_file( + const BongoCatImportCandidate *candidate, const char *name, + char *path, size_t capacity); +bool bongo_cat_tauri_find_resource_directory( + const BongoCatImportCandidate *candidate, const char *name, + char *path, size_t capacity); +bool bongo_cat_tauri_resource_root(const BongoCatImportCandidate *candidate, + char *path, size_t capacity); +bool bongo_cat_tauri_find_package_file( + const BongoCatImportCandidate *candidate, const char *name, + char *path, size_t capacity); + +/* Mver normalization steps. */ +bool bongo_cat_tauri_copy_image_or_placeholder(const char *source, + const char *target, BongoCatError *error); +bool bongo_cat_tauri_copy_preview(const BongoCatImportCandidate *candidate, + const char *mode_root, BongoCatError *error); +bool bongo_cat_tauri_copy_runtime_images( + const BongoCatImportCandidate *candidate, const char *mode_root, + BongoCatError *error); +bool bongo_cat_tauri_copy_input_images( + const BongoCatImportCandidate *candidate, const char *resource_directory, + const char *mode_root, TauriKeyFiles *left, TauriKeyFiles *right, + BongoCatError *error); +bool bongo_cat_tauri_write_config(const char *path, BongoCatModelMode mode, + const TauriKeyFiles *left, const TauriKeyFiles *right, + const TauriMverCalibration *calibration, BongoCatError *error); +bool bongo_cat_tauri_copy_model_tree(const char *source, const char *target, + unsigned depth, BongoCatError *error); +bool bongo_cat_tauri_read_calibration( + const BongoCatImportCandidate *candidate, + TauriMverCalibration *calibration); + +#endif diff --git a/src/runtime/model/import/tauri/model_import_tauri_keys.c b/src/runtime/model/import/tauri/model_import_tauri_keys.c new file mode 100644 index 00000000..a93ddb50 --- /dev/null +++ b/src/runtime/model/import/tauri/model_import_tauri_keys.c @@ -0,0 +1,157 @@ +#include "model_import_tauri_internal.h" + +#include +#include +#include +#include + +static int key_code(const char *filename) { + char name[BONGO_CAT_ID_CAP]; + snprintf(name, sizeof(name), "%s", filename ? filename : ""); + char *dot = strrchr(name, '.'); + if (!dot || SDL_strcasecmp(dot, ".png") != 0) return -1; + *dot = '\0'; + char *end = NULL; + long numeric = strtol(name, &end, 10); + if (name[0] && end && !end[0] && numeric >= 0 && numeric <= 255) + return (int)numeric; + if (strlen(name) == 4 && SDL_strncasecmp(name, "Key", 3) == 0 && + name[3] >= 'A' && name[3] <= 'Z') return name[3]; + if (strlen(name) == 4 && SDL_strncasecmp(name, "Num", 3) == 0 && + name[3] >= '0' && name[3] <= '9') return name[3]; + if ((name[0] == 'F' || name[0] == 'f') && strlen(name) <= 3) { + int value = atoi(name + 1); + if (value >= 1 && value <= 12) return 111 + value; + } + static const struct { const char *name; int code; } map[] = { + {"Backspace", 8}, {"BackSpace", 8}, {"Tab", 9}, {"Return", 13}, + {"Pause", 19}, {"CapsLock", 20}, {"Escape", 27}, {"Space", 32}, + {"PageUp", 33}, {"PageDown", 34}, {"End", 35}, {"Home", 36}, + {"LeftArrow", 37}, {"UpArrow", 38}, {"RightArrow", 39}, + {"DownArrow", 40}, {"PrintScreen", 44}, {"Insert", 45}, + {"Delete", 46}, {"Meta", 91}, {"MetaLeft", 91}, {"MetaRight", 92}, + {"Apps", 93}, {"Kp0", 96}, {"Kp1", 97}, {"Kp2", 98}, + {"Kp3", 99}, {"Kp4", 100}, {"Kp5", 101}, {"Kp6", 102}, + {"Kp7", 103}, {"Kp8", 104}, {"Kp9", 105}, + {"KpMultiply", 106}, {"KpPlus", 107}, {"KpMinus", 109}, + {"KpDecimal", 110}, {"KpDivide", 111}, {"NumLock", 144}, + {"ScrollLock", 145}, {"Semicolon", 186}, {"SemiColon", 186}, + {"Equal", 187}, {"Comma", 188}, {"Minus", 189}, + {"Period", 190}, {"Dot", 190}, {"Slash", 191}, + {"BackQuote", 192}, {"BracketLeft", 219}, {"LeftBracket", 219}, + {"Backslash", 220}, {"BracketRight", 221}, {"RightBracket", 221}, + {"Quote", 222}, {"Shift", 16}, {"ShiftLeft", 16}, + {"ShiftRight", 16}, {"Control", 17}, {"ControlLeft", 17}, + {"ControlRight", 17}, {"Alt", 18}, {"AltGr", 18} + }; + for (size_t i = 0; i < sizeof(map) / sizeof(map[0]); ++i) + if (SDL_strcasecmp(name, map[i].name) == 0) return map[i].code; + static const struct { const char *name; int code; } gamepad[] = { + {"South", 0}, {"East", 1}, {"West", 2}, {"North", 3}, + {"LeftTrigger", 4}, {"RightTrigger", 5}, {"LeftTrigger2", 6}, + {"RightTrigger2", 7}, {"LeftThumb", 8}, {"RightThumb", 9}, + {"DPadLeft", 10}, {"DPadRight", 11}, {"DPadUp", 12}, + {"DPadDown", 13}, {"Start", 14}, {"Select", 15} + }; + for (size_t i = 0; i < sizeof(gamepad) / sizeof(gamepad[0]); ++i) + if (SDL_strcasecmp(name, gamepad[i].name) == 0) return gamepad[i].code; + return -1; +} + +static BongoCatPathVisit collect_keys(void *userdata, + const char *dirname, const char *name) { + TauriKeyFiles *files = userdata; + if (files->count >= TAURI_KEY_CAP || + !bongo_cat_import_has_suffix_ci(name, ".png")) + return BONGO_CAT_PATH_CONTINUE; + int code = key_code(name); + if (code < 0) return BONGO_CAT_PATH_CONTINUE; + TauriKeyFile *item = &files->values[files->count]; + if (!bongo_cat_path_join(item->path, sizeof(item->path), dirname, name)) + return BONGO_CAT_PATH_FAILURE; + if (!bongo_cat_path_is_file(item->path)) return BONGO_CAT_PATH_CONTINUE; + snprintf(item->name, sizeof(item->name), "%s", name); + item->code = code; + files->count++; + return BONGO_CAT_PATH_CONTINUE; +} + +static int compare_keys(const void *left, const void *right) { + const TauriKeyFile *a = left, *b = right; + return SDL_strcasecmp(a->name, b->name); +} + +static bool copy_keys(const BongoCatImportCandidate *candidate, + const char *resource_directory, const char *mode_root, + const char *source_group, const char *target_group, + TauriKeyFiles *files, BongoCatError *error) { + char source[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(source, sizeof(source), resource_directory, + source_group)) return false; + if (!bongo_cat_path_is_dir(source)) { + char resources[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(resources, sizeof(resources), + candidate->directory, "resources") || + !bongo_cat_path_join(source, sizeof(source), resources, + source_group) || !bongo_cat_path_is_dir(source)) return true; + } + if (!bongo_cat_path_enumerate(source, collect_keys, files)) return false; + qsort(files->values, files->count, sizeof(files->values[0]), compare_keys); + char destination[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(destination, sizeof(destination), mode_root, + target_group) || !bongo_cat_path_create_directory(destination)) + return false; + for (size_t i = 0; i < files->count; ++i) { + char filename[32], path[BONGO_CAT_PATH_CAP]; + snprintf(filename, sizeof(filename), "%zu.png", i); + if (!bongo_cat_path_join(path, sizeof(path), destination, filename) || + !bongo_cat_path_copy_file(files->values[i].path, path)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot copy Tauri input image: %s", files->values[i].path); + return false; + } + } + return true; +} + +static bool ensure_key(const BongoCatImportCandidate *candidate, + const char *mode_root, const char *target_group, int fallback_code, + TauriKeyFiles *files, BongoCatError *error) { + if (files->count) return true; + char source[BONGO_CAT_PATH_CAP]; + bool have_source = bongo_cat_tauri_find_resource_file(candidate, + "cover.png", source, sizeof(source)) || + bongo_cat_tauri_find_resource_file(candidate, "background.png", + source, sizeof(source)); + char group[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(group, sizeof(group), mode_root, target_group) || + !bongo_cat_path_create_directory(group) || + !bongo_cat_path_join(target, sizeof(target), group, "0.png") || + !bongo_cat_tauri_copy_image_or_placeholder( + have_source ? source : NULL, target, error)) return false; + files->values[0].code = fallback_code; + files->count = 1; + return true; +} + +bool bongo_cat_tauri_copy_input_images( + const BongoCatImportCandidate *candidate, const char *resource_directory, + const char *mode_root, TauriKeyFiles *left, TauriKeyFiles *right, + BongoCatError *error) { + *left = (TauriKeyFiles){0}; + *right = (TauriKeyFiles){0}; + if (candidate->mode == BONGO_CAT_MODE_STANDARD) { + return copy_keys(candidate, resource_directory, + mode_root, "left-keys", "hand", left, error) && + ensure_key(candidate, mode_root, "hand", 65, + left, error); + } + return copy_keys(candidate, resource_directory, mode_root, + "left-keys", "lefthand", left, error) && + copy_keys(candidate, resource_directory, mode_root, + "right-keys", "righthand", right, error) && + ensure_key(candidate, mode_root, "lefthand", + candidate->mode == BONGO_CAT_MODE_GAMEPAD ? 0 : 65, left, error) && + ensure_key(candidate, mode_root, "righthand", + candidate->mode == BONGO_CAT_MODE_GAMEPAD ? 1 : 65, right, error); +} diff --git a/src/runtime/model/import/tauri/model_import_tauri_metadata.c b/src/runtime/model/import/tauri/model_import_tauri_metadata.c new file mode 100644 index 00000000..1beb31d4 --- /dev/null +++ b/src/runtime/model/import/tauri/model_import_tauri_metadata.c @@ -0,0 +1,114 @@ +#include "model_import_tauri_internal.h" + +#include "bongo_cat/image.h" +#include "bongo_cat/json.h" + +#include +#include +#include + +static bool finite_number(yyjson_val *value) { + return yyjson_is_num(value) && isfinite(yyjson_get_num(value)); +} + +static bool read_offset(yyjson_val *value, double *x, double *y) { + if (!yyjson_is_arr(value) || yyjson_arr_size(value) < 2 || + !finite_number(yyjson_arr_get(value, 0)) || + !finite_number(yyjson_arr_get(value, 1))) return false; + double next_x = yyjson_get_num(yyjson_arr_get(value, 0)); + double next_y = yyjson_get_num(yyjson_arr_get(value, 1)); + if (next_x < -100.0 || next_x > 100.0 || next_y < -100.0 || + next_y > 100.0) return false; + *x = next_x; + *y = next_y; + return true; +} + +static bool read_window(yyjson_val *value, int *width, int *height) { + if (!yyjson_is_arr(value) || yyjson_arr_size(value) < 2 || + !yyjson_is_num(yyjson_arr_get(value, 0)) || + !yyjson_is_num(yyjson_arr_get(value, 1))) return false; + double next_width = yyjson_get_num(yyjson_arr_get(value, 0)); + double next_height = yyjson_get_num(yyjson_arr_get(value, 1)); + if (!isfinite(next_width) || !isfinite(next_height) || + next_width < 64.0 || next_width > 8192.0 || + next_height < 64.0 || next_height > 8192.0) + return false; + int integer_width = (int)next_width; + int integer_height = (int)next_height; + if ((double)integer_width != next_width || + (double)integer_height != next_height) return false; + *width = integer_width; + *height = integer_height; + return true; +} + +static void legacy_defaults(const BongoCatImportCandidate *candidate, + TauriMverCalibration *calibration) { + calibration->l2d_correct = 1.1; + calibration->l2d_offset_x = 0.0; + calibration->l2d_offset_y = 0.0; + calibration->window_width = 612; + calibration->window_height = 352; + calibration->mirror = false; + char path[BONGO_CAT_PATH_CAP]; + const char *names[] = {"background.png", "cover.png"}; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) { + if (!bongo_cat_tauri_find_resource_file(candidate, names[i], path, + sizeof(path))) continue; + int width = 0, height = 0; + if (bongo_cat_image_info(path, &width, &height) && width > 0 && + height > 0) { + calibration->window_width = width; + calibration->window_height = height; + break; + } + } + /* Converter packages before schemaVersion 1 had no Mver calibration. + Its square standard canvas matches the 0.1.6 standard profile. */ + if (candidate && candidate->mode == BONGO_CAT_MODE_STANDARD && + bongo_cat_tauri_find_resource_directory(candidate, "left-keys", path, + sizeof(path)) && + calibration->window_width == calibration->window_height && + calibration->window_width >= 512) { + calibration->l2d_correct = 2.0; + calibration->l2d_offset_y = -0.005; + } +} + +bool bongo_cat_tauri_read_calibration( + const BongoCatImportCandidate *candidate, + TauriMverCalibration *calibration) { + if (!candidate || !calibration) return false; + legacy_defaults(candidate, calibration); + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_tauri_find_package_file(candidate, + BONGO_CAT_TAURI_SOURCE_FILE, path, sizeof(path))) return true; + yyjson_doc *document = bongo_cat_json_read_file(path, 0, NULL); + yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; + yyjson_val *schema = yyjson_obj_get(root, "schemaVersion"); + const char *kind = yyjson_get_str(yyjson_obj_get(root, "kind")); + const char *mode = yyjson_get_str(yyjson_obj_get(root, "mode")); + yyjson_val *decoration = yyjson_obj_get(root, "decoration"); + bool valid = yyjson_is_int(schema) && yyjson_get_int(schema) == 1 && + kind && strcmp(kind, "bongo-cat-mver-source") == 0 && + (!mode || strcmp(mode, bongo_cat_mode_name(candidate->mode)) == 0) && + yyjson_is_obj(decoration); + if (valid) { + yyjson_val *scale = yyjson_obj_get(decoration, "l2d_correct"); + if (finite_number(scale)) { + double value = yyjson_get_num(scale); + if (value > 0.01 && value <= 100.0) + calibration->l2d_correct = value; + } + read_offset(yyjson_obj_get(decoration, "l2d_offset"), + &calibration->l2d_offset_x, &calibration->l2d_offset_y); + read_window(yyjson_obj_get(decoration, "window_size"), + &calibration->window_width, &calibration->window_height); + yyjson_val *mirror = yyjson_obj_get(decoration, + "l2d_horizontal_flip"); + if (yyjson_is_bool(mirror)) calibration->mirror = yyjson_get_bool(mirror); + } + yyjson_doc_free(document); + return true; +} diff --git a/src/runtime/model/import/tauri/model_import_tauri_resources.c b/src/runtime/model/import/tauri/model_import_tauri_resources.c new file mode 100644 index 00000000..16dbb4fe --- /dev/null +++ b/src/runtime/model/import/tauri/model_import_tauri_resources.c @@ -0,0 +1,64 @@ +#include "model_import_tauri_internal.h" + +#include "bongo_cat/path.h" + +#include + +bool bongo_cat_tauri_find_resource_file( + const BongoCatImportCandidate *candidate, const char *name, + char *path, size_t capacity) { + if (!candidate || !name || !path || !capacity) return false; + const char *roots[] = {candidate->assets, candidate->directory, + candidate->package_root}; + for (size_t i = 0; i < sizeof(roots) / sizeof(roots[0]); ++i) { + if (!roots[i][0]) continue; + char resources[BONGO_CAT_PATH_CAP]; + if (bongo_cat_path_join(resources, sizeof(resources), roots[i], + "resources") && bongo_cat_path_join(path, capacity, + resources, name) && bongo_cat_path_is_file(path)) return true; + if (bongo_cat_path_join(path, capacity, roots[i], name) && + bongo_cat_path_is_file(path)) return true; + } + return false; +} + +bool bongo_cat_tauri_find_resource_directory( + const BongoCatImportCandidate *candidate, const char *name, + char *path, size_t capacity) { + if (!candidate || !name || !path || !capacity) return false; + const char *roots[] = {candidate->assets, candidate->directory, + candidate->package_root}; + for (size_t i = 0; i < sizeof(roots) / sizeof(roots[0]); ++i) { + if (!roots[i][0]) continue; + char resources[BONGO_CAT_PATH_CAP]; + if (bongo_cat_path_join(resources, sizeof(resources), roots[i], + "resources") && bongo_cat_path_join(path, capacity, + resources, name) && bongo_cat_path_is_dir(path)) return true; + if (bongo_cat_path_join(path, capacity, roots[i], name) && + bongo_cat_path_is_dir(path)) return true; + } + return false; +} + +bool bongo_cat_tauri_resource_root(const BongoCatImportCandidate *candidate, + char *path, size_t capacity) { + if (!candidate || !path || !capacity) return false; + if (bongo_cat_path_join(path, capacity, candidate->assets, "resources") && + bongo_cat_path_is_dir(path)) return true; + if (bongo_cat_path_join(path, capacity, candidate->directory, + "resources") && bongo_cat_path_is_dir(path)) return true; + int written = snprintf(path, capacity, "%s", candidate->assets); + return written >= 0 && (size_t)written < capacity && path[0]; +} + +bool bongo_cat_tauri_find_package_file( + const BongoCatImportCandidate *candidate, const char *name, + char *path, size_t capacity) { + if (!candidate || !name || !path || !capacity) return false; + const char *roots[] = {candidate->directory, candidate->assets, + candidate->package_root}; + for (size_t i = 0; i < sizeof(roots) / sizeof(roots[0]); ++i) + if (roots[i][0] && bongo_cat_path_join(path, capacity, roots[i], name) && + bongo_cat_path_is_file(path)) return true; + return false; +} diff --git a/src/runtime/model/import/model_import_tauri_mver_tree.c b/src/runtime/model/import/tauri/model_import_tauri_tree.c similarity index 93% rename from src/runtime/model/import/model_import_tauri_mver_tree.c rename to src/runtime/model/import/tauri/model_import_tauri_tree.c index 07e8435c..88b4f142 100644 --- a/src/runtime/model/import/model_import_tauri_mver_tree.c +++ b/src/runtime/model/import/tauri/model_import_tauri_tree.c @@ -1,4 +1,4 @@ -#include "model_import_tauri_mver_internal.h" +#include "model_import_tauri_internal.h" #include @@ -16,7 +16,7 @@ static bool copy_resource_tree(const char *source, const char *target, static bool interface_resource(const char *name) { static const char *const files[] = { "cover.png", "background.png", "cat.png", "bg.png", - "mousebg.png", "tabletbg.png" + "mousebg.png", "tabletbg.png", BONGO_CAT_TAURI_SOURCE_FILE }; if (SDL_strcasecmp(name, "left-keys") == 0 || SDL_strcasecmp(name, "right-keys") == 0) return true; @@ -51,7 +51,9 @@ static BongoCatPathVisit copy_resource_child(void *userdata, static BongoCatPathVisit copy_model_child(void *userdata, const char *dirname, const char *name) { TauriModelCopy *context = userdata; - if (!name || name[0] == '.') return BONGO_CAT_PATH_CONTINUE; + if (!name || name[0] == '.' || (context->depth == 0 && + SDL_strcasecmp(name, BONGO_CAT_TAURI_SOURCE_FILE) == 0)) + return BONGO_CAT_PATH_CONTINUE; char source[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(source, sizeof(source), dirname, name) || !bongo_cat_path_join(target, sizeof(target), context->target_root, diff --git a/tests/model_import/test_model_import_identity.c b/tests/model_import/test_model_import_identity.c index f0852a31..c596619e 100644 --- a/tests/model_import/test_model_import_identity.c +++ b/tests/model_import/test_model_import_identity.c @@ -1,6 +1,7 @@ #include "test_mver_support.h" #include "model_import.h" +#include "model_import_mver.h" #include "model_storage.h" #include "runtime.h" #include "bongo_cat/path.h" diff --git a/tests/model_import/test_mver_container.c b/tests/model_import/test_mver_container.c index 43a572c6..3ab384b4 100644 --- a/tests/model_import/test_mver_container.c +++ b/tests/model_import/test_mver_container.c @@ -99,6 +99,14 @@ void test_mver_container_discovery(void) { CHECK(selected_container.count == 3); size_t patch_count = 0; const BongoCatImportCandidate *contained_patch = NULL; + for (size_t i = 0; i < selected_container.count; ++i) { + if (selected_container.candidates[i].format == + BONGO_CAT_IMPORT_TAURI) continue; + /* Each recursive candidate owns its package tree. The selected + container is only a discovery scope, never a copy source. */ + CHECK(strcmp(selected_container.candidates[i].package_root, + root) != 0); + } for (size_t i = 0; i < selected_container.count; ++i) if (selected_container.candidates[i].format == BONGO_CAT_IMPORT_MVER_PATCH) { diff --git a/tests/model_import/test_tauri_portable.c b/tests/model_import/test_tauri_portable.c index e1d3ba2e..0d0627d2 100644 --- a/tests/model_import/test_tauri_portable.c +++ b/tests/model_import/test_tauri_portable.c @@ -1,5 +1,6 @@ #include "model_import.h" #include "model_import_mver_internal.h" +#include "tauri/model_import_tauri.h" #include "model_storage.h" #include "runtime.h" #include "test_mver_import_internal.h" @@ -110,7 +111,7 @@ void test_tauri_portable(void) { 24 && yyjson_get_int(yyjson_arr_get(window_size, 0)) == expected_width && yyjson_get_int(yyjson_arr_get(window_size, 1)) == expected_height && - yyjson_is_num(l2d_correct) && yyjson_get_num(l2d_correct) == 1.0); + yyjson_is_num(l2d_correct) && yyjson_get_num(l2d_correct) == 1.1); yyjson_doc_free(normalized); CHECK(child(path, sizeof(path), package, "img/keyboard/cat_model/cat.model3.json", false) && @@ -169,6 +170,19 @@ void test_tauri_portable(void) { }; check_portable_images(portable_root, standard_runtime, sizeof(standard_runtime) / sizeof(standard_runtime[0])); + CHECK(child(path, sizeof(path), portable_root, "config.json", false)); + yyjson_doc *standard_config = bongo_cat_json_read_file(path, 0, NULL); + yyjson_val *standard_root = standard_config + ? yyjson_doc_get_root(standard_config) : NULL; + yyjson_val *standard_decoration = yyjson_obj_get(standard_root, + "decoration"); + yyjson_val *standard_l2d_correct = yyjson_obj_get(standard_decoration, + "l2d_correct"); + CHECK(yyjson_get_int(yyjson_obj_get(standard_root, "mode")) == 1 && + yyjson_is_arr(yyjson_obj_get(yyjson_obj_get(standard_root, + "standard"), "hand")) && yyjson_is_num(standard_l2d_correct) && + yyjson_get_num(standard_l2d_correct) == 1.1); + yyjson_doc_free(standard_config); CHECK(child(path, sizeof(path), portable_root, "img/standard/bg.png", false) && !bongo_cat_path_is_file(path)); char adapter_root[BONGO_CAT_PATH_CAP]; From da76482d38710558dc23ba679209bd4a72291fbe Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 30 Aug 2026 19:29:29 +0800 Subject: [PATCH 161/334] fix: harden progressive model imports --- cmake/RuntimeSources.cmake | 2 + include/bongo_cat/app.h | 1 + resources/assets/locales/de-DE.json | 1 + resources/assets/locales/en-US.json | 1 + resources/assets/locales/es-ES.json | 1 + resources/assets/locales/fr-FR.json | 1 + resources/assets/locales/ja-JP.json | 1 + resources/assets/locales/ko-KR.json | 1 + resources/assets/locales/pt-BR.json | 1 + resources/assets/locales/ru-RU.json | 1 + resources/assets/locales/zh-CN.json | 1 + resources/assets/locales/zh-Hant.json | 1 + src/runtime/lifecycle/shutdown.c | 3 +- src/runtime/lifecycle/startup.c | 6 + src/runtime/model/import/model_import.h | 2 + src/runtime/model/import/model_import_app.c | 3 + .../model/import/model_import_discover.c | 3 + .../model/import/model_import_installed.c | 9 +- src/runtime/model/import/model_import_lock.c | 32 +++++ src/runtime/model/import/model_import_lock.h | 10 ++ src/runtime/model/import/model_import_probe.c | 64 +++++++++ src/runtime/model/import/model_import_probe.h | 20 +++ .../model/import/model_import_progressive.c | 69 +++++---- src/runtime/model/import/model_import_scan.c | 94 +++++++++---- .../model/import/model_import_session.c | 132 ++++++++++++++++-- .../model/import/nearby/model_import_nearby.c | 49 ++----- .../nearby/model_import_nearby_internal.h | 3 - .../import/tauri/model_import_tauri_convert.c | 26 +++- .../import/tauri/model_import_tauri_images.c | 12 +- .../import/tauri/model_import_tauri_keys.c | 21 +-- .../tauri/model_import_tauri_metadata.c | 43 ++++-- src/runtime/model/model_catalog_refresh.c | 62 +++++--- src/runtime/model/model_catalog_runtime.c | 90 ++++++++---- src/runtime/runtime.h | 1 - src/ui/preferences/preferences_fonts.c | 5 +- src/ui/preferences/preferences_import.c | 25 +++- .../preferences/preferences_import_internal.h | 5 +- .../preferences/preferences_import_progress.c | 31 ++-- src/ui/preferences/preferences_model.c | 12 +- src/ui/preferences/preferences_model_card.c | 13 +- src/ui/preferences/preferences_model_card.h | 3 +- src/ui/preferences/preferences_model_glyphs.c | 58 +++++++- src/ui/preferences/preferences_model_glyphs.h | 7 + src/ui/preferences/preferences_model_rename.c | 6 + src/ui/preferences/preferences_render.c | 14 +- src/ui/preferences/preferences_state.h | 3 + src/ui/rendering/ui_font_atlas_upload.c | 7 + 47 files changed, 727 insertions(+), 229 deletions(-) create mode 100644 src/runtime/model/import/model_import_lock.c create mode 100644 src/runtime/model/import/model_import_lock.h create mode 100644 src/runtime/model/import/model_import_probe.c create mode 100644 src/runtime/model/import/model_import_probe.h diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 8e132cbc..cbef33a7 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -92,10 +92,12 @@ set(BONGO_CAT_RUNTIME_IMPORT_SOURCES src/runtime/model/import/model_import_manifest.c src/runtime/model/import/model_import_identity.c src/runtime/model/import/model_import_installed.c + src/runtime/model/import/model_import_lock.c src/runtime/model/import/model_import_metadata.c src/runtime/model/import/model_import_package.c src/runtime/model/import/model_import_package_index.c src/runtime/model/import/model_import_path.c + src/runtime/model/import/model_import_probe.c src/runtime/model/import/model_import_progressive.c src/runtime/model/import/model_import_report.c src/runtime/model/import/model_import_scan.c diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index ea1d258a..f9836964 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -197,6 +197,7 @@ void bongo_cat_app_rescan_models(BongoCatApp *app); void bongo_cat_app_refresh_installed_models(BongoCatApp *app); void bongo_cat_app_refresh_nearby_models(BongoCatApp *app); void bongo_cat_app_request_model_refresh(BongoCatApp *app); +bool bongo_cat_app_model_refresh_busy(const BongoCatApp *app); void bongo_cat_app_request_model_package_refresh(BongoCatApp *app, const char *package_id); void bongo_cat_app_request_nearby_model_refresh(BongoCatApp *app); diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index dda84da6..05df8ed6 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -240,6 +240,7 @@ "select": "Auswählen", "selected": "Ausgewählt", "import": "Importieren", + "importing": "Wird importiert...", "builtIn": "integriert" } } diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index 93cef631..e6e100a6 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -240,6 +240,7 @@ "select": "Select", "selected": "Selected", "import": "Import", + "importing": "Importing...", "builtIn": "built-in" } } diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index 0715f9f1..882f8aba 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -240,6 +240,7 @@ "select": "Seleccionar", "selected": "Seleccionado", "import": "Importar", + "importing": "Importando...", "builtIn": "integrado" } } diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index eef2b523..70d10108 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -240,6 +240,7 @@ "select": "Sélectionner", "selected": "Sélectionné", "import": "Importer", + "importing": "Importation...", "builtIn": "intégré" } } diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index 35d85f69..bde11cca 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -240,6 +240,7 @@ "select": "選択", "selected": "選択済み", "import": "インポート", + "importing": "インポート中...", "builtIn": "内蔵" } } diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index d38f0762..2d29b392 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -240,6 +240,7 @@ "select": "선택", "selected": "선택됨", "import": "가져오기", + "importing": "가져오는 중...", "builtIn": "내장" } } diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index 4ef90a1e..18f79911 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -240,6 +240,7 @@ "select": "Selecionar", "selected": "Selecionado", "import": "Importar", + "importing": "Importando...", "builtIn": "integrado" } } diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index 83e212b2..2007f911 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -240,6 +240,7 @@ "select": "Выбрать", "selected": "Выбрано", "import": "Импорт", + "importing": "Импорт...", "builtIn": "встроенная" } } diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index 38984751..f63213fc 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -240,6 +240,7 @@ "select": "选择", "selected": "已选择", "import": "导入", + "importing": "导入中...", "builtIn": "内置" } } diff --git a/resources/assets/locales/zh-Hant.json b/resources/assets/locales/zh-Hant.json index e905659a..8f7290ca 100644 --- a/resources/assets/locales/zh-Hant.json +++ b/resources/assets/locales/zh-Hant.json @@ -240,6 +240,7 @@ "select": "選擇", "selected": "已選擇", "import": "匯入", + "importing": "匯入中...", "builtIn": "內建" } } diff --git a/src/runtime/lifecycle/shutdown.c b/src/runtime/lifecycle/shutdown.c index 28d187db..8e102ae5 100644 --- a/src/runtime/lifecycle/shutdown.c +++ b/src/runtime/lifecycle/shutdown.c @@ -1,4 +1,5 @@ #include "runtime.h" +#include "model_import_lock.h" #include "bongo_cat/audio.h" #include "bongo_cat/i18n.h" #include "bongo_cat/overlay.h" @@ -19,7 +20,7 @@ void bongo_cat_app_shutdown(BongoCatApp *app, const char *stage, bongo_cat_update_destroy(app->update); app->update = NULL; bongo_cat_preferences_destroy(app->preferences); - bongo_cat_import_nearby_shutdown(); + bongo_cat_import_storage_lock_shutdown(); bongo_cat_i18n_destroy(app->i18n); bongo_cat_tray_destroy(app->tray); bongo_cat_gamepads_set_enabled(app, false); diff --git a/src/runtime/lifecycle/startup.c b/src/runtime/lifecycle/startup.c index b057e3d3..4c8ad105 100644 --- a/src/runtime/lifecycle/startup.c +++ b/src/runtime/lifecycle/startup.c @@ -110,6 +110,12 @@ static void begin_log(BongoCatApp *app) { runtime_log_path[0] = '\0'; bongo_cat_path_join(runtime_log_path, sizeof(runtime_log_path), app->log_root, "BongoCat.log"); + /* The primary process owns the session log. Secondary pets append to it + instead of erasing diagnostics already written by the primary. */ + if (runtime_log_path[0] && !app->secondary_pet) { + FILE *fresh_log = bongo_cat_file_open(runtime_log_path, "wb"); + if (fresh_log) fclose(fresh_log); + } set_log_source(app); bool stage_paths_ready = bongo_cat_path_join(stage_path, sizeof(stage_path), app->state_root, "startup-stage.txt") && diff --git a/src/runtime/model/import/model_import.h b/src/runtime/model/import/model_import.h index bfa955d5..d95032ba 100644 --- a/src/runtime/model/import/model_import.h +++ b/src/runtime/model/import/model_import.h @@ -127,6 +127,8 @@ bool bongo_cat_import_render_options(const char *directory, /* Recursive source scanning. */ BongoCatResult bongo_cat_import_scan(const char *root, BongoCatImportVisitor visitor, void *userdata, BongoCatError *error); +BongoCatResult bongo_cat_import_scan_diagnostic(const char *root, + BongoCatImportVisitor visitor, void *userdata, BongoCatError *error); BongoCatResult bongo_cat_import_scan_budget(const char *root, BongoCatImportVisitor visitor, void *userdata, uint64_t budget_ns, BongoCatError *error); diff --git a/src/runtime/model/import/model_import_app.c b/src/runtime/model/import/model_import_app.c index 7811320a..507b29d6 100644 --- a/src/runtime/model/import/model_import_app.c +++ b/src/runtime/model/import/model_import_app.c @@ -1,4 +1,5 @@ #include "model_import.h" +#include "model_import_lock.h" #include "model_storage.h" #include "runtime.h" #include "bongo_cat/path.h" @@ -10,10 +11,12 @@ static void remove_receipt(const char *models_root, const BongoCatImportReceipt *receipt) { char path[BONGO_CAT_PATH_CAP]; if (!receipt || !models_root) return; + bongo_cat_import_storage_lock(); for (size_t i = 0; i < receipt->count; ++i) if (receipt->installed[i] && bongo_cat_path_join(path, sizeof(path), models_root, receipt->ids[i])) bongo_cat_model_remove_tree(path, NULL); + bongo_cat_import_storage_unlock(); } #endif diff --git a/src/runtime/model/import/model_import_discover.c b/src/runtime/model/import/model_import_discover.c index 8ed9f862..21845a6d 100644 --- a/src/runtime/model/import/model_import_discover.c +++ b/src/runtime/model/import/model_import_discover.c @@ -64,6 +64,9 @@ bool bongo_cat_import_discover(const char *source, BongoCatImportDiscovery *discovery, BongoCatError *error) { memset(discovery, 0, sizeof(*discovery)); seed_source_name(source, discovery); + /* Broad package discovery intentionally stays separate from exact probe + dispatch: Mver may be selected from one of its authored subfolders, + while Tauri conversion starts only from an owning package root. */ int mver = bongo_cat_import_mver_discover(source, discovery, error); if (mver > 0) return true; if (mver < 0) return false; diff --git a/src/runtime/model/import/model_import_installed.c b/src/runtime/model/import/model_import_installed.c index bb2edf95..f9b37039 100644 --- a/src/runtime/model/import/model_import_installed.c +++ b/src/runtime/model/import/model_import_installed.c @@ -1,4 +1,5 @@ #include "model_import.h" +#include "model_import_lock.h" #include "model_import_mver_policy.h" #include "model_import_nearby_internal.h" #include "runtime.h" @@ -237,7 +238,7 @@ BongoCatResult bongo_cat_import_installed_package(BongoCatApp *app, } BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); if (!discovery) return BONGO_CAT_ERROR_MEMORY; - bongo_cat_import_model_scan_lock(); + bongo_cat_import_storage_lock(); bool found = bongo_cat_import_discover(directory, discovery, error); InstalledModelScan scan = {app, cache_root, error, BONGO_CAT_OK}; if (found) { @@ -246,7 +247,7 @@ BongoCatResult bongo_cat_import_installed_package(BongoCatApp *app, scan.result = error && error->code ? error->code : BONGO_CAT_ERROR_IO; } - bongo_cat_import_model_scan_unlock(); + bongo_cat_import_storage_unlock(); free(discovery); if (!found && scan.result == BONGO_CAT_OK) scan.result = error && error->code ? error->code : @@ -261,9 +262,9 @@ BongoCatResult bongo_cat_import_installed_models(BongoCatApp *app, char cache_root[BONGO_CAT_PATH_CAP]; if (!installed_cache_root(app, cache_root, error)) return BONGO_CAT_ERROR_IO; InstalledModelScan scan = {app, cache_root, error, BONGO_CAT_OK}; - bongo_cat_import_model_scan_lock(); + bongo_cat_import_storage_lock(); bool ok = bongo_cat_path_enumerate(root, scan_package, &scan); - bongo_cat_import_model_scan_unlock(); + bongo_cat_import_storage_unlock(); if (!ok && scan.result == BONGO_CAT_OK) scan.result = BONGO_CAT_ERROR_IO; return scan.result; } diff --git a/src/runtime/model/import/model_import_lock.c b/src/runtime/model/import/model_import_lock.c new file mode 100644 index 00000000..d56efb30 --- /dev/null +++ b/src/runtime/model/import/model_import_lock.c @@ -0,0 +1,32 @@ +#include "model_import_lock.h" + +#include + +static SDL_InitState storage_lock_init; +static SDL_Mutex *storage_lock; + +static SDL_Mutex *storage_mutex(void) { + if (SDL_ShouldInit(&storage_lock_init)) { + storage_lock = SDL_CreateMutex(); + SDL_SetInitialized(&storage_lock_init, storage_lock != NULL); + } + return storage_lock; +} + +void bongo_cat_import_storage_lock(void) { + SDL_Mutex *mutex = storage_mutex(); + if (mutex) SDL_LockMutex(mutex); +} + +void bongo_cat_import_storage_unlock(void) { + SDL_Mutex *mutex = storage_lock; + if (mutex) SDL_UnlockMutex(mutex); +} + +void bongo_cat_import_storage_lock_shutdown(void) { + if (!SDL_ShouldQuit(&storage_lock_init)) return; + SDL_Mutex *mutex = storage_lock; + storage_lock = NULL; + SDL_SetInitialized(&storage_lock_init, false); + SDL_DestroyMutex(mutex); +} diff --git a/src/runtime/model/import/model_import_lock.h b/src/runtime/model/import/model_import_lock.h new file mode 100644 index 00000000..0981e7f4 --- /dev/null +++ b/src/runtime/model/import/model_import_lock.h @@ -0,0 +1,10 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_LOCK_H +#define BONGO_CAT_MODEL_IMPORT_LOCK_H + +/* Serializes model-directory scans and mutations across import, refresh, + deletion, rollback, and nearby adapter generation. */ +void bongo_cat_import_storage_lock(void); +void bongo_cat_import_storage_unlock(void); +void bongo_cat_import_storage_lock_shutdown(void); + +#endif diff --git a/src/runtime/model/import/model_import_probe.c b/src/runtime/model/import/model_import_probe.c new file mode 100644 index 00000000..bef02fab --- /dev/null +++ b/src/runtime/model/import/model_import_probe.c @@ -0,0 +1,64 @@ +#include "model_import_probe.h" +#include "mver/model_import_mver.h" +#include "tauri/model_import_tauri.h" + +#include +#include + +typedef int (*ExactProbe)(const char *source, + BongoCatImportDiscovery *discovery, BongoCatError *error); + +const char *bongo_cat_import_format_name(BongoCatImportFormat format) { + switch (format) { + case BONGO_CAT_IMPORT_MVER: return "mver"; + case BONGO_CAT_IMPORT_MVER_PATCH: return "mver-patch"; + case BONGO_CAT_IMPORT_TAURI: return "tauri"; + default: return "unknown"; + } +} + +static int run_probe(const char *source, BongoCatImportDiscovery *discovery, + BongoCatImportFormat format, ExactProbe exact, bool diagnostic, + uint64_t started, BongoCatError *error) { + int found = exact(source, discovery, error); + if (found && diagnostic) SDL_Log( + "[runtime] Model import exact probe completed: format=%s result=%d " + "candidates=%llu elapsed_ms=%.1f path=%s", + bongo_cat_import_format_name(format), found, + (unsigned long long)discovery->count, + (SDL_GetTicksNS() - started) / 1000000.0, source); + return found; +} + +int bongo_cat_import_probe_exact(const char *source, + BongoCatImportDiscovery *discovery, BongoCatImportFormat *format, + BongoCatImportProbePolicy policy, bool diagnostic, + BongoCatError *error) { + if (!source || !discovery) return 0; + uint64_t started = SDL_GetTicksNS(); + static const struct { + BongoCatImportFormat format; + ExactProbe probe; + } probes[] = { + {BONGO_CAT_IMPORT_MVER, bongo_cat_import_mver_discover_exact}, + {BONGO_CAT_IMPORT_MVER_PATCH, + bongo_cat_import_mver_patch_discover_exact}, + {BONGO_CAT_IMPORT_TAURI, bongo_cat_import_tauri_discover_exact} + }; + for (size_t i = 0; i < sizeof(probes) / sizeof(probes[0]); ++i) { + memset(discovery, 0, sizeof(*discovery)); + if (error) *error = (BongoCatError){0}; + int found = run_probe(source, discovery, probes[i].format, + probes[i].probe, diagnostic, started, error); + if (found == 0) continue; + if (found < 0 && policy == BONGO_CAT_IMPORT_PROBE_FALLBACK && + i + 1 < sizeof(probes) / sizeof(probes[0])) continue; + if (format) *format = probes[i].format; + return found; + } + if (diagnostic) SDL_Log( + "[runtime] Model import exact probe completed: format=none result=0 " + "candidates=0 elapsed_ms=%.1f path=%s", + (SDL_GetTicksNS() - started) / 1000000.0, source); + return 0; +} diff --git a/src/runtime/model/import/model_import_probe.h b/src/runtime/model/import/model_import_probe.h new file mode 100644 index 00000000..0908400a --- /dev/null +++ b/src/runtime/model/import/model_import_probe.h @@ -0,0 +1,20 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_PROBE_H +#define BONGO_CAT_MODEL_IMPORT_PROBE_H + +#include "model_import.h" + +typedef enum BongoCatImportProbePolicy { + /* A malformed matching package is a user-visible import error. */ + BONGO_CAT_IMPORT_PROBE_STRICT, + /* Container scans may continue trying later source adapters. */ + BONGO_CAT_IMPORT_PROBE_FALLBACK +} BongoCatImportProbePolicy; + +/* Exact package-root dispatch shared by progressive, bounded, and queued + scans. Broad Mver parent lookup remains in the owning import adapter. */ +const char *bongo_cat_import_format_name(BongoCatImportFormat format); +int bongo_cat_import_probe_exact(const char *source, + BongoCatImportDiscovery *discovery, BongoCatImportFormat *format, + BongoCatImportProbePolicy policy, bool diagnostic, BongoCatError *error); + +#endif diff --git a/src/runtime/model/import/model_import_progressive.c b/src/runtime/model/import/model_import_progressive.c index 5aabbe0a..c52d20dd 100644 --- a/src/runtime/model/import/model_import_progressive.c +++ b/src/runtime/model/import/model_import_progressive.c @@ -1,8 +1,8 @@ #include "model_import.h" -#include "mver/model_import_mver.h" -#include "tauri/model_import_tauri.h" +#include "model_import_probe.h" #include "bongo_cat/path.h" +#include #include #include #include @@ -26,19 +26,6 @@ typedef struct ProgressiveScan { size_t model_count; } ProgressiveScan; -static int discover_exact(const char *source, - BongoCatImportDiscovery *discovery, BongoCatError *error) { - memset(discovery, 0, sizeof(*discovery)); - int found = bongo_cat_import_mver_discover_exact(source, discovery, error); - if (found) return found; - memset(discovery, 0, sizeof(*discovery)); - found = bongo_cat_import_mver_patch_discover_exact(source, discovery, - error); - if (found) return found; - memset(discovery, 0, sizeof(*discovery)); - return bongo_cat_import_tauri_discover_exact(source, discovery, error); -} - static BongoCatResult collect_unit(void *userdata, const char *source, BongoCatImportDiscovery *discovery, BongoCatError *error) { ProgressiveScan *scan = userdata; @@ -86,13 +73,16 @@ static bool has_base(const ProgressiveScan *scan, static bool redundant_patch(const ProgressiveScan *scan, const ProgressiveUnit *unit) { if (!unit->patch_only) return false; - BongoCatImportDiscovery discovery; + BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); + if (!discovery) return false; BongoCatError ignored = {0}; - if (discover_exact(unit->source, &discovery, &ignored) <= 0 || - !discovery.count) return false; - for (size_t i = 0; i < discovery.count; ++i) - if (!has_base(scan, &discovery.candidates[i])) return false; - return true; + bool redundant = bongo_cat_import_probe_exact(unit->source, discovery, + NULL, BONGO_CAT_IMPORT_PROBE_STRICT, false, &ignored) > 0 && + discovery->count > 0; + for (size_t i = 0; redundant && i < discovery->count; ++i) + if (!has_base(scan, &discovery->candidates[i])) redundant = false; + free(discovery); + return redundant; } static void record_failure_name(BongoCatImportBatchStats *stats, @@ -109,6 +99,8 @@ static BongoCatResult install_one(BongoCatImportSession *session, const char *source, BongoCatImportReceiptCallback callback, void *userdata, BongoCatImportBatchStats *stats, BongoCatError *error) { BongoCatImportReceipt receipt = {0}; + uint64_t started = SDL_GetTicksNS(); + SDL_Log("[runtime] Model import unit started: path=%s", source); BongoCatResult result = bongo_cat_import_session_install(session, source, &receipt, error); if (result == BONGO_CAT_OK) stats->succeeded_count++; @@ -118,6 +110,11 @@ static BongoCatResult install_one(BongoCatImportSession *session, } if (result == BONGO_CAT_OK && callback) callback(userdata, &receipt); + SDL_Log("[runtime] Model import unit completed: result=%d variants=%llu " + "installed=%llu elapsed_ms=%.1f path=%s", (int)result, + (unsigned long long)receipt.count, + (unsigned long long)receipt.installed_count, + (SDL_GetTicksNS() - started) / 1000000.0, source); return result; } @@ -142,12 +139,24 @@ BongoCatResult bongo_cat_import_session_install_progressive( if (normalized != BONGO_CAT_OK) return fail_source(stats, source, normalized); - BongoCatImportDiscovery exact; + SDL_Log("[runtime] Model import progressive discovery started: path=%s", + directory); + BongoCatImportDiscovery *exact = calloc(1, sizeof(*exact)); + if (!exact) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Cannot allocate exact model discovery workspace"); + return fail_source(stats, directory, BONGO_CAT_ERROR_MEMORY); + } BongoCatError exact_error = {0}; - int exact_result = discover_exact(directory, &exact, &exact_error); - if (exact_result > 0) + int exact_result = bongo_cat_import_probe_exact(directory, exact, NULL, + BONGO_CAT_IMPORT_PROBE_STRICT, true, &exact_error); + free(exact); + if (exact_result > 0) { + SDL_Log("[runtime] Model import progressive discovery selected exact " + "package: path=%s", directory); return install_one(session, directory, callback, userdata, stats, error); + } if (exact_result < 0) { if (error) *error = exact_error; return fail_source(stats, directory, exact_error.code @@ -160,8 +169,16 @@ BongoCatResult bongo_cat_import_session_install_progressive( "Cannot allocate progressive model import scan"); return fail_source(stats, directory, BONGO_CAT_ERROR_MEMORY); } - BongoCatResult scanned = bongo_cat_import_scan(directory, collect_unit, - scan, error); + uint64_t scan_started = SDL_GetTicksNS(); + SDL_Log("[runtime] Model import container scan started: path=%s", + directory); + BongoCatResult scanned = bongo_cat_import_scan_diagnostic(directory, + collect_unit, scan, error); + SDL_Log("[runtime] Model import container scan completed: result=%d " + "units=%llu variants=%llu elapsed_ms=%.1f path=%s", (int)scanned, + (unsigned long long)scan->unit_count, + (unsigned long long)scan->model_count, + (SDL_GetTicksNS() - scan_started) / 1000000.0, directory); if (scanned != BONGO_CAT_OK || !scan->unit_count) { free(scan); return scanned != BONGO_CAT_OK ? fail_source(stats, directory, scanned) : diff --git a/src/runtime/model/import/model_import_scan.c b/src/runtime/model/import/model_import_scan.c index 79923514..81877059 100644 --- a/src/runtime/model/import/model_import_scan.c +++ b/src/runtime/model/import/model_import_scan.c @@ -1,6 +1,5 @@ #include "model_import.h" -#include "mver/model_import_mver.h" -#include "tauri/model_import_tauri.h" +#include "model_import_probe.h" #include "bongo_cat/path.h" #include @@ -32,6 +31,7 @@ typedef struct ImportScan { size_t nodes; uint64_t deadline; bool limited; + bool diagnostic; } ImportScan; typedef struct ImportQueue { @@ -84,6 +84,10 @@ static void enqueue_children(ImportScan *scan, ImportQueue *queue, static BongoCatResult scan_node(ImportScan *scan, ImportQueue *queue, const char *source, int depth) { if (!within_budget(scan)) return BONGO_CAT_OK; + uint64_t started = SDL_GetTicksNS(); + if (scan->diagnostic) SDL_Log( + "[runtime] Model import scan node started: node=%llu depth=%d " + "path=%s", (unsigned long long)scan->nodes, depth, source); ImportWorkspace *work = calloc(1, sizeof(*work)); if (!work) { bongo_cat_error_set(scan->error, BONGO_CAT_ERROR_MEMORY, @@ -91,50 +95,77 @@ static BongoCatResult scan_node(ImportScan *scan, ImportQueue *queue, return BONGO_CAT_ERROR_MEMORY; } BongoCatError local = {0}; - int found = bongo_cat_import_mver_discover_exact(source, &work->discovery, &local); - if (found <= 0) { - memset(&work->discovery, 0, sizeof(work->discovery)); - local = (BongoCatError){0}; - found = bongo_cat_import_mver_patch_discover_exact(source, - &work->discovery, &local); - } - if (found <= 0) { - memset(&work->discovery, 0, sizeof(work->discovery)); - local = (BongoCatError){0}; - found = bongo_cat_import_tauri_discover_exact(source, - &work->discovery, &local); - } + BongoCatImportFormat format = BONGO_CAT_IMPORT_MVER; + int found = bongo_cat_import_probe_exact(source, &work->discovery, + &format, BONGO_CAT_IMPORT_PROBE_FALLBACK, scan->diagnostic, &local); if (found > 0) { - uint64_t started = SDL_GetTicksNS(); + if (scan->diagnostic) SDL_Log( + "[runtime] Model import scan node discovered: format=%s " + "candidates=%llu elapsed_ms=%.1f path=%s", + bongo_cat_import_format_name(format), + (unsigned long long)work->discovery.count, + (SDL_GetTicksNS() - started) / 1000000.0, source); + uint64_t visitor_started = SDL_GetTicksNS(); BongoCatResult result = scan->visitor(scan->userdata, source, &work->discovery, scan->error); - uint64_t finished = SDL_GetTicksNS(), elapsed = finished - started; + uint64_t finished = SDL_GetTicksNS(); + uint64_t elapsed = finished - visitor_started; if (scan->deadline <= UINT64_MAX - elapsed) scan->deadline += elapsed; else scan->deadline = UINT64_MAX; + if (scan->diagnostic) SDL_Log( + "[runtime] Model import scan node collected: result=%d " + "elapsed_ms=%.1f path=%s", (int)result, + (finished - started) / 1000000.0, source); free(work); return result; } - if (depth < IMPORT_SCAN_DEPTH && - bongo_cat_path_enumerate(source, collect_child, &work->children)) { + if (depth < IMPORT_SCAN_DEPTH) { + bool enumerated = bongo_cat_path_enumerate(source, collect_child, + &work->children); + if (!enumerated) { + if (!scan->error || scan->error->code == BONGO_CAT_OK) + bongo_cat_error_set(scan->error, BONGO_CAT_ERROR_IO, + "Cannot enumerate model directory: %s", source); + BongoCatResult result = scan->error && scan->error->code + ? scan->error->code : BONGO_CAT_ERROR_IO; + free(work); + return result; + } if (work->children.limited) scan->limited = true; qsort(work->children.paths, work->children.count, sizeof(work->children.paths[0]), compare_path); enqueue_children(scan, queue, &work->children, depth + 1); } + if (scan->diagnostic) SDL_Log( + "[runtime] Model import scan node completed: children=%llu " + "elapsed_ms=%.1f path=%s", + (unsigned long long)work->children.count, + (SDL_GetTicksNS() - started) / 1000000.0, source); free(work); return BONGO_CAT_OK; } static BongoCatResult scan_with_budget(const char *root, BongoCatImportVisitor visitor, void *userdata, uint64_t budget_ns, - BongoCatError *error) { + bool diagnostic, BongoCatError *error) { if (!root || !visitor) return BONGO_CAT_ERROR_ARGUMENT; ImportWorkspace *work = calloc(1, sizeof(*work)); ImportQueue *queue = calloc(1, sizeof(*queue)); - if (!work || !queue) { free(work); free(queue); return BONGO_CAT_ERROR_MEMORY; } + if (!work || !queue) { + free(work); + free(queue); + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Cannot allocate model import directory scan"); + return BONGO_CAT_ERROR_MEMORY; + } uint64_t now = SDL_GetTicksNS(); uint64_t deadline = budget_ns <= UINT64_MAX - now ? now + budget_ns : UINT64_MAX; - ImportScan scan = {visitor, userdata, error, 0, deadline, false}; + ImportScan scan = { + visitor, userdata, error, 0, deadline, false, diagnostic + }; BongoCatResult result = BONGO_CAT_OK; + if (diagnostic) SDL_Log( + "[runtime] Model import scan started: budget_ms=%.1f path=%s", + budget_ns / 1000000.0, root); if (bongo_cat_path_enumerate(root, collect_child, &work->children)) { if (work->children.limited) scan.limited = true; qsort(work->children.paths, work->children.count, @@ -146,20 +177,35 @@ static BongoCatResult scan_with_budget(const char *root, queue->paths[current], queue->depths[current]); if (item != BONGO_CAT_OK && result == BONGO_CAT_OK) result = item; } + } else { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot enumerate model directory: %s", root); + result = error && error->code ? error->code : BONGO_CAT_ERROR_IO; } if (scan.limited) SDL_Log( "Model folder scan reached its directory budget"); + if (diagnostic) SDL_Log( + "[runtime] Model import scan completed: result=%d nodes=%llu " + "queued=%llu limited=%d elapsed_ms=%.1f path=%s", (int)result, + (unsigned long long)scan.nodes, (unsigned long long)queue->count, + scan.limited, (SDL_GetTicksNS() - now) / 1000000.0, root); free(queue); free(work); return result; } BongoCatResult bongo_cat_import_scan_budget(const char *root, BongoCatImportVisitor visitor, void *userdata, uint64_t budget_ns, BongoCatError *error) { - return scan_with_budget(root, visitor, userdata, budget_ns, error); + return scan_with_budget(root, visitor, userdata, budget_ns, false, error); } BongoCatResult bongo_cat_import_scan(const char *root, BongoCatImportVisitor visitor, void *userdata, BongoCatError *error) { return scan_with_budget(root, visitor, userdata, - IMPORT_SCAN_BUDGET_NS, error); + IMPORT_SCAN_BUDGET_NS, false, error); +} + +BongoCatResult bongo_cat_import_scan_diagnostic(const char *root, + BongoCatImportVisitor visitor, void *userdata, BongoCatError *error) { + return scan_with_budget(root, visitor, userdata, + IMPORT_SCAN_BUDGET_NS, true, error); } diff --git a/src/runtime/model/import/model_import_session.c b/src/runtime/model/import/model_import_session.c index 5b3b2ad6..dfde5fe3 100644 --- a/src/runtime/model/import/model_import_session.c +++ b/src/runtime/model/import/model_import_session.c @@ -1,4 +1,5 @@ #include "model_import.h" +#include "model_import_lock.h" #include "model_import_session_internal.h" #include "model_storage.h" #include "bongo_cat/path.h" @@ -30,6 +31,15 @@ static bool existing_package(BongoCatImportSession *session, discovery, metadata, ids, count); } +static bool canonical_mver_package( + const BongoCatImportDiscovery *discovery) { + if (!discovery || !discovery->count) return false; + for (size_t i = 0; i < discovery->count; ++i) + if (discovery->candidates[i].format != BONGO_CAT_IMPORT_MVER) + return false; + return true; +} + static bool resolve_install_id(const char *base, const char *root, char output[BONGO_CAT_ID_CAP], BongoCatError *error) { for (size_t suffix = 1; suffix < 10000; ++suffix) { @@ -79,12 +89,30 @@ static BongoCatResult install_discovery(BongoCatImportSession *session, BongoCatImportDiscovery *source_discovery, BongoCatPackageMetadata *source_metadata, BongoCatImportReceipt *receipt, BongoCatError *error) { + uint64_t started = SDL_GetTicksNS(); + if (canonical_mver_package(source_discovery)) { + char existing[BONGO_CAT_IMPORT_CANDIDATE_CAP][BONGO_CAT_ID_CAP]; + size_t existing_count = 0; + if (existing_package(session, source_discovery, source_metadata, + existing, &existing_count)) { + SDL_Log("[runtime] Model import storage transaction: " + "stage=source-duplicate variants=%llu elapsed_ms=%.1f", + (unsigned long long)existing_count, + (SDL_GetTicksNS() - started) / 1000000.0); + fill_receipt_ids(receipt, existing, existing_count, false); + return BONGO_CAT_OK; + } + } + ImportInstall install = {0}; char temporary_name[BONGO_CAT_ID_CAP + 24]; snprintf(temporary_name, sizeof(temporary_name), ".import-%.16s-1.tmp", source_metadata[0].content_digest); if (!bongo_cat_path_join(install.temporary, sizeof(install.temporary), session->root, temporary_name)) return BONGO_CAT_ERROR_IO; + SDL_Log("[runtime] Model import storage transaction: stage=prepare " + "candidates=%llu temporary=%s", + (unsigned long long)source_discovery->count, install.temporary); bongo_cat_model_remove_tree(install.temporary, NULL); if (!bongo_cat_import_prepare_storage(source_discovery, install.temporary, error)) { @@ -92,6 +120,9 @@ static BongoCatResult install_discovery(BongoCatImportSession *session, return error && error->code == BONGO_CAT_ERROR_FORMAT ? BONGO_CAT_ERROR_FORMAT : BONGO_CAT_ERROR_IO; } + SDL_Log("[runtime] Model import storage transaction: stage=prepared " + "elapsed_ms=%.1f temporary=%s", + (SDL_GetTicksNS() - started) / 1000000.0, install.temporary); BongoCatImportDiscovery *normalized = calloc(1, sizeof(*normalized)); BongoCatPackageMetadata *metadata = calloc(BONGO_CAT_IMPORT_CANDIDATE_CAP, @@ -104,10 +135,15 @@ static BongoCatResult install_discovery(BongoCatImportSession *session, "Cannot validate normalized model package"); return BONGO_CAT_ERROR_MEMORY; } - bool valid = bongo_cat_import_discover(install.temporary, normalized, - error) && - bongo_cat_import_prepare_package_metadata_cached(normalized, metadata, - session->digests, error); + SDL_Log("[runtime] Model import storage transaction: " + "stage=validate-normalized temporary=%s", install.temporary); + bool discovered = bongo_cat_import_discover(install.temporary, normalized, + error); + SDL_Log("[runtime] Model import storage transaction: " + "stage=normalized-discovered result=%d candidates=%llu temporary=%s", + discovered, (unsigned long long)normalized->count, install.temporary); + bool valid = discovered && bongo_cat_import_prepare_package_metadata_cached( + normalized, metadata, session->digests, error); if (!valid) { free(normalized); free(metadata); @@ -119,6 +155,10 @@ static BongoCatResult install_discovery(BongoCatImportSession *session, size_t existing_count = 0; if (existing_package(session, normalized, metadata, existing, &existing_count)) { + SDL_Log("[runtime] Model import storage transaction: stage=duplicate " + "variants=%llu elapsed_ms=%.1f temporary=%s", + (unsigned long long)existing_count, + (SDL_GetTicksNS() - started) / 1000000.0, install.temporary); fill_receipt_ids(receipt, existing, existing_count, false); free(normalized); free(metadata); @@ -152,6 +192,9 @@ static BongoCatResult install_discovery(BongoCatImportSession *session, return BONGO_CAT_ERROR_IO; } install.committed = true; + SDL_Log("[runtime] Model import storage transaction: stage=committed " + "id=%s elapsed_ms=%.1f target=%s", install.id, + (SDL_GetTicksNS() - started) / 1000000.0, install.target); if (!bongo_cat_import_package_index_add(session->packages, install.id, normalized, metadata, error)) { free(normalized); @@ -165,9 +208,11 @@ static BongoCatResult install_discovery(BongoCatImportSession *session, return BONGO_CAT_OK; } -BongoCatImportSession *bongo_cat_import_session_create(const char *models_root, +static BongoCatImportSession *session_create_unlocked(const char *models_root, BongoCatError *error) { - if (!models_root) return NULL; + uint64_t started = SDL_GetTicksNS(); + SDL_Log("[runtime] Model import session creation started: root=%s", + models_root); BongoCatImportSession *session = calloc(1, sizeof(*session)); if (!session) { bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, @@ -179,17 +224,41 @@ BongoCatImportSession *bongo_cat_import_session_create(const char *models_root, models_root); if (!session->digests || root_length < 0 || (size_t)root_length >= sizeof(session->root) || - !bongo_cat_path_create_directory(session->root) || - !bongo_cat_model_cleanup_imports(session->root, error)) { + !bongo_cat_path_create_directory(session->root)) { + bongo_cat_import_session_destroy(session); + return NULL; + } + SDL_Log("[runtime] Model import session creation: stage=cleanup root=%s", + session->root); + if (!bongo_cat_model_cleanup_imports(session->root, error)) { bongo_cat_import_session_destroy(session); return NULL; } + SDL_Log("[runtime] Model import session creation: stage=index root=%s", + session->root); session->packages = bongo_cat_import_package_index_create(session->root, session->digests, error); if (!session->packages) { bongo_cat_import_session_destroy(session); return NULL; } + SDL_Log("[runtime] Model import session creation completed: " + "elapsed_ms=%.1f root=%s", + (SDL_GetTicksNS() - started) / 1000000.0, session->root); + return session; +} + +BongoCatImportSession *bongo_cat_import_session_create(const char *models_root, + BongoCatError *error) { + if (!models_root) return NULL; + SDL_Log("[runtime] Model import session lock: stage=waiting"); + bongo_cat_import_storage_lock(); + SDL_Log("[runtime] Model import session lock: stage=acquired"); + BongoCatImportSession *session = session_create_unlocked(models_root, + error); + bongo_cat_import_storage_unlock(); + SDL_Log("[runtime] Model import session lock: stage=released result=%d", + session != NULL); return session; } @@ -200,12 +269,11 @@ void bongo_cat_import_session_destroy(BongoCatImportSession *session) { free(session); } -BongoCatResult bongo_cat_import_session_install( +static BongoCatResult session_install_unlocked( BongoCatImportSession *session, const char *source, BongoCatImportReceipt *receipt, BongoCatError *error) { - if (receipt) memset(receipt, 0, sizeof(*receipt)); - if (!session || !source) return BONGO_CAT_ERROR_ARGUMENT; char source_directory[BONGO_CAT_PATH_CAP]; + uint64_t started = SDL_GetTicksNS(); BongoCatResult source_result = bongo_cat_import_source_directory(source, source_directory, sizeof(source_directory), error); if (source_result != BONGO_CAT_OK) return source_result; @@ -219,9 +287,22 @@ BongoCatResult bongo_cat_import_session_install( "Cannot allocate model import workspace"); return BONGO_CAT_ERROR_MEMORY; } - if (!bongo_cat_import_discover(source_directory, discovery, error) || - !bongo_cat_import_prepare_package_metadata_cached(discovery, metadata, - session->digests, error)) { + SDL_Log("[runtime] Model import package discovery started: path=%s", + source_directory); + bool discovered = bongo_cat_import_discover(source_directory, discovery, + error); + SDL_Log("[runtime] Model import package discovery completed: result=%d " + "candidates=%llu elapsed_ms=%.1f path=%s", discovered, + (unsigned long long)discovery->count, + (SDL_GetTicksNS() - started) / 1000000.0, source_directory); + bool metadata_ready = discovered && + bongo_cat_import_prepare_package_metadata_cached(discovery, metadata, + session->digests, error); + SDL_Log("[runtime] Model import package fingerprint completed: result=%d " + "candidates=%llu elapsed_ms=%.1f path=%s", metadata_ready, + (unsigned long long)discovery->count, + (SDL_GetTicksNS() - started) / 1000000.0, source_directory); + if (!metadata_ready) { BongoCatResult result = error && error->code ? error->code : BONGO_CAT_ERROR_FORMAT; free(discovery); @@ -230,11 +311,34 @@ BongoCatResult bongo_cat_import_session_install( } BongoCatResult result = install_discovery(session, discovery, metadata, receipt, error); + SDL_Log("[runtime] Model import package completed: result=%d variants=%llu " + "installed=%llu elapsed_ms=%.1f path=%s", (int)result, + (unsigned long long)(receipt ? receipt->count : 0), + (unsigned long long)(receipt ? receipt->installed_count : 0), + (SDL_GetTicksNS() - started) / 1000000.0, source_directory); free(discovery); free(metadata); return result; } +BongoCatResult bongo_cat_import_session_install( + BongoCatImportSession *session, const char *source, + BongoCatImportReceipt *receipt, BongoCatError *error) { + if (receipt) memset(receipt, 0, sizeof(*receipt)); + if (!session || !source) return BONGO_CAT_ERROR_ARGUMENT; + SDL_Log("[runtime] Model import storage lock: stage=waiting path=%s", + source); + bongo_cat_import_storage_lock(); + SDL_Log("[runtime] Model import storage lock: stage=acquired path=%s", + source); + BongoCatResult result = session_install_unlocked(session, source, receipt, + error); + bongo_cat_import_storage_unlock(); + SDL_Log("[runtime] Model import storage lock: stage=released result=%d " + "path=%s", (int)result, source); + return result; +} + BongoCatResult bongo_cat_import_install(const char *source, const char *models_root, BongoCatImportReceipt *receipt, BongoCatError *error) { diff --git a/src/runtime/model/import/nearby/model_import_nearby.c b/src/runtime/model/import/nearby/model_import_nearby.c index d9de4f72..5c1a5165 100644 --- a/src/runtime/model/import/nearby/model_import_nearby.c +++ b/src/runtime/model/import/nearby/model_import_nearby.c @@ -1,4 +1,6 @@ #include "model_import.h" +#include "model_import_lock.h" +#include "model_import_probe.h" #include "model_import_mver_policy.h" #include "model_import_mver.h" #include "model_import_nearby_internal.h" @@ -14,36 +16,6 @@ #define NEARBY_SCAN_BUDGET_NS 500000000ull -static SDL_InitState nearby_scan_lock_init; -static SDL_Mutex *nearby_scan_lock; - -static SDL_Mutex *scan_mutex(void) { - if (SDL_ShouldInit(&nearby_scan_lock_init)) { - nearby_scan_lock = SDL_CreateMutex(); - SDL_SetInitialized(&nearby_scan_lock_init, - nearby_scan_lock != NULL); - } - return nearby_scan_lock; -} - -void bongo_cat_import_model_scan_lock(void) { - SDL_Mutex *mutex = scan_mutex(); - if (mutex) SDL_LockMutex(mutex); -} - -void bongo_cat_import_model_scan_unlock(void) { - SDL_Mutex *mutex = nearby_scan_lock; - if (mutex) SDL_UnlockMutex(mutex); -} - -void bongo_cat_import_nearby_shutdown(void) { - if (!SDL_ShouldQuit(&nearby_scan_lock_init)) return; - SDL_Mutex *mutex = nearby_scan_lock; - nearby_scan_lock = NULL; - SDL_SetInitialized(&nearby_scan_lock_init, false); - SDL_DestroyMutex(mutex); -} - static bool existing_identity(const BongoCatModelCatalog *models, const char *identity, BongoCatModelMode mode, const BongoCatModelEntry *ignored) { @@ -167,16 +139,15 @@ static BongoCatResult add_scanned(void *userdata, const char *source, static int discover_direct(const char *root, bool bounded, BongoCatImportDiscovery *discovery, BongoCatError *error) { - int found = bounded - ? bongo_cat_import_mver_discover_exact(root, discovery, error) - : bongo_cat_import_mver_discover(root, discovery, error); + if (bounded) return bongo_cat_import_probe_exact(root, discovery, + NULL, BONGO_CAT_IMPORT_PROBE_FALLBACK, false, error); + /* An explicit nearby root uses broad Mver lookup so selecting an inner + authored folder still resolves to its canonical package root. */ + int found = bongo_cat_import_mver_discover(root, discovery, error); if (found <= 0) { memset(discovery, 0, sizeof(*discovery)); if (error) *error = (BongoCatError){0}; - found = bounded - ? bongo_cat_import_mver_patch_discover_exact(root, - discovery, error) - : bongo_cat_import_mver_patch_discover(root, discovery, error); + found = bongo_cat_import_mver_patch_discover(root, discovery, error); } if (found <= 0) { memset(discovery, 0, sizeof(*discovery)); @@ -222,9 +193,9 @@ static BongoCatResult import_root_unlocked(BongoCatApp *app, const char *root, static BongoCatResult import_root(BongoCatApp *app, const char *root, bool bounded, BongoCatError *error) { - bongo_cat_import_model_scan_lock(); + bongo_cat_import_storage_lock(); BongoCatResult result = import_root_unlocked(app, root, bounded, error); - bongo_cat_import_model_scan_unlock(); + bongo_cat_import_storage_unlock(); return result; } diff --git a/src/runtime/model/import/nearby/model_import_nearby_internal.h b/src/runtime/model/import/nearby/model_import_nearby_internal.h index c250122a..786ace44 100644 --- a/src/runtime/model/import/nearby/model_import_nearby_internal.h +++ b/src/runtime/model/import/nearby/model_import_nearby_internal.h @@ -7,9 +7,6 @@ #define BONGO_CAT_NEARBY_CACHE_SCHEMA 5 #define BONGO_CAT_ADAPTER_CACHE_DIRECTORY "model-adapters" -void bongo_cat_import_model_scan_lock(void); -void bongo_cat_import_model_scan_unlock(void); - bool bongo_cat_nearby_signature(const BongoCatImportCandidate *candidate, char output[65], BongoCatError *error); bool bongo_cat_nearby_cached_inspection(const char *target, diff --git a/src/runtime/model/import/tauri/model_import_tauri_convert.c b/src/runtime/model/import/tauri/model_import_tauri_convert.c index ee70e526..30e24788 100644 --- a/src/runtime/model/import/tauri/model_import_tauri_convert.c +++ b/src/runtime/model/import/tauri/model_import_tauri_convert.c @@ -2,6 +2,12 @@ #include "bongo_cat/path.h" #include +#include + +typedef struct TauriConversionWorkspace { + TauriKeyFiles left; + TauriKeyFiles right; +} TauriConversionWorkspace; static bool create_target_tree(const char *target, BongoCatModelMode mode, char *mode_root, size_t mode_capacity, char *model_root, @@ -26,22 +32,32 @@ bool bongo_cat_import_tauri_convert_to_mver( sizeof(mode_root), model_root, sizeof(model_root)) || !bongo_cat_tauri_copy_model_tree(candidate->directory, model_root, 0, error)) return false; + TauriConversionWorkspace *work = calloc(1, sizeof(*work)); + if (!work) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Cannot allocate Tauri conversion workspace"); + return false; + } char resource_directory[BONGO_CAT_PATH_CAP]; - TauriKeyFiles left, right; if (!bongo_cat_tauri_resource_root(candidate, resource_directory, sizeof(resource_directory)) || !bongo_cat_tauri_copy_input_images(candidate, resource_directory, - mode_root, &left, &right, error)) + mode_root, &work->left, &work->right, error)) { + free(work); return false; + } TauriMverCalibration calibration; char config[BONGO_CAT_PATH_CAP]; if (!bongo_cat_tauri_read_calibration(candidate, &calibration) || !bongo_cat_path_join(config, sizeof(config), target, "config.json") || - !bongo_cat_tauri_write_config(config, candidate->mode, &left, &right, - &calibration, error) || + !bongo_cat_tauri_write_config(config, candidate->mode, &work->left, + &work->right, &calibration, error) || !bongo_cat_tauri_copy_preview(candidate, mode_root, error) || - !bongo_cat_tauri_copy_runtime_images(candidate, mode_root, error)) + !bongo_cat_tauri_copy_runtime_images(candidate, mode_root, error)) { + free(work); return false; + } + free(work); *installed = *candidate; snprintf(installed->directory, sizeof(installed->directory), "%s", model_root); diff --git a/src/runtime/model/import/tauri/model_import_tauri_images.c b/src/runtime/model/import/tauri/model_import_tauri_images.c index 3fbdd9bc..e9a5beaa 100644 --- a/src/runtime/model/import/tauri/model_import_tauri_images.c +++ b/src/runtime/model/import/tauri/model_import_tauri_images.c @@ -9,10 +9,10 @@ static bool write_placeholder(const char *path) { static const unsigned char png[] = { 0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a,0x00,0x00,0x00,0x0d, 0x49,0x48,0x44,0x52,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x01, - 0x08,0x06,0x00,0x00,0x00,0x1f,0x15,0xc4,0x89,0x00,0x00,0x00, - 0x0d,0x49,0x44,0x41,0x54,0x08,0xd7,0x63,0xf8,0xcf,0xc0,0xf0, - 0x1f,0x00,0x05,0x00,0x01,0xff,0x89,0x99,0x3d,0x1d,0x00,0x00, - 0x00,0x00,0x49,0x45,0x4e,0x44,0xae,0x42,0x60,0x82 + 0x08,0x04,0x00,0x00,0x00,0xb5,0x1c,0x0c,0x02,0x00,0x00,0x00, + 0x0b,0x49,0x44,0x41,0x54,0x78,0xda,0x63,0x60,0x60,0x00,0x00, + 0x00,0x03,0x00,0x01,0x2b,0x09,0x4d,0x84,0x00,0x00,0x00,0x00, + 0x49,0x45,0x4e,0x44,0xae,0x42,0x60,0x82 }; FILE *file = bongo_cat_file_open(path, "wb"); if (!file) return false; @@ -97,10 +97,6 @@ bool bongo_cat_tauri_copy_preview(const BongoCatImportCandidate *candidate, have_cover ? source : NULL, cat, error)) return false; bool have_background = bongo_cat_tauri_find_resource_file(candidate, "background.png", source, sizeof(source)); - if (!have_background && have_cover) { - snprintf(source, sizeof(source), "%s", cat); - have_background = true; - } const char *background_name = candidate->mode == BONGO_CAT_MODE_STANDARD ? "mousebg.png" : "bg.png"; if (!bongo_cat_path_join(target, sizeof(target), mode_root, diff --git a/src/runtime/model/import/tauri/model_import_tauri_keys.c b/src/runtime/model/import/tauri/model_import_tauri_keys.c index a93ddb50..78cf46bf 100644 --- a/src/runtime/model/import/tauri/model_import_tauri_keys.c +++ b/src/runtime/model/import/tauri/model_import_tauri_keys.c @@ -114,21 +114,15 @@ static bool copy_keys(const BongoCatImportCandidate *candidate, return true; } -static bool ensure_key(const BongoCatImportCandidate *candidate, - const char *mode_root, const char *target_group, int fallback_code, - TauriKeyFiles *files, BongoCatError *error) { +static bool ensure_key(const char *mode_root, const char *target_group, + int fallback_code, TauriKeyFiles *files, BongoCatError *error) { if (files->count) return true; - char source[BONGO_CAT_PATH_CAP]; - bool have_source = bongo_cat_tauri_find_resource_file(candidate, - "cover.png", source, sizeof(source)) || - bongo_cat_tauri_find_resource_file(candidate, "background.png", - source, sizeof(source)); char group[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(group, sizeof(group), mode_root, target_group) || !bongo_cat_path_create_directory(group) || !bongo_cat_path_join(target, sizeof(target), group, "0.png") || - !bongo_cat_tauri_copy_image_or_placeholder( - have_source ? source : NULL, target, error)) return false; + !bongo_cat_tauri_copy_image_or_placeholder(NULL, target, error)) + return false; files->values[0].code = fallback_code; files->count = 1; return true; @@ -143,15 +137,14 @@ bool bongo_cat_tauri_copy_input_images( if (candidate->mode == BONGO_CAT_MODE_STANDARD) { return copy_keys(candidate, resource_directory, mode_root, "left-keys", "hand", left, error) && - ensure_key(candidate, mode_root, "hand", 65, - left, error); + ensure_key(mode_root, "hand", 65, left, error); } return copy_keys(candidate, resource_directory, mode_root, "left-keys", "lefthand", left, error) && copy_keys(candidate, resource_directory, mode_root, "right-keys", "righthand", right, error) && - ensure_key(candidate, mode_root, "lefthand", + ensure_key(mode_root, "lefthand", candidate->mode == BONGO_CAT_MODE_GAMEPAD ? 0 : 65, left, error) && - ensure_key(candidate, mode_root, "righthand", + ensure_key(mode_root, "righthand", candidate->mode == BONGO_CAT_MODE_GAMEPAD ? 1 : 65, right, error); } diff --git a/src/runtime/model/import/tauri/model_import_tauri_metadata.c b/src/runtime/model/import/tauri/model_import_tauri_metadata.c index 1beb31d4..f713e37b 100644 --- a/src/runtime/model/import/tauri/model_import_tauri_metadata.c +++ b/src/runtime/model/import/tauri/model_import_tauri_metadata.c @@ -11,6 +11,10 @@ static bool finite_number(yyjson_val *value) { return yyjson_is_num(value) && isfinite(yyjson_get_num(value)); } +static bool valid_canvas_size(int width, int height) { + return width >= 64 && width <= 8192 && height >= 64 && height <= 8192; +} + static bool read_offset(yyjson_val *value, double *x, double *y) { if (!yyjson_is_arr(value) || yyjson_arr_size(value) < 2 || !finite_number(yyjson_arr_get(value, 0)) || @@ -43,6 +47,24 @@ static bool read_window(yyjson_val *value, int *width, int *height) { return true; } +static bool legacy_converter_canvas( + const BongoCatImportCandidate *candidate, int width, int height) { + if (!candidate || !valid_canvas_size(width, height) || + width < 256 || height < 256) return false; + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_tauri_find_resource_directory(candidate, "left-keys", + path, sizeof(path))) return false; + if (candidate->mode == BONGO_CAT_MODE_STANDARD && width == height && + width >= 512) + return true; + if (!bongo_cat_tauri_find_resource_file(candidate, "cover.png", path, + sizeof(path))) return false; + int cover_width = 0, cover_height = 0; + return bongo_cat_image_info(path, &cover_width, &cover_height) && + valid_canvas_size(cover_width, cover_height) && + (cover_width != width || cover_height != height); +} + static void legacy_defaults(const BongoCatImportCandidate *candidate, TauriMverCalibration *calibration) { calibration->l2d_correct = 1.1; @@ -57,22 +79,23 @@ static void legacy_defaults(const BongoCatImportCandidate *candidate, if (!bongo_cat_tauri_find_resource_file(candidate, names[i], path, sizeof(path))) continue; int width = 0, height = 0; - if (bongo_cat_image_info(path, &width, &height) && width > 0 && - height > 0) { + if (bongo_cat_image_info(path, &width, &height) && + valid_canvas_size(width, height)) { calibration->window_width = width; calibration->window_height = height; break; } } - /* Converter packages before schemaVersion 1 had no Mver calibration. - Its square standard canvas matches the 0.1.6 standard profile. */ - if (candidate && candidate->mode == BONGO_CAT_MODE_STANDARD && - bongo_cat_tauri_find_resource_directory(candidate, "left-keys", path, - sizeof(path)) && - calibration->window_width == calibration->window_height && - calibration->window_width >= 512) { + /* Converter packages before schemaVersion 1 omitted their Mver projection + calibration. Their model scale is approximately 2.0 across modes; the + old 1.1 fallback leaves the Live2D layer much smaller than its authored + background and makes the keyboard or desk appear duplicated. */ + if (legacy_converter_canvas(candidate, calibration->window_width, + calibration->window_height)) { calibration->l2d_correct = 2.0; - calibration->l2d_offset_y = -0.005; + if (candidate->mode == BONGO_CAT_MODE_STANDARD && + calibration->window_width == calibration->window_height) + calibration->l2d_offset_y = -0.005; } } diff --git a/src/runtime/model/model_catalog_refresh.c b/src/runtime/model/model_catalog_refresh.c index 8cf8deb0..17085b55 100644 --- a/src/runtime/model/model_catalog_refresh.c +++ b/src/runtime/model/model_catalog_refresh.c @@ -31,10 +31,11 @@ struct BongoCatModelRefresh { Uint32 event_type; uint64_t revision; bool busy; - bool rerun; bool active_nearby; + bool rerun_full; bool rerun_nearby; - char rerun_package_id[BONGO_CAT_ID_CAP]; + size_t pending_package_count; + char pending_packages[BONGO_CAT_MODEL_CAP][BONGO_CAT_ID_CAP]; }; static BongoCatModelRefresh *create_refresh(void) { @@ -120,10 +121,7 @@ static bool start_refresh(BongoCatApp *app, bool include_nearby, memcpy(job->removed_models, app->settings.removed_models, job->removed_model_count * sizeof(job->removed_models[0])); refresh->busy = true; - refresh->rerun = false; refresh->active_nearby = include_nearby; - refresh->rerun_nearby = false; - refresh->rerun_package_id[0] = '\0'; refresh->worker = SDL_CreateThread(refresh_worker, BONGO_CAT_SLUG "-model-catalog", job); if (refresh->worker) return true; @@ -143,15 +141,23 @@ static void request_refresh(BongoCatApp *app, bool include_nearby, return; } if (refresh->busy) { - if (!refresh->rerun) - snprintf(refresh->rerun_package_id, - sizeof(refresh->rerun_package_id), "%s", - package_id ? package_id : ""); - else if (!package_id || !refresh->rerun_package_id[0] || - strcmp(refresh->rerun_package_id, package_id)) - refresh->rerun_package_id[0] = '\0'; - refresh->rerun = true; - refresh->rerun_nearby = refresh->rerun_nearby || include_nearby; + if (!package_id || include_nearby) { + refresh->rerun_full = true; + refresh->rerun_nearby = refresh->rerun_nearby || include_nearby; + refresh->pending_package_count = 0; + return; + } + if (refresh->rerun_full) return; + for (size_t i = 0; i < refresh->pending_package_count; ++i) + if (!strcmp(refresh->pending_packages[i], package_id)) return; + if (refresh->pending_package_count < BONGO_CAT_MODEL_CAP) + snprintf(refresh->pending_packages[ + refresh->pending_package_count++], BONGO_CAT_ID_CAP, + "%s", package_id); + else { + refresh->rerun_full = true; + refresh->pending_package_count = 0; + } return; } if (!start_refresh(app, include_nearby, package_id)) @@ -163,6 +169,10 @@ void bongo_cat_app_request_model_refresh(BongoCatApp *app) { request_refresh(app, false, NULL); } +bool bongo_cat_app_model_refresh_busy(const BongoCatApp *app) { + return app && app->model_refresh && app->model_refresh->busy; +} + void bongo_cat_app_request_model_package_refresh(BongoCatApp *app, const char *package_id) { if (!package_id || !package_id[0]) return; @@ -179,10 +189,10 @@ void bongo_cat_model_refresh_invalidate(BongoCatApp *app) { if (!refresh) return; refresh->revision++; if (refresh->busy) { - refresh->rerun = true; + refresh->rerun_full = true; refresh->rerun_nearby = refresh->rerun_nearby || refresh->active_nearby; - refresh->rerun_package_id[0] = '\0'; + refresh->pending_package_count = 0; } } @@ -217,15 +227,27 @@ void bongo_cat_model_refresh_update(BongoCatApp *app) { "Background model refresh failed: %s", job->error.message[0] ? job->error.message : "not enough memory"); free(job); - bool rerun = refresh->rerun; + bool rerun_full = refresh->rerun_full; bool include_nearby = refresh->rerun_nearby; char package_id[BONGO_CAT_ID_CAP]; - snprintf(package_id, sizeof(package_id), "%s", - refresh->rerun_package_id); - if (rerun && !start_refresh(app, include_nearby, + package_id[0] = '\0'; + if (rerun_full) { + refresh->rerun_full = false; + refresh->rerun_nearby = false; + refresh->pending_package_count = 0; + } else if (refresh->pending_package_count) { + snprintf(package_id, sizeof(package_id), "%s", + refresh->pending_packages[0]); + memmove(refresh->pending_packages, refresh->pending_packages + 1, + (refresh->pending_package_count - 1) * + sizeof(refresh->pending_packages[0])); + --refresh->pending_package_count; + } + if ((rerun_full || package_id[0]) && !start_refresh(app, include_nearby, package_id[0] ? package_id : NULL)) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Cannot restart background model refresh: %s", SDL_GetError()); + bongo_cat_preferences_invalidate(app->preferences); } bool bongo_cat_model_refresh_event(BongoCatApp *app, diff --git a/src/runtime/model/model_catalog_runtime.c b/src/runtime/model/model_catalog_runtime.c index 782a1cf6..74c768c3 100644 --- a/src/runtime/model/model_catalog_runtime.c +++ b/src/runtime/model/model_catalog_runtime.c @@ -1,6 +1,7 @@ #include "runtime.h" #include "model_catalog_selection.h" #include "model_import.h" +#include "model_import_lock.h" #include "model_storage.h" #include "bongo_cat/path.h" #include "bongo_cat/preferences.h" @@ -39,6 +40,23 @@ static bool shortcut_model_exists(const BongoCatModelCatalog *models, return false; } +static BongoCatModelCatalog *model_catalog_snapshot( + const BongoCatModelCatalog *models) { + if (!models) return NULL; + BongoCatModelCatalog *snapshot = malloc(sizeof(*snapshot)); + if (snapshot) *snapshot = *models; + return snapshot; +} + +static void restore_model_catalog(BongoCatApp *app, + const BongoCatModelCatalog *snapshot, const char *active_model_id) { + if (!app || !snapshot) return; + app->models = *snapshot; + if (active_model_id) + snprintf(app->session.active_model_id, + sizeof(app->session.active_model_id), "%s", active_model_id); +} + static void prune_behavior_shortcuts(BongoCatApp *app) { size_t output = 0; for (size_t i = 0; i < app->settings.behavior_shortcut_count; ++i) { @@ -65,25 +83,23 @@ static BongoCatResult scan_nearby_root(BongoCatApp *app, const char *root) { return result; } +static BongoCatResult scan_owned_storage(BongoCatApp *app, bool cleanup, + BongoCatError *error) { + BongoCatResult result = bongo_cat_model_install_builtins(app->asset_root, + app->models_root, error); + if (result != BONGO_CAT_OK) return result; + if (cleanup && !bongo_cat_model_cleanup_imports(app->models_root, error)) + return error && error->code ? error->code : BONGO_CAT_ERROR_IO; + return bongo_cat_models_scan(&app->models, app->models_root, false, + error); +} + static BongoCatResult scan_owned_models(BongoCatApp *app, bool cleanup) { BongoCatError error = {0}; bongo_cat_models_init(&app->models); - BongoCatResult result = bongo_cat_model_install_builtins(app->asset_root, - app->models_root, &error); - if (result != BONGO_CAT_OK) { - if (error.message[0]) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "%s", error.message); - return result; - } - if (cleanup) { - if (!bongo_cat_model_cleanup_imports(app->models_root, &error)) { - if (error.message[0]) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "%s", error.message); - return error.code ? error.code : BONGO_CAT_ERROR_IO; - } - } - result = bongo_cat_models_scan(&app->models, app->models_root, false, - &error); + bongo_cat_import_storage_lock(); + BongoCatResult result = scan_owned_storage(app, cleanup, &error); + bongo_cat_import_storage_unlock(); if (result != BONGO_CAT_OK) { if (error.message[0]) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", error.message); @@ -119,35 +135,40 @@ BongoCatResult bongo_cat_model_catalog_scan(BongoCatApp *app, bool cleanup, void bongo_cat_app_rescan_models(BongoCatApp *app) { if (!app) return; bongo_cat_model_refresh_invalidate(app); - BongoCatModelCatalog previous = app->models; + BongoCatModelCatalog *previous = model_catalog_snapshot(&app->models); + if (!previous) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Model catalog rescan skipped: cannot allocate catalog snapshot"); + return; + } char active_model_id[BONGO_CAT_ID_CAP]; snprintf(active_model_id, sizeof(active_model_id), "%s", app->session.active_model_id); BongoCatResult result = bongo_cat_model_catalog_scan(app, true, app->nearby_root); if (result != BONGO_CAT_OK) { - app->models = previous; - snprintf(app->session.active_model_id, - sizeof(app->session.active_model_id), "%s", active_model_id); + restore_model_catalog(app, previous, active_model_id); + free(previous); SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Model catalog rescan failed; keeping previous catalog"); return; } + free(previous); bongo_cat_model_catalog_finish(app); } void bongo_cat_app_refresh_installed_models(BongoCatApp *app) { if (!app) return; bongo_cat_model_refresh_invalidate(app); - BongoCatModelCatalog *previous = malloc(sizeof(*previous)); + BongoCatModelCatalog *previous = model_catalog_snapshot(&app->models); if (!previous) { - bongo_cat_app_rescan_models(app); + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Installed model refresh skipped: cannot allocate catalog snapshot"); return; } - *previous = app->models; BongoCatResult result = scan_owned_models(app, false); if (result != BONGO_CAT_OK) { - app->models = *previous; + restore_model_catalog(app, previous, NULL); free(previous); SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Installed model refresh failed; keeping previous catalog"); @@ -167,21 +188,26 @@ void bongo_cat_app_refresh_installed_models(BongoCatApp *app) { void bongo_cat_app_refresh_nearby_models(BongoCatApp *app) { if (!app || SDL_getenv("BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN")) return; bongo_cat_model_refresh_invalidate(app); + BongoCatModelCatalog *previous = model_catalog_snapshot(&app->models); + if (!previous) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Nearby model refresh skipped: cannot allocate catalog snapshot"); + return; + } char active_model_id[BONGO_CAT_ID_CAP]; snprintf(active_model_id, sizeof(active_model_id), "%s", app->session.active_model_id); - BongoCatModelCatalog previous = app->models; BongoCatResult result = scan_owned_models(app, false); if (result == BONGO_CAT_OK) result = scan_nearby_root(app, app->nearby_root); if (result != BONGO_CAT_OK) { - app->models = previous; - snprintf(app->session.active_model_id, - sizeof(app->session.active_model_id), "%s", active_model_id); + restore_model_catalog(app, previous, active_model_id); + free(previous); SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Nearby model refresh failed; keeping previous catalog"); return; } + free(previous); snprintf(app->session.active_model_id, sizeof(app->session.active_model_id), "%s", active_model_id); bongo_cat_model_catalog_finish(app); @@ -273,7 +299,13 @@ BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, "Cannot record removed model version: %s", id); return BONGO_CAT_ERROR_FORMAT; } - if (!shared_storage && !bongo_cat_model_remove_tree(directory, error)) { + bool removed = true; + if (!shared_storage) { + bongo_cat_import_storage_lock(); + removed = bongo_cat_model_remove_tree(directory, error); + bongo_cat_import_storage_unlock(); + } + if (!removed) { bongo_cat_app_rescan_models(app); bool restore_selection = additional; if (primary && bongo_cat_models_find(&app->models, id)) { diff --git a/src/runtime/runtime.h b/src/runtime/runtime.h index 7066396d..466fba7a 100644 --- a/src/runtime/runtime.h +++ b/src/runtime/runtime.h @@ -20,7 +20,6 @@ bool bongo_cat_model_refresh_event(BongoCatApp *app, const SDL_Event *event); void bongo_cat_model_refresh_update(BongoCatApp *app); void bongo_cat_model_refresh_shutdown(BongoCatApp *app); -void bongo_cat_import_nearby_shutdown(void); BongoCatResult bongo_cat_app_locate_assets(BongoCatApp *app, BongoCatError *error); bool bongo_cat_startup_prepare(BongoCatApp *app, int argc, char **argv, BongoCatError *error); diff --git a/src/ui/preferences/preferences_fonts.c b/src/ui/preferences/preferences_fonts.c index 814595f3..03ff71e0 100644 --- a/src/ui/preferences/preferences_fonts.c +++ b/src/ui/preferences/preferences_fonts.c @@ -63,7 +63,10 @@ bool bongo_cat_preferences_reload_fonts(BongoCatPreferences *value) { BongoCatPreferenceFonts fonts; bongo_cat_preferences_fonts_resolve(value, &fonts); bool reloaded = reload(value, &fonts, value->ui.raster_scale); - if (reloaded) value->font_reload_pending = false; + if (reloaded) { + value->font_reload_pending = false; + bongo_cat_preferences_model_glyphs_clear_pending(value); + } return reloaded; } diff --git a/src/ui/preferences/preferences_import.c b/src/ui/preferences/preferences_import.c index 11653aad..063bdc3d 100644 --- a/src/ui/preferences/preferences_import.c +++ b/src/ui/preferences/preferences_import.c @@ -89,6 +89,8 @@ static int SDLCALL import_worker(void *userdata) { SDL_ClearError(); } job->result = BONGO_CAT_ERROR_FORMAT; + SDL_Log("[runtime] Model import started: sources=%llu", + (unsigned long long)job->count); BongoCatImportSession *session = bongo_cat_import_session_create( job->models_root, &job->error); if (!session) { @@ -103,9 +105,17 @@ static int SDLCALL import_worker(void *userdata) { size_t before = progress.completed; BongoCatImportBatchStats stats = {0}; BongoCatError error = {0}; + SDL_Log("[runtime] Model import source started: index=%llu/%llu path=%s", + (unsigned long long)(i + 1), (unsigned long long)job->count, + job->paths[i]); BongoCatResult result = bongo_cat_import_session_install_progressive( session, job->paths[i], bongo_cat_preferences_import_receive, &progress, &stats, &error); + SDL_Log("[runtime] Model import source completed: index=%llu/%llu " + "result=%d succeeded=%llu failed=%llu", + (unsigned long long)(i + 1), (unsigned long long)job->count, + (int)result, (unsigned long long)stats.succeeded_count, + (unsigned long long)stats.failed_count); job->succeeded_count += stats.succeeded_count; job->failed_count += stats.failed_count; bongo_cat_preferences_import_merge_failures(job, &stats); @@ -117,10 +127,10 @@ static int SDLCALL import_worker(void *userdata) { if (stats.failed_count) { progress.completed += stats.failed_count; bongo_cat_preferences_import_report_progress(job, NULL, - progress.completed); + BONGO_CAT_MODEL_CAP, progress.completed); } else if (progress.completed == before) { bongo_cat_preferences_import_report_progress(job, NULL, - ++progress.completed); + BONGO_CAT_MODEL_CAP, ++progress.completed); } } bongo_cat_import_session_destroy(session); @@ -128,6 +138,12 @@ static int SDLCALL import_worker(void *userdata) { if (!job->succeeded_count && job->error.code == BONGO_CAT_OK) bongo_cat_error_set(&job->error, BONGO_CAT_ERROR_FORMAT, "No model could be imported"); + SDL_Log("[runtime] Model import finished: resolved=%llu installed=%llu " + "succeeded=%llu failed=%llu result=%d", + (unsigned long long)job->resolved_count, + (unsigned long long)job->installed_count, + (unsigned long long)job->succeeded_count, + (unsigned long long)job->failed_count, (int)job->result); SDL_Event event = {0}; SDL_LockMutex(job->dialog->mutex); bool notify = job->dialog->active; @@ -247,7 +263,10 @@ static void complete_job(BongoCatImportDialog *dialog, BongoCatApp *app, job->package_ids[i])) restored_count++; bool catalog_changed = job->installed_count > 0 || restored_count > 0; if (job->result == BONGO_CAT_OK && catalog_changed) - bongo_cat_app_request_model_refresh(app); + for (size_t i = 0; i < job->package_id_count; ++i) + if (!job->package_refresh_requested[i]) + bongo_cat_app_request_model_package_refresh(app, + job->package_ids[i]); bongo_cat_preferences_import_complete(app, job->result, &job->error, job->resolved_count, job->installed_count + restored_count, job->succeeded_count, job->failed_count, job->failed_names, diff --git a/src/ui/preferences/preferences_import_internal.h b/src/ui/preferences/preferences_import_internal.h index a919dc26..8b9bf8fa 100644 --- a/src/ui/preferences/preferences_import_internal.h +++ b/src/ui/preferences/preferences_import_internal.h @@ -9,6 +9,7 @@ typedef struct BongoCatImportJob { char **paths; char models_root[BONGO_CAT_PATH_CAP]; char package_ids[BONGO_CAT_MODEL_CAP][BONGO_CAT_ID_CAP]; + bool package_refresh_requested[BONGO_CAT_MODEL_CAP]; size_t package_id_count; size_t resolved_count; size_t installed_count; @@ -30,6 +31,7 @@ typedef struct BongoCatImportProgressContext { typedef struct BongoCatImportProgress { BongoCatImportJob *job; char package_id[BONGO_CAT_ID_CAP]; + size_t package_index; size_t installed_count; } BongoCatImportProgress; @@ -55,7 +57,8 @@ struct BongoCatImportDialog { }; void bongo_cat_preferences_import_report_progress(BongoCatImportJob *job, - const BongoCatImportReceipt *receipt, size_t completed); + const BongoCatImportReceipt *receipt, size_t package_index, + size_t completed); void bongo_cat_preferences_import_receive(void *userdata, const BongoCatImportReceipt *receipt); void bongo_cat_preferences_import_record_failure(BongoCatImportJob *job, diff --git a/src/ui/preferences/preferences_import_progress.c b/src/ui/preferences/preferences_import_progress.c index 4cd53d70..c0d13f90 100644 --- a/src/ui/preferences/preferences_import_progress.c +++ b/src/ui/preferences/preferences_import_progress.c @@ -1,4 +1,5 @@ #include "preferences_import_internal.h" +#include "preferences_model_glyphs.h" #include "model_import.h" #include "bongo_cat/path.h" @@ -24,33 +25,38 @@ void bongo_cat_preferences_import_merge_failures(BongoCatImportJob *job, stats->failure_names[i]); } -static void remember_package(BongoCatImportJob *job, const char *id) { - if (!job || !id || !id[0]) return; +static size_t remember_package(BongoCatImportJob *job, const char *id) { + if (!job || !id || !id[0]) return BONGO_CAT_MODEL_CAP; for (size_t i = 0; i < job->package_id_count; ++i) - if (!strcmp(job->package_ids[i], id)) return; - if (job->package_id_count >= BONGO_CAT_MODEL_CAP) return; - snprintf(job->package_ids[job->package_id_count++], BONGO_CAT_ID_CAP, - "%s", id); + if (!strcmp(job->package_ids[i], id)) return i; + if (job->package_id_count >= BONGO_CAT_MODEL_CAP) + return BONGO_CAT_MODEL_CAP; + size_t index = job->package_id_count++; + snprintf(job->package_ids[index], BONGO_CAT_ID_CAP, "%s", id); + return index; } void bongo_cat_preferences_import_receive(void *userdata, const BongoCatImportReceipt *receipt) { BongoCatImportProgressContext *progress = userdata; BongoCatImportJob *job = progress->job; - if (receipt->count) remember_package(job, receipt->ids[0]); + size_t package_index = receipt->count + ? remember_package(job, receipt->ids[0]) : BONGO_CAT_MODEL_CAP; job->resolved_count += receipt->count; job->installed_count += receipt->installed_count; job->result = BONGO_CAT_OK; - bongo_cat_preferences_import_report_progress(job, receipt, + bongo_cat_preferences_import_report_progress(job, receipt, package_index, ++progress->completed); } void bongo_cat_preferences_import_report_progress(BongoCatImportJob *job, - const BongoCatImportReceipt *receipt, size_t completed) { + const BongoCatImportReceipt *receipt, size_t package_index, + size_t completed) { BongoCatImportProgress *progress = receipt && receipt->count ? SDL_calloc(1, sizeof(*progress)) : NULL; if (progress) { progress->job = job; + progress->package_index = package_index; progress->installed_count = receipt->installed_count; bongo_cat_import_package_base_id(progress->package_id, sizeof(progress->package_id), receipt->ids[0]); @@ -92,8 +98,15 @@ bool bongo_cat_preferences_import_progress_event( &app->settings, progress->package_id); if (restored) progress->job->restored_count++; if (progress->installed_count || restored) + bongo_cat_preferences_model_glyphs_note(app->preferences, + progress->package_id); + if (progress->installed_count || restored) { + if (progress->package_index < BONGO_CAT_MODEL_CAP) + progress->job->package_refresh_requested[ + progress->package_index] = true; bongo_cat_app_request_model_package_refresh(app, progress->package_id); + } } SDL_free(progress); return true; diff --git a/src/ui/preferences/preferences_model.c b/src/ui/preferences/preferences_model.c index 585aa3c1..5bd41bb8 100644 --- a/src/ui/preferences/preferences_model.c +++ b/src/ui/preferences/preferences_model.c @@ -251,13 +251,14 @@ static int preset_model_order(const BongoCatModelEntry *entry) { } static void draw_models(BongoCatPreferences *value, - struct nk_context *context, bool managed) { + struct nk_context *context, bool managed, bool storage_busy) { for (int order = 0; order <= 3; ++order) { for (size_t i = 0; i < value->app->models.count; ++i) { const BongoCatModelEntry *entry = &value->app->models.entries[i]; if (entry->managed != managed || preset_model_order(entry) != order) continue; - bongo_cat_preferences_model_card(value, context, entry); + bongo_cat_preferences_model_card(value, context, entry, + storage_busy); } } } @@ -284,12 +285,15 @@ void bongo_cat_preferences_page_model(BongoCatPreferences *value, nk_layout_row_dynamic(context, MODEL_CARD_HEIGHT, columns); if (bongo_cat_preferences_model_import_card(value, context)) bongo_cat_preferences_request_model_import(app->preferences); - draw_models(value, context, false); + bool storage_busy = bongo_cat_preferences_import_status( + value->import_dialog, NULL, NULL, NULL) || + bongo_cat_app_model_refresh_busy(app); + draw_models(value, context, false, storage_busy); if (model_count(value, true)) { bongo_cat_pref_section(context, tr(app, "pages.preference.model.nearbyTitle", "Nearby models")); nk_layout_row_dynamic(context, MODEL_CARD_HEIGHT, columns); - draw_models(value, context, true); + draw_models(value, context, true, storage_busy); } context->style.window.spacing = old_spacing; bongo_cat_preferences_model_covers_prune(app); diff --git a/src/ui/preferences/preferences_model_card.c b/src/ui/preferences/preferences_model_card.c index f021c22f..5512d6b4 100644 --- a/src/ui/preferences/preferences_model_card.c +++ b/src/ui/preferences/preferences_model_card.c @@ -155,11 +155,13 @@ static void draw_cover(BongoCatPreferences *value, static void draw_actions(BongoCatPreferences *value, struct nk_context *context, struct nk_command_buffer *canvas, struct nk_rect bounds, const BongoCatModelEntry *entry, - BongoCatUIPalette p, bool selected, bool primary, bool *action_hover) { + BongoCatUIPalette p, bool selected, bool primary, bool storage_busy, + bool *action_hover) { struct nk_rect actions = nk_rect(bounds.x + 1, bounds.y + bounds.h - 39, bounds.w - 2, 38); nk_fill_rect(canvas, actions, 0, p.field); bool deletable = !entry->preset && !entry->managed; + bool delete_enabled = deletable && !storage_busy; float width = actions.w / (deletable ? 3.0f : 2.0f); struct nk_rect items[3] = { nk_rect(actions.x, actions.y, width, actions.h), @@ -173,7 +175,7 @@ static void draw_actions(BongoCatPreferences *value, deletable && nk_input_is_mouse_hovering_rect( &context->input, items[2])}; bool hover[3] = {behavior_enabled && region_hover[0], - region_hover[1], region_hover[2]}; + region_hover[1], delete_enabled && region_hover[2]}; for (int i = 1; i < (deletable ? 3 : 2); ++i) nk_stroke_line(canvas, items[i].x, items[i].y + 10, items[i].x, items[i].y + items[i].h - 10, 1, p.border_subtle); @@ -184,7 +186,7 @@ static void draw_actions(BongoCatPreferences *value, action_icon(value, canvas, items[1], BONGO_CAT_UI_ICON_FOLDER, hover[1] ? p.pink : p.muted); if (deletable) action_icon(value, canvas, items[2], BONGO_CAT_UI_ICON_TRASH, - hover[2] ? p.danger : p.muted); + hover[2] ? p.danger : delete_enabled ? p.muted : p.border_subtle); *action_hover = region_hover[0] || region_hover[1] || region_hover[2]; if (hover[0] && nk_input_is_mouse_click_in_rect(&context->input, NK_BUTTON_LEFT, items[0])) { @@ -211,7 +213,8 @@ static void draw_selection_badge(BongoCatPreferences *value, } void bongo_cat_preferences_model_card(BongoCatPreferences *value, - struct nk_context *context, const BongoCatModelEntry *entry) { + struct nk_context *context, const BongoCatModelEntry *entry, + bool storage_busy) { BongoCatApp *app = value->app; bool selected = bongo_cat_app_model_active(app, entry->id); bool primary = !strcmp(entry->id, app->session.active_model_id); @@ -257,7 +260,7 @@ void bongo_cat_preferences_model_card(BongoCatPreferences *value, context, canvas, entry, name_bounds, p); bool action_hover = false; draw_actions(value, context, canvas, bounds, entry, p, selected, primary, - &action_hover); + storage_busy, &action_hover); float outline_width = 1.0f + selection_amount; struct nk_rect outline = bongo_cat_preferences_model_card_outline_bounds( bounds, outline_width); diff --git a/src/ui/preferences/preferences_model_card.h b/src/ui/preferences/preferences_model_card.h index 50bb6e6b..d37b3ce8 100644 --- a/src/ui/preferences/preferences_model_card.h +++ b/src/ui/preferences/preferences_model_card.h @@ -11,7 +11,8 @@ bool bongo_cat_preferences_model_import_card(BongoCatPreferences *value, bool bongo_cat_preferences_model_section(BongoCatPreferences *value, struct nk_context *context); void bongo_cat_preferences_model_card(BongoCatPreferences *value, - struct nk_context *context, const BongoCatModelEntry *entry); + struct nk_context *context, const BongoCatModelEntry *entry, + bool storage_busy); void bongo_cat_preferences_model_select(BongoCatPreferences *value, const BongoCatModelEntry *entry); diff --git a/src/ui/preferences/preferences_model_glyphs.c b/src/ui/preferences/preferences_model_glyphs.c index c485b49c..76feaf41 100644 --- a/src/ui/preferences/preferences_model_glyphs.c +++ b/src/ui/preferences/preferences_model_glyphs.c @@ -1,6 +1,7 @@ #include "preferences_state.h" #include "preferences_model_glyphs.h" +#include #include static bool glyph_covered(const uint32_t *ranges, size_t used, uint32_t rune) { @@ -12,11 +13,16 @@ static bool glyph_covered(const uint32_t *ranges, size_t used, uint32_t rune) { static void add_text(uint32_t *ranges, size_t capacity, size_t *used, const char *text) { const char *cursor = text; + size_t remaining = text ? strlen(text) : 0; while (cursor && *cursor) { nk_rune rune = 0; - int decoded = nk_utf_decode(cursor, &rune, (int)strlen(cursor)); - if (decoded <= 0) { cursor++; continue; } - cursor += decoded; + int decoded = nk_utf_decode(cursor, &rune, (int)remaining); + if (decoded <= 0) { + cursor++; + if (remaining) remaining--; + continue; + } + cursor += decoded; remaining -= (size_t)decoded; if (rune <= 0x7e || glyph_covered(ranges, *used, rune)) continue; if (*used + 2 >= capacity) return; ranges[(*used)++] = rune; @@ -25,6 +31,46 @@ static void add_text(uint32_t *ranges, size_t capacity, } } +static bool text_covered(const uint32_t *ranges, size_t capacity, + const char *text) { + if (!ranges || !capacity || !text || !text[0]) return false; + size_t used = 0; + while (used < capacity && ranges[used]) used++; + const char *cursor = text; + size_t remaining = text ? strlen(text) : 0; + while (cursor && *cursor) { + nk_rune rune = 0; + int decoded = nk_utf_decode(cursor, &rune, (int)remaining); + if (decoded <= 0) return false; + if (rune > 0x7e && !glyph_covered(ranges, used, rune)) return false; + cursor += decoded; + remaining -= (size_t)decoded; + } + return true; +} + +void bongo_cat_preferences_model_glyphs_note( + BongoCatPreferences *preferences, const char *name) { + if (!preferences || !name || !name[0]) return; + for (size_t i = 0; i < preferences->pending_import_name_count; ++i) + if (!strcmp(preferences->pending_import_names[i], name)) return; + if (preferences->pending_import_name_count >= BONGO_CAT_MODEL_CAP) return; + snprintf(preferences->pending_import_names[ + preferences->pending_import_name_count++], BONGO_CAT_ID_CAP, "%s", name); +} + +bool bongo_cat_preferences_model_glyphs_ready( + const BongoCatPreferences *preferences, const char *name) { + return preferences && text_covered(preferences->glyph_ranges, + sizeof(preferences->glyph_ranges) / + sizeof(preferences->glyph_ranges[0]), name); +} + +void bongo_cat_preferences_model_glyphs_clear_pending( + BongoCatPreferences *preferences) { + if (preferences) preferences->pending_import_name_count = 0; +} + void bongo_cat_preferences_model_glyphs(const BongoCatApp *app, uint32_t *ranges, size_t capacity) { if (!app || !ranges) return; @@ -42,4 +88,10 @@ void bongo_cat_preferences_model_glyphs(const BongoCatApp *app, i < app->settings.behavior_shortcut_count; ++i) add_text(ranges, capacity, &used, app->settings.behavior_shortcuts[i].label); + if (app->preferences) { + const BongoCatPreferences *preferences = app->preferences; + for (size_t i = 0; i < preferences->pending_import_name_count; ++i) + add_text(ranges, capacity, &used, + preferences->pending_import_names[i]); + } } diff --git a/src/ui/preferences/preferences_model_glyphs.h b/src/ui/preferences/preferences_model_glyphs.h index 273e0966..54621cc1 100644 --- a/src/ui/preferences/preferences_model_glyphs.h +++ b/src/ui/preferences/preferences_model_glyphs.h @@ -5,8 +5,15 @@ #include typedef struct BongoCatApp BongoCatApp; +typedef struct BongoCatPreferences BongoCatPreferences; void bongo_cat_preferences_model_glyphs(const BongoCatApp *app, uint32_t *ranges, size_t capacity); +void bongo_cat_preferences_model_glyphs_note(BongoCatPreferences *preferences, + const char *name); +bool bongo_cat_preferences_model_glyphs_ready( + const BongoCatPreferences *preferences, const char *name); +void bongo_cat_preferences_model_glyphs_clear_pending( + BongoCatPreferences *preferences); #endif diff --git a/src/ui/preferences/preferences_model_rename.c b/src/ui/preferences/preferences_model_rename.c index bbcb1096..ce6233d0 100644 --- a/src/ui/preferences/preferences_model_rename.c +++ b/src/ui/preferences/preferences_model_rename.c @@ -1,4 +1,5 @@ #include "preferences_state.h" +#include "preferences_model_glyphs.h" #include "preferences_text_edit.h" #include @@ -20,6 +21,11 @@ bool bongo_cat_preferences_model_name_draw(BongoCatPreferences *value, } const char *shown = renaming ? session->text : bongo_cat_model_name(&value->app->settings, entry); + if (!renaming && value->import_render_active && + value->font_reload_pending && + !bongo_cat_preferences_model_glyphs_ready(value, shown)) + shown = bongo_cat_i18n_get(value->app->i18n, "native.importing", + "Importing..."); struct nk_rect text_bounds = nk_rect(bounds.x + 5, bounds.y + 3, bounds.w - 10, 22); if (renaming && session->select_all) diff --git a/src/ui/preferences/preferences_render.c b/src/ui/preferences/preferences_render.c index 1f429cdd..d3cb51a2 100644 --- a/src/ui/preferences/preferences_render.c +++ b/src/ui/preferences/preferences_render.c @@ -236,9 +236,16 @@ void bongo_cat_preferences_render(BongoCatPreferences *value) { "Preferences GL context could not be activated: %s", SDL_GetError()); return; } - value->render_retry_ns = 0; bongo_cat_preferences_refresh_raster(value); - bongo_cat_preferences_reload_language(value); - if (value->font_reload_pending) { + value->render_retry_ns = 0; + bool importing = bongo_cat_preferences_import_status( + value->import_dialog, NULL, NULL, NULL); + value->import_render_active = importing; + bool refreshing_models = bongo_cat_app_model_refresh_busy(value->app); + if (!importing && !refreshing_models) { + bongo_cat_preferences_refresh_raster(value); + bongo_cat_preferences_reload_language(value); + } + if (value->font_reload_pending && !importing && !refreshing_models) { if (value->font_reload_defer_once) { value->font_reload_defer_once = false; value->render_dirty = true; @@ -274,7 +281,6 @@ void bongo_cat_preferences_render(BongoCatPreferences *value) { "Preferences frame presentation failed: %s", SDL_GetError()); } else bongo_cat_memory_policy_ui_frame_presented(); bongo_cat_preferences_record_frame(value); - bool importing = bongo_cat_preferences_import_status(value->import_dialog, NULL, NULL, NULL); if (value->shortcut_recording || value->model_load_visual_active || importing || bongo_cat_pref_controls_animating(&value->ui.context) || bongo_cat_ui_animations_active(&value->ui.context)) diff --git a/src/ui/preferences/preferences_state.h b/src/ui/preferences/preferences_state.h index 521a64ac..7bc6955c 100644 --- a/src/ui/preferences/preferences_state.h +++ b/src/ui/preferences/preferences_state.h @@ -49,11 +49,14 @@ struct BongoCatPreferences { bool input_active; bool import_requested; bool import_drop_active; + bool import_render_active; BongoCatImportDialog *import_dialog; bool frame_checked; bool render_dirty; bool font_reload_pending; bool font_reload_defer_once; + size_t pending_import_name_count; + char pending_import_names[BONGO_CAT_MODEL_CAP][BONGO_CAT_ID_CAP]; bool model_glyphs_loaded; bool model_directory_watch_active; bool model_directory_watch_known; diff --git a/src/ui/rendering/ui_font_atlas_upload.c b/src/ui/rendering/ui_font_atlas_upload.c index 092ee64d..05294d6f 100644 --- a/src/ui/rendering/ui_font_atlas_upload.c +++ b/src/ui/rendering/ui_font_atlas_upload.c @@ -7,6 +7,11 @@ bool bongo_cat_ui_font_upload_atlas(BongoCatUIBackend *ui) { const void *pixels = nk_font_atlas_bake(&ui->atlas, &width, &height, NK_FONT_ATLAS_ALPHA8); if (!pixels || width < 1 || height < 1) return false; + SDL_Window *window = SDL_GL_GetCurrentWindow(); + SDL_GLContext context = SDL_GL_GetCurrentContext(); + SDL_Log("Preferences font atlas upload starting: %dx%d window=%u " + "context=%p", width, height, + window ? (unsigned)SDL_GetWindowID(window) : 0, (void *)context); GLint maximum = 0; glGetIntegerv(GL_MAX_TEXTURE_SIZE, &maximum); if (width > maximum || height > maximum) { @@ -44,5 +49,7 @@ bool bongo_cat_ui_font_upload_atlas(BongoCatUIBackend *ui) { } nk_font_atlas_end(&ui->atlas, nk_handle_id((int)ui->font_texture), &ui->null_texture); + SDL_Log("Preferences font atlas upload completed: texture=%u size=%dx%d", + ui->font_texture, width, height); return true; } From 99b8d0f325dff96956e8f2acbe773ffbb66c1a34 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 30 Aug 2026 21:03:02 +0800 Subject: [PATCH 162/334] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9329b971..22f87803 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@

- 💘C/C++ × SDL3 × OpenGL, stir it up, mash it together! Bong~ Bongocat!!! + 💘C/C++ × SDL3 × OpenGL, stir it up, mash it together! Bong~ Bongo Cat!!!

From 940e40ebc0f94e889b6444f90015a206e664f2c9 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 30 Aug 2026 22:45:03 +0800 Subject: [PATCH 163/334] fix: correct legacy Tauri model projection fallback --- .../tauri/model_import_tauri_metadata.c | 39 ++++++++++--------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/src/runtime/model/import/tauri/model_import_tauri_metadata.c b/src/runtime/model/import/tauri/model_import_tauri_metadata.c index f713e37b..c4ef3096 100644 --- a/src/runtime/model/import/tauri/model_import_tauri_metadata.c +++ b/src/runtime/model/import/tauri/model_import_tauri_metadata.c @@ -47,32 +47,29 @@ static bool read_window(yyjson_val *value, int *width, int *height) { return true; } -static bool legacy_converter_canvas( +static bool legacy_converter_double_scale( const BongoCatImportCandidate *candidate, int width, int height) { if (!candidate || !valid_canvas_size(width, height) || width < 256 || height < 256) return false; char path[BONGO_CAT_PATH_CAP]; if (!bongo_cat_tauri_find_resource_directory(candidate, "left-keys", path, sizeof(path))) return false; - if (candidate->mode == BONGO_CAT_MODE_STANDARD && width == height && - width >= 512) - return true; - if (!bongo_cat_tauri_find_resource_file(candidate, "cover.png", path, - sizeof(path))) return false; - int cover_width = 0, cover_height = 0; - return bongo_cat_image_info(path, &cover_width, &cover_height) && - valid_canvas_size(cover_width, cover_height) && - (cover_width != width || cover_height != height); + if (candidate->mode != BONGO_CAT_MODE_STANDARD) return true; + return width == height && width >= 512; } -static void legacy_defaults(const BongoCatImportCandidate *candidate, - TauriMverCalibration *calibration) { +static void mver_defaults(TauriMverCalibration *calibration) { calibration->l2d_correct = 1.1; calibration->l2d_offset_x = 0.0; calibration->l2d_offset_y = 0.0; calibration->window_width = 612; calibration->window_height = 352; calibration->mirror = false; +} + +static void legacy_defaults(const BongoCatImportCandidate *candidate, + TauriMverCalibration *calibration) { + mver_defaults(calibration); char path[BONGO_CAT_PATH_CAP]; const char *names[] = {"background.png", "cover.png"}; for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) { @@ -87,10 +84,11 @@ static void legacy_defaults(const BongoCatImportCandidate *candidate, } } /* Converter packages before schemaVersion 1 omitted their Mver projection - calibration. Their model scale is approximately 2.0 across modes; the - old 1.1 fallback leaves the Live2D layer much smaller than its authored - background and makes the keyboard or desk appear duplicated. */ - if (legacy_converter_canvas(candidate, calibration->window_width, + calibration. Keyboard/gamepad packages and the converter's square + standard profile use approximately 2.0. Non-square standard packages + retain Mver's 1.1 default; cover.png is only a cropped preview and must + not be used to infer runtime projection. */ + if (legacy_converter_double_scale(candidate, calibration->window_width, calibration->window_height)) { calibration->l2d_correct = 2.0; if (candidate->mode == BONGO_CAT_MODE_STANDARD && @@ -103,10 +101,12 @@ bool bongo_cat_tauri_read_calibration( const BongoCatImportCandidate *candidate, TauriMverCalibration *calibration) { if (!candidate || !calibration) return false; - legacy_defaults(candidate, calibration); char path[BONGO_CAT_PATH_CAP]; if (!bongo_cat_tauri_find_package_file(candidate, - BONGO_CAT_TAURI_SOURCE_FILE, path, sizeof(path))) return true; + BONGO_CAT_TAURI_SOURCE_FILE, path, sizeof(path))) { + legacy_defaults(candidate, calibration); + return true; + } yyjson_doc *document = bongo_cat_json_read_file(path, 0, NULL); yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; yyjson_val *schema = yyjson_obj_get(root, "schemaVersion"); @@ -118,6 +118,7 @@ bool bongo_cat_tauri_read_calibration( (!mode || strcmp(mode, bongo_cat_mode_name(candidate->mode)) == 0) && yyjson_is_obj(decoration); if (valid) { + mver_defaults(calibration); yyjson_val *scale = yyjson_obj_get(decoration, "l2d_correct"); if (finite_number(scale)) { double value = yyjson_get_num(scale); @@ -131,7 +132,7 @@ bool bongo_cat_tauri_read_calibration( yyjson_val *mirror = yyjson_obj_get(decoration, "l2d_horizontal_flip"); if (yyjson_is_bool(mirror)) calibration->mirror = yyjson_get_bool(mirror); - } + } else legacy_defaults(candidate, calibration); yyjson_doc_free(document); return true; } From 3bb67b71bfbc179d379d854531cfcbcfa1751b24 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 30 Aug 2026 22:56:20 +0800 Subject: [PATCH 164/334] chore: bump version to 1.3.0 --- CMakeLists.txt | 3 ++- packaging/get-project-version.ps1 | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index f8cffe96..cc0a0bfc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,6 @@ cmake_minimum_required(VERSION 3.24) -project(BongoCat VERSION 1.2.0 DESCRIPTION "BongoCat" HOMEPAGE_URL "https://github.com/vladelaina/BongoCat" LANGUAGES C CXX) +set(BONGO_CAT_VERSION "1.3.0") +project(BongoCat VERSION ${BONGO_CAT_VERSION} DESCRIPTION "BongoCat" HOMEPAGE_URL "https://github.com/vladelaina/BongoCat" LANGUAGES C CXX) include(cmake/Version.cmake) include(FetchContent) include(CTest) diff --git a/packaging/get-project-version.ps1 b/packaging/get-project-version.ps1 index 76d239b8..f80705bb 100644 --- a/packaging/get-project-version.ps1 +++ b/packaging/get-project-version.ps1 @@ -8,7 +8,7 @@ $repositoryRoot = Split-Path $PSScriptRoot -Parent $cmakeLists = Get-Content -LiteralPath ( Join-Path $repositoryRoot 'CMakeLists.txt') -Raw if ($cmakeLists -notmatch - '(?m)^project\(BongoCat VERSION ([0-9]+)\.([0-9]+)\.([0-9]+)\b') { + '(?m)^\s*set\(BONGO_CAT_VERSION\s+"?([0-9]+)\.([0-9]+)\.([0-9]+)') { throw 'Unable to read the BongoCat version from CMakeLists.txt.' } From 4970896e3c6a1d0a2c10185152ca701648ed8234 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 31 Aug 2026 00:13:18 +0800 Subject: [PATCH 165/334] refactor: split oversized source files --- CMakeLists.txt | 2 + cmake/CheckLines.cmake | 2 +- cmake/Packaging.cmake | 78 +------ cmake/PackagingWindowsInit.cmake | 77 +++++++ cmake/PlatformRuntime.cmake | 4 +- cmake/RuntimeSources.cmake | 14 +- src/live2d/cubism_model_bounds.cpp | 91 -------- src/live2d/cubism_model_load.cpp | 25 --- src/live2d/cubism_model_renderer.cpp | 27 +++ src/live2d/cubism_model_update.cpp | 111 ---------- src/live2d/cubism_model_viewport.cpp | 102 +++++++++ src/live2d/cubism_motion_playback.cpp | 5 + src/live2d/cubism_motion_preview.cpp | 118 ++++++++++ src/live2d/cubism_motion_state.cpp | 5 - src/platform/macos/macos.m | 70 ------ src/platform/macos/macos_tray.m | 96 ++++++++ src/platform/windows/windows_layered.c | 55 +---- .../windows/windows_layered_internal.h | 26 +++ src/platform/windows/windows_layered_state.c | 53 +++++ src/runtime/diagnostics/live2d_audit.c | 122 +++-------- .../diagnostics/live2d_pointer_audit.c | 80 +++++++ .../diagnostics/live2d_pointer_audit.h | 21 ++ src/runtime/lifecycle/app.c | 132 ----------- src/runtime/lifecycle/app_loop.c | 167 ++++++++++++++ .../model/import/model_import_digest.c | 46 +--- .../import/model_import_digest_internal.h | 14 ++ .../model/import/model_import_digest_path.c | 51 +++++ .../model/import/model_import_install.c | 207 ++++++++++++++++++ .../model/import/model_import_package.c | 57 ++++- src/runtime/model/import/model_import_path.c | 26 +++ src/runtime/model/import/model_import_path.h | 2 + .../model/import/model_import_session.c | 204 +---------------- .../import/model_import_session_internal.h | 5 + .../import/mver/model_import_mver_copy.c | 43 +--- src/runtime/model/model_behavior_cache.c | 41 ++++ src/runtime/model/model_behavior_cache.h | 11 + src/runtime/model/model_catalog_remove.c | 143 ++++++++++++ src/runtime/model/model_catalog_runtime.c | 133 ----------- src/runtime/model/model_files.c | 42 +--- src/runtime/model/multi_pet.c | 65 ------ src/runtime/model/multi_pet_process.c | 11 +- src/runtime/model/multi_pet_selection.c | 78 +++++++ src/runtime/runtime.h | 1 + src/runtime/shell/window_wheel.c | 123 +---------- src/runtime/shell/window_wheel_internal.h | 8 + src/runtime/shell/window_wheel_test.c | 119 ++++++++++ src/ui/backend/ui_backend.c | 34 --- src/ui/backend/ui_backend_fonts.c | 35 +++ src/ui/preferences/preferences_import.c | 123 ++--------- .../preferences/preferences_import_internal.h | 4 + .../preferences/preferences_import_worker.c | 92 ++++++++ src/ui/preferences/preferences_model.c | 82 ------- src/ui/preferences/preferences_model_card.c | 16 +- .../preferences_model_card_paint.c | 16 ++ .../preferences_model_card_paint.h | 5 +- src/ui/preferences/preferences_model_import.c | 92 ++++++++ src/ui/preferences/preferences_present.c | 98 +++++++++ src/ui/preferences/preferences_render.c | 89 +------- .../preferences/preferences_render_internal.h | 9 + src/ui/rendering/ui_paint.c | 10 - src/ui/rendering/ui_paint_lifecycle.c | 10 + tests/model_import/test_tauri_portable.c | 4 +- 62 files changed, 2002 insertions(+), 1630 deletions(-) create mode 100644 cmake/PackagingWindowsInit.cmake create mode 100644 src/live2d/cubism_model_viewport.cpp create mode 100644 src/live2d/cubism_motion_preview.cpp create mode 100644 src/platform/macos/macos_tray.m create mode 100644 src/platform/windows/windows_layered_internal.h create mode 100644 src/platform/windows/windows_layered_state.c create mode 100644 src/runtime/diagnostics/live2d_pointer_audit.c create mode 100644 src/runtime/diagnostics/live2d_pointer_audit.h create mode 100644 src/runtime/lifecycle/app_loop.c create mode 100644 src/runtime/model/import/model_import_digest_internal.h create mode 100644 src/runtime/model/import/model_import_digest_path.c create mode 100644 src/runtime/model/import/model_import_install.c create mode 100644 src/runtime/model/model_behavior_cache.c create mode 100644 src/runtime/model/model_behavior_cache.h create mode 100644 src/runtime/model/model_catalog_remove.c create mode 100644 src/runtime/shell/window_wheel_internal.h create mode 100644 src/runtime/shell/window_wheel_test.c create mode 100644 src/ui/backend/ui_backend_fonts.c create mode 100644 src/ui/preferences/preferences_import_worker.c create mode 100644 src/ui/preferences/preferences_model_import.c create mode 100644 src/ui/preferences/preferences_present.c create mode 100644 src/ui/preferences/preferences_render_internal.h create mode 100644 src/ui/rendering/ui_paint_lifecycle.c diff --git a/CMakeLists.txt b/CMakeLists.txt index cc0a0bfc..913e4860 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -54,9 +54,11 @@ if(EXISTS "${CUBISM_CORE_HEADER}") src/live2d/cubism_parameter_overrides.cpp src/live2d/cubism_cover_capture.cpp src/live2d/cubism_motion_playback.cpp + src/live2d/cubism_motion_preview.cpp src/live2d/cubism_motion_selection.cpp src/live2d/cubism_motion_state.cpp src/live2d/cubism_viewer_look.cpp src/live2d/cubism_model_bounds.cpp + src/live2d/cubism_model_viewport.cpp src/live2d/cubism_model_validate.cpp) set(BONGO_CAT_CUBISM_ENABLED ON) endif() diff --git a/cmake/CheckLines.cmake b/cmake/CheckLines.cmake index 44f0f3ca..acf4985e 100644 --- a/cmake/CheckLines.cmake +++ b/cmake/CheckLines.cmake @@ -24,7 +24,7 @@ foreach(FILE IN LISTS FILES) file(RELATIVE_PATH RELATIVE_FILE "${ROOT}" "${FILE}") string(REPLACE "\\" "/" RELATIVE_FILE "${RELATIVE_FILE}") - set(LIMIT 299) + set(LIMIT 280) if(COUNT GREATER LIMIT) list(APPEND FAILED "${RELATIVE_FILE}: ${COUNT} (limit ${LIMIT})") diff --git a/cmake/Packaging.cmake b/cmake/Packaging.cmake index 39a458ea..0a4747ad 100644 --- a/cmake/Packaging.cmake +++ b/cmake/Packaging.cmake @@ -253,83 +253,7 @@ bongo_cat_upgrade_failed: bongo_cat_upgrade_ready: SetOutPath "$INSTDIR" ]=] BONGO_CAT_NSIS_TEMPLATE "${BONGO_CAT_NSIS_TEMPLATE}") - bongo_cat_replace_nsis_function(BONGO_CAT_NSIS_TEMPLATE ".onInit" - [=[Function .onInit - SetShellVarContext current - StrCpy $STARTMENU_FOLDER "BongoCat" - StrCpy $INSTALL_DESKTOP "1" - StrCpy $BONGO_CAT_UPGRADE_DIR "" - StrCpy $BONGO_CAT_UPDATE_SHUTDOWN "" - - ReadRegStr $0 SHCTX \ - "Software\Microsoft\Windows\CurrentVersion\Uninstall\BongoCat" \ - "DisplayVersion" - StrCmp "$0" "" bongo_cat_check_legacy - ReadRegStr $1 SHCTX \ - "Software\Microsoft\Windows\CurrentVersion\Uninstall\BongoCat" \ - "InstallLocation" - StrCmp "$1" "" 0 bongo_cat_compare_versions - ReadRegStr $1 SHCTX "Software\BongoCat\BongoCat" "" -bongo_cat_compare_versions: - StrCmp "$1" "" bongo_cat_install - IfFileExists "$1\BongoCat.exe" 0 bongo_cat_install - ${VersionCompare} "$0" "@CPACK_PACKAGE_VERSION@" $2 - StrCmp "$2" "2" bongo_cat_upgrade - Goto bongo_cat_launch_installed - -bongo_cat_check_legacy: - GetFullPathName $1 "$LOCALAPPDATA\..\LocalPrograms\BongoCat" - IfFileExists "$1\BongoCat.exe" 0 bongo_cat_install - ClearErrors - GetDLLVersion "$1\BongoCat.exe" $6 $7 - IfErrors bongo_cat_install - IntOp $0 $6 >> 16 - IntOp $8 $6 & 0x0000FFFF - IntOp $9 $7 >> 16 - StrCpy $0 "$0.$8.$9" - ${VersionCompare} "$0" "@CPACK_PACKAGE_VERSION@" $2 - StrCmp "$2" "2" bongo_cat_upgrade_legacy - -bongo_cat_launch_installed: - ClearErrors - Exec '"$1\BongoCat.exe"' - IfErrors bongo_cat_launch_failed - Quit - -bongo_cat_launch_failed: - MessageBox MB_OK|MB_ICONSTOP \ - "The installed BongoCat could not be started." - Quit - -bongo_cat_upgrade: - ; 0.1.0 treats an unknown update argument as a normal launch. Waiting for - ; that process would block forever when the application was not running. - ${VersionCompare} "$0" "0.1.1" $2 - StrCmp "$2" "2" bongo_cat_upgrade_paths - StrCpy $BONGO_CAT_UPDATE_SHUTDOWN "1" -bongo_cat_upgrade_paths: - StrCpy $BONGO_CAT_UPGRADE_DIR "$1" - StrCpy $INSTDIR "$1" - Goto bongo_cat_install - -bongo_cat_upgrade_legacy: - StrCpy $BONGO_CAT_UPGRADE_DIR "$1" - StrCpy $INSTDIR "$1" - Goto bongo_cat_install - -bongo_cat_install: - SetShellVarContext current -FunctionEnd]=] - BONGO_CAT_NSIS_TEMPLATE) - bongo_cat_replace_nsis_function(BONGO_CAT_NSIS_TEMPLATE "un.onInit" - [=[Function un.onInit - SetShellVarContext current - StrCpy $BONGO_CAT_KEEP_USER_DATA "1" -FunctionEnd]=] - BONGO_CAT_NSIS_TEMPLATE) - file(WRITE "${BONGO_CAT_CPACK_TEMPLATE_DIR}/NSIS.template.in" - "${BONGO_CAT_NSIS_TEMPLATE}") - list(PREPEND CMAKE_MODULE_PATH "${BONGO_CAT_CPACK_TEMPLATE_DIR}") + include(cmake/PackagingWindowsInit.cmake) endif() include(CPack) diff --git a/cmake/PackagingWindowsInit.cmake b/cmake/PackagingWindowsInit.cmake new file mode 100644 index 00000000..835e5099 --- /dev/null +++ b/cmake/PackagingWindowsInit.cmake @@ -0,0 +1,77 @@ +bongo_cat_replace_nsis_function(BONGO_CAT_NSIS_TEMPLATE ".onInit" + [=[Function .onInit + SetShellVarContext current + StrCpy $STARTMENU_FOLDER "BongoCat" + StrCpy $INSTALL_DESKTOP "1" + StrCpy $BONGO_CAT_UPGRADE_DIR "" + StrCpy $BONGO_CAT_UPDATE_SHUTDOWN "" + + ReadRegStr $0 SHCTX \ + "Software\Microsoft\Windows\CurrentVersion\Uninstall\BongoCat" \ + "DisplayVersion" + StrCmp "$0" "" bongo_cat_check_legacy + ReadRegStr $1 SHCTX \ + "Software\Microsoft\Windows\CurrentVersion\Uninstall\BongoCat" \ + "InstallLocation" + StrCmp "$1" "" 0 bongo_cat_compare_versions + ReadRegStr $1 SHCTX "Software\BongoCat\BongoCat" "" +bongo_cat_compare_versions: + StrCmp "$1" "" bongo_cat_install + IfFileExists "$1\BongoCat.exe" 0 bongo_cat_install + ${VersionCompare} "$0" "@CPACK_PACKAGE_VERSION@" $2 + StrCmp "$2" "2" bongo_cat_upgrade + Goto bongo_cat_launch_installed + +bongo_cat_check_legacy: + GetFullPathName $1 "$LOCALAPPDATA\..\LocalPrograms\BongoCat" + IfFileExists "$1\BongoCat.exe" 0 bongo_cat_install + ClearErrors + GetDLLVersion "$1\BongoCat.exe" $6 $7 + IfErrors bongo_cat_install + IntOp $0 $6 >> 16 + IntOp $8 $6 & 0x0000FFFF + IntOp $9 $7 >> 16 + StrCpy $0 "$0.$8.$9" + ${VersionCompare} "$0" "@CPACK_PACKAGE_VERSION@" $2 + StrCmp "$2" "2" bongo_cat_upgrade_legacy + +bongo_cat_launch_installed: + ClearErrors + Exec '"$1\BongoCat.exe"' + IfErrors bongo_cat_launch_failed + Quit + +bongo_cat_launch_failed: + MessageBox MB_OK|MB_ICONSTOP \ + "The installed BongoCat could not be started." + Quit + +bongo_cat_upgrade: + ; 0.1.0 treats an unknown update argument as a normal launch. Waiting for + ; that process would block forever when the application was not running. + ${VersionCompare} "$0" "0.1.1" $2 + StrCmp "$2" "2" bongo_cat_upgrade_paths + StrCpy $BONGO_CAT_UPDATE_SHUTDOWN "1" +bongo_cat_upgrade_paths: + StrCpy $BONGO_CAT_UPGRADE_DIR "$1" + StrCpy $INSTDIR "$1" + Goto bongo_cat_install + +bongo_cat_upgrade_legacy: + StrCpy $BONGO_CAT_UPGRADE_DIR "$1" + StrCpy $INSTDIR "$1" + Goto bongo_cat_install + +bongo_cat_install: + SetShellVarContext current +FunctionEnd]=] + BONGO_CAT_NSIS_TEMPLATE) +bongo_cat_replace_nsis_function(BONGO_CAT_NSIS_TEMPLATE "un.onInit" + [=[Function un.onInit + SetShellVarContext current + StrCpy $BONGO_CAT_KEEP_USER_DATA "1" +FunctionEnd]=] + BONGO_CAT_NSIS_TEMPLATE) +file(WRITE "${BONGO_CAT_CPACK_TEMPLATE_DIR}/NSIS.template.in" + "${BONGO_CAT_NSIS_TEMPLATE}") +list(PREPEND CMAKE_MODULE_PATH "${BONGO_CAT_CPACK_TEMPLATE_DIR}") diff --git a/cmake/PlatformRuntime.cmake b/cmake/PlatformRuntime.cmake index cd9a32af..1be6bde4 100644 --- a/cmake/PlatformRuntime.cmake +++ b/cmake/PlatformRuntime.cmake @@ -14,6 +14,7 @@ if(WIN32) src/platform/windows/windows_input.c src/platform/windows/windows_popup.c src/platform/windows/windows_layered.c + src/platform/windows/windows_layered_state.c src/platform/windows/windows_opacity.c src/platform/windows/windows_tray.c src/platform/windows/windows_assets.c @@ -31,7 +32,8 @@ elseif(APPLE) src/platform/macos/macos_preferences.m src/platform/macos/macos_input.m src/platform/macos/macos_keys.m - src/platform/macos/macos_menu.m) + src/platform/macos/macos_menu.m + src/platform/macos/macos_tray.m) target_link_libraries(bongo_cat_runtime PRIVATE "-framework Cocoa" "-framework ApplicationServices") else() diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index cbef33a7..649c87c0 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -33,6 +33,7 @@ set(BONGO_CAT_RUNTIME_LIFECYCLE_SOURCES src/runtime/lifecycle/app.c src/runtime/lifecycle/app_entry.c src/runtime/lifecycle/app_input.c + src/runtime/lifecycle/app_loop.c src/runtime/lifecycle/assets.c src/runtime/lifecycle/config_store.c src/runtime/lifecycle/frame_clock.c @@ -54,6 +55,7 @@ set(BONGO_CAT_RUNTIME_DIAGNOSTIC_SOURCES src/runtime/diagnostics/frame_audit.c src/runtime/diagnostics/frame_presentation_audit.c src/runtime/diagnostics/live2d_audit.c + src/runtime/diagnostics/live2d_pointer_audit.c src/runtime/diagnostics/live2d_audit_scenario.c src/runtime/diagnostics/live2d_viewer_audit.c src/runtime/diagnostics/live2d_viewer_timing.c @@ -66,10 +68,12 @@ set(BONGO_CAT_RUNTIME_INPUT_SOURCES src/runtime/input/shortcuts.c) set(BONGO_CAT_RUNTIME_MODEL_SOURCES + src/runtime/model/model_behavior_cache.c src/runtime/model/model_behavior_state.c src/runtime/model/model_catalog_runtime.c src/runtime/model/model_catalog_builtins.c src/runtime/model/model_catalog_commit.c + src/runtime/model/model_catalog_remove.c src/runtime/model/model_catalog_refresh.c src/runtime/model/model_catalog_selection.c src/runtime/model/model_files.c @@ -88,10 +92,12 @@ set(BONGO_CAT_RUNTIME_IMPORT_SOURCES src/runtime/model/import/model_import_digest.c src/runtime/model/import/model_import_digest_api.c src/runtime/model/import/model_import_digest_cache.c + src/runtime/model/import/model_import_digest_path.c src/runtime/model/import/model_import_discover.c src/runtime/model/import/model_import_manifest.c src/runtime/model/import/model_import_identity.c src/runtime/model/import/model_import_installed.c + src/runtime/model/import/model_import_install.c src/runtime/model/import/model_import_lock.c src/runtime/model/import/model_import_metadata.c src/runtime/model/import/model_import_package.c @@ -146,11 +152,13 @@ set(BONGO_CAT_RUNTIME_SHELL_SOURCES src/runtime/shell/window_menu_behavior.c src/runtime/shell/window_menu_behavior_test.c src/runtime/shell/window_menu_preview.c - src/runtime/shell/window_wheel.c) + src/runtime/shell/window_wheel.c + src/runtime/shell/window_wheel_test.c) set(BONGO_CAT_UI_BACKEND_SOURCES src/ui/backend/nuklear_impl.c src/ui/backend/ui_backend.c + src/ui/backend/ui_backend_fonts.c src/ui/backend/ui_cursor.c src/ui/backend/ui_input.c src/ui/backend/ui_resize_cache.c @@ -169,6 +177,7 @@ set(BONGO_CAT_UI_RENDERING_SOURCES src/ui/rendering/ui_paint.c src/ui/rendering/ui_paint_border.c src/ui/rendering/ui_paint_cache.c + src/ui/rendering/ui_paint_lifecycle.c src/ui/rendering/ui_paint_shell.c) set(BONGO_CAT_UI_THEME_SOURCES @@ -196,6 +205,7 @@ set(BONGO_CAT_UI_PREFERENCES_SOURCES src/ui/preferences/preferences_import.c src/ui/preferences/preferences_import_dialog.c src/ui/preferences/preferences_import_progress.c + src/ui/preferences/preferences_import_worker.c src/ui/preferences/preferences_icons.c src/ui/preferences/preferences_language.c src/ui/preferences/preferences_lifecycle.c @@ -207,11 +217,13 @@ set(BONGO_CAT_UI_PREFERENCES_SOURCES src/ui/preferences/preferences_model_section.c src/ui/preferences/preferences_model_watch.c src/ui/preferences/preferences_model_glyphs.c + src/ui/preferences/preferences_model_import.c src/ui/preferences/preferences_model_rename.c src/ui/preferences/preferences_notice.c src/ui/preferences/preferences_overlay.c src/ui/preferences/preferences_page_dispatch.c src/ui/preferences/preferences_pages.c + src/ui/preferences/preferences_present.c src/ui/preferences/preferences_render.c src/ui/preferences/preferences_row_icons.c src/ui/preferences/preferences_scale.c diff --git a/src/live2d/cubism_model_bounds.cpp b/src/live2d/cubism_model_bounds.cpp index 95fa9740..7c52d349 100644 --- a/src/live2d/cubism_model_bounds.cpp +++ b/src/live2d/cubism_model_bounds.cpp @@ -203,95 +203,4 @@ void NativeModel::prepare_expression_frame() { update_viewport(); } -void NativeModel::update_viewport() { - float horizontal = 1.0f + frame_.left + frame_.right; - float vertical = 1.0f + frame_.top + frame_.bottom; - if (!std::isfinite(horizontal) || !std::isfinite(vertical) || - horizontal <= 0.0f || vertical <= 0.0f || width_ <= 0 || height_ <= 0) { - viewport_x_ = viewport_y_ = 0; - viewport_width_ = std::max(1, width_); - viewport_height_ = std::max(1, height_); - return; - } - float content_width = width_ / horizontal; - float content_height = height_ / vertical; - int left = (int)std::lround(frame_.left * content_width); - int right = (int)std::lround(frame_.right * content_width); - int bottom = (int)std::lround(frame_.bottom * content_height); - int top = (int)std::lround(frame_.top * content_height); - viewport_x_ = std::max(0, std::min(width_ - 1, left)); - viewport_y_ = std::max(0, std::min(height_ - 1, bottom)); - viewport_width_ = std::max(1, width_ - viewport_x_ - std::max(0, right)); - viewport_height_ = std::max(1, height_ - viewport_y_ - std::max(0, top)); -} - -bool NativeModel::frame(BongoCatLive2DFrame *frame) const { - if (!_model || !frame) return false; - *frame = frame_; - return true; -} - -bool NativeModel::viewport(int *x, int *y, int *width, int *height) const { - if (!_model || !x || !y || !width || !height) return false; - *x = viewport_x_; - *y = viewport_y_; - *width = viewport_width_; - *height = viewport_height_; - return true; -} - -void NativeModel::apply_viewport_projection( - Csm::CubismMatrix44 &projection) const { - if (width_ <= 0 || height_ <= 0 || viewport_width_ <= 0 || - viewport_height_ <= 0) return; - float scale_x = (float)viewport_width_ / (float)width_; - float scale_y = (float)viewport_height_ / (float)height_; - float translate_x = (2.0f * viewport_x_ + viewport_width_) / - (float)width_ - 1.0f; - float translate_y = (2.0f * viewport_y_ + viewport_height_) / - (float)height_ - 1.0f; - float *matrix = projection.GetArray(); - matrix[0] *= scale_x; - matrix[4] *= scale_x; - matrix[12] = matrix[12] * scale_x + translate_x; - matrix[1] *= scale_y; - matrix[5] *= scale_y; - matrix[13] = matrix[13] * scale_y + translate_y; -} - -void NativeModel::record_visible_state(Csm::CubismMatrix44 &projection) { - visual_state_.drawable_count = _model->GetDrawableCount(); - for (int i = 0; i < _model->GetDrawableCount(); ++i) { - if (_model->GetDrawableDynamicFlagIsVisible(i) && - _model->GetDrawableOpacity(i) > 0.001f) - ++visual_state_.drawable_visible; - if (_model->GetDrawableDynamicFlagVertexPositionsDidChange(i)) - ++visual_state_.drawable_vertex_changed; - } - visual_state_.offscreen_count = _model->GetOffscreenCount(); - for (int i = 0; i < _model->GetOffscreenCount(); ++i) - if (_model->GetOffscreenOpacity(i) > 0.001f) - ++visual_state_.offscreen_positive; - visual_state_.part_count = _model->GetPartCount(); - for (int i = 0; i < _model->GetPartCount(); ++i) - if (_model->GetPartOpacity(i) > 0.001f) ++visual_state_.part_positive; - ModelBounds bounds = capture_visible_bounds(); - if (!bounds.valid) return; - float x0 = projection.TransformX(bounds.min_x); - float x1 = projection.TransformX(bounds.max_x); - float y0 = projection.TransformY(bounds.min_y); - float y1 = projection.TransformY(bounds.max_y); - visual_state_.visible_min_x = std::min(x0, x1); - visual_state_.visible_max_x = std::max(x0, x1); - visual_state_.visible_min_y = std::min(y0, y1); - visual_state_.visible_max_y = std::max(y0, y1); - visual_state_.visible = true; -} - -bool NativeModel::visual_state(BongoCatLive2DVisualState *state) const { - if (!state || !visual_state_ready_) return false; - *state = visual_state_; - return true; -} - } // namespace bongo_cat diff --git a/src/live2d/cubism_model_load.cpp b/src/live2d/cubism_model_load.cpp index df4639d8..740101a0 100644 --- a/src/live2d/cubism_model_load.cpp +++ b/src/live2d/cubism_model_load.cpp @@ -264,29 +264,4 @@ bool NativeModel::load_textures(BongoCatError *error, renderer_height_ = height_; return true; } -void NativeModel::release_textures() { - if (!textures_.empty()) - glDeleteTextures((GLsizei)textures_.size(), textures_.data()); - textures_.clear(); - texture_alpha_.clear(); -} -void NativeModel::release_renderer() { - DeleteRenderer(); - renderer_width_ = 0; - renderer_height_ = 0; -} - -void NativeModel::release_render_resources() { - release_textures(); - release_renderer(); -} - -void NativeModel::bind_textures() { - auto *renderer = GetRenderer(); - if (!renderer) return; - for (size_t i = 0; i < textures_.size(); ++i) - if (textures_[i]) renderer->BindTexture((Csm::csmInt32)i, textures_[i]); - renderer->IsPremultipliedAlpha(false); -} - } // namespace bongo_cat diff --git a/src/live2d/cubism_model_renderer.cpp b/src/live2d/cubism_model_renderer.cpp index a322744b..b4a6f4ac 100644 --- a/src/live2d/cubism_model_renderer.cpp +++ b/src/live2d/cubism_model_renderer.cpp @@ -5,6 +5,33 @@ namespace bongo_cat { +void NativeModel::bind_textures() { + auto *renderer = GetRenderer(); + if (!renderer) return; + for (size_t i = 0; i < textures_.size(); ++i) + if (textures_[i]) + renderer->BindTexture((Csm::csmInt32)i, textures_[i]); + renderer->IsPremultipliedAlpha(false); +} + +void NativeModel::release_textures() { + if (!textures_.empty()) + glDeleteTextures((GLsizei)textures_.size(), textures_.data()); + textures_.clear(); + texture_alpha_.clear(); +} + +void NativeModel::release_renderer() { + DeleteRenderer(); + renderer_width_ = 0; + renderer_height_ = 0; +} + +void NativeModel::release_render_resources() { + release_textures(); + release_renderer(); +} + bool NativeModel::create_renderer(BongoCatError *error) { if (!SDL_GL_GetCurrentContext()) { bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, diff --git a/src/live2d/cubism_model_update.cpp b/src/live2d/cubism_model_update.cpp index e27b77d7..9e9d533f 100644 --- a/src/live2d/cubism_model_update.cpp +++ b/src/live2d/cubism_model_update.cpp @@ -180,115 +180,4 @@ bool NativeModel::parameter(const char *id, float *minimum, float *maximum, floa return true; } -void NativeModel::capture_motion_preview() { - if (!_model || motion_preview_active_) return; - const int parameter_count = _model->GetParameterCount(); - const bool overrides_were_applied = parameter_overrides_applied_; - std::vector current_parameters((size_t)parameter_count); - for (int i = 0; i < parameter_count; ++i) - current_parameters[(size_t)i] = _model->GetParameterValue(i); - _model->LoadParameters(); - parameter_overrides_applied_ = false; - capture_parameter_baseline(); - motion_preview_parameters_.resize((size_t)parameter_count); - for (int i = 0; i < parameter_count; ++i) { - motion_preview_parameters_[(size_t)i] = _model->GetParameterValue(i); - _model->SetParameterValue(i, current_parameters[(size_t)i]); - } - if (overrides_were_applied) apply_parameter_overrides(); - const int part_count = _model->GetPartCount(); - motion_preview_parts_.resize((size_t)part_count); - for (int i = 0; i < part_count; ++i) - motion_preview_parts_[(size_t)i] = _model->GetPartOpacity(i); - motion_preview_opacity_ = _model->GetModelOpacity(); - motion_preview_active_ = true; -} - -void NativeModel::restore_motion_preview_state() { - if (!_model || !motion_preview_active_) return; - _motionManager->StopAllMotions(); - clear_motion_runs(); - parameter_overrides_applied_ = false; - const int parameter_count = _model->GetParameterCount(); - for (int i = 0; i < parameter_count && - (size_t)i < motion_preview_parameters_.size(); ++i) { - _model->SetParameterValue(i, motion_preview_parameters_[(size_t)i]); - } - save_parameters(); - const int part_count = _model->GetPartCount(); - for (int i = 0; i < part_count && - (size_t)i < motion_preview_parts_.size(); ++i) - _model->SetPartOpacity(i, motion_preview_parts_[(size_t)i]); - _model->SetModelOpacity(motion_preview_opacity_); - _opacity = motion_preview_opacity_; - apply_parameter_overrides(); -} - -bool NativeModel::preview_motion(const char *group, int index) { - if (!_model || !group || index < 0) return false; - if (motion_preview_active_) restore_motion_preview_state(); - else capture_motion_preview(); - std::string key = std::string(group) + "_" + std::to_string(index); - auto owner = motion_toggle_owners_.find(key); - motion_preview_key_ = owner == motion_toggle_owners_.end() ? key : owner->second; - bool active = selected_motion_keys_.find(motion_preview_key_) != - selected_motion_keys_.end(); - std::string playback = motion_to_play(key, &motion_preview_selected_); - /* A running one-shot is previewed as a cancellation, while a committed - click keeps the normal replay behavior in start_motion(). */ - if (active && !motion_is_persistent(motion_preview_key_)) { - motion_preview_selected_ = false; - playback.clear(); - } - motion_preview_completed_ = false; - if (playback.empty()) - return restore_motion_defaults(motion_preview_key_); - auto found = motions_.find(playback); - if (found == motions_.end()) { - restore_motion_preview(); - return false; - } - constexpr int priority = 2; - Csm::CubismMotionQueueEntryHandle handle = - _motionManager->StartMotionPriority(found->second, false, priority); - if (handle == Csm::InvalidMotionQueueEntryHandleValue) { - restore_motion_preview(); - return false; - } - record_motion_run(handle, motion_preview_key_, - motion_preview_selected_, false); - return true; -} - -bool NativeModel::commit_motion_preview(const char *group, int index) { - if (!motion_preview_active_ || !group || index < 0) return false; - std::string key = std::string(group) + "_" + std::to_string(index); - auto owner = motion_toggle_owners_.find(key); - const std::string &canonical = owner == motion_toggle_owners_.end() ? - key : owner->second; - if (canonical != motion_preview_key_) return false; - select_motion(canonical, motion_preview_selected_); - for (auto &run : motion_runs_) - if (!run.committed && run.key == canonical) run.committed = true; - if (motion_preview_completed_ && !motion_is_persistent(canonical)) - select_motion(canonical, false); - motion_preview_parameters_.clear(); - motion_preview_parts_.clear(); - motion_preview_key_.clear(); - motion_preview_active_ = false; - motion_preview_completed_ = false; - return true; -} - -bool NativeModel::restore_motion_preview() { - if (!motion_preview_active_) return false; - restore_motion_preview_state(); - motion_preview_parameters_.clear(); - motion_preview_parts_.clear(); - motion_preview_key_.clear(); - motion_preview_active_ = false; - motion_preview_completed_ = false; - return true; -} - } // namespace bongo_cat diff --git a/src/live2d/cubism_model_viewport.cpp b/src/live2d/cubism_model_viewport.cpp new file mode 100644 index 00000000..2442278a --- /dev/null +++ b/src/live2d/cubism_model_viewport.cpp @@ -0,0 +1,102 @@ +#include "cubism_model.hpp" + +#include +#include + +namespace bongo_cat { + +void NativeModel::update_viewport() { + float horizontal = 1.0f + frame_.left + frame_.right; + float vertical = 1.0f + frame_.top + frame_.bottom; + if (!std::isfinite(horizontal) || !std::isfinite(vertical) || + horizontal <= 0.0f || vertical <= 0.0f || width_ <= 0 || height_ <= 0) { + viewport_x_ = viewport_y_ = 0; + viewport_width_ = std::max(1, width_); + viewport_height_ = std::max(1, height_); + return; + } + float content_width = width_ / horizontal; + float content_height = height_ / vertical; + int left = (int)std::lround(frame_.left * content_width); + int right = (int)std::lround(frame_.right * content_width); + int bottom = (int)std::lround(frame_.bottom * content_height); + int top = (int)std::lround(frame_.top * content_height); + viewport_x_ = std::max(0, std::min(width_ - 1, left)); + viewport_y_ = std::max(0, std::min(height_ - 1, bottom)); + viewport_width_ = std::max(1, + width_ - viewport_x_ - std::max(0, right)); + viewport_height_ = std::max(1, + height_ - viewport_y_ - std::max(0, top)); +} + +bool NativeModel::frame(BongoCatLive2DFrame *frame) const { + if (!_model || !frame) return false; + *frame = frame_; + return true; +} + +bool NativeModel::viewport(int *x, int *y, int *width, int *height) const { + if (!_model || !x || !y || !width || !height) return false; + *x = viewport_x_; + *y = viewport_y_; + *width = viewport_width_; + *height = viewport_height_; + return true; +} + +void NativeModel::apply_viewport_projection( + Csm::CubismMatrix44 &projection) const { + if (width_ <= 0 || height_ <= 0 || viewport_width_ <= 0 || + viewport_height_ <= 0) return; + float scale_x = (float)viewport_width_ / (float)width_; + float scale_y = (float)viewport_height_ / (float)height_; + float translate_x = (2.0f * viewport_x_ + viewport_width_) / + (float)width_ - 1.0f; + float translate_y = (2.0f * viewport_y_ + viewport_height_) / + (float)height_ - 1.0f; + float *matrix = projection.GetArray(); + matrix[0] *= scale_x; + matrix[4] *= scale_x; + matrix[12] = matrix[12] * scale_x + translate_x; + matrix[1] *= scale_y; + matrix[5] *= scale_y; + matrix[13] = matrix[13] * scale_y + translate_y; +} + +void NativeModel::record_visible_state(Csm::CubismMatrix44 &projection) { + visual_state_.drawable_count = _model->GetDrawableCount(); + for (int i = 0; i < _model->GetDrawableCount(); ++i) { + if (_model->GetDrawableDynamicFlagIsVisible(i) && + _model->GetDrawableOpacity(i) > 0.001f) + ++visual_state_.drawable_visible; + if (_model->GetDrawableDynamicFlagVertexPositionsDidChange(i)) + ++visual_state_.drawable_vertex_changed; + } + visual_state_.offscreen_count = _model->GetOffscreenCount(); + for (int i = 0; i < _model->GetOffscreenCount(); ++i) + if (_model->GetOffscreenOpacity(i) > 0.001f) + ++visual_state_.offscreen_positive; + visual_state_.part_count = _model->GetPartCount(); + for (int i = 0; i < _model->GetPartCount(); ++i) + if (_model->GetPartOpacity(i) > 0.001f) + ++visual_state_.part_positive; + ModelBounds bounds = capture_visible_bounds(); + if (!bounds.valid) return; + float x0 = projection.TransformX(bounds.min_x); + float x1 = projection.TransformX(bounds.max_x); + float y0 = projection.TransformY(bounds.min_y); + float y1 = projection.TransformY(bounds.max_y); + visual_state_.visible_min_x = std::min(x0, x1); + visual_state_.visible_max_x = std::max(x0, x1); + visual_state_.visible_min_y = std::min(y0, y1); + visual_state_.visible_max_y = std::max(y0, y1); + visual_state_.visible = true; +} + +bool NativeModel::visual_state(BongoCatLive2DVisualState *state) const { + if (!state || !visual_state_ready_) return false; + *state = visual_state_; + return true; +} + +} // namespace bongo_cat diff --git a/src/live2d/cubism_motion_playback.cpp b/src/live2d/cubism_motion_playback.cpp index de77bc6d..592e9774 100644 --- a/src/live2d/cubism_motion_playback.cpp +++ b/src/live2d/cubism_motion_playback.cpp @@ -6,6 +6,11 @@ namespace bongo_cat { +bool motion_run_clears_selection(bool one_shot, bool committed, + bool replacement_running) { + return one_shot && committed && !replacement_running; +} + bool NativeModel::start_motion(const char *group, int index) { if (!group || index < 0) return false; std::string key = std::string(group) + "_" + std::to_string(index); diff --git a/src/live2d/cubism_motion_preview.cpp b/src/live2d/cubism_motion_preview.cpp new file mode 100644 index 00000000..b1fe8c77 --- /dev/null +++ b/src/live2d/cubism_motion_preview.cpp @@ -0,0 +1,118 @@ +#include "cubism_model.hpp" + +#include + +namespace bongo_cat { + +void NativeModel::capture_motion_preview() { + if (!_model || motion_preview_active_) return; + const int parameter_count = _model->GetParameterCount(); + const bool overrides_were_applied = parameter_overrides_applied_; + std::vector current_parameters((size_t)parameter_count); + for (int i = 0; i < parameter_count; ++i) + current_parameters[(size_t)i] = _model->GetParameterValue(i); + _model->LoadParameters(); + parameter_overrides_applied_ = false; + capture_parameter_baseline(); + motion_preview_parameters_.resize((size_t)parameter_count); + for (int i = 0; i < parameter_count; ++i) { + motion_preview_parameters_[(size_t)i] = _model->GetParameterValue(i); + _model->SetParameterValue(i, current_parameters[(size_t)i]); + } + if (overrides_were_applied) apply_parameter_overrides(); + const int part_count = _model->GetPartCount(); + motion_preview_parts_.resize((size_t)part_count); + for (int i = 0; i < part_count; ++i) + motion_preview_parts_[(size_t)i] = _model->GetPartOpacity(i); + motion_preview_opacity_ = _model->GetModelOpacity(); + motion_preview_active_ = true; +} + +void NativeModel::restore_motion_preview_state() { + if (!_model || !motion_preview_active_) return; + _motionManager->StopAllMotions(); + clear_motion_runs(); + parameter_overrides_applied_ = false; + const int parameter_count = _model->GetParameterCount(); + for (int i = 0; i < parameter_count && + (size_t)i < motion_preview_parameters_.size(); ++i) + _model->SetParameterValue(i, motion_preview_parameters_[(size_t)i]); + save_parameters(); + const int part_count = _model->GetPartCount(); + for (int i = 0; i < part_count && + (size_t)i < motion_preview_parts_.size(); ++i) + _model->SetPartOpacity(i, motion_preview_parts_[(size_t)i]); + _model->SetModelOpacity(motion_preview_opacity_); + _opacity = motion_preview_opacity_; + apply_parameter_overrides(); +} + +bool NativeModel::preview_motion(const char *group, int index) { + if (!_model || !group || index < 0) return false; + if (motion_preview_active_) restore_motion_preview_state(); + else capture_motion_preview(); + std::string key = std::string(group) + "_" + std::to_string(index); + auto owner = motion_toggle_owners_.find(key); + motion_preview_key_ = owner == motion_toggle_owners_.end() ? + key : owner->second; + bool active = selected_motion_keys_.find(motion_preview_key_) != + selected_motion_keys_.end(); + std::string playback = motion_to_play(key, &motion_preview_selected_); + /* A running one-shot is previewed as a cancellation, while a committed + click keeps the normal replay behavior in start_motion(). */ + if (active && !motion_is_persistent(motion_preview_key_)) { + motion_preview_selected_ = false; + playback.clear(); + } + motion_preview_completed_ = false; + if (playback.empty()) + return restore_motion_defaults(motion_preview_key_); + auto found = motions_.find(playback); + if (found == motions_.end()) { + restore_motion_preview(); + return false; + } + constexpr int priority = 2; + Csm::CubismMotionQueueEntryHandle handle = + _motionManager->StartMotionPriority(found->second, false, priority); + if (handle == Csm::InvalidMotionQueueEntryHandleValue) { + restore_motion_preview(); + return false; + } + record_motion_run(handle, motion_preview_key_, + motion_preview_selected_, false); + return true; +} + +bool NativeModel::commit_motion_preview(const char *group, int index) { + if (!motion_preview_active_ || !group || index < 0) return false; + std::string key = std::string(group) + "_" + std::to_string(index); + auto owner = motion_toggle_owners_.find(key); + const std::string &canonical = owner == motion_toggle_owners_.end() ? + key : owner->second; + if (canonical != motion_preview_key_) return false; + select_motion(canonical, motion_preview_selected_); + for (auto &run : motion_runs_) + if (!run.committed && run.key == canonical) run.committed = true; + if (motion_preview_completed_ && !motion_is_persistent(canonical)) + select_motion(canonical, false); + motion_preview_parameters_.clear(); + motion_preview_parts_.clear(); + motion_preview_key_.clear(); + motion_preview_active_ = false; + motion_preview_completed_ = false; + return true; +} + +bool NativeModel::restore_motion_preview() { + if (!motion_preview_active_) return false; + restore_motion_preview_state(); + motion_preview_parameters_.clear(); + motion_preview_parts_.clear(); + motion_preview_key_.clear(); + motion_preview_active_ = false; + motion_preview_completed_ = false; + return true; +} + +} // namespace bongo_cat diff --git a/src/live2d/cubism_motion_state.cpp b/src/live2d/cubism_motion_state.cpp index d148f544..61388c80 100644 --- a/src/live2d/cubism_motion_state.cpp +++ b/src/live2d/cubism_motion_state.cpp @@ -217,11 +217,6 @@ bool NativeModel::motion_persistent(const char *group, int index) const { std::string(group) + "_" + std::to_string(index)); } -bool motion_run_clears_selection(bool one_shot, bool committed, - bool replacement_running) { - return one_shot && committed && !replacement_running; -} - bool NativeModel::restore_motion_defaults(const std::string &key) { auto state = motion_states_.find(key); if (_model == nullptr || state == motion_states_.end()) return false; diff --git a/src/platform/macos/macos.m b/src/platform/macos/macos.m index d02ba51f..50d051ff 100644 --- a/src/platform/macos/macos.m +++ b/src/platform/macos/macos.m @@ -16,59 +16,6 @@ static NSObject *instance_observer; static bool instance_show_pending; -/* SDL 3.2's pinned Cocoa tray layout; kept local to the platform adapter. */ -typedef struct BongoCatSDLTrayMenu { NSMenu *menu; } BongoCatSDLTrayMenu; -typedef struct BongoCatSDLTray { - NSStatusBar *status_bar; NSStatusItem *status_item; BongoCatSDLTrayMenu *menu; -} BongoCatSDLTray; - -@interface BongoCatTrayTarget : NSObject { - NSStatusItem *item_; NSMenu *menu_; BongoCatTrayClick callback_; - BongoCatModalTick modal_tick_; void *userdata_; -} -- (id)initWithItem:(NSStatusItem *)item menu:(NSMenu *)menu - callback:(BongoCatTrayClick)callback modalTick:(BongoCatModalTick)modalTick - userdata:(void *)userdata; -- (void)clicked:(id)sender; -- (void)tickModal:(NSTimer *)timer; -- (void)unbind; -@end - -@implementation BongoCatTrayTarget -- (id)initWithItem:(NSStatusItem *)item menu:(NSMenu *)menu - callback:(BongoCatTrayClick)callback modalTick:(BongoCatModalTick)modalTick - userdata:(void *)userdata { - self = [super init]; - if (self) { item_ = item; menu_ = menu; callback_ = callback; - modal_tick_ = modalTick; userdata_ = userdata; } - return self; -} -- (void)clicked:(id)sender { - (void)sender; - NSEvent *event = [NSApp currentEvent]; - if ([event type] == NSEventTypeLeftMouseUp) callback_(userdata_); - else { - if (modal_tick_) modal_tick_(userdata_); - NSTimer *timer = modal_tick_ ? [NSTimer timerWithTimeInterval:1.0 / 60.0 - target:self selector:@selector(tickModal:) userInfo:nil repeats:YES] : nil; - if (timer) [[NSRunLoop currentRunLoop] addTimer:timer - forMode:NSRunLoopCommonModes]; - [NSMenu popUpContextMenu:menu_ withEvent:event forView:[item_ button]]; - [timer invalidate]; - } -} -- (void)tickModal:(NSTimer *)timer { - (void)timer; - if (modal_tick_) modal_tick_(userdata_); -} -- (void)unbind { - [[item_ button] setTarget:nil]; [[item_ button] setAction:nil]; - [item_ setMenu:menu_]; -} -@end - -static BongoCatTrayTarget *tray_target; - @interface BongoCatModalTarget : NSObject { BongoCatModalTick callback_; void *userdata_; } - (id)initWithCallback:(BongoCatModalTick)callback userdata:(void *)userdata; - (void)tick:(NSTimer *)timer; @@ -243,23 +190,6 @@ bool bongo_cat_platform_open_directory(const char *path) { } } -void bongo_cat_platform_set_tray_callbacks(void *tray, - BongoCatTrayClick left_click, BongoCatModalTick modal_tick, - BongoCatTrayRestore restore, void *userdata) { - (void)restore; BongoCatSDLTray *native = tray; - if (tray_target) { - [tray_target unbind]; [tray_target release]; tray_target = nil; - } - if (!native || !left_click || !native->status_item || !native->menu) return; - tray_target = [[BongoCatTrayTarget alloc] initWithItem:native->status_item - menu:native->menu->menu callback:left_click modalTick:modal_tick - userdata:userdata]; - [native->status_item setMenu:nil]; - [[native->status_item button] setTarget:tray_target]; - [[native->status_item button] setAction:@selector(clicked:)]; - [[native->status_item button] sendActionOn:NSEventMaskLeftMouseUp | - NSEventMaskRightMouseUp]; -} bool bongo_cat_platform_single_instance_begin(void) { char path[96]; snprintf(path, sizeof(path), "/tmp/" BONGO_CAT_SLUG "-%lu.lock", diff --git a/src/platform/macos/macos_tray.m b/src/platform/macos/macos_tray.m new file mode 100644 index 00000000..a62e558f --- /dev/null +++ b/src/platform/macos/macos_tray.m @@ -0,0 +1,96 @@ +#include "bongo_cat/platform.h" + +#ifdef __APPLE__ +#import + +/* SDL 3.2's pinned Cocoa tray layout; kept local to the tray adapter. */ +typedef struct BongoCatSDLTrayMenu { NSMenu *menu; } BongoCatSDLTrayMenu; +typedef struct BongoCatSDLTray { + NSStatusBar *status_bar; + NSStatusItem *status_item; + BongoCatSDLTrayMenu *menu; +} BongoCatSDLTray; + +@interface BongoCatTrayTarget : NSObject { + NSStatusItem *item_; + NSMenu *menu_; + BongoCatTrayClick callback_; + BongoCatModalTick modal_tick_; + void *userdata_; +} +- (id)initWithItem:(NSStatusItem *)item menu:(NSMenu *)menu + callback:(BongoCatTrayClick)callback modalTick:(BongoCatModalTick)modalTick + userdata:(void *)userdata; +- (void)clicked:(id)sender; +- (void)tickModal:(NSTimer *)timer; +- (void)unbind; +@end + +@implementation BongoCatTrayTarget +- (id)initWithItem:(NSStatusItem *)item menu:(NSMenu *)menu + callback:(BongoCatTrayClick)callback modalTick:(BongoCatModalTick)modalTick + userdata:(void *)userdata { + self = [super init]; + if (self) { + item_ = item; + menu_ = menu; + callback_ = callback; + modal_tick_ = modalTick; + userdata_ = userdata; + } + return self; +} + +- (void)clicked:(id)sender { + (void)sender; + NSEvent *event = [NSApp currentEvent]; + if ([event type] == NSEventTypeLeftMouseUp) callback_(userdata_); + else { + if (modal_tick_) modal_tick_(userdata_); + NSTimer *timer = modal_tick_ ? + [NSTimer timerWithTimeInterval:1.0 / 60.0 target:self + selector:@selector(tickModal:) userInfo:nil repeats:YES] : nil; + if (timer) + [[NSRunLoop currentRunLoop] addTimer:timer + forMode:NSRunLoopCommonModes]; + [NSMenu popUpContextMenu:menu_ withEvent:event forView:[item_ button]]; + [timer invalidate]; + } +} + +- (void)tickModal:(NSTimer *)timer { + (void)timer; + if (modal_tick_) modal_tick_(userdata_); +} + +- (void)unbind { + [[item_ button] setTarget:nil]; + [[item_ button] setAction:nil]; + [item_ setMenu:menu_]; +} +@end + +static BongoCatTrayTarget *tray_target; + +void bongo_cat_platform_set_tray_callbacks(void *tray, + BongoCatTrayClick left_click, BongoCatModalTick modal_tick, + BongoCatTrayRestore restore, void *userdata) { + (void)restore; + BongoCatSDLTray *native = tray; + if (tray_target) { + [tray_target unbind]; + [tray_target release]; + tray_target = nil; + } + if (!native || !left_click || !native->status_item || !native->menu) + return; + tray_target = [[BongoCatTrayTarget alloc] initWithItem:native->status_item + menu:native->menu->menu callback:left_click modalTick:modal_tick + userdata:userdata]; + [native->status_item setMenu:nil]; + [[native->status_item button] setTarget:tray_target]; + [[native->status_item button] setAction:@selector(clicked:)]; + [[native->status_item button] sendActionOn:NSEventMaskLeftMouseUp | + NSEventMaskRightMouseUp]; +} +#endif diff --git a/src/platform/windows/windows_layered.c b/src/platform/windows/windows_layered.c index 83b21aed..9b20ee97 100644 --- a/src/platform/windows/windows_layered.c +++ b/src/platform/windows/windows_layered.c @@ -1,4 +1,5 @@ #include "windows_layered.h" +#include "windows_layered_internal.h" #include "windows_capture.h" #ifdef _WIN32 #include @@ -10,18 +11,6 @@ static const wchar_t proxy_class[] = L"BongoCat.LayeredPresenter"; static const wchar_t proxy_property[] = L"BongoCat.LayeredProxy"; /* SDL's OpenGL window owns a DC, so a separate layered window presents frames. */ -typedef struct BongoCatWindowsLayered { - HDC memory_dc; - HBITMAP bitmap; - HGDIOBJ original_bitmap; - unsigned char *pixels; - unsigned char *readback; - size_t readback_capacity; - HWND proxy; - int width, height, source_width, source_height; - bool readback_valid, active, has_frame, mode_logged; - bool source_transparent, visible, topmost; -} BongoCatWindowsLayered; static HWND native_window(BongoCatPlatform *platform) { return platform && platform->window ? (HWND)SDL_GetPointerProperty( SDL_GetWindowProperties(platform->window), @@ -107,7 +96,7 @@ static bool ensure_proxy(BongoCatPlatform *platform) { bongo_cat_windows_capture_log(value->proxy, "layered-proxy-created"); return true; } -static bool update_proxy(BongoCatPlatform *platform, +bool bongo_cat_windows_layered_update_proxy(BongoCatPlatform *platform, BongoCatWindowsLayered *value) { HWND source_window = native_window(platform); RECT bounds; @@ -184,41 +173,6 @@ HWND bongo_cat_windows_layered_proxy(HWND source) { return source ? (HWND)GetPropW(source, proxy_property) : NULL; } -bool bongo_cat_platform_set_opacity(BongoCatPlatform *platform, float opacity) { - if (!platform || !platform->window) return false; - BongoCatWindowsLayered *value = platform->presenter; - platform->window_opacity = SDL_clamp(opacity, 0.0f, 1.0f); - if (value && value->active) - return !value->has_frame || update_proxy(platform, value); - return SDL_SetWindowOpacity(platform->window, platform->window_opacity); -} - - -bool bongo_cat_platform_frame_alpha(const BongoCatPlatform *platform, - int width, int height, int x, int y, uint8_t *alpha) { - const BongoCatWindowsLayered *value = platform ? platform->presenter : NULL; - const unsigned char *pixels = value && value->readback_valid ? - value->readback : value ? value->pixels : NULL; - if (!value || !value->active || !value->has_frame || !pixels || - value->source_width != width || value->source_height != height || !alpha || - x < 0 || y < 0 || x >= width || y >= height) return false; - *alpha = pixels[((size_t)y * (size_t)width + (size_t)x) * 4 + 3]; - return true; -} - -void bongo_cat_platform_set_visible(BongoCatPlatform *platform, bool visible) { - if (!platform || !platform->window) return; - BongoCatWindowsLayered *value = platform->presenter; - if (value) value->visible = visible; - if (!visible && value && value->proxy) ShowWindow(value->proxy, SW_HIDE); - visible ? SDL_ShowWindow(platform->window) : SDL_HideWindow(platform->window); - if (visible) { - HWND source = native_window(platform); - bongo_cat_windows_capture_configure(source); - bongo_cat_windows_capture_log(source, "visible"); - } -} - static bool resize_bitmap(BongoCatWindowsLayered *value, int width, int height) { if (value->bitmap && value->width == width && value->height == height) return true; release_bitmap(value); @@ -287,7 +241,10 @@ bool bongo_cat_platform_present(BongoCatPlatform *platform, int width, int heigh value->has_frame = false; return SDL_SetError("Cannot allocate the Windows layered frame"); } - if (!update_proxy(platform, value)) { value->has_frame = false; return false; } + if (!bongo_cat_windows_layered_update_proxy(platform, value)) { + value->has_frame = false; + return false; + } value->has_frame = true; if (!make_source_transparent(platform)) return SDL_SetError("Cannot suppress the OpenGL source window"); diff --git a/src/platform/windows/windows_layered_internal.h b/src/platform/windows/windows_layered_internal.h new file mode 100644 index 00000000..19bca1bc --- /dev/null +++ b/src/platform/windows/windows_layered_internal.h @@ -0,0 +1,26 @@ +#ifndef BONGO_CAT_WINDOWS_LAYERED_INTERNAL_H +#define BONGO_CAT_WINDOWS_LAYERED_INTERNAL_H + +#include "windows_layered.h" + +#ifdef _WIN32 +#include + +typedef struct BongoCatWindowsLayered { + HDC memory_dc; + HBITMAP bitmap; + HGDIOBJ original_bitmap; + unsigned char *pixels; + unsigned char *readback; + size_t readback_capacity; + HWND proxy; + int width, height, source_width, source_height; + bool readback_valid, active, has_frame, mode_logged; + bool source_transparent, visible, topmost; +} BongoCatWindowsLayered; + +bool bongo_cat_windows_layered_update_proxy(BongoCatPlatform *platform, + BongoCatWindowsLayered *value); +#endif + +#endif diff --git a/src/platform/windows/windows_layered_state.c b/src/platform/windows/windows_layered_state.c new file mode 100644 index 00000000..11923a59 --- /dev/null +++ b/src/platform/windows/windows_layered_state.c @@ -0,0 +1,53 @@ +#include "windows_layered_internal.h" +#include "windows_capture.h" + +#ifdef _WIN32 +#include +#include +#include + +static HWND native_window(BongoCatPlatform *platform) { + return platform && platform->window ? (HWND)SDL_GetPointerProperty( + SDL_GetWindowProperties(platform->window), + SDL_PROP_WINDOW_WIN32_HWND_POINTER, NULL) : NULL; +} + +bool bongo_cat_platform_set_opacity(BongoCatPlatform *platform, + float opacity) { + if (!platform || !platform->window) return false; + BongoCatWindowsLayered *value = platform->presenter; + platform->window_opacity = SDL_clamp(opacity, 0.0f, 1.0f); + if (value && value->active) + return !value->has_frame || + bongo_cat_windows_layered_update_proxy(platform, value); + return SDL_SetWindowOpacity(platform->window, platform->window_opacity); +} + +bool bongo_cat_platform_frame_alpha(const BongoCatPlatform *platform, + int width, int height, int x, int y, uint8_t *alpha) { + const BongoCatWindowsLayered *value = platform ? platform->presenter : NULL; + const unsigned char *pixels = value && value->readback_valid ? + value->readback : value ? value->pixels : NULL; + if (!value || !value->active || !value->has_frame || !pixels || + value->source_width != width || value->source_height != height || + !alpha || x < 0 || y < 0 || x >= width || y >= height) + return false; + *alpha = pixels[((size_t)y * (size_t)width + (size_t)x) * 4 + 3]; + return true; +} + +void bongo_cat_platform_set_visible(BongoCatPlatform *platform, + bool visible) { + if (!platform || !platform->window) return; + BongoCatWindowsLayered *value = platform->presenter; + if (value) value->visible = visible; + if (!visible && value && value->proxy) ShowWindow(value->proxy, SW_HIDE); + if (visible) SDL_ShowWindow(platform->window); + else SDL_HideWindow(platform->window); + if (visible) { + HWND source = native_window(platform); + bongo_cat_windows_capture_configure(source); + bongo_cat_windows_capture_log(source, "visible"); + } +} +#endif diff --git a/src/runtime/diagnostics/live2d_audit.c b/src/runtime/diagnostics/live2d_audit.c index 60fa6bee..4eccbe6e 100644 --- a/src/runtime/diagnostics/live2d_audit.c +++ b/src/runtime/diagnostics/live2d_audit.c @@ -1,5 +1,6 @@ #include "runtime.h" #include "live2d_audit_scenario.h" +#include "live2d_pointer_audit.h" #include "bongo_cat/file.h" #include "bongo_cat/path.h" @@ -11,21 +12,6 @@ #define motion bongo_cat_live2d_audit_motion #define parameter_override_scenario bongo_cat_live2d_audit_parameter_override -typedef struct PointerAudit { - bool ran; - bool has_mouse; - float angle_x[4]; - float angle_y[4]; - float mouse_x[4]; - float mouse_y[4]; - float maximum_step; -} PointerAudit; - -static PointerAudit pointer_audit; - -/* Viewer 5.3.03: (7.2727275 / 30) target speed * 30-degree range * 1.2 gain. */ -#define POINTER_VISIBLE_STEP_LIMIT 8.8f - static bool value(BongoCatApp *app, const char *id, float *output) { BongoCatParameterRange range; if (!bongo_cat_live2d_parameter(app->live2d, id, &range)) return false; @@ -33,69 +19,8 @@ static bool value(BongoCatApp *app, const char *id, float *output) { return true; } -static bool pointer_test_center(BongoCatApp *app, SDL_Rect *bounds, - double *center_x, double *center_y) { - int window_x, window_y, width, height; - if (!app || !app->window || !bounds || !center_x || !center_y || - !SDL_GetWindowPosition(app->window, &window_x, &window_y) || - !SDL_GetWindowSize(app->window, &width, &height)) return false; - *center_x = window_x + width * 0.5; - *center_y = window_y + height * 0.5; - SDL_Point point = {(int)*center_x, (int)*center_y}; - SDL_DisplayID display = SDL_GetDisplayForPoint(&point); - return display && SDL_GetDisplayBounds(display, bounds) && - bounds->w > 0 && bounds->h > 0; -} - -static bool pointer(BongoCatApp *app, bool mirror) { - SDL_Rect bounds; - double center_x, center_y; - if (!pointer_test_center(app, &bounds, ¢er_x, ¢er_y)) return false; - app->settings.model.mouse_mirror = mirror; - app->left_mouse_down = false; - bongo_cat_app_apply_mouse_position(app, center_x + bounds.w * 0.4, - center_y - bounds.h * 0.4, 1.0f / 60.0f); - for (int frame = 0; frame < 90; ++frame) - bongo_cat_app_step_live2d(app, 1.0f / 60.0f); - return true; -} - -static bool reverse_pointer(BongoCatApp *app) { - SDL_Rect bounds; - double center_x, center_y; - if (!pointer_test_center(app, &bounds, ¢er_x, ¢er_y)) return false; - pointer_audit = (PointerAudit){.ran = true}; - app->settings.model.mouse_mirror = false; - app->left_mouse_down = false; - const float ratios[4][2] = { - {-0.4f, -0.4f}, {0.4f, -0.4f}, {-0.4f, 0.4f}, {0.4f, 0.4f}}; - float previous_x = 0.0f; - bool previous_ready = value(app, "ParamAngleX", &previous_x); - for (int corner = 0; corner < 4; ++corner) { - bongo_cat_app_apply_mouse_position(app, - center_x + bounds.w * ratios[corner][0], - center_y + bounds.h * ratios[corner][1], 1.0f / 60.0f); - for (int frame = 0; frame < 90; ++frame) { - bongo_cat_app_step_live2d(app, 1.0f / 60.0f); - float current_x = previous_x; - if (!value(app, "ParamAngleX", ¤t_x)) return false; - if (previous_ready) { - float step = SDL_fabsf(current_x - previous_x); - if (step > pointer_audit.maximum_step) - pointer_audit.maximum_step = step; - } - previous_x = current_x; - previous_ready = true; - } - if (!value(app, "ParamAngleX", &pointer_audit.angle_x[corner]) || - !value(app, "ParamAngleY", &pointer_audit.angle_y[corner])) return false; - bool mouse_x = value(app, "ParamMouseX", &pointer_audit.mouse_x[corner]); - bool mouse_y = value(app, "ParamMouseY", &pointer_audit.mouse_y[corner]); - if (corner == 0) pointer_audit.has_mouse = mouse_x && mouse_y; - else if (pointer_audit.has_mouse != (mouse_x && mouse_y)) return false; - } - return true; -} +/* Viewer 5.3.03: (7.2727275 / 30) target speed * 30-degree range * 1.2 gain. */ +#define POINTER_VISIBLE_STEP_LIMIT 8.8f static bool apply(BongoCatApp *app, const char *scenario) { if (strncmp(scenario, "switch:", 7) == 0) @@ -109,10 +34,12 @@ static bool apply(BongoCatApp *app, const char *scenario) { if (strcmp(scenario, "mouse-hand-screen") == 0) return bongo_cat_app_audit_display_pointer(app); if (strcmp(scenario, "mirror") == 0) app->settings.model.mirror = true; - else if (strcmp(scenario, "mouse-move") == 0) return pointer(app, false); - else if (strcmp(scenario, "mouse-move-mirror") == 0) return pointer(app, true); + else if (strcmp(scenario, "mouse-move") == 0) + return bongo_cat_live2d_pointer_audit_run(app, false); + else if (strcmp(scenario, "mouse-move-mirror") == 0) + return bongo_cat_live2d_pointer_audit_run(app, true); else if (strcmp(scenario, "mouse-reverse") == 0) - return reverse_pointer(app); + return bongo_cat_live2d_pointer_reverse_audit_run(app); else if (strcmp(scenario, "key-left") == 0) input(app, BONGO_CAT_INPUT_KEY_DOWN, "KeyA", 1.0f); else if (strcmp(scenario, "key-tab-left") == 0) @@ -220,17 +147,17 @@ static bool assertions(BongoCatApp *app, const char *scenario, bool operation) { signed_value(app, "ParamAngleY", true); if (strcmp(scenario, "mouse-reverse") == 0) { bool horizontal = - pointer_audit.angle_x[0] < -5.0f && - pointer_audit.angle_x[1] > 5.0f && - pointer_audit.angle_x[2] < -5.0f && - pointer_audit.angle_x[3] > 5.0f; - bool direction = pointer_audit.ran && horizontal && - pointer_audit.angle_y[0] > 5.0f && - pointer_audit.angle_y[1] > 5.0f && - pointer_audit.angle_y[2] < -5.0f && - pointer_audit.angle_y[3] < -5.0f; + bongo_cat_pointer_audit.angle_x[0] < -5.0f && + bongo_cat_pointer_audit.angle_x[1] > 5.0f && + bongo_cat_pointer_audit.angle_x[2] < -5.0f && + bongo_cat_pointer_audit.angle_x[3] > 5.0f; + bool direction = bongo_cat_pointer_audit.ran && horizontal && + bongo_cat_pointer_audit.angle_y[0] > 5.0f && + bongo_cat_pointer_audit.angle_y[1] > 5.0f && + bongo_cat_pointer_audit.angle_y[2] < -5.0f && + bongo_cat_pointer_audit.angle_y[3] < -5.0f; return direction && - pointer_audit.maximum_step < POINTER_VISIBLE_STEP_LIMIT; + bongo_cat_pointer_audit.maximum_step < POINTER_VISIBLE_STEP_LIMIT; } if (strcmp(scenario, "gamepad-sticks") == 0) return signed_value(app, "CatParamStickLX", true) && @@ -276,19 +203,20 @@ void bongo_cat_live2d_audit_run(BongoCatApp *app) { "ParamEyeBallX", "ParamEyeBallY"}; for (size_t i = 0; i < sizeof(parameters) / sizeof(parameters[0]); ++i) parameter(file, app, parameters[i]); - if (pointer_audit.ran) { + if (bongo_cat_pointer_audit.ran) { static const char *names[] = {"tl", "tr", "bl", "br"}; fprintf(file, "pointer.maximum_step=%.4f\npointer.has_mouse=%d\n", - pointer_audit.maximum_step, pointer_audit.has_mouse); + bongo_cat_pointer_audit.maximum_step, + bongo_cat_pointer_audit.has_mouse); for (int corner = 0; corner < 4; ++corner) fprintf(file, "pointer.%s.angle_x=%.4f\n" "pointer.%s.angle_y=%.4f\n" "pointer.%s.mouse_x=%.4f\n" "pointer.%s.mouse_y=%.4f\n", names[corner], - pointer_audit.angle_x[corner], names[corner], - pointer_audit.angle_y[corner], names[corner], - pointer_audit.mouse_x[corner], names[corner], - pointer_audit.mouse_y[corner]); + bongo_cat_pointer_audit.angle_x[corner], names[corner], + bongo_cat_pointer_audit.angle_y[corner], names[corner], + bongo_cat_pointer_audit.mouse_x[corner], names[corner], + bongo_cat_pointer_audit.mouse_y[corner]); } fclose(file); } diff --git a/src/runtime/diagnostics/live2d_pointer_audit.c b/src/runtime/diagnostics/live2d_pointer_audit.c new file mode 100644 index 00000000..ba9e6c4a --- /dev/null +++ b/src/runtime/diagnostics/live2d_pointer_audit.c @@ -0,0 +1,80 @@ +#include "live2d_pointer_audit.h" + +BongoCatPointerAudit bongo_cat_pointer_audit; + +static bool value(BongoCatApp *app, const char *id, float *output) { + BongoCatParameterRange range; + if (!bongo_cat_live2d_parameter(app->live2d, id, &range)) return false; + if (output) *output = range.value; + return true; +} + +static bool test_center(BongoCatApp *app, SDL_Rect *bounds, + double *center_x, double *center_y) { + int window_x, window_y, width, height; + if (!app || !app->window || !bounds || !center_x || !center_y || + !SDL_GetWindowPosition(app->window, &window_x, &window_y) || + !SDL_GetWindowSize(app->window, &width, &height)) return false; + *center_x = window_x + width * 0.5; + *center_y = window_y + height * 0.5; + SDL_Point point = {(int)*center_x, (int)*center_y}; + SDL_DisplayID display = SDL_GetDisplayForPoint(&point); + return display && SDL_GetDisplayBounds(display, bounds) && + bounds->w > 0 && bounds->h > 0; +} + +bool bongo_cat_live2d_pointer_audit_run(BongoCatApp *app, bool mirror) { + SDL_Rect bounds; + double center_x, center_y; + if (!test_center(app, &bounds, ¢er_x, ¢er_y)) return false; + app->settings.model.mouse_mirror = mirror; + app->left_mouse_down = false; + bongo_cat_app_apply_mouse_position(app, center_x + bounds.w * 0.4, + center_y - bounds.h * 0.4, 1.0f / 60.0f); + for (int frame = 0; frame < 90; ++frame) + bongo_cat_app_step_live2d(app, 1.0f / 60.0f); + return true; +} + +bool bongo_cat_live2d_pointer_reverse_audit_run(BongoCatApp *app) { + SDL_Rect bounds; + double center_x, center_y; + if (!test_center(app, &bounds, ¢er_x, ¢er_y)) return false; + bongo_cat_pointer_audit = (BongoCatPointerAudit){.ran = true}; + app->settings.model.mouse_mirror = false; + app->left_mouse_down = false; + const float ratios[4][2] = { + {-0.4f, -0.4f}, {0.4f, -0.4f}, {-0.4f, 0.4f}, {0.4f, 0.4f}}; + float previous_x = 0.0f; + bool previous_ready = value(app, "ParamAngleX", &previous_x); + for (int corner = 0; corner < 4; ++corner) { + bongo_cat_app_apply_mouse_position(app, + center_x + bounds.w * ratios[corner][0], + center_y + bounds.h * ratios[corner][1], 1.0f / 60.0f); + for (int frame = 0; frame < 90; ++frame) { + bongo_cat_app_step_live2d(app, 1.0f / 60.0f); + float current_x = previous_x; + if (!value(app, "ParamAngleX", ¤t_x)) return false; + if (previous_ready) { + float step = SDL_fabsf(current_x - previous_x); + if (step > bongo_cat_pointer_audit.maximum_step) + bongo_cat_pointer_audit.maximum_step = step; + } + previous_x = current_x; + previous_ready = true; + } + if (!value(app, "ParamAngleX", + &bongo_cat_pointer_audit.angle_x[corner]) || + !value(app, "ParamAngleY", + &bongo_cat_pointer_audit.angle_y[corner])) return false; + bool mouse_x = value(app, "ParamMouseX", + &bongo_cat_pointer_audit.mouse_x[corner]); + bool mouse_y = value(app, "ParamMouseY", + &bongo_cat_pointer_audit.mouse_y[corner]); + if (corner == 0) + bongo_cat_pointer_audit.has_mouse = mouse_x && mouse_y; + else if (bongo_cat_pointer_audit.has_mouse != (mouse_x && mouse_y)) + return false; + } + return true; +} diff --git a/src/runtime/diagnostics/live2d_pointer_audit.h b/src/runtime/diagnostics/live2d_pointer_audit.h new file mode 100644 index 00000000..d0b848b4 --- /dev/null +++ b/src/runtime/diagnostics/live2d_pointer_audit.h @@ -0,0 +1,21 @@ +#ifndef BONGO_CAT_LIVE2D_POINTER_AUDIT_H +#define BONGO_CAT_LIVE2D_POINTER_AUDIT_H + +#include "runtime.h" + +typedef struct BongoCatPointerAudit { + bool ran; + bool has_mouse; + float angle_x[4]; + float angle_y[4]; + float mouse_x[4]; + float mouse_y[4]; + float maximum_step; +} BongoCatPointerAudit; + +extern BongoCatPointerAudit bongo_cat_pointer_audit; + +bool bongo_cat_live2d_pointer_audit_run(BongoCatApp *app, bool mirror); +bool bongo_cat_live2d_pointer_reverse_audit_run(BongoCatApp *app); + +#endif diff --git a/src/runtime/lifecycle/app.c b/src/runtime/lifecycle/app.c index 95492c02..fa4e1029 100644 --- a/src/runtime/lifecycle/app.c +++ b/src/runtime/lifecycle/app.c @@ -158,135 +158,3 @@ bool bongo_cat_app_initialize(BongoCatApp *app, int argc, char **argv, if (!app->session.window.visible) bongo_cat_startup_ready(app); return true; } -static void handle_event(BongoCatApp *app, const SDL_Event *event) { - if (bongo_cat_update_event(app->update, event)) return; - if (bongo_cat_model_refresh_event(app, event)) return; - if (bongo_cat_preferences_event(app->preferences, event)) return; - if (!bongo_cat_window_event(app, event)) app->running = false; - if (event->type >= SDL_EVENT_GAMEPAD_AXIS_MOTION && - event->type <= SDL_EVENT_GAMEPAD_TOUCHPAD_UP) bongo_cat_gamepad_event(app, event); -} -static void update_model(BongoCatApp *app, uint64_t now) { - float elapsed = (float)((now - app->last_frame_ns) / 1000000000.0); - if (elapsed > 0.25f) elapsed = 0.25f; - app->last_frame_ns = now; - if (app->smoke_freeze_model) return; - bongo_cat_app_step_live2d(app, elapsed); -} -static bool render(BongoCatApp *app, bool present) { - uint64_t now = SDL_GetTicksNS(); - if (app->render_retry_ns > now) return false; - if (!SDL_GL_MakeCurrent(app->window, app->gl_context)) { - app->dirty = true; - app->render_retry_ns = now + 1000000000ull; - SDL_LogError(SDL_LOG_CATEGORY_VIDEO, - "Main GL context could not be activated: %s", SDL_GetError()); - return false; - } - app->render_retry_ns = 0; - int width, height; - SDL_GetWindowSizeInPixels(app->window, &width, &height); - glViewport(0, 0, width, height); glEnable(GL_MULTISAMPLE); - glDisable(GL_SCISSOR_TEST); - glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); - bongo_cat_window_clear_background(app); - int content_x = 0, content_y = 0, content_width = width, - content_height = height; - bool content_viewport = bongo_cat_live2d_viewport(app->live2d, - &content_x, &content_y, &content_width, &content_height) && - content_width > 0 && content_height > 0; - if (content_viewport) - glViewport(content_x, content_y, content_width, content_height); - bongo_cat_overlay_draw_background(app->overlay, app->settings.model.mirror); - glViewport(0, 0, width, height); - bongo_cat_live2d_set_mirror(app->live2d, app->settings.model.mirror); - bongo_cat_live2d_draw(app->live2d); - if (content_viewport) - glViewport(content_x, content_y, content_width, content_height); - bongo_cat_overlay_draw_pointer_before_keys(app->overlay); - if (app->settings.model.mouse_centered && app->pointer_known && !app->model_pointer_anchor_ready) bongo_cat_app_apply_mouse_position(app, app->pointer_x, app->pointer_y, 0.0f); - bongo_cat_overlay_draw_keys(app->overlay, app->settings.model.mirror); - bongo_cat_overlay_draw_effect(app->overlay, app->settings.model.mirror); - bongo_cat_overlay_draw_pointer_after_keys(app->overlay); - glViewport(0, 0, width, height); - bongo_cat_frame_capture_pending(app, width, height); - if (!present) { - app->dirty = true; - return true; - } - bongo_cat_frame_audit(app, width, height); - bongo_cat_window_capture_pointer_hit(app); - if (!bongo_cat_platform_present(&app->platform, width, height)) { - app->dirty = true; - app->render_retry_ns = now + 1000000000ull; - SDL_LogError(SDL_LOG_CATEGORY_VIDEO, - "Main frame presentation failed: %s", SDL_GetError()); - return false; - } - bongo_cat_frame_presented_audit(app); - bongo_cat_startup_ready(app); bongo_cat_memory_policy_frame_presented(); app->dirty = false; - bongo_cat_window_sync_click_through(app); bongo_cat_window_schedule_hit_check(app); - return true; -} -void bongo_cat_app_render_now(BongoCatApp *app) { if (app && app->window && - app->session.window.visible && !app->window_minimized) render(app, true); } -bool bongo_cat_app_capture_pending_frame(BongoCatApp *app) { - return app && app->window && app->pending_model_cover_path[0] && - !app->window_minimized && render(app, false); -} -static void take_instance_wake(BongoCatApp *app) { - if (!bongo_cat_platform_single_instance_take_wake()) return; - bongo_cat_window_set_visible(app, true); - bongo_cat_platform_raise_window(app->window); - SDL_Log("Existing instance requested window reveal"); -} -static bool take_update_shutdown(BongoCatApp *app) { - if (!bongo_cat_platform_single_instance_take_update_shutdown()) return false; - app->running = false; - SDL_Log("Installed update requested application shutdown"); - return true; -} -void bongo_cat_app_loop(BongoCatApp *app) { - uint64_t iterations = 0, wakes = 0, zero_waits = 0; - while (app->running) { - iterations++; - int wait_ms = bongo_cat_window_wait_timeout(app, SDL_GetTicksNS()); - if (app->secondary_pet && wait_ms > 100) wait_ms = 100; - if (!wait_ms) zero_waits++; - bongo_cat_preferences_input_begin(app->preferences); - SDL_Event event; - if (bongo_cat_wait_event(&event, wait_ms)) { - wakes++; - handle_event(app, &event); - unsigned queued = 0; - while (queued++ < 256 && SDL_PollEvent(&event)) - handle_event(app, &event); - } - bongo_cat_preferences_input_end(app->preferences); - uint64_t now = SDL_GetTicksNS(); - bongo_cat_preferences_model_watch(app->preferences, now); - bongo_cat_model_refresh_update(app); - take_instance_wake(app); - if (take_update_shutdown(app)) continue; - now = SDL_GetTicksNS(); bongo_cat_window_update_wheel_animation(app, now); - bongo_cat_multi_pet_update(app, now); - bongo_cat_random_expression_update(app, now); - bongo_cat_window_update_display_recovery(app, now); - bongo_cat_runtime_flow_update(app, now); - bongo_cat_window_apply_pending_resize(app); - bongo_cat_app_update_hover(app, now); - bongo_cat_app_drain_input(app, true); - if (bongo_cat_model_frame_due(app, now)) update_model(app, now); - else if (!app->session.window.visible || app->window_minimized) { app->last_frame_ns = now; bongo_cat_memory_policy_idle(); } - if (app->session.window.visible && !app->window_minimized && app->dirty) render(app, true); - bongo_cat_preferences_render(app->preferences); - bongo_cat_preferences_process_model_selection(app->preferences); - if (app->session.window.visible && !app->window_minimized && app->dirty) render(app, true); - bongo_cat_tray_sync(app->tray); bongo_cat_window_raise_when_due(app, now); - bongo_cat_config_store_update(app, now); - if (app->smoke_deadline_ns && now >= app->smoke_deadline_ns) app->running = false; - } - if (app->smoke) SDL_Log("Smoke loop: iterations=%llu wakes=%llu zero_waits=%llu", - (unsigned long long)iterations, (unsigned long long)wakes, - (unsigned long long)zero_waits); -} diff --git a/src/runtime/lifecycle/app_loop.c b/src/runtime/lifecycle/app_loop.c new file mode 100644 index 00000000..c7679e76 --- /dev/null +++ b/src/runtime/lifecycle/app_loop.c @@ -0,0 +1,167 @@ +#include "runtime.h" +#include "bongo_cat/overlay.h" +#include "bongo_cat/preferences.h" +#include "bongo_cat/tray.h" + +#include + +static void handle_event(BongoCatApp *app, const SDL_Event *event) { + if (bongo_cat_update_event(app->update, event)) return; + if (bongo_cat_model_refresh_event(app, event)) return; + if (bongo_cat_preferences_event(app->preferences, event)) return; + if (!bongo_cat_window_event(app, event)) app->running = false; + if (event->type >= SDL_EVENT_GAMEPAD_AXIS_MOTION && + event->type <= SDL_EVENT_GAMEPAD_TOUCHPAD_UP) + bongo_cat_gamepad_event(app, event); +} + +static void update_model(BongoCatApp *app, uint64_t now) { + float elapsed = (float)((now - app->last_frame_ns) / 1000000000.0); + if (elapsed > 0.25f) elapsed = 0.25f; + app->last_frame_ns = now; + if (!app->smoke_freeze_model) bongo_cat_app_step_live2d(app, elapsed); +} + +static bool render(BongoCatApp *app, bool present) { + uint64_t now = SDL_GetTicksNS(); + if (app->render_retry_ns > now) return false; + if (!SDL_GL_MakeCurrent(app->window, app->gl_context)) { + app->dirty = true; + app->render_retry_ns = now + 1000000000ull; + SDL_LogError(SDL_LOG_CATEGORY_VIDEO, + "Main GL context could not be activated: %s", SDL_GetError()); + return false; + } + app->render_retry_ns = 0; + int width, height; + SDL_GetWindowSizeInPixels(app->window, &width, &height); + glViewport(0, 0, width, height); + glEnable(GL_MULTISAMPLE); + glDisable(GL_SCISSOR_TEST); + glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + bongo_cat_window_clear_background(app); + int content_x = 0, content_y = 0, content_width = width, + content_height = height; + bool content_viewport = bongo_cat_live2d_viewport(app->live2d, + &content_x, &content_y, &content_width, &content_height) && + content_width > 0 && content_height > 0; + if (content_viewport) + glViewport(content_x, content_y, content_width, content_height); + bongo_cat_overlay_draw_background(app->overlay, + app->settings.model.mirror); + glViewport(0, 0, width, height); + bongo_cat_live2d_set_mirror(app->live2d, app->settings.model.mirror); + bongo_cat_live2d_draw(app->live2d); + if (content_viewport) + glViewport(content_x, content_y, content_width, content_height); + bongo_cat_overlay_draw_pointer_before_keys(app->overlay); + if (app->settings.model.mouse_centered && app->pointer_known && + !app->model_pointer_anchor_ready) + bongo_cat_app_apply_mouse_position(app, app->pointer_x, + app->pointer_y, 0.0f); + bongo_cat_overlay_draw_keys(app->overlay, app->settings.model.mirror); + bongo_cat_overlay_draw_effect(app->overlay, app->settings.model.mirror); + bongo_cat_overlay_draw_pointer_after_keys(app->overlay); + glViewport(0, 0, width, height); + bongo_cat_frame_capture_pending(app, width, height); + if (!present) { + app->dirty = true; + return true; + } + bongo_cat_frame_audit(app, width, height); + bongo_cat_window_capture_pointer_hit(app); + if (!bongo_cat_platform_present(&app->platform, width, height)) { + app->dirty = true; + app->render_retry_ns = now + 1000000000ull; + SDL_LogError(SDL_LOG_CATEGORY_VIDEO, + "Main frame presentation failed: %s", SDL_GetError()); + return false; + } + bongo_cat_frame_presented_audit(app); + bongo_cat_startup_ready(app); + bongo_cat_memory_policy_frame_presented(); + app->dirty = false; + bongo_cat_window_sync_click_through(app); + bongo_cat_window_schedule_hit_check(app); + return true; +} + +void bongo_cat_app_render_now(BongoCatApp *app) { + if (app && app->window && app->session.window.visible && + !app->window_minimized) + render(app, true); +} + +bool bongo_cat_app_capture_pending_frame(BongoCatApp *app) { + return app && app->window && app->pending_model_cover_path[0] && + !app->window_minimized && render(app, false); +} + +static void take_instance_wake(BongoCatApp *app) { + if (!bongo_cat_platform_single_instance_take_wake()) return; + bongo_cat_window_set_visible(app, true); + bongo_cat_platform_raise_window(app->window); + SDL_Log("Existing instance requested window reveal"); +} + +static bool take_update_shutdown(BongoCatApp *app) { + if (!bongo_cat_platform_single_instance_take_update_shutdown()) + return false; + app->running = false; + SDL_Log("Installed update requested application shutdown"); + return true; +} + +void bongo_cat_app_loop(BongoCatApp *app) { + uint64_t iterations = 0, wakes = 0, zero_waits = 0; + while (app->running) { + iterations++; + int wait_ms = bongo_cat_window_wait_timeout(app, SDL_GetTicksNS()); + if (app->secondary_pet && wait_ms > 100) wait_ms = 100; + if (!wait_ms) zero_waits++; + bongo_cat_preferences_input_begin(app->preferences); + SDL_Event event; + if (bongo_cat_wait_event(&event, wait_ms)) { + wakes++; + handle_event(app, &event); + unsigned queued = 0; + while (queued++ < 256 && SDL_PollEvent(&event)) + handle_event(app, &event); + } + bongo_cat_preferences_input_end(app->preferences); + uint64_t now = SDL_GetTicksNS(); + bongo_cat_preferences_model_watch(app->preferences, now); + bongo_cat_model_refresh_update(app); + take_instance_wake(app); + if (take_update_shutdown(app)) continue; + now = SDL_GetTicksNS(); + bongo_cat_window_update_wheel_animation(app, now); + bongo_cat_multi_pet_update(app, now); + bongo_cat_random_expression_update(app, now); + bongo_cat_window_update_display_recovery(app, now); + bongo_cat_runtime_flow_update(app, now); + bongo_cat_window_apply_pending_resize(app); + bongo_cat_app_update_hover(app, now); + bongo_cat_app_drain_input(app, true); + if (bongo_cat_model_frame_due(app, now)) update_model(app, now); + else if (!app->session.window.visible || app->window_minimized) { + app->last_frame_ns = now; + bongo_cat_memory_policy_idle(); + } + if (app->session.window.visible && !app->window_minimized && app->dirty) + render(app, true); + bongo_cat_preferences_render(app->preferences); + bongo_cat_preferences_process_model_selection(app->preferences); + if (app->session.window.visible && !app->window_minimized && app->dirty) + render(app, true); + bongo_cat_tray_sync(app->tray); + bongo_cat_window_raise_when_due(app, now); + bongo_cat_config_store_update(app, now); + if (app->smoke_deadline_ns && now >= app->smoke_deadline_ns) + app->running = false; + } + if (app->smoke) + SDL_Log("Smoke loop: iterations=%llu wakes=%llu zero_waits=%llu", + (unsigned long long)iterations, (unsigned long long)wakes, + (unsigned long long)zero_waits); +} diff --git a/src/runtime/model/import/model_import_digest.c b/src/runtime/model/import/model_import_digest.c index 7184d21c..f725b14f 100644 --- a/src/runtime/model/import/model_import_digest.c +++ b/src/runtime/model/import/model_import_digest.c @@ -1,4 +1,5 @@ #include "model_import.h" +#include "model_import_digest_internal.h" #include "model_import_mver_policy.h" #include "bongo_cat/path.h" #include "bongo_cat/sha256.h" @@ -8,6 +9,10 @@ #include #define DIGEST_ROOT_CAP 12 +#define path_length bongo_cat_import_digest_path_length +#define path_contains bongo_cat_import_digest_path_contains +#define hash_text bongo_cat_import_digest_hash_text +#define mix bongo_cat_import_digest_mix typedef struct DigestStamp { uint64_t sum, exclusive, bytes, files, entries; } DigestStamp; @@ -25,47 +30,6 @@ typedef struct DigestWalk { typedef struct DigestRoot { char path[BONGO_CAT_PATH_CAP]; const char *group; } DigestRoot; -static bool separator(char value) { return value == '/' || value == '\\'; } - -static bool path_character_equal(char left, char right) { - if (separator(left) && separator(right)) return true; -#ifdef _WIN32 - if (left >= 'A' && left <= 'Z') left = (char)(left - 'A' + 'a'); - if (right >= 'A' && right <= 'Z') right = (char)(right - 'A' + 'a'); -#endif - return left == right; -} - -static size_t path_length(const char *path) { - size_t length = path ? strlen(path) : 0; - while (length > 1 && separator(path[length - 1])) length--; - return length; -} - -static bool path_contains(const char *root, const char *path) { - size_t root_length = path_length(root), item_length = path_length(path); - if (!root_length || item_length < root_length) return false; - for (size_t i = 0; i < root_length; ++i) - if (!path_character_equal(root[i], path[i])) return false; - return item_length == root_length || separator(path[root_length]); -} - -static uint64_t hash_text(const char *value) { - uint64_t hash = 1469598103934665603ull; - for (const unsigned char *cursor = (const unsigned char *)value; - cursor && *cursor; ++cursor) { - unsigned char byte = *cursor == '\\' ? '/' : *cursor; - hash ^= byte; hash *= 1099511628211ull; - } - return hash; -} - -static uint64_t mix(uint64_t value) { - value ^= value >> 30; value *= 0xbf58476d1ce4e5b9ull; - value ^= value >> 27; value *= 0x94d049bb133111ebull; - return value ^ (value >> 31); -} - static bool stamp_path(const char *path, DigestWalk *walk); static BongoCatPathVisit stamp_item(void *userdata, diff --git a/src/runtime/model/import/model_import_digest_internal.h b/src/runtime/model/import/model_import_digest_internal.h new file mode 100644 index 00000000..3ed46cda --- /dev/null +++ b/src/runtime/model/import/model_import_digest_internal.h @@ -0,0 +1,14 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_DIGEST_INTERNAL_H +#define BONGO_CAT_MODEL_IMPORT_DIGEST_INTERNAL_H + +#include +#include +#include + +size_t bongo_cat_import_digest_path_length(const char *path); +bool bongo_cat_import_digest_path_contains(const char *root, + const char *path); +uint64_t bongo_cat_import_digest_hash_text(const char *value); +uint64_t bongo_cat_import_digest_mix(uint64_t value); + +#endif diff --git a/src/runtime/model/import/model_import_digest_path.c b/src/runtime/model/import/model_import_digest_path.c new file mode 100644 index 00000000..efc023bf --- /dev/null +++ b/src/runtime/model/import/model_import_digest_path.c @@ -0,0 +1,51 @@ +#include "model_import_digest_internal.h" + +#include + +static bool separator(char value) { + return value == '/' || value == '\\'; +} + +static bool character_equal(char left, char right) { + if (separator(left) && separator(right)) return true; +#ifdef _WIN32 + if (left >= 'A' && left <= 'Z') left = (char)(left - 'A' + 'a'); + if (right >= 'A' && right <= 'Z') right = (char)(right - 'A' + 'a'); +#endif + return left == right; +} + +size_t bongo_cat_import_digest_path_length(const char *path) { + size_t length = path ? strlen(path) : 0; + while (length > 1 && separator(path[length - 1])) length--; + return length; +} + +bool bongo_cat_import_digest_path_contains(const char *root, + const char *path) { + size_t root_length = bongo_cat_import_digest_path_length(root); + size_t item_length = bongo_cat_import_digest_path_length(path); + if (!root_length || item_length < root_length) return false; + for (size_t i = 0; i < root_length; ++i) + if (!character_equal(root[i], path[i])) return false; + return item_length == root_length || separator(path[root_length]); +} + +uint64_t bongo_cat_import_digest_hash_text(const char *value) { + uint64_t hash = 1469598103934665603ull; + for (const unsigned char *cursor = (const unsigned char *)value; + cursor && *cursor; ++cursor) { + unsigned char byte = *cursor == '\\' ? '/' : *cursor; + hash ^= byte; + hash *= 1099511628211ull; + } + return hash; +} + +uint64_t bongo_cat_import_digest_mix(uint64_t value) { + value ^= value >> 30; + value *= 0xbf58476d1ce4e5b9ull; + value ^= value >> 27; + value *= 0x94d049bb133111ebull; + return value ^ (value >> 31); +} diff --git a/src/runtime/model/import/model_import_install.c b/src/runtime/model/import/model_import_install.c new file mode 100644 index 00000000..bed99e33 --- /dev/null +++ b/src/runtime/model/import/model_import_install.c @@ -0,0 +1,207 @@ +#include "model_import_session_internal.h" +#include "model_storage.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +typedef struct ImportInstall { + char id[BONGO_CAT_ID_CAP]; + char temporary[BONGO_CAT_PATH_CAP]; + char target[BONGO_CAT_PATH_CAP]; + bool committed; +} ImportInstall; + +static void cleanup(ImportInstall *install) { + if (!install) return; + bongo_cat_model_remove_tree(install->committed + ? install->target : install->temporary, NULL); +} + +static bool existing_package(BongoCatImportSession *session, + const BongoCatImportDiscovery *discovery, + const BongoCatPackageMetadata *metadata, + char ids[BONGO_CAT_IMPORT_CANDIDATE_CAP][BONGO_CAT_ID_CAP], + size_t *count) { + return bongo_cat_import_package_index_find(session->packages, + discovery, metadata, ids, count); +} + +static bool canonical_mver_package( + const BongoCatImportDiscovery *discovery) { + if (!discovery || !discovery->count) return false; + for (size_t i = 0; i < discovery->count; ++i) + if (discovery->candidates[i].format != BONGO_CAT_IMPORT_MVER) + return false; + return true; +} + +static bool resolve_install_id(const char *base, const char *root, + char output[BONGO_CAT_ID_CAP], BongoCatError *error) { + for (size_t suffix = 1; suffix < 10000; ++suffix) { + char id[BONGO_CAT_ID_CAP]; + int written = suffix == 1 + ? snprintf(id, sizeof(id), "%s", base) + : snprintf(id, sizeof(id), "%s-%zu", base, suffix); + if (written < 0 || (size_t)written >= sizeof(id)) continue; + char path[BONGO_CAT_PATH_CAP]; + bool exists = bongo_cat_path_join(path, sizeof(path), root, id) && + (bongo_cat_path_is_dir(path) || bongo_cat_path_is_file(path)); + if (!exists) { + snprintf(output, BONGO_CAT_ID_CAP, "%s", id); + return true; + } + } + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Cannot allocate a unique model directory for %s", base); + return false; +} + +static void fill_receipt(BongoCatImportReceipt *receipt, const char *id, + size_t count, bool installed) { + if (!receipt) return; + receipt->count = count; + receipt->installed_count = installed ? count : 0; + for (size_t i = 0; i < count; ++i) { + bongo_cat_import_variant_id(receipt->ids[i], + sizeof(receipt->ids[i]), id, i); + receipt->installed[i] = installed; + } +} + +static void fill_receipt_ids(BongoCatImportReceipt *receipt, + const char ids[BONGO_CAT_IMPORT_CANDIDATE_CAP][BONGO_CAT_ID_CAP], + size_t count, bool installed) { + if (!receipt) return; + receipt->count = count; + receipt->installed_count = installed ? count : 0; + for (size_t i = 0; i < count; ++i) { + snprintf(receipt->ids[i], sizeof(receipt->ids[i]), "%s", ids[i]); + receipt->installed[i] = installed; + } +} + +BongoCatResult bongo_cat_import_install_discovery( + BongoCatImportSession *session, + BongoCatImportDiscovery *source_discovery, + BongoCatPackageMetadata *source_metadata, + BongoCatImportReceipt *receipt, BongoCatError *error) { + uint64_t started = SDL_GetTicksNS(); + if (canonical_mver_package(source_discovery)) { + char existing[BONGO_CAT_IMPORT_CANDIDATE_CAP][BONGO_CAT_ID_CAP]; + size_t existing_count = 0; + if (existing_package(session, source_discovery, source_metadata, + existing, &existing_count)) { + SDL_Log("[runtime] Model import storage transaction: " + "stage=source-duplicate variants=%llu elapsed_ms=%.1f", + (unsigned long long)existing_count, + (SDL_GetTicksNS() - started) / 1000000.0); + fill_receipt_ids(receipt, existing, existing_count, false); + return BONGO_CAT_OK; + } + } + + ImportInstall install = {0}; + char temporary_name[BONGO_CAT_ID_CAP + 24]; + snprintf(temporary_name, sizeof(temporary_name), ".import-%.16s-1.tmp", + source_metadata[0].content_digest); + if (!bongo_cat_path_join(install.temporary, sizeof(install.temporary), + session->root, temporary_name)) return BONGO_CAT_ERROR_IO; + SDL_Log("[runtime] Model import storage transaction: stage=prepare " + "candidates=%llu temporary=%s", + (unsigned long long)source_discovery->count, install.temporary); + bongo_cat_model_remove_tree(install.temporary, NULL); + if (!bongo_cat_import_prepare_storage(source_discovery, + install.temporary, error)) { + cleanup(&install); + return error && error->code == BONGO_CAT_ERROR_FORMAT + ? BONGO_CAT_ERROR_FORMAT : BONGO_CAT_ERROR_IO; + } + SDL_Log("[runtime] Model import storage transaction: stage=prepared " + "elapsed_ms=%.1f temporary=%s", + (SDL_GetTicksNS() - started) / 1000000.0, install.temporary); + + BongoCatImportDiscovery *normalized = calloc(1, sizeof(*normalized)); + BongoCatPackageMetadata *metadata = calloc(BONGO_CAT_IMPORT_CANDIDATE_CAP, + sizeof(*metadata)); + if (!normalized || !metadata) { + free(normalized); + free(metadata); + cleanup(&install); + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Cannot validate normalized model package"); + return BONGO_CAT_ERROR_MEMORY; + } + SDL_Log("[runtime] Model import storage transaction: " + "stage=validate-normalized temporary=%s", install.temporary); + bool discovered = bongo_cat_import_discover(install.temporary, normalized, + error); + SDL_Log("[runtime] Model import storage transaction: " + "stage=normalized-discovered result=%d candidates=%llu temporary=%s", + discovered, (unsigned long long)normalized->count, install.temporary); + bool valid = discovered && bongo_cat_import_prepare_package_metadata_cached( + normalized, metadata, session->digests, error); + if (!valid) { + free(normalized); + free(metadata); + cleanup(&install); + return error && error->code ? error->code : BONGO_CAT_ERROR_FORMAT; + } + + char existing[BONGO_CAT_IMPORT_CANDIDATE_CAP][BONGO_CAT_ID_CAP]; + size_t existing_count = 0; + if (existing_package(session, normalized, metadata, existing, + &existing_count)) { + SDL_Log("[runtime] Model import storage transaction: stage=duplicate " + "variants=%llu elapsed_ms=%.1f temporary=%s", + (unsigned long long)existing_count, + (SDL_GetTicksNS() - started) / 1000000.0, install.temporary); + fill_receipt_ids(receipt, existing, existing_count, false); + free(normalized); + free(metadata); + cleanup(&install); + return BONGO_CAT_OK; + } + if (!bongo_cat_import_package_index_has_capacity(session->packages, + normalized->count)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Too many installed model variants"); + free(normalized); + free(metadata); + cleanup(&install); + return BONGO_CAT_ERROR_FORMAT; + } + if (!resolve_install_id(source_metadata[0].package_id, session->root, + install.id, error) || + !bongo_cat_path_join(install.target, sizeof(install.target), + session->root, install.id)) { + free(normalized); + free(metadata); + cleanup(&install); + return error && error->code ? error->code : BONGO_CAT_ERROR_FORMAT; + } + if (!bongo_cat_path_rename(install.temporary, install.target)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot finish model import: %s", SDL_GetError()); + free(normalized); + free(metadata); + cleanup(&install); + return BONGO_CAT_ERROR_IO; + } + install.committed = true; + SDL_Log("[runtime] Model import storage transaction: stage=committed " + "id=%s elapsed_ms=%.1f target=%s", install.id, + (SDL_GetTicksNS() - started) / 1000000.0, install.target); + if (!bongo_cat_import_package_index_add(session->packages, install.id, + normalized, metadata, error)) { + free(normalized); + free(metadata); + cleanup(&install); + return error && error->code ? error->code : BONGO_CAT_ERROR_FORMAT; + } + fill_receipt(receipt, install.id, normalized->count, true); + free(normalized); + free(metadata); + return BONGO_CAT_OK; +} diff --git a/src/runtime/model/import/model_import_package.c b/src/runtime/model/import/model_import_package.c index 52fa5e1c..764fdbda 100644 --- a/src/runtime/model/import/model_import_package.c +++ b/src/runtime/model/import/model_import_package.c @@ -1,9 +1,11 @@ #include "model_import.h" +#include "model_import_path.h" #include "model_import_mver_copy.h" #include "tauri/model_import_tauri.h" #include "bongo_cat/path.h" #include +#include static bool copy_package_files(const BongoCatImportCandidate *candidate, const char *target, BongoCatImportCandidate *installed, @@ -43,6 +45,58 @@ static bool tauri_variant_directory(char *output, size_t capacity, bongo_cat_path_join(output, capacity, target, name); } +static bool common_package_root(const BongoCatImportDiscovery *discovery, + char root[BONGO_CAT_PATH_CAP]) { + if (!bongo_cat_import_parent_path(discovery->candidates[0].package_root, + root, BONGO_CAT_PATH_CAP)) return false; + for (size_t i = 1; i < discovery->count; ++i) { + char relative[BONGO_CAT_PATH_CAP]; + while (!bongo_cat_import_relative_path(root, + discovery->candidates[i].package_root, relative, + sizeof(relative))) { + char parent[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_import_parent_path(root, parent, sizeof(parent))) + return false; + snprintf(root, BONGO_CAT_PATH_CAP, "%s", parent); + } + } + return true; +} + +static bool prepare_container_storage( + const BongoCatImportDiscovery *discovery, const char *target, + BongoCatError *error) { + char root[BONGO_CAT_PATH_CAP]; + if (!common_package_root(discovery, root)) return false; + for (size_t i = 0; i < discovery->count; ++i) { + bool already_copied = false; + for (size_t j = 0; j < i; ++j) + if (strcmp(discovery->candidates[j].package_root, + discovery->candidates[i].package_root) == 0) { + already_copied = true; + break; + } + if (already_copied) continue; + char relative[BONGO_CAT_PATH_CAP], package[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_import_relative_path(root, + discovery->candidates[i].package_root, relative, + sizeof(relative)) || !relative[0] || + !bongo_cat_path_join(package, sizeof(package), target, relative)) + return false; + BongoCatImportCandidate installed; + if (!bongo_cat_import_prepare_package(&discovery->candidates[i], + package, &installed, error)) return false; + } + return true; +} + +static bool single_package_root(const BongoCatImportDiscovery *discovery) { + for (size_t i = 1; i < discovery->count; ++i) + if (strcmp(discovery->candidates[0].package_root, + discovery->candidates[i].package_root) != 0) return false; + return true; +} + bool bongo_cat_import_prepare_storage( const BongoCatImportDiscovery *discovery, const char *target, BongoCatError *error) { @@ -51,11 +105,12 @@ bool bongo_cat_import_prepare_storage( for (size_t i = 0; i < discovery->count; ++i) tauri = tauri && discovery->candidates[i].format == BONGO_CAT_IMPORT_TAURI; - if (!tauri) { + if (!tauri && single_package_root(discovery)) { BongoCatImportCandidate installed; return bongo_cat_import_prepare_package(&discovery->candidates[0], target, &installed, error); } + if (!tauri) return prepare_container_storage(discovery, target, error); for (size_t i = 0; i < discovery->count; ++i) { char package[BONGO_CAT_PATH_CAP]; if (discovery->count == 1) diff --git a/src/runtime/model/import/model_import_path.c b/src/runtime/model/import/model_import_path.c index 8bd2a031..df26cd4c 100644 --- a/src/runtime/model/import/model_import_path.c +++ b/src/runtime/model/import/model_import_path.c @@ -1,12 +1,22 @@ #include "model_import_path.h" #include +#include #include static bool separator(char value) { return value == '/' || value == '\\'; } +static bool character_equal(char left, char right) { + if (separator(left) && separator(right)) return true; +#ifdef _WIN32 + if (left >= 'A' && left <= 'Z') left = (char)(left - 'A' + 'a'); + if (right >= 'A' && right <= 'Z') right = (char)(right - 'A' + 'a'); +#endif + return left == right; +} + bool bongo_cat_import_parent_path(const char *path, char *parent, size_t capacity) { size_t length = path ? strlen(path) : 0; @@ -19,6 +29,22 @@ bool bongo_cat_import_parent_path(const char *path, char *parent, return true; } +bool bongo_cat_import_relative_path(const char *root, const char *path, + char *relative, size_t capacity) { + size_t root_length = root ? strlen(root) : 0; + while (root_length > 1 && separator(root[root_length - 1])) root_length--; + size_t path_length = path ? strlen(path) : 0; + if (!root_length || path_length < root_length) return false; + for (size_t i = 0; i < root_length; ++i) + if (!character_equal(root[i], path[i])) return false; + if (path_length > root_length && !separator(path[root_length])) + return false; + const char *value = path + root_length; + while (separator(*value)) value++; + int written = snprintf(relative, capacity, "%s", value); + return written >= 0 && (size_t)written < capacity; +} + bool bongo_cat_import_has_suffix(const char *value, const char *suffix) { size_t value_length = value ? strlen(value) : 0; size_t suffix_length = suffix ? strlen(suffix) : 0; diff --git a/src/runtime/model/import/model_import_path.h b/src/runtime/model/import/model_import_path.h index 9266cd82..55526475 100644 --- a/src/runtime/model/import/model_import_path.h +++ b/src/runtime/model/import/model_import_path.h @@ -7,6 +7,8 @@ /* Small path operations shared by source-format modules. */ bool bongo_cat_import_parent_path(const char *path, char *parent, size_t capacity); +bool bongo_cat_import_relative_path(const char *root, const char *path, + char *relative, size_t capacity); bool bongo_cat_import_has_suffix(const char *value, const char *suffix); bool bongo_cat_import_has_suffix_ci(const char *value, const char *suffix); diff --git a/src/runtime/model/import/model_import_session.c b/src/runtime/model/import/model_import_session.c index dfde5fe3..58d058e9 100644 --- a/src/runtime/model/import/model_import_session.c +++ b/src/runtime/model/import/model_import_session.c @@ -3,211 +3,11 @@ #include "model_import_session_internal.h" #include "model_storage.h" #include "bongo_cat/path.h" - #include #include #include #include -typedef struct ImportInstall { - char id[BONGO_CAT_ID_CAP]; - char temporary[BONGO_CAT_PATH_CAP]; - char target[BONGO_CAT_PATH_CAP]; - bool committed; -} ImportInstall; - -static void cleanup(ImportInstall *install) { - if (!install) return; - bongo_cat_model_remove_tree(install->committed - ? install->target : install->temporary, NULL); -} - -static bool existing_package(BongoCatImportSession *session, - const BongoCatImportDiscovery *discovery, - const BongoCatPackageMetadata *metadata, - char ids[BONGO_CAT_IMPORT_CANDIDATE_CAP][BONGO_CAT_ID_CAP], - size_t *count) { - return bongo_cat_import_package_index_find(session->packages, - discovery, metadata, ids, count); -} - -static bool canonical_mver_package( - const BongoCatImportDiscovery *discovery) { - if (!discovery || !discovery->count) return false; - for (size_t i = 0; i < discovery->count; ++i) - if (discovery->candidates[i].format != BONGO_CAT_IMPORT_MVER) - return false; - return true; -} - -static bool resolve_install_id(const char *base, const char *root, - char output[BONGO_CAT_ID_CAP], BongoCatError *error) { - for (size_t suffix = 1; suffix < 10000; ++suffix) { - char id[BONGO_CAT_ID_CAP]; - int written = suffix == 1 - ? snprintf(id, sizeof(id), "%s", base) - : snprintf(id, sizeof(id), "%s-%zu", base, suffix); - if (written < 0 || (size_t)written >= sizeof(id)) continue; - char path[BONGO_CAT_PATH_CAP]; - bool exists = bongo_cat_path_join(path, sizeof(path), root, id) && - (bongo_cat_path_is_dir(path) || bongo_cat_path_is_file(path)); - if (!exists) { - snprintf(output, BONGO_CAT_ID_CAP, "%s", id); - return true; - } - } - bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Cannot allocate a unique model directory for %s", base); - return false; -} - -static void fill_receipt(BongoCatImportReceipt *receipt, const char *id, - size_t count, bool installed) { - if (!receipt) return; - receipt->count = count; - receipt->installed_count = installed ? count : 0; - for (size_t i = 0; i < count; ++i) { - bongo_cat_import_variant_id(receipt->ids[i], - sizeof(receipt->ids[i]), id, i); - receipt->installed[i] = installed; - } -} - -static void fill_receipt_ids(BongoCatImportReceipt *receipt, - const char ids[BONGO_CAT_IMPORT_CANDIDATE_CAP][BONGO_CAT_ID_CAP], - size_t count, bool installed) { - if (!receipt) return; - receipt->count = count; - receipt->installed_count = installed ? count : 0; - for (size_t i = 0; i < count; ++i) { - snprintf(receipt->ids[i], sizeof(receipt->ids[i]), "%s", ids[i]); - receipt->installed[i] = installed; - } -} - -static BongoCatResult install_discovery(BongoCatImportSession *session, - BongoCatImportDiscovery *source_discovery, - BongoCatPackageMetadata *source_metadata, - BongoCatImportReceipt *receipt, BongoCatError *error) { - uint64_t started = SDL_GetTicksNS(); - if (canonical_mver_package(source_discovery)) { - char existing[BONGO_CAT_IMPORT_CANDIDATE_CAP][BONGO_CAT_ID_CAP]; - size_t existing_count = 0; - if (existing_package(session, source_discovery, source_metadata, - existing, &existing_count)) { - SDL_Log("[runtime] Model import storage transaction: " - "stage=source-duplicate variants=%llu elapsed_ms=%.1f", - (unsigned long long)existing_count, - (SDL_GetTicksNS() - started) / 1000000.0); - fill_receipt_ids(receipt, existing, existing_count, false); - return BONGO_CAT_OK; - } - } - - ImportInstall install = {0}; - char temporary_name[BONGO_CAT_ID_CAP + 24]; - snprintf(temporary_name, sizeof(temporary_name), ".import-%.16s-1.tmp", - source_metadata[0].content_digest); - if (!bongo_cat_path_join(install.temporary, sizeof(install.temporary), - session->root, temporary_name)) return BONGO_CAT_ERROR_IO; - SDL_Log("[runtime] Model import storage transaction: stage=prepare " - "candidates=%llu temporary=%s", - (unsigned long long)source_discovery->count, install.temporary); - bongo_cat_model_remove_tree(install.temporary, NULL); - if (!bongo_cat_import_prepare_storage(source_discovery, - install.temporary, error)) { - cleanup(&install); - return error && error->code == BONGO_CAT_ERROR_FORMAT - ? BONGO_CAT_ERROR_FORMAT : BONGO_CAT_ERROR_IO; - } - SDL_Log("[runtime] Model import storage transaction: stage=prepared " - "elapsed_ms=%.1f temporary=%s", - (SDL_GetTicksNS() - started) / 1000000.0, install.temporary); - - BongoCatImportDiscovery *normalized = calloc(1, sizeof(*normalized)); - BongoCatPackageMetadata *metadata = calloc(BONGO_CAT_IMPORT_CANDIDATE_CAP, - sizeof(*metadata)); - if (!normalized || !metadata) { - free(normalized); - free(metadata); - cleanup(&install); - bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, - "Cannot validate normalized model package"); - return BONGO_CAT_ERROR_MEMORY; - } - SDL_Log("[runtime] Model import storage transaction: " - "stage=validate-normalized temporary=%s", install.temporary); - bool discovered = bongo_cat_import_discover(install.temporary, normalized, - error); - SDL_Log("[runtime] Model import storage transaction: " - "stage=normalized-discovered result=%d candidates=%llu temporary=%s", - discovered, (unsigned long long)normalized->count, install.temporary); - bool valid = discovered && bongo_cat_import_prepare_package_metadata_cached( - normalized, metadata, session->digests, error); - if (!valid) { - free(normalized); - free(metadata); - cleanup(&install); - return error && error->code ? error->code : BONGO_CAT_ERROR_FORMAT; - } - - char existing[BONGO_CAT_IMPORT_CANDIDATE_CAP][BONGO_CAT_ID_CAP]; - size_t existing_count = 0; - if (existing_package(session, normalized, metadata, existing, - &existing_count)) { - SDL_Log("[runtime] Model import storage transaction: stage=duplicate " - "variants=%llu elapsed_ms=%.1f temporary=%s", - (unsigned long long)existing_count, - (SDL_GetTicksNS() - started) / 1000000.0, install.temporary); - fill_receipt_ids(receipt, existing, existing_count, false); - free(normalized); - free(metadata); - cleanup(&install); - return BONGO_CAT_OK; - } - if (!bongo_cat_import_package_index_has_capacity(session->packages, - normalized->count)) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Too many installed model variants"); - free(normalized); - free(metadata); - cleanup(&install); - return BONGO_CAT_ERROR_FORMAT; - } - if (!resolve_install_id(source_metadata[0].package_id, session->root, - install.id, error) || - !bongo_cat_path_join(install.target, sizeof(install.target), - session->root, install.id)) { - free(normalized); - free(metadata); - cleanup(&install); - return error && error->code ? error->code : BONGO_CAT_ERROR_FORMAT; - } - if (!bongo_cat_path_rename(install.temporary, install.target)) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot finish model import: %s", SDL_GetError()); - free(normalized); - free(metadata); - cleanup(&install); - return BONGO_CAT_ERROR_IO; - } - install.committed = true; - SDL_Log("[runtime] Model import storage transaction: stage=committed " - "id=%s elapsed_ms=%.1f target=%s", install.id, - (SDL_GetTicksNS() - started) / 1000000.0, install.target); - if (!bongo_cat_import_package_index_add(session->packages, install.id, - normalized, metadata, error)) { - free(normalized); - free(metadata); - cleanup(&install); - return error && error->code ? error->code : BONGO_CAT_ERROR_FORMAT; - } - fill_receipt(receipt, install.id, normalized->count, true); - free(normalized); - free(metadata); - return BONGO_CAT_OK; -} - static BongoCatImportSession *session_create_unlocked(const char *models_root, BongoCatError *error) { uint64_t started = SDL_GetTicksNS(); @@ -309,8 +109,8 @@ static BongoCatResult session_install_unlocked( free(metadata); return result; } - BongoCatResult result = install_discovery(session, discovery, metadata, - receipt, error); + BongoCatResult result = bongo_cat_import_install_discovery(session, + discovery, metadata, receipt, error); SDL_Log("[runtime] Model import package completed: result=%d variants=%llu " "installed=%llu elapsed_ms=%.1f path=%s", (int)result, (unsigned long long)(receipt ? receipt->count : 0), diff --git a/src/runtime/model/import/model_import_session_internal.h b/src/runtime/model/import/model_import_session_internal.h index 4efb5516..c9438bef 100644 --- a/src/runtime/model/import/model_import_session_internal.h +++ b/src/runtime/model/import/model_import_session_internal.h @@ -27,5 +27,10 @@ bool bongo_cat_import_package_index_has_capacity( bool bongo_cat_import_package_index_add(BongoCatImportPackageIndex *index, const char *id, const BongoCatImportDiscovery *discovery, const BongoCatPackageMetadata *metadata, BongoCatError *error); +BongoCatResult bongo_cat_import_install_discovery( + BongoCatImportSession *session, + BongoCatImportDiscovery *source_discovery, + BongoCatPackageMetadata *source_metadata, + BongoCatImportReceipt *receipt, BongoCatError *error); #endif diff --git a/src/runtime/model/import/mver/model_import_mver_copy.c b/src/runtime/model/import/mver/model_import_mver_copy.c index 681b417b..a946c187 100644 --- a/src/runtime/model/import/mver/model_import_mver_copy.c +++ b/src/runtime/model/import/mver/model_import_mver_copy.c @@ -1,4 +1,5 @@ #include "model_import_mver_copy.h" +#include "model_import_path.h" #include "bongo_cat/path.h" #include @@ -11,38 +12,11 @@ typedef struct MverDirectoryCopy { unsigned depth; } MverDirectoryCopy; -static bool separator(char value) { - return value == '/' || value == '\\'; -} - -static bool character_equal(char left, char right) { - if (separator(left) && separator(right)) return true; -#ifdef _WIN32 - if (left >= 'A' && left <= 'Z') left = (char)(left - 'A' + 'a'); - if (right >= 'A' && right <= 'Z') right = (char)(right - 'A' + 'a'); -#endif - return left == right; -} - -static bool relative_path(const char *root, const char *path, - char *relative, size_t capacity) { - size_t root_length = root ? strlen(root) : 0; - while (root_length > 1 && separator(root[root_length - 1])) root_length--; - size_t path_length = path ? strlen(path) : 0; - if (!root_length || path_length < root_length) return false; - for (size_t i = 0; i < root_length; ++i) - if (!character_equal(root[i], path[i])) return false; - if (path_length > root_length && !separator(path[root_length])) return false; - const char *value = path + root_length; - while (separator(*value)) value++; - int written = snprintf(relative, capacity, "%s", value); - return written >= 0 && (size_t)written < capacity; -} - static bool rebase(const char *source_root, const char *installed_root, const char *source, char *target, size_t capacity) { char relative[BONGO_CAT_PATH_CAP]; - if (!relative_path(source_root, source, relative, sizeof(relative))) + if (!bongo_cat_import_relative_path(source_root, source, relative, + sizeof(relative))) return false; if (!relative[0]) { int written = snprintf(target, capacity, "%s", installed_root); @@ -54,7 +28,8 @@ static bool rebase(const char *source_root, const char *installed_root, static bool copy_relative_file(const char *source_root, const char *source, const char *target_root, BongoCatError *error) { char relative[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; - if (!relative_path(source_root, source, relative, sizeof(relative)) || + if (!bongo_cat_import_relative_path(source_root, source, relative, + sizeof(relative)) || !relative[0] || !bongo_cat_path_join(target, sizeof(target), target_root, relative)) return false; if (bongo_cat_path_is_file(target)) return true; @@ -125,7 +100,8 @@ static bool copy_tree(const char *source, const char *target, static bool copy_relative_directory(const char *source_root, const char *source, const char *target_root, BongoCatError *error) { char relative[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; - if (!relative_path(source_root, source, relative, sizeof(relative)) || + if (!bongo_cat_import_relative_path(source_root, source, relative, + sizeof(relative)) || !relative[0] || !bongo_cat_path_join(target, sizeof(target), target_root, relative)) return false; return copy_tree(source, target, 1, error); @@ -147,8 +123,9 @@ bool bongo_cat_import_mver_copy_package( bool patch = candidate->format == BONGO_CAT_IMPORT_MVER_PATCH; if (patch) { char patch_relative[BONGO_CAT_PATH_CAP]; - bool patch_inside_package = relative_path(candidate->package_root, - candidate->patch_root, patch_relative, sizeof(patch_relative)); + bool patch_inside_package = bongo_cat_import_relative_path( + candidate->package_root, candidate->patch_root, patch_relative, + sizeof(patch_relative)); const char *patch_source_root = patch_inside_package ? candidate->package_root : candidate->patch_root; snprintf(base, sizeof(base), "%s", target); diff --git a/src/runtime/model/model_behavior_cache.c b/src/runtime/model/model_behavior_cache.c new file mode 100644 index 00000000..6c7352fe --- /dev/null +++ b/src/runtime/model/model_behavior_cache.c @@ -0,0 +1,41 @@ +#include "model_behavior_cache.h" + +#include +#include +#include + +static bool cacheable(const BongoCatModelEntry *entry) { + /* Installed Mver trees keep a stable content digest. Nearby models are + managed by their source directory, so do not retain stale data. */ + return entry && (entry->preset || (!entry->managed && + entry->content_digest[0])); +} + +bool bongo_cat_model_behavior_cache_matches(const BongoCatApp *app, + const BongoCatModelEntry *entry) { + return app && app->behavior_cache && app->behavior_cache_valid && + cacheable(entry) && + strcmp(app->behavior_cache_model_id, entry->id) == 0 && + (entry->preset || strcmp(app->behavior_cache_digest, + entry->content_digest) == 0); +} + +void bongo_cat_model_behavior_cache_store(BongoCatApp *app, + const BongoCatModelEntry *entry) { + if (!app || !cacheable(entry)) { + if (app) app->behavior_cache_valid = false; + return; + } + if (!app->behavior_cache) + app->behavior_cache = malloc(sizeof(*app->behavior_cache)); + if (!app->behavior_cache) { + app->behavior_cache_valid = false; + return; + } + *app->behavior_cache = app->behaviors; + snprintf(app->behavior_cache_model_id, + sizeof(app->behavior_cache_model_id), "%s", entry->id); + snprintf(app->behavior_cache_digest, sizeof(app->behavior_cache_digest), + "%s", entry->content_digest); + app->behavior_cache_valid = true; +} diff --git a/src/runtime/model/model_behavior_cache.h b/src/runtime/model/model_behavior_cache.h new file mode 100644 index 00000000..fc228a88 --- /dev/null +++ b/src/runtime/model/model_behavior_cache.h @@ -0,0 +1,11 @@ +#ifndef BONGO_CAT_MODEL_BEHAVIOR_CACHE_H +#define BONGO_CAT_MODEL_BEHAVIOR_CACHE_H + +#include "bongo_cat/app.h" + +bool bongo_cat_model_behavior_cache_matches(const BongoCatApp *app, + const BongoCatModelEntry *entry); +void bongo_cat_model_behavior_cache_store(BongoCatApp *app, + const BongoCatModelEntry *entry); + +#endif diff --git a/src/runtime/model/model_catalog_remove.c b/src/runtime/model/model_catalog_remove.c new file mode 100644 index 00000000..899da6bf --- /dev/null +++ b/src/runtime/model/model_catalog_remove.c @@ -0,0 +1,143 @@ +#include "runtime.h" +#include "model_catalog_selection.h" +#include "model_import.h" +#include "model_import_lock.h" +#include "model_storage.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +static size_t models_using_storage(const BongoCatModelCatalog *models, + const char *directory) { + size_t count = 0; + if (!models || !directory || !directory[0]) return 0; + for (size_t i = 0; i < models->count; ++i) + if (!strcmp(models->entries[i].storage_directory, directory)) count++; + return count; +} + +static bool storage_package_id(const char *directory, + char output[BONGO_CAT_ID_CAP]) { + const char *name = directory ? bongo_cat_path_name(directory) : NULL; + return name && name[0] && bongo_cat_import_package_id(output, + BONGO_CAT_ID_CAP, name); +} + +BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, + BongoCatError *error) { + if (!app || !id) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, + "Missing model id"); + return BONGO_CAT_ERROR_ARGUMENT; + } + const BongoCatModelEntry *entry = bongo_cat_models_find(&app->models, id); + if (!entry) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, + "Model is not installed: %s", id); + return BONGO_CAT_ERROR_ARGUMENT; + } + if (entry->preset || entry->managed) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, entry->managed + ? "Nearby models are managed by their source directory: %s" + : "Built-in models cannot be removed: %s", id); + return BONGO_CAT_ERROR_ARGUMENT; + } + bool primary = !strcmp(id, app->session.active_model_id) || + !strcmp(id, app->loaded_model); + bool additional = !primary && bongo_cat_app_model_active(app, id); + BongoCatModelSelection previous_selection = + bongo_cat_model_selection_capture(app); + char directory[BONGO_CAT_PATH_CAP]; + snprintf(directory, sizeof(directory), "%s", entry->storage_directory); + char package_id[BONGO_CAT_ID_CAP]; + if (!directory[0] || !storage_package_id(directory, package_id)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Model storage directory is invalid: %s", id); + return BONGO_CAT_ERROR_FORMAT; + } + bool shared_storage = models_using_storage(&app->models, directory) > 1; + bongo_cat_settings_validate(&app->settings); + bool already_removed = bongo_cat_settings_model_removed( + &app->settings, id); + if (shared_storage && !already_removed && + app->settings.removed_model_count >= BONGO_CAT_MODEL_CAP) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Too many removed model versions are recorded"); + return BONGO_CAT_ERROR_FORMAT; + } + if (additional) { + BongoCatError deactivate_error = {0}; + if (!bongo_cat_app_set_model_active(app, id, false, + &deactivate_error)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, + "Cannot stop the active model: %s", + deactivate_error.message); + return BONGO_CAT_ERROR_PLATFORM; + } + } else if (primary) { + BongoCatError load_error = {0}; + bool replacement = false; + for (size_t i = 0; i < app->models.count; ++i) + if (strcmp(app->models.entries[i].id, id) && + bongo_cat_app_select_model_with_error(app, + app->models.entries[i].id, &load_error)) { + replacement = true; + break; + } + if (!replacement) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, + "Cannot delete the active model because no replacement could be displayed: %s", + load_error.message[0] ? load_error.message : + "no installed models"); + return BONGO_CAT_ERROR_CUBISM; + } + } + if (shared_storage && !already_removed && + !bongo_cat_settings_set_model_removed(&app->settings, id, true)) { + if (primary) { + BongoCatError restore_error = {0}; + if (!bongo_cat_app_select_model_with_error(app, id, + &restore_error)) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Unable to restore model after recording deletion failed: %s", + restore_error.message[0] ? restore_error.message : + "unknown error"); + } + if (additional) + bongo_cat_model_selection_restore(app, &previous_selection); + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Cannot record removed model version: %s", id); + return BONGO_CAT_ERROR_FORMAT; + } + bool removed = true; + if (!shared_storage) { + bongo_cat_import_storage_lock(); + removed = bongo_cat_model_remove_tree(directory, error); + bongo_cat_import_storage_unlock(); + } + if (!removed) { + bongo_cat_app_rescan_models(app); + bool restore_selection = additional; + if (primary && bongo_cat_models_find(&app->models, id)) { + BongoCatError restore_error = {0}; + restore_selection = bongo_cat_app_select_model_with_error(app, id, + &restore_error); + if (!restore_selection) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Unable to restore model after deletion failed: %s", + restore_error.message[0] ? restore_error.message : + "unknown error"); + } + if (restore_selection) + bongo_cat_model_selection_restore(app, &previous_selection); + return BONGO_CAT_ERROR_IO; + } + if (!shared_storage) + bongo_cat_settings_restore_model_package(&app->settings, package_id); + bongo_cat_app_forget_behavior_state(app, id); + bongo_cat_settings_set_model_label(&app->settings, id, ""); + bongo_cat_app_rescan_models(app); + return BONGO_CAT_OK; +} diff --git a/src/runtime/model/model_catalog_runtime.c b/src/runtime/model/model_catalog_runtime.c index 74c768c3..d980b2ca 100644 --- a/src/runtime/model/model_catalog_runtime.c +++ b/src/runtime/model/model_catalog_runtime.c @@ -11,22 +11,6 @@ #include #include -static size_t models_using_storage(const BongoCatModelCatalog *models, - const char *directory) { - size_t count = 0; - if (!models || !directory || !directory[0]) return 0; - for (size_t i = 0; i < models->count; ++i) - if (!strcmp(models->entries[i].storage_directory, directory)) count++; - return count; -} - -static bool storage_package_id(const char *directory, - char output[BONGO_CAT_ID_CAP]) { - const char *name = directory ? bongo_cat_path_name(directory) : NULL; - return name && name[0] && bongo_cat_import_package_id(output, - BONGO_CAT_ID_CAP, name); -} - static bool shortcut_model_exists(const BongoCatModelCatalog *models, const char *shortcut_id) { const char *separator = shortcut_id ? strchr(shortcut_id, ':') : NULL; @@ -212,120 +196,3 @@ void bongo_cat_app_refresh_nearby_models(BongoCatApp *app) { sizeof(app->session.active_model_id), "%s", active_model_id); bongo_cat_model_catalog_finish(app); } - -BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, - BongoCatError *error) { - if (!app || !id) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, - "Missing model id"); - return BONGO_CAT_ERROR_ARGUMENT; - } - const BongoCatModelEntry *entry = bongo_cat_models_find(&app->models, id); - if (!entry) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, - "Model is not installed: %s", id); - return BONGO_CAT_ERROR_ARGUMENT; - } - if (entry->preset || entry->managed) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, entry->managed - ? "Nearby models are managed by their source directory: %s" - : "Built-in models cannot be removed: %s", id); - return BONGO_CAT_ERROR_ARGUMENT; - } - bool primary = !strcmp(id, app->session.active_model_id) || - !strcmp(id, app->loaded_model); - bool additional = !primary && bongo_cat_app_model_active(app, id); - BongoCatModelSelection previous_selection = - bongo_cat_model_selection_capture(app); - char directory[BONGO_CAT_PATH_CAP]; - snprintf(directory, sizeof(directory), "%s", entry->storage_directory); - char package_id[BONGO_CAT_ID_CAP]; - if (!directory[0] || !storage_package_id(directory, package_id)) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Model storage directory is invalid: %s", id); - return BONGO_CAT_ERROR_FORMAT; - } - bool shared_storage = models_using_storage(&app->models, directory) > 1; - bongo_cat_settings_validate(&app->settings); - bool already_removed = bongo_cat_settings_model_removed( - &app->settings, id); - if (shared_storage && !already_removed && - app->settings.removed_model_count >= BONGO_CAT_MODEL_CAP) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Too many removed model versions are recorded"); - return BONGO_CAT_ERROR_FORMAT; - } - if (additional) { - BongoCatError deactivate_error = {0}; - if (!bongo_cat_app_set_model_active(app, id, false, - &deactivate_error)) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, - "Cannot stop the active model: %s", - deactivate_error.message); - return BONGO_CAT_ERROR_PLATFORM; - } - } else if (primary) { - BongoCatError load_error = {0}; - bool replacement = false; - for (size_t i = 0; i < app->models.count; ++i) - if (strcmp(app->models.entries[i].id, id) && - bongo_cat_app_select_model_with_error(app, - app->models.entries[i].id, &load_error)) { - replacement = true; - break; - } - if (!replacement) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, - "Cannot delete the active model because no replacement could be displayed: %s", - load_error.message[0] ? load_error.message : - "no installed models"); - return BONGO_CAT_ERROR_CUBISM; - } - } - if (shared_storage && !already_removed && - !bongo_cat_settings_set_model_removed(&app->settings, id, true)) { - if (primary) { - BongoCatError restore_error = {0}; - if (!bongo_cat_app_select_model_with_error(app, id, - &restore_error)) - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "Unable to restore model after recording deletion failed: %s", - restore_error.message[0] ? restore_error.message : - "unknown error"); - } - if (additional) - bongo_cat_model_selection_restore(app, &previous_selection); - bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Cannot record removed model version: %s", id); - return BONGO_CAT_ERROR_FORMAT; - } - bool removed = true; - if (!shared_storage) { - bongo_cat_import_storage_lock(); - removed = bongo_cat_model_remove_tree(directory, error); - bongo_cat_import_storage_unlock(); - } - if (!removed) { - bongo_cat_app_rescan_models(app); - bool restore_selection = additional; - if (primary && bongo_cat_models_find(&app->models, id)) { - BongoCatError restore_error = {0}; - restore_selection = bongo_cat_app_select_model_with_error(app, id, - &restore_error); - if (!restore_selection) - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "Unable to restore model after deletion failed: %s", - restore_error.message[0] ? restore_error.message : - "unknown error"); - } - if (restore_selection) - bongo_cat_model_selection_restore(app, &previous_selection); - return BONGO_CAT_ERROR_IO; - } - if (!shared_storage) - bongo_cat_settings_restore_model_package(&app->settings, package_id); - bongo_cat_app_forget_behavior_state(app, id); - bongo_cat_settings_set_model_label(&app->settings, id, ""); - bongo_cat_app_rescan_models(app); - return BONGO_CAT_OK; -} diff --git a/src/runtime/model/model_files.c b/src/runtime/model/model_files.c index efb8ef26..c9de785c 100644 --- a/src/runtime/model/model_files.c +++ b/src/runtime/model/model_files.c @@ -1,5 +1,6 @@ #include "runtime.h" #include "model_import.h" +#include "model_behavior_cache.h" #include "model_geometry.h" #include "bongo_cat/audio.h" #include "bongo_cat/overlay.h" @@ -50,42 +51,6 @@ static void model_running_stage(BongoCatApp *app, const char *model_id) { bongo_cat_runtime_stage(app, state); } -static bool behavior_cacheable(const BongoCatModelEntry *entry) { - /* Installed Mver trees keep a stable content digest. Nearby models are - managed by their source directory, so do not retain stale data. */ - return entry && (entry->preset || (!entry->managed && - entry->content_digest[0])); -} - -static bool behavior_cache_matches(const BongoCatApp *app, - const BongoCatModelEntry *entry) { - return app && app->behavior_cache && app->behavior_cache_valid && - behavior_cacheable(entry) && - strcmp(app->behavior_cache_model_id, entry->id) == 0 && - (entry->preset || strcmp(app->behavior_cache_digest, - entry->content_digest) == 0); -} - -static void behavior_cache_store(BongoCatApp *app, - const BongoCatModelEntry *entry) { - if (!app || !behavior_cacheable(entry)) { - if (app) app->behavior_cache_valid = false; - return; - } - if (!app->behavior_cache) - app->behavior_cache = malloc(sizeof(*app->behavior_cache)); - if (!app->behavior_cache) { - app->behavior_cache_valid = false; - return; - } - *app->behavior_cache = app->behaviors; - snprintf(app->behavior_cache_model_id, - sizeof(app->behavior_cache_model_id), "%s", entry->id); - snprintf(app->behavior_cache_digest, sizeof(app->behavior_cache_digest), - "%s", entry->content_digest); - app->behavior_cache_valid = true; -} - static void model_progress_runtime_stage(BongoCatApp *app, float progress) { static const char *names[] = {"", "model-core", "texture-loading", "finalizing"}; @@ -163,7 +128,8 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, "Cannot allocate model behavior state"); return false; } - bool behavior_catalog_valid = behavior_cache_matches(app, entry); + bool behavior_catalog_valid = bongo_cat_model_behavior_cache_matches( + app, entry); if (behavior_catalog_valid) { *behaviors = *app->behavior_cache; } else { @@ -245,7 +211,7 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, if (app->loaded_model[0] && app->behavior_catalog_valid) { const BongoCatModelEntry *previous_entry = bongo_cat_models_find( &app->models, app->loaded_model); - behavior_cache_store(app, previous_entry); + bongo_cat_model_behavior_cache_store(app, previous_entry); } else if (app->loaded_model[0]) { app->behavior_cache_valid = false; } diff --git a/src/runtime/model/multi_pet.c b/src/runtime/model/multi_pet.c index 0c1bfe1e..264d4c36 100644 --- a/src/runtime/model/multi_pet.c +++ b/src/runtime/model/multi_pet.c @@ -221,68 +221,3 @@ void bongo_cat_multi_pet_update(BongoCatApp *app, uint64_t now) { if (app->secondary_pet) update_secondary(app, now); else bongo_cat_multi_pet_primary_update(app, now); } - -static bool load_pet_window(const BongoCatApp *app, const char *model_id, - BongoCatWindowState *window) { - char directory[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; - BongoCatSessionState session; - BongoCatError error = {0}; - bongo_cat_session_defaults(&session); - if (!bongo_cat_multi_pet_state_directory(directory, sizeof(directory), - app->primary_state_root, model_id) || - !bongo_cat_path_join(path, sizeof(path), directory, "session.json") || - bongo_cat_session_load(path, &session, &error) != BONGO_CAT_OK || - !session.window.position_known) return false; - *window = session.window; - return true; -} -static void adopt_pet_window(BongoCatApp *app, - const BongoCatWindowState *window) { - if (!bongo_cat_window_apply_geometry(app, window->x, window->y, - window->scale_percent, window->width, window->height)) return; - app->session.window.opacity_percent = window->opacity_percent; - bongo_cat_platform_set_opacity(&app->platform, - window->opacity_percent / 100.0f); -} - -bool bongo_cat_app_set_model_active(BongoCatApp *app, const char *id, - bool active, BongoCatError *error) { - if (!app || !id || !bongo_cat_models_find(&app->models, id)) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, - "Model is not installed: %s", id ? id : ""); - return false; - } - if (!app->settings.model.multiple_pets) - return active ? bongo_cat_app_select_model_with_error(app, id, error) : true; - bool primary = !strcmp(app->session.active_model_id, id); - if (active) { - if (!bongo_cat_session_add_model(&app->session, id)) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, - "At most %d desktop pets can be displayed", - BONGO_CAT_ACTIVE_MODEL_CAP); - return false; - } - } else if (primary) { - if (!app->session.additional_model_count) return true; - char replacement[BONGO_CAT_ID_CAP]; - BongoCatWindowState replacement_window; - snprintf(replacement, sizeof(replacement), "%s", - app->session.additional_model_ids[0]); - bool preserve_window = load_pet_window(app, replacement, - &replacement_window); - if (!bongo_cat_app_select_model_with_error(app, replacement, error)) - return false; - bongo_cat_session_remove_model(&app->session, replacement); - if (preserve_window) adopt_pet_window(app, &replacement_window); - } else bongo_cat_session_remove_model(&app->session, id); - bongo_cat_multi_pet_primary_update(app, SDL_GetTicksNS()); - return true; -} - -void bongo_cat_app_set_multiple_pets(BongoCatApp *app, bool enabled) { - if (!app) return; - app->settings.model.multiple_pets = enabled; - if (!enabled) bongo_cat_session_clear_additional_models(&app->session); - if (!app->secondary_pet) - bongo_cat_multi_pet_primary_update(app, SDL_GetTicksNS()); -} diff --git a/src/runtime/model/multi_pet_process.c b/src/runtime/model/multi_pet_process.c index 1c493c38..8d2f2a1e 100644 --- a/src/runtime/model/multi_pet_process.c +++ b/src/runtime/model/multi_pet_process.c @@ -197,15 +197,6 @@ static void reap_pets(BongoCatApp *app, uint64_t now) { } } -static void prune_selection(BongoCatApp *app) { - bongo_cat_session_validate(&app->session); - for (size_t i = app->session.additional_model_count; i > 0; --i) { - const char *id = app->session.additional_model_ids[i - 1]; - if (!bongo_cat_models_find(&app->models, id)) - bongo_cat_session_remove_model(&app->session, id); - } -} - static void consume_remove_requests(BongoCatApp *app) { BongoCatMultiPetRuntime *runtime = app->multi_pet; for (size_t i = runtime->count; i > 0; --i) { @@ -219,7 +210,7 @@ static void consume_remove_requests(BongoCatApp *app) { } void bongo_cat_multi_pet_primary_update(BongoCatApp *app, uint64_t now) { - prune_selection(app); + bongo_cat_multi_pet_prune_selection(app); if (!app->multi_pet && app->session.additional_model_count) app->multi_pet = calloc(1, sizeof(*app->multi_pet)); if (!app->multi_pet) return; diff --git a/src/runtime/model/multi_pet_selection.c b/src/runtime/model/multi_pet_selection.c index 89e68a17..42132f68 100644 --- a/src/runtime/model/multi_pet_selection.c +++ b/src/runtime/model/multi_pet_selection.c @@ -1,7 +1,33 @@ #include "runtime.h" +#include "bongo_cat/path.h" +#include #include +static bool load_pet_window(const BongoCatApp *app, const char *model_id, + BongoCatWindowState *window) { + char directory[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; + BongoCatSessionState session; + BongoCatError error = {0}; + bongo_cat_session_defaults(&session); + if (!bongo_cat_multi_pet_state_directory(directory, sizeof(directory), + app->primary_state_root, model_id) || + !bongo_cat_path_join(path, sizeof(path), directory, "session.json") || + bongo_cat_session_load(path, &session, &error) != BONGO_CAT_OK || + !session.window.position_known) return false; + *window = session.window; + return true; +} + +static void adopt_pet_window(BongoCatApp *app, + const BongoCatWindowState *window) { + if (!bongo_cat_window_apply_geometry(app, window->x, window->y, + window->scale_percent, window->width, window->height)) return; + app->session.window.opacity_percent = window->opacity_percent; + bongo_cat_platform_set_opacity(&app->platform, + window->opacity_percent / 100.0f); +} + bool bongo_cat_app_model_active(const BongoCatApp *app, const char *id) { if (!app || !id) return false; if (!strcmp(app->session.active_model_id, id)) return true; @@ -13,3 +39,55 @@ size_t bongo_cat_app_active_model_count(const BongoCatApp *app) { return app && app->settings.model.multiple_pets ? 1 + app->session.additional_model_count : app ? 1 : 0; } + +bool bongo_cat_app_set_model_active(BongoCatApp *app, const char *id, + bool active, BongoCatError *error) { + if (!app || !id || !bongo_cat_models_find(&app->models, id)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, + "Model is not installed: %s", id ? id : ""); + return false; + } + if (!app->settings.model.multiple_pets) + return active ? bongo_cat_app_select_model_with_error(app, id, error) + : true; + bool primary = !strcmp(app->session.active_model_id, id); + if (active) { + if (!bongo_cat_session_add_model(&app->session, id)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, + "At most %d desktop pets can be displayed", + BONGO_CAT_ACTIVE_MODEL_CAP); + return false; + } + } else if (primary) { + if (!app->session.additional_model_count) return true; + char replacement[BONGO_CAT_ID_CAP]; + BongoCatWindowState replacement_window; + snprintf(replacement, sizeof(replacement), "%s", + app->session.additional_model_ids[0]); + bool preserve_window = load_pet_window(app, replacement, + &replacement_window); + if (!bongo_cat_app_select_model_with_error(app, replacement, error)) + return false; + bongo_cat_session_remove_model(&app->session, replacement); + if (preserve_window) adopt_pet_window(app, &replacement_window); + } else bongo_cat_session_remove_model(&app->session, id); + bongo_cat_multi_pet_primary_update(app, SDL_GetTicksNS()); + return true; +} + +void bongo_cat_app_set_multiple_pets(BongoCatApp *app, bool enabled) { + if (!app) return; + app->settings.model.multiple_pets = enabled; + if (!enabled) bongo_cat_session_clear_additional_models(&app->session); + if (!app->secondary_pet) + bongo_cat_multi_pet_primary_update(app, SDL_GetTicksNS()); +} + +void bongo_cat_multi_pet_prune_selection(BongoCatApp *app) { + bongo_cat_session_validate(&app->session); + for (size_t i = app->session.additional_model_count; i > 0; --i) { + const char *id = app->session.additional_model_ids[i - 1]; + if (!bongo_cat_models_find(&app->models, id)) + bongo_cat_session_remove_model(&app->session, id); + } +} diff --git a/src/runtime/runtime.h b/src/runtime/runtime.h index 466fba7a..d27f94ef 100644 --- a/src/runtime/runtime.h +++ b/src/runtime/runtime.h @@ -124,6 +124,7 @@ bool bongo_cat_multi_pet_secondary_argument(int argc, char **argv); bool bongo_cat_multi_pet_state_directory(char *target, size_t capacity, const char *root, const char *model_id); void bongo_cat_multi_pet_update(BongoCatApp *app, uint64_t now); +void bongo_cat_multi_pet_prune_selection(BongoCatApp *app); void bongo_cat_multi_pet_primary_update(BongoCatApp *app, uint64_t now); bool bongo_cat_multi_pet_request_remove(BongoCatApp *app); bool bongo_cat_multi_pet_request_preferences(BongoCatApp *app); diff --git a/src/runtime/shell/window_wheel.c b/src/runtime/shell/window_wheel.c index d7efc73f..57134fb4 100644 --- a/src/runtime/shell/window_wheel.c +++ b/src/runtime/shell/window_wheel.c @@ -1,6 +1,6 @@ #include "runtime.h" +#include "window_wheel_internal.h" #include "bongo_cat/preferences.h" -#include #define WHEEL_OPACITY_STEP 5.0f #define WHEEL_SCALE_STEP 5.0f @@ -10,7 +10,6 @@ #define WHEEL_OPACITY_SPEED_PER_SECOND 100.0f #define WHEEL_SCALE_SPEED_PER_SECOND 150.0f #define WHEEL_MAX_FRAME_SECONDS (1.0f / 60.0f) -#define WHEEL_GESTURE_IDLE_NS 120000000ull static float wheel_delta(const SDL_MouseWheelEvent *event) { float value = event ? event->y : 0.0f; @@ -62,7 +61,7 @@ static bool initialize_targets(BongoCatApp *app, bool reset) { return true; } -static int round_position(float value) { +int bongo_cat_window_wheel_round_position(float value) { return (int)(value + (value < 0.0f ? -0.5f : 0.5f)); } @@ -85,7 +84,7 @@ void bongo_cat_window_wheel(BongoCatApp *app, const SDL_MouseWheelEvent *event) delta = SDL_clamp(delta, -1.0f, 1.0f); uint64_t event_ns = event->timestamp ? event->timestamp : SDL_GetTicksNS(); bool continuing = app->wheel_gesture_active && event_ns >= app->wheel_event_ns && - event_ns - app->wheel_event_ns < WHEEL_GESTURE_IDLE_NS; + event_ns - app->wheel_event_ns < BONGO_CAT_WHEEL_GESTURE_IDLE_NS; if (!initialize_targets(app, !continuing)) return; app->wheel_event_ns = event_ns; app->wheel_gesture_active = true; @@ -141,8 +140,10 @@ static void apply_scale(BongoCatApp *app, float scale) { app->session.window.scale_percent = actual; return; } - int next_x = round_position(app->wheel_center_x - next_width * 0.5f); - int next_y = round_position(app->wheel_center_y - next_height * 0.5f); + int next_x = bongo_cat_window_wheel_round_position( + app->wheel_center_x - next_width * 0.5f); + int next_y = bongo_cat_window_wheel_round_position( + app->wheel_center_y - next_height * 0.5f); clamp_position(app, next_width, next_height, &next_x, &next_y); if (!bongo_cat_window_apply_geometry(app, next_x, next_y, actual, next_width, next_height)) { @@ -177,7 +178,7 @@ void bongo_cat_window_update_wheel_animation(BongoCatApp *app, uint64_t now) { SDL_fabsf(app->session.window.opacity_percent - app->wheel_opacity_target) < 0.01f && SDL_fabsf(app->session.window.scale_percent - app->wheel_scale_target) < 0.01f; bool recent_input = now >= app->wheel_input_ns && - now - app->wheel_input_ns < WHEEL_GESTURE_IDLE_NS; + now - app->wheel_input_ns < BONGO_CAT_WHEEL_GESTURE_IDLE_NS; app->wheel_animation_active = !reached || recent_input; if (changed || !app->wheel_animation_active) app->dirty = true; if (changed) bongo_cat_preferences_invalidate(app->preferences); @@ -188,111 +189,3 @@ void bongo_cat_window_cancel_wheel_animation(BongoCatApp *app) { app->wheel_animation_active = false; app->wheel_gesture_active = false; } - -bool bongo_cat_window_wheel_self_test(BongoCatApp *app) { - if (!app || !app->window) return false; - BongoCatWindowState backup = app->session.window; - int original_x, original_y, original_width, original_height; - SDL_GetWindowPosition(app->window, &original_x, &original_y); - SDL_GetWindowSize(app->window, &original_width, &original_height); - SDL_Keymod modifiers = SDL_GetModState(); - SDL_SetModState(modifiers | SDL_KMOD_CTRL); - app->session.window.opacity_percent = 80.0f; - app->session.window.scale_percent = 100.0f; - bongo_cat_window_cancel_wheel_animation(app); - SDL_MouseWheelEvent wheel = {.y = -1.0f, - .windowID = SDL_GetWindowID(app->window), - .mouse_x = original_width * 0.5f, - .mouse_y = original_height * 0.5f}; - float untouched_scale = app->session.window.scale_percent; - SDL_WindowID own_window = wheel.windowID; - wheel.windowID = 0; - bongo_cat_window_wheel(app, &wheel); - bool foreign = !app->wheel_animation_active && - app->session.window.scale_percent == untouched_scale; - wheel.windowID = own_window; - bongo_cat_window_wheel(app, &wheel); - uint64_t started = app->wheel_animation_ns; - for (int i = 1; i <= 30; ++i) - bongo_cat_window_update_wheel_animation(app, started + i * 16666667ull); - bool opacity = SDL_fabsf(app->session.window.opacity_percent - 75.0f) < 0.1f; - SDL_SetModState(modifiers & ~SDL_KMOD_CTRL); - wheel.y = 1.0f; bongo_cat_window_wheel(app, &wheel); - started = app->wheel_animation_ns; - for (int i = 1; i <= 8; ++i) - bongo_cat_window_update_wheel_animation(app, started + i * 16666667ull); - bool responsive = SDL_fabsf(app->session.window.scale_percent - 105.0f) < 0.1f; - bongo_cat_window_update_wheel_animation(app, - app->wheel_input_ns + WHEEL_GESTURE_IDLE_NS + 1); - bool scale = responsive && !app->wheel_animation_active; - bongo_cat_window_cancel_wheel_animation(app); - bongo_cat_window_apply_geometry(app, original_x, original_y, 100.0f, - original_width, original_height); - wheel.y = 1000.0f; - for (int i = 0; i < 2; ++i) bongo_cat_window_wheel(app, &wheel); - bool burst = app->wheel_scale_target >= 109.5f && - app->wheel_scale_target <= 110.1f; - bongo_cat_window_cancel_wheel_animation(app); - bongo_cat_window_apply_geometry(app, original_x, original_y, 100.0f, - original_width, original_height); - wheel.y = 3.0f; - bongo_cat_window_wheel(app, &wheel); - bool aggregated = app->wheel_scale_target >= 104.5f && - app->wheel_scale_target <= 105.1f; - bongo_cat_window_cancel_wheel_animation(app); - bongo_cat_window_apply_geometry(app, original_x, original_y, 100.0f, - original_width, original_height); - wheel.y = 1.0f; - bongo_cat_window_wheel(app, &wheel); - wheel.y = -1.0f; - bongo_cat_window_wheel(app, &wheel); - started = app->wheel_animation_ns; - for (int i = 1; i <= 30; ++i) - bongo_cat_window_update_wheel_animation(app, started + i * 16666667ull); - bool reversal = !app->wheel_animation_active && - SDL_fabsf(app->session.window.scale_percent - 100.0f) < 0.1f; - wheel.y = 1.0f; - wheel.direction = SDL_MOUSEWHEEL_FLIPPED; - bongo_cat_window_wheel(app, &wheel); - started = app->wheel_animation_ns; - for (int i = 1; i <= 30; ++i) - bongo_cat_window_update_wheel_animation(app, started + i * 16666667ull); - bool flipped = SDL_fabsf(app->session.window.scale_percent - 95.0f) < 0.1f; - wheel.direction = SDL_MOUSEWHEEL_NORMAL; - bongo_cat_window_cancel_wheel_animation(app); - app->session.window.scale_percent = 500.0f; - bongo_cat_window_wheel(app, &wheel); - bool maximum = !app->wheel_animation_active; - bongo_cat_window_cancel_wheel_animation(app); - app->session.window.scale_percent = 10.0f; - wheel.y = -1.0f; - bongo_cat_window_wheel(app, &wheel); - bool minimum = !app->wheel_animation_active; - bongo_cat_window_cancel_wheel_animation(app); - SDL_SetModState(modifiers | SDL_KMOD_CTRL); - app->session.window.opacity_percent = 100.0f; - wheel.y = 1.0f; - bongo_cat_window_wheel(app, &wheel); - bool opacity_maximum = !app->wheel_animation_active; - bongo_cat_window_cancel_wheel_animation(app); - app->session.window.opacity_percent = 10.0f; - wheel.y = -1.0f; - bongo_cat_window_wheel(app, &wheel); - bool opacity_minimum = !app->wheel_animation_active; - bool rounding = round_position(-10.6f) == -11 && round_position(10.6f) == 11; - SDL_SetModState(modifiers); - app->session.window = backup; - bongo_cat_window_cancel_wheel_animation(app); - bongo_cat_platform_set_opacity(&app->platform, - backup.opacity_percent / 100.0f); - bongo_cat_platform_set_geometry(&app->platform, original_x, original_y, - original_width, original_height); - bool passed = foreign && opacity && scale && burst && aggregated && reversal && flipped && - maximum && minimum && opacity_maximum && opacity_minimum && rounding; - if (!passed) fprintf(stderr, "wheel self-test: foreign=%d opacity=%d scale=%d burst=%d " - "aggregated=%d reversal=%d flipped=%d maximum=%d minimum=%d opacity_max=%d " - "opacity_min=%d rounding=%d\n", foreign, opacity, scale, burst, aggregated, - reversal, flipped, maximum, minimum, opacity_maximum, opacity_minimum, - rounding); - return passed; -} diff --git a/src/runtime/shell/window_wheel_internal.h b/src/runtime/shell/window_wheel_internal.h new file mode 100644 index 00000000..7909b1ab --- /dev/null +++ b/src/runtime/shell/window_wheel_internal.h @@ -0,0 +1,8 @@ +#ifndef BONGO_CAT_WINDOW_WHEEL_INTERNAL_H +#define BONGO_CAT_WINDOW_WHEEL_INTERNAL_H + +#define BONGO_CAT_WHEEL_GESTURE_IDLE_NS 120000000ull + +int bongo_cat_window_wheel_round_position(float value); + +#endif diff --git a/src/runtime/shell/window_wheel_test.c b/src/runtime/shell/window_wheel_test.c new file mode 100644 index 00000000..e6ee9c80 --- /dev/null +++ b/src/runtime/shell/window_wheel_test.c @@ -0,0 +1,119 @@ +#include "runtime.h" +#include "window_wheel_internal.h" + +#include + +bool bongo_cat_window_wheel_self_test(BongoCatApp *app) { + if (!app || !app->window) return false; + BongoCatWindowState backup = app->session.window; + int original_x, original_y, original_width, original_height; + SDL_GetWindowPosition(app->window, &original_x, &original_y); + SDL_GetWindowSize(app->window, &original_width, &original_height); + SDL_Keymod modifiers = SDL_GetModState(); + SDL_SetModState(modifiers | SDL_KMOD_CTRL); + app->session.window.opacity_percent = 80.0f; + app->session.window.scale_percent = 100.0f; + bongo_cat_window_cancel_wheel_animation(app); + SDL_MouseWheelEvent wheel = {.y = -1.0f, + .windowID = SDL_GetWindowID(app->window), + .mouse_x = original_width * 0.5f, + .mouse_y = original_height * 0.5f}; + float untouched_scale = app->session.window.scale_percent; + SDL_WindowID own_window = wheel.windowID; + wheel.windowID = 0; + bongo_cat_window_wheel(app, &wheel); + bool foreign = !app->wheel_animation_active && + app->session.window.scale_percent == untouched_scale; + wheel.windowID = own_window; + bongo_cat_window_wheel(app, &wheel); + uint64_t started = app->wheel_animation_ns; + for (int i = 1; i <= 30; ++i) + bongo_cat_window_update_wheel_animation(app, started + i * 16666667ull); + bool opacity = SDL_fabsf( + app->session.window.opacity_percent - 75.0f) < 0.1f; + SDL_SetModState(modifiers & ~SDL_KMOD_CTRL); + wheel.y = 1.0f; + bongo_cat_window_wheel(app, &wheel); + started = app->wheel_animation_ns; + for (int i = 1; i <= 8; ++i) + bongo_cat_window_update_wheel_animation(app, started + i * 16666667ull); + bool responsive = SDL_fabsf( + app->session.window.scale_percent - 105.0f) < 0.1f; + bongo_cat_window_update_wheel_animation(app, + app->wheel_input_ns + BONGO_CAT_WHEEL_GESTURE_IDLE_NS + 1); + bool scale = responsive && !app->wheel_animation_active; + bongo_cat_window_cancel_wheel_animation(app); + bongo_cat_window_apply_geometry(app, original_x, original_y, 100.0f, + original_width, original_height); + wheel.y = 1000.0f; + for (int i = 0; i < 2; ++i) bongo_cat_window_wheel(app, &wheel); + bool burst = app->wheel_scale_target >= 109.5f && + app->wheel_scale_target <= 110.1f; + bongo_cat_window_cancel_wheel_animation(app); + bongo_cat_window_apply_geometry(app, original_x, original_y, 100.0f, + original_width, original_height); + wheel.y = 3.0f; + bongo_cat_window_wheel(app, &wheel); + bool aggregated = app->wheel_scale_target >= 104.5f && + app->wheel_scale_target <= 105.1f; + bongo_cat_window_cancel_wheel_animation(app); + bongo_cat_window_apply_geometry(app, original_x, original_y, 100.0f, + original_width, original_height); + wheel.y = 1.0f; + bongo_cat_window_wheel(app, &wheel); + wheel.y = -1.0f; + bongo_cat_window_wheel(app, &wheel); + started = app->wheel_animation_ns; + for (int i = 1; i <= 30; ++i) + bongo_cat_window_update_wheel_animation(app, started + i * 16666667ull); + bool reversal = !app->wheel_animation_active && + SDL_fabsf(app->session.window.scale_percent - 100.0f) < 0.1f; + wheel.y = 1.0f; + wheel.direction = SDL_MOUSEWHEEL_FLIPPED; + bongo_cat_window_wheel(app, &wheel); + started = app->wheel_animation_ns; + for (int i = 1; i <= 30; ++i) + bongo_cat_window_update_wheel_animation(app, started + i * 16666667ull); + bool flipped = SDL_fabsf( + app->session.window.scale_percent - 95.0f) < 0.1f; + wheel.direction = SDL_MOUSEWHEEL_NORMAL; + bongo_cat_window_cancel_wheel_animation(app); + app->session.window.scale_percent = 500.0f; + bongo_cat_window_wheel(app, &wheel); + bool maximum = !app->wheel_animation_active; + bongo_cat_window_cancel_wheel_animation(app); + app->session.window.scale_percent = 10.0f; + wheel.y = -1.0f; + bongo_cat_window_wheel(app, &wheel); + bool minimum = !app->wheel_animation_active; + bongo_cat_window_cancel_wheel_animation(app); + SDL_SetModState(modifiers | SDL_KMOD_CTRL); + app->session.window.opacity_percent = 100.0f; + wheel.y = 1.0f; + bongo_cat_window_wheel(app, &wheel); + bool opacity_maximum = !app->wheel_animation_active; + bongo_cat_window_cancel_wheel_animation(app); + app->session.window.opacity_percent = 10.0f; + wheel.y = -1.0f; + bongo_cat_window_wheel(app, &wheel); + bool opacity_minimum = !app->wheel_animation_active; + bool rounding = bongo_cat_window_wheel_round_position(-10.6f) == -11 && + bongo_cat_window_wheel_round_position(10.6f) == 11; + SDL_SetModState(modifiers); + app->session.window = backup; + bongo_cat_window_cancel_wheel_animation(app); + bongo_cat_platform_set_opacity(&app->platform, + backup.opacity_percent / 100.0f); + bongo_cat_platform_set_geometry(&app->platform, original_x, original_y, + original_width, original_height); + bool passed = foreign && opacity && scale && burst && aggregated && + reversal && flipped && maximum && minimum && opacity_maximum && + opacity_minimum && rounding; + if (!passed) + fprintf(stderr, "wheel self-test: foreign=%d opacity=%d scale=%d " + "burst=%d aggregated=%d reversal=%d flipped=%d maximum=%d " + "minimum=%d opacity_max=%d opacity_min=%d rounding=%d\n", + foreign, opacity, scale, burst, aggregated, reversal, flipped, + maximum, minimum, opacity_maximum, opacity_minimum, rounding); + return passed; +} diff --git a/src/ui/backend/ui_backend.c b/src/ui/backend/ui_backend.c index 421d9606..acf3b99e 100644 --- a/src/ui/backend/ui_backend.c +++ b/src/ui/backend/ui_backend.c @@ -250,37 +250,3 @@ BongoCatUIBackend *bongo_cat_ui_backend_for_context( return context_backend && context == &context_backend->context ? context_backend : NULL; } - -static const BongoCatUIBackend *backend(const struct nk_context *context) { - return bongo_cat_ui_backend_for_context(context); -} - -const struct nk_user_font *bongo_cat_ui_caption_font( - const struct nk_context *context) { - const BongoCatUIBackend *ui = backend(context); - return ui && ui->caption_font ? ui->caption_font : context->style.font; -} - -const struct nk_user_font *bongo_cat_ui_body_font( - const struct nk_context *context) { - const BongoCatUIBackend *ui = backend(context); - return ui && ui->body_font ? ui->body_font : context->style.font; -} - -const struct nk_user_font *bongo_cat_ui_label_font( - const struct nk_context *context) { - const BongoCatUIBackend *ui = backend(context); - return ui && ui->label_font ? ui->label_font : context->style.font; -} - -const struct nk_user_font *bongo_cat_ui_heading_font( - const struct nk_context *context) { - const BongoCatUIBackend *ui = backend(context); - return ui && ui->heading_font ? ui->heading_font : context->style.font; -} - -const struct nk_user_font *bongo_cat_ui_hero_font( - const struct nk_context *context) { - const BongoCatUIBackend *ui = backend(context); - return ui && ui->hero_font ? ui->hero_font : context->style.font; -} diff --git a/src/ui/backend/ui_backend_fonts.c b/src/ui/backend/ui_backend_fonts.c new file mode 100644 index 00000000..fdae9498 --- /dev/null +++ b/src/ui/backend/ui_backend_fonts.c @@ -0,0 +1,35 @@ +#include "ui_backend.h" + +static const BongoCatUIBackend *backend(const struct nk_context *context) { + return bongo_cat_ui_backend_for_context(context); +} + +const struct nk_user_font *bongo_cat_ui_caption_font( + const struct nk_context *context) { + const BongoCatUIBackend *ui = backend(context); + return ui && ui->caption_font ? ui->caption_font : context->style.font; +} + +const struct nk_user_font *bongo_cat_ui_body_font( + const struct nk_context *context) { + const BongoCatUIBackend *ui = backend(context); + return ui && ui->body_font ? ui->body_font : context->style.font; +} + +const struct nk_user_font *bongo_cat_ui_label_font( + const struct nk_context *context) { + const BongoCatUIBackend *ui = backend(context); + return ui && ui->label_font ? ui->label_font : context->style.font; +} + +const struct nk_user_font *bongo_cat_ui_heading_font( + const struct nk_context *context) { + const BongoCatUIBackend *ui = backend(context); + return ui && ui->heading_font ? ui->heading_font : context->style.font; +} + +const struct nk_user_font *bongo_cat_ui_hero_font( + const struct nk_context *context) { + const BongoCatUIBackend *ui = backend(context); + return ui && ui->hero_font ? ui->hero_font : context->style.font; +} diff --git a/src/ui/preferences/preferences_import.c b/src/ui/preferences/preferences_import.c index 063bdc3d..c60889d8 100644 --- a/src/ui/preferences/preferences_import.c +++ b/src/ui/preferences/preferences_import.c @@ -8,7 +8,7 @@ #include #include #include -static void free_job(BongoCatImportJob *job) { +void bongo_cat_preferences_import_job_free(BongoCatImportJob *job) { if (!job) return; for (size_t i = 0; i < job->count; ++i) SDL_free(job->paths[i]); SDL_free(job->paths); @@ -24,9 +24,10 @@ static void free_import_event(const SDL_Event *event, bool *release_worker) { BongoCatImportJob *job = (BongoCatImportJob *)event->user.data1; if (release_worker && event->user.code == BONGO_CAT_IMPORT_COMPLETE_CODE) *release_worker = true; - free_job(job); + bongo_cat_preferences_import_job_free(job); } -static void release_dialog(BongoCatImportDialog *dialog) { +void bongo_cat_preferences_import_dialog_release( + BongoCatImportDialog *dialog) { bool destroy = false; SDL_LockMutex(dialog->mutex); destroy = --dialog->references == 0; @@ -58,8 +59,8 @@ void bongo_cat_preferences_import_destroy(BongoCatImportDialog *dialog) { dialog->started_ns = 0; dialog->completed = dialog->total = 0; SDL_UnlockMutex(dialog->mutex); - if (release_worker) release_dialog(dialog); - release_dialog(dialog); + if (release_worker) bongo_cat_preferences_import_dialog_release(dialog); + bongo_cat_preferences_import_dialog_release(dialog); } static BongoCatImportJob *copy_job(const char *const *files) { if (!files || !files[0]) return NULL; @@ -72,7 +73,10 @@ static BongoCatImportJob *copy_job(const char *const *files) { job->count = count; for (size_t i = 0; i < count; ++i) { job->paths[i] = SDL_strdup(files[i]); - if (!job->paths[i]) { free_job(job); return NULL; } + if (!job->paths[i]) { + bongo_cat_preferences_import_job_free(job); + return NULL; + } } return job; } @@ -81,95 +85,6 @@ static BongoCatImportJob *copy_path(const char *path) { return path && path[0] ? copy_job(files) : NULL; } -static int SDLCALL import_worker(void *userdata) { - BongoCatImportJob *job = userdata; - if (!SDL_SetCurrentThreadPriority(SDL_THREAD_PRIORITY_LOW)) { - SDL_LogDebug(SDL_LOG_CATEGORY_APPLICATION, - "Cannot lower model import thread priority: %s", SDL_GetError()); - SDL_ClearError(); - } - job->result = BONGO_CAT_ERROR_FORMAT; - SDL_Log("[runtime] Model import started: sources=%llu", - (unsigned long long)job->count); - BongoCatImportSession *session = bongo_cat_import_session_create( - job->models_root, &job->error); - if (!session) { - job->result = job->error.code ? job->error.code : BONGO_CAT_ERROR_IO; - if (job->error.code == BONGO_CAT_OK) job->error.code = job->result; - job->failed_count = job->count; - for (size_t i = 0; i < job->count; ++i) - bongo_cat_preferences_import_record_failure(job, job->paths[i]); - } - BongoCatImportProgressContext progress = {job, 0}; - for (size_t i = 0; session && i < job->count; ++i) { - size_t before = progress.completed; - BongoCatImportBatchStats stats = {0}; - BongoCatError error = {0}; - SDL_Log("[runtime] Model import source started: index=%llu/%llu path=%s", - (unsigned long long)(i + 1), (unsigned long long)job->count, - job->paths[i]); - BongoCatResult result = bongo_cat_import_session_install_progressive( - session, job->paths[i], bongo_cat_preferences_import_receive, - &progress, &stats, &error); - SDL_Log("[runtime] Model import source completed: index=%llu/%llu " - "result=%d succeeded=%llu failed=%llu", - (unsigned long long)(i + 1), (unsigned long long)job->count, - (int)result, (unsigned long long)stats.succeeded_count, - (unsigned long long)stats.failed_count); - job->succeeded_count += stats.succeeded_count; - job->failed_count += stats.failed_count; - bongo_cat_preferences_import_merge_failures(job, &stats); - if (result != BONGO_CAT_OK && job->error.code == BONGO_CAT_OK) { - job->result = result; - job->error = error; - if (job->error.code == BONGO_CAT_OK) job->error.code = result; - } - if (stats.failed_count) { - progress.completed += stats.failed_count; - bongo_cat_preferences_import_report_progress(job, NULL, - BONGO_CAT_MODEL_CAP, progress.completed); - } else if (progress.completed == before) { - bongo_cat_preferences_import_report_progress(job, NULL, - BONGO_CAT_MODEL_CAP, ++progress.completed); - } - } - bongo_cat_import_session_destroy(session); - if (job->succeeded_count) job->result = BONGO_CAT_OK; - if (!job->succeeded_count && job->error.code == BONGO_CAT_OK) - bongo_cat_error_set(&job->error, BONGO_CAT_ERROR_FORMAT, - "No model could be imported"); - SDL_Log("[runtime] Model import finished: resolved=%llu installed=%llu " - "succeeded=%llu failed=%llu result=%d", - (unsigned long long)job->resolved_count, - (unsigned long long)job->installed_count, - (unsigned long long)job->succeeded_count, - (unsigned long long)job->failed_count, (int)job->result); - SDL_Event event = {0}; - SDL_LockMutex(job->dialog->mutex); - bool notify = job->dialog->active; - if (notify) { - event.type = job->dialog->event_type; - event.user.windowID = job->dialog->window_id; - event.user.code = BONGO_CAT_IMPORT_COMPLETE_CODE; - event.user.data1 = job; - event.user.data2 = job->dialog; - notify = SDL_PushEvent(&event); - } - if (!notify) { - job->dialog->worker_job = NULL; - job->dialog->busy = false; - job->dialog->started_ns = 0; - job->dialog->completed = job->dialog->total = 0; - } - SDL_UnlockMutex(job->dialog->mutex); - if (!notify) { - BongoCatImportDialog *dialog = job->dialog; - free_job(job); - release_dialog(dialog); - } - return 0; -} - static bool start_job(BongoCatImportDialog *dialog, BongoCatApp *app, SDL_WindowID window_id, BongoCatImportJob *job) { if (!dialog || !app || !job || !app->models_root[0]) return false; @@ -187,7 +102,7 @@ static bool start_job(BongoCatImportDialog *dialog, BongoCatApp *app, dialog->completed = 0; dialog->total = job->count; ++dialog->references; - dialog->worker = SDL_CreateThread(import_worker, + dialog->worker = SDL_CreateThread(bongo_cat_preferences_import_worker, BONGO_CAT_SLUG "-model-import", job); if (!dialog->worker) { dialog->worker_job = NULL; @@ -217,8 +132,8 @@ static void SDLCALL import_callback(void *userdata, const char *const *files, SDL_Log("[runtime] Folder dialog result ignored during shutdown"); if (!pushed) dialog->open = false; SDL_UnlockMutex(dialog->mutex); - if (!pushed) free_job(job); - release_dialog(dialog); + if (!pushed) bongo_cat_preferences_import_job_free(job); + bongo_cat_preferences_import_dialog_release(dialog); } bool bongo_cat_preferences_import_open(BongoCatImportDialog *dialog, @@ -271,8 +186,8 @@ static void complete_job(BongoCatImportDialog *dialog, BongoCatApp *app, job->resolved_count, job->installed_count + restored_count, job->succeeded_count, job->failed_count, job->failed_names, job->failed_name_count); - free_job(job); - release_dialog(dialog); + bongo_cat_preferences_import_job_free(job); + bongo_cat_preferences_import_dialog_release(dialog); } bool bongo_cat_preferences_import_event(BongoCatImportDialog *dialog, @@ -287,8 +202,9 @@ bool bongo_cat_preferences_import_event(BongoCatImportDialog *dialog, bool accept = dialog->active && owner != NULL; SDL_UnlockMutex(dialog->mutex); if (accept && app && job && !start_job(dialog, app, - event->user.windowID, job)) free_job(job); - else if (!accept) free_job(job); + event->user.windowID, job)) + bongo_cat_preferences_import_job_free(job); + else if (!accept) bongo_cat_preferences_import_job_free(job); return true; } if (bongo_cat_preferences_import_progress_event(dialog, app, event)) @@ -305,6 +221,7 @@ void bongo_cat_preferences_import_path(BongoCatApp *app, if (!app || !app->preferences || !window || !path || !path[0]) return; BongoCatImportJob *job = copy_path(path); if (!job || !start_job(app->preferences->import_dialog, app, - SDL_GetWindowID(window), job)) free_job(job); + SDL_GetWindowID(window), job)) + bongo_cat_preferences_import_job_free(job); else app->preferences->render_dirty = true; } diff --git a/src/ui/preferences/preferences_import_internal.h b/src/ui/preferences/preferences_import_internal.h index 8b9bf8fa..c2e4f242 100644 --- a/src/ui/preferences/preferences_import_internal.h +++ b/src/ui/preferences/preferences_import_internal.h @@ -67,5 +67,9 @@ void bongo_cat_preferences_import_merge_failures(BongoCatImportJob *job, const BongoCatImportBatchStats *stats); bool bongo_cat_preferences_import_progress_event( BongoCatImportDialog *dialog, BongoCatApp *app, const SDL_Event *event); +void bongo_cat_preferences_import_job_free(BongoCatImportJob *job); +void bongo_cat_preferences_import_dialog_release( + BongoCatImportDialog *dialog); +int SDLCALL bongo_cat_preferences_import_worker(void *userdata); #endif diff --git a/src/ui/preferences/preferences_import_worker.c b/src/ui/preferences/preferences_import_worker.c new file mode 100644 index 00000000..b954d538 --- /dev/null +++ b/src/ui/preferences/preferences_import_worker.c @@ -0,0 +1,92 @@ +#include "preferences_import_internal.h" + +#include + +int SDLCALL bongo_cat_preferences_import_worker(void *userdata) { + BongoCatImportJob *job = userdata; + if (!SDL_SetCurrentThreadPriority(SDL_THREAD_PRIORITY_LOW)) { + SDL_LogDebug(SDL_LOG_CATEGORY_APPLICATION, + "Cannot lower model import thread priority: %s", SDL_GetError()); + SDL_ClearError(); + } + job->result = BONGO_CAT_ERROR_FORMAT; + SDL_Log("[runtime] Model import started: sources=%llu", + (unsigned long long)job->count); + BongoCatImportSession *session = bongo_cat_import_session_create( + job->models_root, &job->error); + if (!session) { + job->result = job->error.code ? job->error.code : BONGO_CAT_ERROR_IO; + if (job->error.code == BONGO_CAT_OK) job->error.code = job->result; + job->failed_count = job->count; + for (size_t i = 0; i < job->count; ++i) + bongo_cat_preferences_import_record_failure(job, job->paths[i]); + } + BongoCatImportProgressContext progress = {job, 0}; + for (size_t i = 0; session && i < job->count; ++i) { + size_t before = progress.completed; + BongoCatImportBatchStats stats = {0}; + BongoCatError error = {0}; + SDL_Log("[runtime] Model import source started: index=%llu/%llu path=%s", + (unsigned long long)(i + 1), (unsigned long long)job->count, + job->paths[i]); + BongoCatResult result = bongo_cat_import_session_install_progressive( + session, job->paths[i], bongo_cat_preferences_import_receive, + &progress, &stats, &error); + SDL_Log("[runtime] Model import source completed: index=%llu/%llu " + "result=%d succeeded=%llu failed=%llu", + (unsigned long long)(i + 1), (unsigned long long)job->count, + (int)result, (unsigned long long)stats.succeeded_count, + (unsigned long long)stats.failed_count); + job->succeeded_count += stats.succeeded_count; + job->failed_count += stats.failed_count; + bongo_cat_preferences_import_merge_failures(job, &stats); + if (result != BONGO_CAT_OK && job->error.code == BONGO_CAT_OK) { + job->result = result; + job->error = error; + if (job->error.code == BONGO_CAT_OK) job->error.code = result; + } + if (stats.failed_count) { + progress.completed += stats.failed_count; + bongo_cat_preferences_import_report_progress(job, NULL, + BONGO_CAT_MODEL_CAP, progress.completed); + } else if (progress.completed == before) { + bongo_cat_preferences_import_report_progress(job, NULL, + BONGO_CAT_MODEL_CAP, ++progress.completed); + } + } + bongo_cat_import_session_destroy(session); + if (job->succeeded_count) job->result = BONGO_CAT_OK; + if (!job->succeeded_count && job->error.code == BONGO_CAT_OK) + bongo_cat_error_set(&job->error, BONGO_CAT_ERROR_FORMAT, + "No model could be imported"); + SDL_Log("[runtime] Model import finished: resolved=%llu installed=%llu " + "succeeded=%llu failed=%llu result=%d", + (unsigned long long)job->resolved_count, + (unsigned long long)job->installed_count, + (unsigned long long)job->succeeded_count, + (unsigned long long)job->failed_count, (int)job->result); + SDL_Event event = {0}; + SDL_LockMutex(job->dialog->mutex); + bool notify = job->dialog->active; + if (notify) { + event.type = job->dialog->event_type; + event.user.windowID = job->dialog->window_id; + event.user.code = BONGO_CAT_IMPORT_COMPLETE_CODE; + event.user.data1 = job; + event.user.data2 = job->dialog; + notify = SDL_PushEvent(&event); + } + if (!notify) { + job->dialog->worker_job = NULL; + job->dialog->busy = false; + job->dialog->started_ns = 0; + job->dialog->completed = job->dialog->total = 0; + } + SDL_UnlockMutex(job->dialog->mutex); + if (!notify) { + BongoCatImportDialog *dialog = job->dialog; + bongo_cat_preferences_import_job_free(job); + bongo_cat_preferences_import_dialog_release(dialog); + } + return 0; +} diff --git a/src/ui/preferences/preferences_model.c b/src/ui/preferences/preferences_model.c index 5bd41bb8..be134f86 100644 --- a/src/ui/preferences/preferences_model.c +++ b/src/ui/preferences/preferences_model.c @@ -132,88 +132,6 @@ void bongo_cat_preferences_process_model_selection(BongoCatPreferences *value) { card click that queued this selection and immediately toggle it back. */ } -static const char *import_failure_message(BongoCatApp *app, - BongoCatResult result) { - switch (result) { - case BONGO_CAT_ERROR_ARGUMENT: - return tr(app, "pages.preference.model.hints.importInvalidSource", - "The selected source no longer exists or cannot be used"); - case BONGO_CAT_ERROR_FORMAT: - return tr(app, "pages.preference.model.hints.importInvalidFormat", - "No supported model was found, or the model package is incomplete"); - case BONGO_CAT_ERROR_IO: - return tr(app, "pages.preference.model.hints.importFileAccess", - "The model files could not be read or saved"); - case BONGO_CAT_ERROR_MEMORY: - return tr(app, "pages.preference.model.hints.importOutOfMemory", - "There is not enough memory to import this model"); - case BONGO_CAT_ERROR_CUBISM: - return tr(app, "pages.preference.model.hints.importLoadFailed", - "The model was imported, but it could not be displayed"); - default: - return tr(app, "pages.preference.model.hints.importFailed", - "Model import failed. Check the selected files and try again"); - } -} - -void bongo_cat_preferences_import_complete(BongoCatApp *app, - BongoCatResult result, const BongoCatError *error, size_t resolved_count, - size_t installed_count, size_t succeeded_count, size_t failed_count, - const char (*failed_names)[BONGO_CAT_ID_CAP], size_t failed_name_count) { - if (!app || !app->preferences) return; - bool failed = failed_count > 0 || result != BONGO_CAT_OK; - char batch_message[384]; - char failure_list[220] = ""; - char more_failures[64] = ""; - size_t listed_count; - size_t shown_names = failed_names && failed_name_count > - BONGO_CAT_IMPORT_FAILURE_NAME_CAP ? BONGO_CAT_IMPORT_FAILURE_NAME_CAP - : failed_names ? failed_name_count : 0; - for (size_t i = 0; i < shown_names; ++i) { - size_t used = strlen(failure_list); - snprintf(failure_list + used, sizeof(failure_list) - used, - "%s%s", i ? ", " : "", failed_names[i]); - } - listed_count = shown_names; - if (!failure_list[0]) { - snprintf(failure_list, sizeof(failure_list), "%s", - tr(app, "pages.preference.model.hints.importUnknownSource", - "unknown source")); - listed_count = failed_count ? 1 : 0; - } - if (failed_count > listed_count) snprintf(more_failures, - sizeof(more_failures), tr(app, - "pages.preference.model.hints.importMoreFailures", - " and %zu more"), failed_count - listed_count); - const char *message = installed_count ? tr(app, - "pages.preference.model.hints.importSuccess", "Model imported") - : tr(app, "pages.preference.model.hints.importExists", - "Model already exists"); - if (failed) { - BongoCatResult failure = error && error->code != BONGO_CAT_OK - ? error->code : result; - snprintf(batch_message, sizeof(batch_message), tr(app, - "pages.preference.model.hints.importBatchResult", - "Import succeeded: %zu; failed: %zu. Failed models: %s%s. First failure: %s"), - succeeded_count, failed_count, failure_list, more_failures, - import_failure_message(app, failure)); - message = batch_message; - } - if (failed && error && error->message[0]) - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, - "Model import failed: %s", error->message); - if (app->smoke) { - if (failed) app->exit_code = 1; - } else bongo_cat_preferences_notice_show(app, message, - failed); - if (result == BONGO_CAT_OK) { - SDL_Log("Resolved %zu model package(s); installed %zu new package(s)", - resolved_count, installed_count); - } - bongo_cat_preferences_invalidate(app->preferences); - bongo_cat_preferences_render(app->preferences); -} - static void smoke_model_behavior(BongoCatPreferences *value) { BongoCatApp *app = value->app; if (app->smoke_preference_model_select) { diff --git a/src/ui/preferences/preferences_model_card.c b/src/ui/preferences/preferences_model_card.c index 5512d6b4..fa991576 100644 --- a/src/ui/preferences/preferences_model_card.c +++ b/src/ui/preferences/preferences_model_card.c @@ -199,19 +199,6 @@ static void draw_actions(BongoCatPreferences *value, bongo_cat_preferences_remove_dialog_open(value->app, entry->id); } -static void draw_selection_badge(BongoCatPreferences *value, - struct nk_command_buffer *canvas, struct nk_rect preview, - BongoCatUIPalette p, float amount) { - if (amount <= .001f) return; - float size = 25.0f * (.85f + .15f * amount); - struct nk_rect badge = nk_rect(preview.x + preview.w - size - 8, - preview.y + 8, size, size); - nk_fill_circle(canvas, badge, bongo_cat_ui_color_alpha(p.pink, amount)); - bongo_cat_preferences_icon_draw(value, canvas, BONGO_CAT_UI_ICON_CHECK, - nk_rect(badge.x + 5, badge.y + 5, badge.w - 10, badge.h - 10), - bongo_cat_ui_color_alpha(nk_rgb(255, 255, 255), amount)); -} - void bongo_cat_preferences_model_card(BongoCatPreferences *value, struct nk_context *context, const BongoCatModelEntry *entry, bool storage_busy) { @@ -252,7 +239,8 @@ void bongo_cat_preferences_model_card(BongoCatPreferences *value, nk_fill_rect(canvas, preview, 12, p.field); draw_cover(value, canvas, preview, entry, p); if (app->settings.model.multiple_pets) - draw_selection_badge(value, canvas, preview, p, selection_amount); + bongo_cat_preferences_model_card_draw_selection_badge( + value, canvas, preview, p, selection_amount); float info_y = preview.y + preview.h + 8; struct nk_rect name_bounds = nk_rect(bounds.x + 9, info_y + 1, bounds.w - 18, 30); diff --git a/src/ui/preferences/preferences_model_card_paint.c b/src/ui/preferences/preferences_model_card_paint.c index fa236cad..27f45f55 100644 --- a/src/ui/preferences/preferences_model_card_paint.c +++ b/src/ui/preferences/preferences_model_card_paint.c @@ -1,4 +1,6 @@ #include "preferences_model_card_paint.h" +#include "ui_icons.h" +#include "ui_paint.h" #include @@ -86,3 +88,17 @@ void bongo_cat_preferences_model_card_draw_progress( if (partial_count >= 2) nk_stroke_polyline(canvas, partial, partial_count, thickness, color); } + +void bongo_cat_preferences_model_card_draw_selection_badge( + BongoCatPreferences *value, struct nk_command_buffer *canvas, + struct nk_rect preview, BongoCatUIPalette palette, float amount) { + if (amount <= .001f) return; + float size = 25.0f * (.85f + .15f * amount); + struct nk_rect badge = nk_rect(preview.x + preview.w - size - 8, + preview.y + 8, size, size); + nk_fill_circle(canvas, badge, + bongo_cat_ui_color_alpha(palette.pink, amount)); + bongo_cat_preferences_icon_draw(value, canvas, BONGO_CAT_UI_ICON_CHECK, + nk_rect(badge.x + 5, badge.y + 5, badge.w - 10, badge.h - 10), + bongo_cat_ui_color_alpha(nk_rgb(255, 255, 255), amount)); +} diff --git a/src/ui/preferences/preferences_model_card_paint.h b/src/ui/preferences/preferences_model_card_paint.h index ed1dabf9..8de273b6 100644 --- a/src/ui/preferences/preferences_model_card_paint.h +++ b/src/ui/preferences/preferences_model_card_paint.h @@ -1,12 +1,15 @@ #ifndef BONGO_CAT_PREFERENCES_MODEL_CARD_PAINT_H #define BONGO_CAT_PREFERENCES_MODEL_CARD_PAINT_H -#include "nuklear_config.h" +#include "preferences_state.h" struct nk_rect bongo_cat_preferences_model_card_outline_bounds( struct nk_rect bounds, float thickness); void bongo_cat_preferences_model_card_draw_progress( struct nk_command_buffer *canvas, struct nk_rect bounds, float rounding, float thickness, float progress, struct nk_color color); +void bongo_cat_preferences_model_card_draw_selection_badge( + BongoCatPreferences *value, struct nk_command_buffer *canvas, + struct nk_rect preview, BongoCatUIPalette palette, float amount); #endif diff --git a/src/ui/preferences/preferences_model_import.c b/src/ui/preferences/preferences_model_import.c new file mode 100644 index 00000000..e6f68b77 --- /dev/null +++ b/src/ui/preferences/preferences_model_import.c @@ -0,0 +1,92 @@ +#include "preferences_state.h" +#include "preferences_notice.h" +#include "model_import.h" +#include "bongo_cat/i18n.h" +#include "bongo_cat/preferences.h" + +#include +#include + +static const char *tr(BongoCatApp *app, const char *key, + const char *fallback) { + return bongo_cat_i18n_get(app->i18n, key, fallback); +} + +static const char *import_failure_message(BongoCatApp *app, + BongoCatResult result) { + switch (result) { + case BONGO_CAT_ERROR_ARGUMENT: + return tr(app, "pages.preference.model.hints.importInvalidSource", + "The selected source no longer exists or cannot be used"); + case BONGO_CAT_ERROR_FORMAT: + return tr(app, "pages.preference.model.hints.importInvalidFormat", + "No supported model was found, or the model package is incomplete"); + case BONGO_CAT_ERROR_IO: + return tr(app, "pages.preference.model.hints.importFileAccess", + "The model files could not be read or saved"); + case BONGO_CAT_ERROR_MEMORY: + return tr(app, "pages.preference.model.hints.importOutOfMemory", + "There is not enough memory to import this model"); + case BONGO_CAT_ERROR_CUBISM: + return tr(app, "pages.preference.model.hints.importLoadFailed", + "The model was imported, but it could not be displayed"); + default: + return tr(app, "pages.preference.model.hints.importFailed", + "Model import failed. Check the selected files and try again"); + } +} + +void bongo_cat_preferences_import_complete(BongoCatApp *app, + BongoCatResult result, const BongoCatError *error, size_t resolved_count, + size_t installed_count, size_t succeeded_count, size_t failed_count, + const char (*failed_names)[BONGO_CAT_ID_CAP], size_t failed_name_count) { + if (!app || !app->preferences) return; + bool failed = failed_count > 0 || result != BONGO_CAT_OK; + char batch_message[384]; + char failure_list[220] = ""; + char more_failures[64] = ""; + size_t shown_names = failed_names && failed_name_count > + BONGO_CAT_IMPORT_FAILURE_NAME_CAP ? BONGO_CAT_IMPORT_FAILURE_NAME_CAP + : failed_names ? failed_name_count : 0; + for (size_t i = 0; i < shown_names; ++i) { + size_t used = strlen(failure_list); + snprintf(failure_list + used, sizeof(failure_list) - used, + "%s%s", i ? ", " : "", failed_names[i]); + } + size_t listed_count = shown_names; + if (!failure_list[0]) { + snprintf(failure_list, sizeof(failure_list), "%s", + tr(app, "pages.preference.model.hints.importUnknownSource", + "unknown source")); + listed_count = failed_count ? 1 : 0; + } + if (failed_count > listed_count) + snprintf(more_failures, sizeof(more_failures), tr(app, + "pages.preference.model.hints.importMoreFailures", + " and %zu more"), failed_count - listed_count); + const char *message = installed_count ? tr(app, + "pages.preference.model.hints.importSuccess", "Model imported") + : tr(app, "pages.preference.model.hints.importExists", + "Model already exists"); + if (failed) { + BongoCatResult failure = error && error->code != BONGO_CAT_OK + ? error->code : result; + snprintf(batch_message, sizeof(batch_message), tr(app, + "pages.preference.model.hints.importBatchResult", + "Import succeeded: %zu; failed: %zu. Failed models: %s%s. First failure: %s"), + succeeded_count, failed_count, failure_list, more_failures, + import_failure_message(app, failure)); + message = batch_message; + } + if (failed && error && error->message[0]) + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "Model import failed: %s", error->message); + if (app->smoke) { + if (failed) app->exit_code = 1; + } else bongo_cat_preferences_notice_show(app, message, failed); + if (result == BONGO_CAT_OK) + SDL_Log("Resolved %zu model package(s); installed %zu new package(s)", + resolved_count, installed_count); + bongo_cat_preferences_invalidate(app->preferences); + bongo_cat_preferences_render(app->preferences); +} diff --git a/src/ui/preferences/preferences_present.c b/src/ui/preferences/preferences_present.c new file mode 100644 index 00000000..06f1ab6f --- /dev/null +++ b/src/ui/preferences/preferences_present.c @@ -0,0 +1,98 @@ +#include "preferences_state.h" +#include "preferences_render_internal.h" +#include "preferences_controls.h" +#include "ui_animation.h" +#include "ui_paint.h" +#include "bongo_cat/memory_policy.h" + +#include + +void bongo_cat_preferences_render(BongoCatPreferences *value) { + if (!value || !value->window || !value->visible) return; + bongo_cat_preferences_drag_tick(value); + uint64_t now = SDL_GetTicksNS(); + bool raster_due = value->pending_raster_scale > 0.0f && + value->raster_retry_ns <= now; + if (value->render_retry_ns > now || + (!value->render_dirty && value->last_render_ns && !raster_due)) return; + value->render_dirty = false; + value->ui.context.delta_time_seconds = value->last_render_ns ? NK_CLAMP( + .001f, (float)(now - value->last_render_ns) / 1000000000.0f, + 1.0f / 60.0f) : 1.0f / 60.0f; + value->last_render_ns = now; + bongo_cat_preferences_input_end(value); + if (!SDL_GL_MakeCurrent(value->window, value->gl_context)) { + value->render_dirty = true; + value->render_retry_ns = now + 1000000000ull; + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Preferences GL context could not be activated: %s", SDL_GetError()); + return; + } + value->render_retry_ns = 0; + bool importing = bongo_cat_preferences_import_status( + value->import_dialog, NULL, NULL, NULL); + value->import_render_active = importing; + bool refreshing_models = bongo_cat_app_model_refresh_busy(value->app); + if (!importing && !refreshing_models) { + bongo_cat_preferences_refresh_raster(value); + bongo_cat_preferences_reload_language(value); + } + if (value->font_reload_pending && !importing && !refreshing_models) { + if (value->font_reload_defer_once) { + value->font_reload_defer_once = false; + value->render_dirty = true; + } else if (!bongo_cat_preferences_reload_fonts(value)) { + value->font_reload_pending = false; + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "Deferred preferences font atlas rebuild failed"); + } + } + bongo_cat_preferences_apply_theme(value); + float width = 0.0f, height = 0.0f; + bongo_cat_ui_logical_size(&value->ui, &width, &height); + bongo_cat_ui_paint_begin_frame(&value->ui); + bongo_cat_ui_cursor_begin(&value->ui); + bool dark = bongo_cat_preferences_resolved_theme(value) != 0; + value->ui.frame_building = true; + bool close_requested = bongo_cat_preferences_draw_frame( + value, width, height, dark); + value->ui.frame_building = false; + bongo_cat_preferences_shortcut_smoke(value); + BongoCatUIPalette palette = bongo_cat_ui_palette(dark); + glDisable(GL_SCISSOR_TEST); + glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + glClearColor( + value->transparent_window ? 0.0f : palette.background.r / 255.0f, + value->transparent_window ? 0.0f : palette.background.g / 255.0f, + value->transparent_window ? 0.0f : palette.background.b / 255.0f, + value->transparent_window ? 0.0f : 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + bongo_cat_ui_render(&value->ui); + bongo_cat_preferences_smoke_frame(value); + if (!SDL_GL_SwapWindow(value->window)) { + value->render_dirty = true; + value->render_retry_ns = now + 1000000000ull; + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Preferences frame presentation failed: %s", SDL_GetError()); + } else bongo_cat_memory_policy_ui_frame_presented(); + bongo_cat_preferences_record_frame(value); + if (value->shortcut_recording || value->model_load_visual_active || + importing || bongo_cat_pref_controls_animating(&value->ui.context) || + bongo_cat_ui_animations_active(&value->ui.context)) + value->render_dirty = true; + else if (!value->chrome_dragging && !value->live_resize_active) + bongo_cat_ui_trim_idle(&value->ui); + SDL_GL_MakeCurrent(value->app->window, value->app->gl_context); + bongo_cat_ui_cursor_apply(&value->ui); + if (close_requested) { + bongo_cat_preferences_close(value); + return; + } + if (value->import_requested && !bongo_cat_preferences_import_is_open( + value->import_dialog)) { + value->import_requested = false; + if (!bongo_cat_preferences_import_open(value->import_dialog, + value->window)) + value->render_dirty = true; + } +} diff --git a/src/ui/preferences/preferences_render.c b/src/ui/preferences/preferences_render.c index d3cb51a2..7734c255 100644 --- a/src/ui/preferences/preferences_render.c +++ b/src/ui/preferences/preferences_render.c @@ -1,4 +1,5 @@ #include "preferences_state.h" +#include "preferences_render_internal.h" #include "preferences_page_dispatch.h" #include "preferences_controls.h" #include "preferences_notice.h" @@ -201,7 +202,8 @@ static bool draw_shell(BongoCatPreferences *value, struct nk_context *context, bongo_cat_preferences_behavior_dialog_draw(value, context); return close_requested; } -static bool draw_frame(BongoCatPreferences *value, float width, float height, +bool bongo_cat_preferences_draw_frame(BongoCatPreferences *value, + float width, float height, bool dark) { struct nk_context *context = &value->ui.context; RootStyle saved = root_style_save(context); @@ -215,88 +217,3 @@ static bool draw_frame(BongoCatPreferences *value, float width, float height, root_style_restore(context, &saved); return close_requested; } -void bongo_cat_preferences_render(BongoCatPreferences *value) { - if (!value || !value->window || !value->visible) return; - bongo_cat_preferences_drag_tick(value); - uint64_t now = SDL_GetTicksNS(); - bool raster_due = value->pending_raster_scale > 0.0f && - value->raster_retry_ns <= now; - if (value->render_retry_ns > now || - (!value->render_dirty && value->last_render_ns && !raster_due)) return; - value->render_dirty = false; - value->ui.context.delta_time_seconds = value->last_render_ns ? NK_CLAMP( - .001f, (float)(now - value->last_render_ns) / 1000000000.0f, 1.0f / 60.0f) : - 1.0f / 60.0f; - value->last_render_ns = now; - bongo_cat_preferences_input_end(value); - if (!SDL_GL_MakeCurrent(value->window, value->gl_context)) { - value->render_dirty = true; - value->render_retry_ns = now + 1000000000ull; - SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, - "Preferences GL context could not be activated: %s", SDL_GetError()); - return; - } - value->render_retry_ns = 0; - bool importing = bongo_cat_preferences_import_status( - value->import_dialog, NULL, NULL, NULL); - value->import_render_active = importing; - bool refreshing_models = bongo_cat_app_model_refresh_busy(value->app); - if (!importing && !refreshing_models) { - bongo_cat_preferences_refresh_raster(value); - bongo_cat_preferences_reload_language(value); - } - if (value->font_reload_pending && !importing && !refreshing_models) { - if (value->font_reload_defer_once) { - value->font_reload_defer_once = false; - value->render_dirty = true; - } else if (!bongo_cat_preferences_reload_fonts(value)) { - value->font_reload_pending = false; - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, - "Deferred preferences font atlas rebuild failed"); - } - } - bongo_cat_preferences_apply_theme(value); - float width = 0.0f, height = 0.0f; - bongo_cat_ui_logical_size(&value->ui, &width, &height); - bongo_cat_ui_paint_begin_frame(&value->ui); - bongo_cat_ui_cursor_begin(&value->ui); - bool dark = bongo_cat_preferences_resolved_theme(value) != 0; - value->ui.frame_building = true; - bool close_requested = draw_frame(value, width, height, dark); - value->ui.frame_building = false; - bongo_cat_preferences_shortcut_smoke(value); - BongoCatUIPalette palette = bongo_cat_ui_palette(dark); - glDisable(GL_SCISSOR_TEST); glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); - glClearColor(value->transparent_window ? 0.0f : palette.background.r / 255.0f, - value->transparent_window ? 0.0f : palette.background.g / 255.0f, - value->transparent_window ? 0.0f : palette.background.b / 255.0f, - value->transparent_window ? 0.0f : 1.0f); - glClear(GL_COLOR_BUFFER_BIT); - bongo_cat_ui_render(&value->ui); - bongo_cat_preferences_smoke_frame(value); - if (!SDL_GL_SwapWindow(value->window)) { - value->render_dirty = true; - value->render_retry_ns = now + 1000000000ull; - SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, - "Preferences frame presentation failed: %s", SDL_GetError()); - } else bongo_cat_memory_policy_ui_frame_presented(); - bongo_cat_preferences_record_frame(value); - if (value->shortcut_recording || value->model_load_visual_active || importing || - bongo_cat_pref_controls_animating(&value->ui.context) || - bongo_cat_ui_animations_active(&value->ui.context)) - value->render_dirty = true; - else if (!value->chrome_dragging && !value->live_resize_active) - bongo_cat_ui_trim_idle(&value->ui); - SDL_GL_MakeCurrent(value->app->window, value->app->gl_context); - bongo_cat_ui_cursor_apply(&value->ui); - if (close_requested) { - bongo_cat_preferences_close(value); - return; - } - if (value->import_requested && !bongo_cat_preferences_import_is_open( - value->import_dialog)) { - value->import_requested = false; - if (!bongo_cat_preferences_import_open(value->import_dialog, value->window)) - value->render_dirty = true; - } -} diff --git a/src/ui/preferences/preferences_render_internal.h b/src/ui/preferences/preferences_render_internal.h new file mode 100644 index 00000000..bb471857 --- /dev/null +++ b/src/ui/preferences/preferences_render_internal.h @@ -0,0 +1,9 @@ +#ifndef BONGO_CAT_PREFERENCES_RENDER_INTERNAL_H +#define BONGO_CAT_PREFERENCES_RENDER_INTERNAL_H + +#include "bongo_cat/preferences.h" + +bool bongo_cat_preferences_draw_frame(BongoCatPreferences *value, + float width, float height, bool dark); + +#endif diff --git a/src/ui/rendering/ui_paint.c b/src/ui/rendering/ui_paint.c index f15f6e8c..e5364662 100644 --- a/src/ui/rendering/ui_paint.c +++ b/src/ui/rendering/ui_paint.c @@ -12,7 +12,6 @@ static float clamp01(float value) { return NK_CLAMP(0.0f, value, 1.0f); } - struct nk_color bongo_cat_ui_color_mix(struct nk_color first, struct nk_color second, float amount) { amount = clamp01(amount); @@ -100,7 +99,6 @@ void bongo_cat_ui_paint_gradient(struct nk_context *context, bongo_cat_ui_paint_cache_draw(context, bounds, item, nk_rgb(255, 255, 255)); } - static void paint_radial(struct nk_context *context, int kind, struct nk_rect bounds, struct nk_color center, struct nk_color edge, float midpoint, float outer) { @@ -276,11 +274,3 @@ void bongo_cat_ui_paint_dashed_rounded(struct nk_context *context, bongo_cat_ui_paint_cache_draw(context, bounds, item, nk_rgb(255, 255, 255)); } - -void bongo_cat_ui_paint_begin_frame(BongoCatUIBackend *backend) { - bongo_cat_ui_paint_cache_begin_frame(backend); -} - -void bongo_cat_ui_paint_destroy(BongoCatUIBackend *backend) { - bongo_cat_ui_paint_cache_destroy(backend); -} diff --git a/src/ui/rendering/ui_paint_lifecycle.c b/src/ui/rendering/ui_paint_lifecycle.c new file mode 100644 index 00000000..49b254e7 --- /dev/null +++ b/src/ui/rendering/ui_paint_lifecycle.c @@ -0,0 +1,10 @@ +#include "ui_paint.h" +#include "ui_paint_cache.h" + +void bongo_cat_ui_paint_begin_frame(BongoCatUIBackend *backend) { + bongo_cat_ui_paint_cache_begin_frame(backend); +} + +void bongo_cat_ui_paint_destroy(BongoCatUIBackend *backend) { + bongo_cat_ui_paint_cache_destroy(backend); +} diff --git a/tests/model_import/test_tauri_portable.c b/tests/model_import/test_tauri_portable.c index 0d0627d2..ed94e375 100644 --- a/tests/model_import/test_tauri_portable.c +++ b/tests/model_import/test_tauri_portable.c @@ -111,7 +111,7 @@ void test_tauri_portable(void) { 24 && yyjson_get_int(yyjson_arr_get(window_size, 0)) == expected_width && yyjson_get_int(yyjson_arr_get(window_size, 1)) == expected_height && - yyjson_is_num(l2d_correct) && yyjson_get_num(l2d_correct) == 1.1); + yyjson_is_num(l2d_correct) && yyjson_get_num(l2d_correct) == 2.0); yyjson_doc_free(normalized); CHECK(child(path, sizeof(path), package, "img/keyboard/cat_model/cat.model3.json", false) && @@ -181,7 +181,7 @@ void test_tauri_portable(void) { CHECK(yyjson_get_int(yyjson_obj_get(standard_root, "mode")) == 1 && yyjson_is_arr(yyjson_obj_get(yyjson_obj_get(standard_root, "standard"), "hand")) && yyjson_is_num(standard_l2d_correct) && - yyjson_get_num(standard_l2d_correct) == 1.1); + yyjson_get_num(standard_l2d_correct) == 2.0); yyjson_doc_free(standard_config); CHECK(child(path, sizeof(path), portable_root, "img/standard/bg.png", false) && !bongo_cat_path_is_file(path)); From 0967ac849d8498a27d9ab46386aa4f0ef828665c Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 31 Aug 2026 14:35:51 +0800 Subject: [PATCH 166/334] refactor: organize model cover capture workflow --- CMakeLists.txt | 1 - cmake/RuntimeSources.cmake | 1 + include/bongo_cat/app.h | 6 +- include/bongo_cat/model.h | 4 - src/live2d/cubism_bridge.cpp | 14 +- src/live2d/cubism_bridge_load.cpp | 12 +- src/live2d/cubism_cover_capture.cpp | 27 -- src/live2d/cubism_model.hpp | 1 - src/live2d/cubism_runtime.hpp | 1 - src/live2d/live2d_stub.c | 17 +- src/runtime/diagnostics/frame_audit.c | 35 --- src/runtime/lifecycle/app_loop.c | 17 +- .../model/import/model_import_adapter.c | 17 +- src/runtime/model/model_cover.c | 232 ++++++++++++++++++ src/runtime/model/model_cover.h | 14 ++ src/runtime/model/model_cover_paths.h | 13 + src/runtime/model/model_files.c | 4 + src/runtime/runtime.h | 3 +- src/ui/preferences/preferences_model_cover.c | 41 +++- src/ui/preferences/preferences_model_cover.h | 2 + 20 files changed, 351 insertions(+), 111 deletions(-) delete mode 100644 src/live2d/cubism_cover_capture.cpp create mode 100644 src/runtime/model/model_cover.c create mode 100644 src/runtime/model/model_cover.h create mode 100644 src/runtime/model/model_cover_paths.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 913e4860..b9367540 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -52,7 +52,6 @@ if(EXISTS "${CUBISM_CORE_HEADER}") src/live2d/cubism_model_idle.cpp src/live2d/cubism_model_update.cpp src/live2d/cubism_parameter_overrides.cpp - src/live2d/cubism_cover_capture.cpp src/live2d/cubism_motion_playback.cpp src/live2d/cubism_motion_preview.cpp src/live2d/cubism_motion_selection.cpp diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 649c87c0..24e02302 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -76,6 +76,7 @@ set(BONGO_CAT_RUNTIME_MODEL_SOURCES src/runtime/model/model_catalog_remove.c src/runtime/model/model_catalog_refresh.c src/runtime/model/model_catalog_selection.c + src/runtime/model/model_cover.c src/runtime/model/model_files.c src/runtime/model/model_geometry.c src/runtime/model/multi_pet.c diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index f9836964..03c83f43 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -18,6 +18,8 @@ typedef struct BongoCatMultiPetRuntime BongoCatMultiPetRuntime; typedef struct BongoCatModelRefresh BongoCatModelRefresh; typedef struct BongoCatUpdateService BongoCatUpdateService; +#define BONGO_CAT_MODEL_COVER_PENDING_CAP 8 + typedef struct BongoCatApp { BongoCatSettings settings; BongoCatSessionState session; @@ -67,7 +69,9 @@ typedef struct BongoCatApp { char smoke_runtime_model[BONGO_CAT_ID_CAP]; char smoke_live2d_scenario[BONGO_CAT_ID_CAP]; char smoke_viewer_trace[BONGO_CAT_PATH_CAP]; - char pending_model_cover_path[BONGO_CAT_PATH_CAP]; + char pending_model_cover_ids[BONGO_CAT_MODEL_COVER_PENDING_CAP][BONGO_CAT_ID_CAP]; + char pending_model_cover_paths[BONGO_CAT_MODEL_COVER_PENDING_CAP][BONGO_CAT_PATH_CAP]; + size_t pending_model_cover_count; char loaded_model[BONGO_CAT_ID_CAP]; char loading_model[BONGO_CAT_ID_CAP]; BongoCatModelMode loaded_mode; diff --git a/include/bongo_cat/model.h b/include/bongo_cat/model.h index 4dac002d..c570dfbc 100644 --- a/include/bongo_cat/model.h +++ b/include/bongo_cat/model.h @@ -127,8 +127,6 @@ typedef struct BongoCatLive2DRenderOptions { } BongoCatLive2DRenderOptions; BongoCatLive2D *bongo_cat_live2d_create(const char *asset_root, BongoCatError *error); -BongoCatLive2D *bongo_cat_live2d_create_cover_runtime( - const char *asset_root, BongoCatError *error); void bongo_cat_live2d_destroy(BongoCatLive2D *live2d); BongoCatResult bongo_cat_live2d_load(BongoCatLive2D *live2d, const char *model_dir, const char *setting_file, bool preset, @@ -176,8 +174,6 @@ bool bongo_cat_live2d_motion_same_toggle(const BongoCatLive2D *live2d, const char *right_group, int right_index); bool bongo_cat_live2d_set_expression(BongoCatLive2D *live2d, int index); int bongo_cat_live2d_expression(const BongoCatLive2D *live2d); -/* Accepts only a runtime created by bongo_cat_live2d_create_cover_runtime. */ -bool bongo_cat_live2d_prepare_cover(BongoCatLive2D *live2d); bool bongo_cat_live2d_visual_state(const BongoCatLive2D *live2d, BongoCatLive2DVisualState *state); diff --git a/src/live2d/cubism_bridge.cpp b/src/live2d/cubism_bridge.cpp index 705167c9..65c26e2c 100644 --- a/src/live2d/cubism_bridge.cpp +++ b/src/live2d/cubism_bridge.cpp @@ -118,26 +118,22 @@ void stop_framework() { } // namespace static BongoCatLive2D *create_runtime(const char *asset_root, - bool cover_runtime, BongoCatError *error) { + BongoCatError *error) { resource_root = asset_root ? asset_root : ""; if (!start_framework(error)) return nullptr; BongoCatLive2D *runtime = new(std::nothrow) BongoCatLive2D{}; if (!runtime) { stop_framework(); bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, "Cannot allocate Cubism runtime"); - } else runtime->cover_runtime = cover_runtime; + } return runtime; } extern "C" BongoCatLive2D *bongo_cat_live2d_create(const char *asset_root, BongoCatError *error) { - return create_runtime(asset_root, false, error); + return create_runtime(asset_root, error); } -extern "C" BongoCatLive2D *bongo_cat_live2d_create_cover_runtime( - const char *asset_root, BongoCatError *error) { - return create_runtime(asset_root, true, error); } - extern "C" void bongo_cat_live2d_destroy(BongoCatLive2D *runtime) { if (!runtime) return; if (runtime->retired_count) SDL_Log("[runtime] Live2D resource handoff: " @@ -260,7 +256,3 @@ extern "C" bool bongo_cat_live2d_set_expression(BongoCatLive2D *runtime, int ind return runtime && runtime->model && runtime->model->set_expression(index); } extern "C" int bongo_cat_live2d_expression(const BongoCatLive2D *runtime) { return runtime && runtime->model ? runtime->model->expression() : -1; } -extern "C" bool bongo_cat_live2d_prepare_cover(BongoCatLive2D *runtime) { - try { return runtime && runtime->cover_runtime && runtime->model && - runtime->model->prepare_cover(); } catch (...) { return false; } -} diff --git a/src/live2d/cubism_bridge_load.cpp b/src/live2d/cubism_bridge_load.cpp index b5f5967f..1df2ea95 100644 --- a/src/live2d/cubism_bridge_load.cpp +++ b/src/live2d/cubism_bridge_load.cpp @@ -54,8 +54,8 @@ extern "C" BongoCatResult bongo_cat_live2d_load(BongoCatLive2D *runtime, } GLenum ready_error = glGetError(); SDL_Log("[runtime] Live2D resource handoff: stage=replacement-ready " - "cover=%d new_textures=%zu previous_textures=%zu current_window=%p " - "current_context=%p gl_error=0x%x", runtime->cover_runtime, + "new_textures=%zu previous_textures=%zu current_window=%p " + "current_context=%p gl_error=0x%x", model->texture_count(), previous ? previous->texture_count() : 0, (void *)SDL_GL_GetCurrentWindow(), (void *)SDL_GL_GetCurrentContext(), (unsigned)ready_error); @@ -63,14 +63,14 @@ extern "C" BongoCatResult bongo_cat_live2d_load(BongoCatLive2D *runtime, runtime->model = model; retire_previous(runtime, previous); SDL_Log("[runtime] Live2D resource handoff: stage=retirement-queued " - "cover=%d previous=%d previous_textures=%zu queue=%u frames=%u", - runtime->cover_runtime, previous != nullptr, + "previous=%d previous_textures=%zu queue=%u frames=%u", + previous != nullptr, previous ? previous->texture_count() : 0, runtime->retired_count, previous ? 3u : 0u); GLenum retired_error = glGetError(); - SDL_Log("[runtime] Live2D resource handoff: stage=complete cover=%d " + SDL_Log("[runtime] Live2D resource handoff: stage=complete " "new_textures=%zu current_window=%p current_context=%p gl_error=0x%x", - runtime->cover_runtime, model->texture_count(), + model->texture_count(), (void *)SDL_GL_GetCurrentWindow(), (void *)SDL_GL_GetCurrentContext(), (unsigned)retired_error); return BONGO_CAT_OK; diff --git a/src/live2d/cubism_cover_capture.cpp b/src/live2d/cubism_cover_capture.cpp deleted file mode 100644 index f755e882..00000000 --- a/src/live2d/cubism_cover_capture.cpp +++ /dev/null @@ -1,27 +0,0 @@ -#include "cubism_model.hpp" - -namespace bongo_cat { - -bool NativeModel::prepare_cover() { - if (!_model) return false; - int parameter_count = _model->GetParameterCount(); - int part_count = _model->GetPartCount(); - for (const auto &item : motion_states_) { - auto owner = motion_toggle_owners_.find(item.first); - if ((owner != motion_toggle_owners_.end() && - owner->second != item.first) || - !motion_is_persistent(item.first)) continue; - for (const MotionStateCurve &curve : item.second.curves) { - if (curve.parameter >= 0 && curve.parameter < parameter_count) - _model->SetParameterValue(curve.parameter, curve.end); - else if (curve.part >= 0 && curve.part < part_count) - _model->SetPartOpacity(curve.part, curve.end); - else if (curve.model_opacity) - _model->SetModelOpacity(curve.end); - } - } - _model->Update(); - return true; -} - -} // namespace bongo_cat diff --git a/src/live2d/cubism_model.hpp b/src/live2d/cubism_model.hpp index de57bbed..975e188b 100644 --- a/src/live2d/cubism_model.hpp +++ b/src/live2d/cubism_model.hpp @@ -58,7 +58,6 @@ class NativeModel final : public Csm::CubismUserModel { const char *right_group, int right_index) const; bool set_expression(int index); int expression() const { return expression_index_; } - bool prepare_cover(); bool visual_state(BongoCatLive2DVisualState *state) const; public: diff --git a/src/live2d/cubism_runtime.hpp b/src/live2d/cubism_runtime.hpp index 9f9eed70..bb8080cf 100644 --- a/src/live2d/cubism_runtime.hpp +++ b/src/live2d/cubism_runtime.hpp @@ -15,7 +15,6 @@ struct BongoCatLive2D { unsigned retired_count = 0; int width = 612; int height = 354; - bool cover_runtime = false; }; #endif diff --git a/src/live2d/live2d_stub.c b/src/live2d/live2d_stub.c index 01099a4d..96827140 100644 --- a/src/live2d/live2d_stub.c +++ b/src/live2d/live2d_stub.c @@ -8,14 +8,13 @@ struct BongoCatLive2D { int width; int height; bool loaded; - bool cover_runtime; }; static BongoCatLive2D *create_runtime(const char *asset_root, - bool cover_runtime, BongoCatError *error) { + BongoCatError *error) { static bool warning_logged; (void)asset_root; - if (!cover_runtime && !warning_logged) { + if (!warning_logged) { warning_logged = true; SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Cubism SDK unavailable: diagnostic backend active; Live2D " @@ -24,18 +23,12 @@ static BongoCatLive2D *create_runtime(const char *asset_root, } BongoCatLive2D *value = calloc(1, sizeof(*value)); if (!value) bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, "Cannot allocate Live2D runtime"); - else value->cover_runtime = cover_runtime; return value; } BongoCatLive2D *bongo_cat_live2d_create(const char *asset_root, BongoCatError *error) { - return create_runtime(asset_root, false, error); -} - -BongoCatLive2D *bongo_cat_live2d_create_cover_runtime( - const char *asset_root, BongoCatError *error) { - return create_runtime(asset_root, true, error); + return create_runtime(asset_root, error); } void bongo_cat_live2d_destroy(BongoCatLive2D *live2d) { free(live2d); } @@ -155,10 +148,6 @@ bool bongo_cat_live2d_set_expression(BongoCatLive2D *value, int index) { int bongo_cat_live2d_expression(const BongoCatLive2D *value) { (void)value; return -1; } -bool bongo_cat_live2d_prepare_cover(BongoCatLive2D *value) { - if (!value || !value->cover_runtime) return false; - return false; -} bool bongo_cat_live2d_visual_state(const BongoCatLive2D *value, BongoCatLive2DVisualState *state) { (void)value; (void)state; return false; diff --git a/src/runtime/diagnostics/frame_audit.c b/src/runtime/diagnostics/frame_audit.c index 0fc01884..917dd775 100644 --- a/src/runtime/diagnostics/frame_audit.c +++ b/src/runtime/diagnostics/frame_audit.c @@ -6,41 +6,6 @@ #include #include #include -#include - -typedef struct CoverWriter { FILE *file; bool ok; } CoverWriter; - -static void cover_write(void *context, void *data, int size) { - CoverWriter *writer = context; - if (writer->ok && fwrite(data, 1, (size_t)size, writer->file) != (size_t)size) - writer->ok = false; -} - -void bongo_cat_frame_capture_pending(BongoCatApp *app, int width, int height) { - if (!app || !app->pending_model_cover_path[0]) return; - char path[BONGO_CAT_PATH_CAP]; - snprintf(path, sizeof(path), "%s", app->pending_model_cover_path); - app->pending_model_cover_path[0] = '\0'; - if (width < 2 || height < 2 || width > 2048 || height > 2048) return; - size_t pitch = (size_t)width * 4, bytes = pitch * (size_t)height; - unsigned char *pixels = malloc(bytes); - if (!pixels) return; - glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels); - for (int y = 0; y < height / 2; ++y) { - unsigned char *top = pixels + (size_t)y * pitch; - unsigned char *bottom = pixels + (size_t)(height - y - 1) * pitch; - for (size_t i = 0; i < pitch; ++i) { - unsigned char value = top[i]; top[i] = bottom[i]; bottom[i] = value; - } - } - FILE *file = bongo_cat_file_open(path, "wb"); - CoverWriter writer = {file, file != NULL}; - bool ok = file && stbi_write_png_to_func(cover_write, &writer, - width, height, 4, pixels, (int)pitch) && writer.ok; - if (file && fclose(file) != 0) ok = false; - if (!ok) bongo_cat_file_remove(path); - free(pixels); -} typedef struct FrameStats { unsigned visible; diff --git a/src/runtime/lifecycle/app_loop.c b/src/runtime/lifecycle/app_loop.c index c7679e76..4386bb01 100644 --- a/src/runtime/lifecycle/app_loop.c +++ b/src/runtime/lifecycle/app_loop.c @@ -1,4 +1,5 @@ #include "runtime.h" +#include "model_cover.h" #include "bongo_cat/overlay.h" #include "bongo_cat/preferences.h" #include "bongo_cat/tray.h" @@ -63,7 +64,7 @@ static bool render(BongoCatApp *app, bool present) { bongo_cat_overlay_draw_effect(app->overlay, app->settings.model.mirror); bongo_cat_overlay_draw_pointer_after_keys(app->overlay); glViewport(0, 0, width, height); - bongo_cat_frame_capture_pending(app, width, height); + bongo_cat_model_cover_capture(app, width, height); if (!present) { app->dirty = true; return true; @@ -92,9 +93,16 @@ void bongo_cat_app_render_now(BongoCatApp *app) { render(app, true); } -bool bongo_cat_app_capture_pending_frame(BongoCatApp *app) { - return app && app->window && app->pending_model_cover_path[0] && - !app->window_minimized && render(app, false); +bool bongo_cat_app_capture_pending_model_cover(BongoCatApp *app) { + if (!app || !app->window || !bongo_cat_model_cover_pending(app)) return false; + if (app->window_minimized) { + SDL_Log("Pending model cover deferred while window is minimized: %s", + bongo_cat_model_cover_pending_path(app)); + return false; + } + SDL_Log("Capturing pending model cover: visible=%d path=%s", + app->session.window.visible, bongo_cat_model_cover_pending_path(app)); + return render(app, false); } static void take_instance_wake(BongoCatApp *app) { @@ -148,6 +156,7 @@ void bongo_cat_app_loop(BongoCatApp *app) { app->last_frame_ns = now; bongo_cat_memory_policy_idle(); } + bongo_cat_app_capture_pending_model_cover(app); if (app->session.window.visible && !app->window_minimized && app->dirty) render(app, true); bongo_cat_preferences_render(app->preferences); diff --git a/src/runtime/model/import/model_import_adapter.c b/src/runtime/model/import/model_import_adapter.c index f04695df..e10e56cc 100644 --- a/src/runtime/model/import/model_import_adapter.c +++ b/src/runtime/model/import/model_import_adapter.c @@ -1,4 +1,5 @@ #include "model_import.h" +#include "../model_cover_paths.h" #include "mver/model_import_mver.h" #include "model_storage.h" #include "runtime.h" @@ -49,11 +50,21 @@ static bool preview_file_exists(const char *directory, const char *name) { static void mark_preview_fallback(const char *resources) { char path[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(path, sizeof(path), resources, - ".bongo-cat-cover-fallback")) return; + BONGO_CAT_MODEL_COVER_FALLBACK_NAME)) return; FILE *file = bongo_cat_file_open(path, "wb"); if (file) fclose(file); } +static void clear_preview_markers(const char *resources) { + char path[BONGO_CAT_PATH_CAP]; + if (bongo_cat_path_join(path, sizeof(path), resources, + BONGO_CAT_MODEL_COVER_FALLBACK_NAME)) + bongo_cat_file_remove(path); + if (bongo_cat_path_join(path, sizeof(path), resources, + BONGO_CAT_MODEL_COVER_GENERATED_NAME)) + bongo_cat_file_remove(path); +} + static bool copy_preview(const BongoCatImportCandidate *candidate, const char *target, BongoCatError *error) { char source_resources[BONGO_CAT_PATH_CAP], target_resources[BONGO_CAT_PATH_CAP]; @@ -81,8 +92,8 @@ static bool copy_preview(const BongoCatImportCandidate *candidate, (preview_file_exists(target_resources, "background.png") || copy_preview_file(source_resources, candidate->assets, backgrounds, 4, target_resources, "background.png")); - if (ok && !authored_cover && preview_file_exists(target_resources, - "cover.png")) mark_preview_fallback(target_resources); + if (ok && !authored_cover) mark_preview_fallback(target_resources); + else if (ok) clear_preview_markers(target_resources); if (!ok) bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, "Cannot copy model preview assets: %s", SDL_GetError()); return ok; diff --git a/src/runtime/model/model_cover.c b/src/runtime/model/model_cover.c new file mode 100644 index 00000000..6f1e8098 --- /dev/null +++ b/src/runtime/model/model_cover.c @@ -0,0 +1,232 @@ +#include "model_cover.h" + +#include "preferences_model_cover.h" +#include "runtime.h" +#include "bongo_cat/file.h" +#include "bongo_cat/path.h" + +#include +#include +#include +#include +#include +#include + +typedef struct CoverWriter { + FILE *file; + bool ok; +} CoverWriter; + +static bool cover_path(char *path, size_t capacity, + const char *adapter, const char *relative) { + return adapter && adapter[0] && bongo_cat_path_join(path, capacity, + adapter, relative); +} + +static bool marker_exists(const char *adapter, const char *relative) { + char path[BONGO_CAT_PATH_CAP]; + return cover_path(path, sizeof(path), adapter, relative) && + bongo_cat_path_is_file(path); +} + +static bool write_marker(const char *adapter, const char *relative) { + char path[BONGO_CAT_PATH_CAP]; + if (!cover_path(path, sizeof(path), adapter, relative)) return false; + FILE *file = bongo_cat_file_open(path, "wb"); + if (!file) return false; + return fclose(file) == 0; +} + +static void remove_marker(const char *adapter, const char *relative) { + char path[BONGO_CAT_PATH_CAP]; + if (cover_path(path, sizeof(path), adapter, relative)) + bongo_cat_file_remove(path); +} + +static bool task_matches_loaded(const BongoCatApp *app, size_t index) { + return app && index < app->pending_model_cover_count && + app->loaded_model[0] && + strcmp(app->pending_model_cover_ids[index], app->loaded_model) == 0; +} + +static void discard_stale_tasks(BongoCatApp *app) { + if (!app) return; + size_t first = 0; + while (first < app->pending_model_cover_count && + !task_matches_loaded(app, first)) { + SDL_Log("Discarding stale model cover task: model=%s current=%s", + app->pending_model_cover_ids[first], app->loaded_model[0] ? + app->loaded_model : "none"); + first++; + } + if (!first) return; + size_t remaining = app->pending_model_cover_count - first; + memmove(app->pending_model_cover_ids, + app->pending_model_cover_ids + first, + remaining * sizeof(app->pending_model_cover_ids[0])); + memmove(app->pending_model_cover_paths, + app->pending_model_cover_paths + first, + remaining * sizeof(app->pending_model_cover_paths[0])); + app->pending_model_cover_count = remaining; +} + +static void remove_first_task(BongoCatApp *app) { + if (!app || !app->pending_model_cover_count) return; + size_t remaining = --app->pending_model_cover_count; + if (remaining) { + memmove(app->pending_model_cover_ids, + app->pending_model_cover_ids + 1, + remaining * sizeof(app->pending_model_cover_ids[0])); + memmove(app->pending_model_cover_paths, + app->pending_model_cover_paths + 1, + remaining * sizeof(app->pending_model_cover_paths[0])); + } + app->pending_model_cover_ids[app->pending_model_cover_count][0] = '\0'; + app->pending_model_cover_paths[app->pending_model_cover_count][0] = '\0'; +} + +void bongo_cat_model_cover_schedule(BongoCatApp *app, + const BongoCatModelEntry *entry) { + if (!app || !entry || !entry->adapter_directory[0]) return; + bool fallback = marker_exists(entry->adapter_directory, + BONGO_CAT_MODEL_COVER_FALLBACK_MARKER); + bool generated = marker_exists(entry->adapter_directory, + BONGO_CAT_MODEL_COVER_GENERATED_MARKER); + if (!fallback && !generated) return; + char path[BONGO_CAT_PATH_CAP]; + if (!cover_path(path, sizeof(path), entry->adapter_directory, + BONGO_CAT_MODEL_COVER_FILE)) return; + for (size_t i = 0; i < app->pending_model_cover_count; ++i) + if (strcmp(app->pending_model_cover_ids[i], entry->id) == 0 || + strcmp(app->pending_model_cover_paths[i], path) == 0) return; + if (app->pending_model_cover_count >= BONGO_CAT_MODEL_COVER_PENDING_CAP) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Model cover queue is full; dropping refresh: id=%s", entry->id); + return; + } + size_t index = app->pending_model_cover_count++; + snprintf(app->pending_model_cover_ids[index], + sizeof(app->pending_model_cover_ids[index]), "%s", entry->id); + snprintf(app->pending_model_cover_paths[index], + sizeof(app->pending_model_cover_paths[index]), "%s", path); + SDL_Log("Scheduling model cover refresh: id=%s source=%s path=%s", + entry->id, generated ? "generated" : "fallback", path); + app->dirty = true; +} + +bool bongo_cat_model_cover_pending(const BongoCatApp *app) { + if (!app || !app->loaded_model[0]) return false; + for (size_t i = 0; i < app->pending_model_cover_count; ++i) + if (strcmp(app->pending_model_cover_ids[i], app->loaded_model) == 0) + return true; + return false; +} + +const char *bongo_cat_model_cover_pending_path(const BongoCatApp *app) { + if (!app || !app->loaded_model[0]) return NULL; + for (size_t i = 0; i < app->pending_model_cover_count; ++i) + if (strcmp(app->pending_model_cover_ids[i], app->loaded_model) == 0) + return app->pending_model_cover_paths[i]; + return NULL; +} + +void bongo_cat_model_cover_capture_before_switch(BongoCatApp *app) { + if (!app || !app->loaded_model[0]) return; + const BongoCatModelEntry *entry = bongo_cat_models_find(&app->models, + app->loaded_model); + if (!entry) return; + bongo_cat_model_cover_schedule(app, entry); + if (!bongo_cat_model_cover_pending(app)) return; + if (!bongo_cat_app_capture_pending_model_cover(app)) + SDL_Log("Model cover refresh before switch deferred: id=%s", + entry->id); +} + +static void cover_write(void *context, void *data, int size) { + CoverWriter *writer = context; + if (writer->ok && fwrite(data, 1, (size_t)size, writer->file) != + (size_t)size) + writer->ok = false; +} + +void bongo_cat_model_cover_capture(BongoCatApp *app, int width, int height) { + if (!app || !app->pending_model_cover_count) return; + discard_stale_tasks(app); + if (!app->pending_model_cover_count) return; + uint64_t started_ns = SDL_GetTicksNS(); + char path[BONGO_CAT_PATH_CAP]; + snprintf(path, sizeof(path), "%s", app->pending_model_cover_paths[0]); + remove_first_task(app); + /* Keep the desktop's native pixel size while bounding readback memory. */ + if (width < 2 || height < 2 || width > 4096 || height > 4096) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Model cover refresh skipped for unsupported size %dx%d: %s", + width, height, path); + return; + } + size_t pitch = (size_t)width * 4; + size_t bytes = pitch * (size_t)height; + if (pitch / 4 != (size_t)width || bytes / pitch != (size_t)height) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Model cover refresh skipped due to size overflow: %s", path); + return; + } + unsigned char *pixels = malloc(bytes); + if (!pixels) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Model cover refresh allocation failed for %zu bytes: %s", + bytes, path); + return; + } + GLenum before = GL_NO_ERROR; + GLenum stale; + while ((stale = glGetError()) != GL_NO_ERROR) before = stale; + glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels); + GLenum after = glGetError(); + if (after != GL_NO_ERROR) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Model cover framebuffer readback failed: path=%s " + "gl_error_before=0x%x gl_error_after=0x%x", path, before, after); + free(pixels); + return; + } + for (int y = 0; y < height / 2; ++y) { + unsigned char *top = pixels + (size_t)y * pitch; + unsigned char *bottom = pixels + (size_t)(height - y - 1) * pitch; + for (size_t i = 0; i < pitch; ++i) { + unsigned char value = top[i]; + top[i] = bottom[i]; + bottom[i] = value; + } + } + FILE *file = bongo_cat_file_open(path, "wb"); + CoverWriter writer = {file, file != NULL}; + bool ok = file && stbi_write_png_to_func(cover_write, &writer, + width, height, 4, pixels, (int)pitch) && writer.ok; + if (file && fclose(file) != 0) ok = false; + if (!ok) { + bongo_cat_file_remove(path); + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Model cover could not be written: %s", path); + free(pixels); + return; + } + char adapter[BONGO_CAT_PATH_CAP]; + snprintf(adapter, sizeof(adapter), "%s", path); + char *separator = strrchr(adapter, '/'); + if (!separator) separator = strrchr(adapter, '\\'); + if (separator) *separator = '\0'; + bool marked_generated = write_marker(adapter, + BONGO_CAT_MODEL_COVER_GENERATED_NAME); + if (marked_generated) + remove_marker(adapter, BONGO_CAT_MODEL_COVER_FALLBACK_NAME); + else + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Model cover written but generated marker could not be created: %s", + adapter); + if (app->preferences) + bongo_cat_preferences_model_cover_reload(app, path); + SDL_Log("Model cover refreshed: path=%s (%dx%d) elapsed_ms=%.2f", path, + width, height, (double)(SDL_GetTicksNS() - started_ns) / 1000000.0); + free(pixels); +} diff --git a/src/runtime/model/model_cover.h b/src/runtime/model/model_cover.h new file mode 100644 index 00000000..27bd93d1 --- /dev/null +++ b/src/runtime/model/model_cover.h @@ -0,0 +1,14 @@ +#ifndef BONGO_CAT_MODEL_COVER_H +#define BONGO_CAT_MODEL_COVER_H + +#include "bongo_cat/app.h" +#include "model_cover_paths.h" + +void bongo_cat_model_cover_schedule(BongoCatApp *app, + const BongoCatModelEntry *entry); +bool bongo_cat_model_cover_pending(const BongoCatApp *app); +const char *bongo_cat_model_cover_pending_path(const BongoCatApp *app); +void bongo_cat_model_cover_capture_before_switch(BongoCatApp *app); +void bongo_cat_model_cover_capture(BongoCatApp *app, int width, int height); + +#endif diff --git a/src/runtime/model/model_cover_paths.h b/src/runtime/model/model_cover_paths.h new file mode 100644 index 00000000..82953020 --- /dev/null +++ b/src/runtime/model/model_cover_paths.h @@ -0,0 +1,13 @@ +#ifndef BONGO_CAT_MODEL_COVER_PATHS_H +#define BONGO_CAT_MODEL_COVER_PATHS_H + +#define BONGO_CAT_MODEL_COVER_RESOURCES "resources" +#define BONGO_CAT_MODEL_COVER_FILE BONGO_CAT_MODEL_COVER_RESOURCES "/cover.png" +#define BONGO_CAT_MODEL_COVER_FALLBACK_NAME ".bongo-cat-cover-fallback" +#define BONGO_CAT_MODEL_COVER_GENERATED_NAME ".bongo-cat-cover-generated" +#define BONGO_CAT_MODEL_COVER_FALLBACK_MARKER \ + BONGO_CAT_MODEL_COVER_RESOURCES "/" BONGO_CAT_MODEL_COVER_FALLBACK_NAME +#define BONGO_CAT_MODEL_COVER_GENERATED_MARKER \ + BONGO_CAT_MODEL_COVER_RESOURCES "/" BONGO_CAT_MODEL_COVER_GENERATED_NAME + +#endif diff --git a/src/runtime/model/model_files.c b/src/runtime/model/model_files.c index c9de785c..c1732b2a 100644 --- a/src/runtime/model/model_files.c +++ b/src/runtime/model/model_files.c @@ -1,6 +1,7 @@ #include "runtime.h" #include "model_import.h" #include "model_behavior_cache.h" +#include "model_cover.h" #include "model_geometry.h" #include "bongo_cat/audio.h" #include "bongo_cat/overlay.h" @@ -121,6 +122,8 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, } bongo_cat_app_capture_behavior_state(app); bool replacing_model = app->loaded_model[0] != '\0'; + if (replacing_model) + bongo_cat_model_cover_capture_before_switch(app); BongoCatError optional = {0}; BongoCatBehaviorCatalog *behaviors = calloc(1, sizeof(*behaviors)); if (!behaviors) { @@ -240,6 +243,7 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, commit_model(app, entry, true, replacing_model); bongo_cat_app_reapply_input(app); bongo_cat_app_apply_mouse(app); + bongo_cat_model_cover_schedule(app, entry); /* Do not leave the previous frame cropped during the UI completion pass. */ if (replacing_model) bongo_cat_app_render_now(app); if (restore_context && previous_window && previous_context && diff --git a/src/runtime/runtime.h b/src/runtime/runtime.h index d27f94ef..67ec432b 100644 --- a/src/runtime/runtime.h +++ b/src/runtime/runtime.h @@ -110,9 +110,8 @@ void bongo_cat_frame_presentation_prepare(BongoCatApp *app, const unsigned char *pixels, int width, int height, bool visible); void bongo_cat_frame_presented_audit(BongoCatApp *app); void bongo_cat_window_clear_background(BongoCatApp *app); -void bongo_cat_frame_capture_pending(BongoCatApp *app, int width, int height); void bongo_cat_app_render_now(BongoCatApp *app); -bool bongo_cat_app_capture_pending_frame(BongoCatApp *app); +bool bongo_cat_app_capture_pending_model_cover(BongoCatApp *app); void bongo_cat_runtime_flow_update(BongoCatApp *app, uint64_t now); void bongo_cat_random_expression_update(BongoCatApp *app, uint64_t now); void bongo_cat_random_expression_reset(BongoCatApp *app); diff --git a/src/ui/preferences/preferences_model_cover.c b/src/ui/preferences/preferences_model_cover.c index e7e40759..4680d456 100644 --- a/src/ui/preferences/preferences_model_cover.c +++ b/src/ui/preferences/preferences_model_cover.c @@ -31,6 +31,36 @@ void bongo_cat_preferences_model_cover_cache_clear(BongoCatApp *app) { } } +bool bongo_cat_preferences_model_cover_reload(BongoCatApp *app, + const char *path) { + if (!app || !path || !path[0]) return false; + for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) { + ModelCoverSlot *slot = &cover_cache[i]; + if (!slot->image.texture || slot->app != app || + strcmp(slot->path, path) != 0) continue; + int width = 0, height = 0; + BongoCatError ignored = {0}; + unsigned int texture = bongo_cat_image_texture_thumbnail(path, + slot->requested_width, slot->requested_height, &width, &height, + &ignored); + if (!texture) { + SDL_LogWarn(SDL_LOG_CATEGORY_RENDER, + "Model cover cache reload failed: %s", path); + return false; + } + unsigned int previous = slot->image.texture; + slot->image.texture = texture; + slot->image.width = width; + slot->image.height = height; + slot->generation = cover_generation; + glDeleteTextures(1, &previous); + SDL_Log("Model cover cache reloaded: %s (%dx%d)", path, + width, height); + return true; + } + return true; +} + void bongo_cat_preferences_model_cache_clear(BongoCatApp *app) { bongo_cat_preferences_remove_dialog_clear(app); bongo_cat_preferences_model_cover_cache_clear(app); @@ -56,7 +86,16 @@ const BongoCatModelCover *bongo_cat_preferences_model_cover( int pixel_width, int pixel_height) { char path[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(path, sizeof(path), entry->adapter_directory, - "resources/cover.png") || !bongo_cat_path_is_file(path)) return NULL; + "resources/cover.png")) return NULL; + if (!bongo_cat_path_is_file(path)) { + static char last_missing[BONGO_CAT_PATH_CAP]; + if (strcmp(last_missing, path)) { + snprintf(last_missing, sizeof(last_missing), "%s", path); + SDL_Log("Model cover unavailable: id=%s path=%s", + entry->id, path); + } + return NULL; + } ModelCoverSlot *empty = NULL; for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) { ModelCoverSlot *slot = &cover_cache[i]; diff --git a/src/ui/preferences/preferences_model_cover.h b/src/ui/preferences/preferences_model_cover.h index f4515cfd..c035901d 100644 --- a/src/ui/preferences/preferences_model_cover.h +++ b/src/ui/preferences/preferences_model_cover.h @@ -13,6 +13,8 @@ typedef struct BongoCatModelCover { void bongo_cat_preferences_model_covers_begin(BongoCatApp *app); void bongo_cat_preferences_model_cover_cache_clear(BongoCatApp *app); +bool bongo_cat_preferences_model_cover_reload(BongoCatApp *app, + const char *path); void bongo_cat_preferences_model_cache_abandon(BongoCatApp *app); const BongoCatModelCover *bongo_cat_preferences_model_cover( BongoCatApp *app, const BongoCatModelEntry *entry, From 0ff6d106a6fcd178a1b8c6bdaac310e7a93ed5c0 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 1 Sep 2026 00:23:41 +0800 Subject: [PATCH 167/334] =?UTF-8?q?=F0=9F=8C=9F=20chore:=20adjust=20projec?= =?UTF-8?q?t=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 315 +++++++++++++++++------------------------------------- 1 file changed, 100 insertions(+), 215 deletions(-) diff --git a/README.md b/README.md index 22f87803..c74787f4 100644 --- a/README.md +++ b/README.md @@ -63,252 +63,138 @@ only; it does not relicense the BongoCat source code or native runtime. ## Technical Architecture -> The current native version is built on C/C++, SDL3, and OpenGL. The diagram below may look a little complicated, but it’s actually not difficult to understand. +> The current native version is built on C/C++, SDL3, and OpenGL. The diagram below focuses on the runtime data flow; build and packaging details live in CMake. ### Runtime ownership and frame scheduling -The native runtime is intentionally split at ownership boundaries: +Each process owns one `BongoCatApp` and one main-thread event and render loop. +Platform listeners stop at the input boundary: ```text -Platform input listeners +Platform listeners (keyboard / pointer) | v C11 input state - (atomic edge queue + coalesced pointer path) + (atomic edge queue + coalesced pointer position) | v - main-thread application <----- SDL3 events + main-thread application <----- SDL3 events | v model parameters, overlay, and UI state | v - Cubism update -> OpenGL composition -> platform presentation + model update -> OpenGL composition -> platform presentation ``` -Platform input listeners never manipulate a model directly. Windows low-level -hooks, the macOS Quartz event tap, and Linux XInput2 publish timestamped -keyboard and mouse-button transitions to application-owned input state. On -Windows, DirectInput is sampled through the platform pointer interface when an -imported model requests relative movement; SDL3 gamepad events are normalized -on the main thread. Pointer coordinates use a separate atomic coalescing path, -so high-frequency motion does not displace the bounded queue used for key and -button edges. - -Each loop iteration waits for SDL or native wakeups, the next scheduler -deadline, or pending UI/window work. It then drains queued events and release -recovery, applies input-derived parameters and overlay state, and advances the -model with elapsed time. In the normal pet-window path, a frame is presented -when the application is marked dirty; explicit preview, resize, and capture -operations may request additional renders. Presentation is platform-specific: -`SDL_GL_SwapWindow` is used on macOS and Linux, and on Windows when layered -presentation is inactive; active Windows layered presentation uses -`UpdateLayeredWindow`. - -The scheduler does not require a fixed simulation step. With Cubism enabled, -the next model update is derived from the configured maximum FPS (defaulting to -60 FPS), and the runtime receives the elapsed time since the previous update. -Elapsed gaps are capped at 250 ms and subdivided into at most eight substeps -for stability. If an update produces no visual change, the normal pet window -is not presented again unless another state transition requests a render. - -The C runtime calls the C ABI declared in `include/bongo_cat/model.h`, whose -bridge and model implementation live in `src/live2d`. When the Cubism SDK is -enabled, that implementation is C++17 because the SDK exposes a C++ API; the -rest of the native C sources use C11. Cubism SDK types stay behind opaque C -handles and C-compatible structures, while `src/live2d/live2d_stub.c` provides +The Windows low-level hooks, macOS Quartz event tap, and Linux XInput2 listener +run outside the main loop. They publish timestamped key and mouse-button edges +to the bounded atomic queue and publish pointer coordinates through a separate +coalescing slot; a successful publish pushes a native SDL wake event. This +keeps high-frequency motion from displacing ordered key and button edges. On +Windows, DirectInput is used only through the platform pointer interface when a +model requests relative movement. SDL3 window, preferences, and gamepad events +are handled on the main thread, where gamepad events are normalized before they +reach model parameters or shortcuts. No platform listener calls Live2D, +overlay, or UI code directly. + +`bongo_cat_app_run` handles update-shutdown and secondary-process arguments, +enforces single-instance ownership for the primary process, allocates the +application state, runs initialization, enters `bongo_cat_app_loop`, and then +flushes state and destroys resources in a defined order. Initialization loads +configuration and storage paths, locates assets, creates the SDL/OpenGL pet +window, initializes the platform backend, creates the Live2D, overlay, and +audio services, scans the built-in/installed/nearby model sources, and loads a +usable model. `BongoCatApp` owns settings, session state, model and behavior +catalogs, platform handles, and runtime service handles. + +Installed model packages use Mver as the canonical format. The import workflow +resolves a selected file or directory, discovers and validates candidates, +fingerprints package identity, converts Tauri sources to Mver, applies image +patches, and commits the normalized package under `models_root`. It then +generates the runtime adapter and refreshes the catalogs. Nearby sources are +discovered without installing their source tree; their adapters and inspection +results are cached outside `models_root` under `cache_root`. + +Each main-loop iteration waits for SDL/native wakeups or the earliest pending +frame, UI, animation, or pointer-hit deadline (with a maximum wait of 250 ms). +It dispatches queued SDL events, drains the atomic input queue and release +recovery, updates window and model-refresh state, and applies input-derived +parameters. With Cubism enabled, the model deadline follows +`settings.model.max_fps` (60 FPS by default); diagnostic builds use a 100 ms +fallback interval. The elapsed model time is capped at 250 ms and split into up +to eight substeps, targeting no more than 1/30 s per substep. + +The normal pet path renders only when the window is visible, not minimized, and +marked dirty. A frame clears the background, draws the model, and composites +pointer, key, and effect overlays before calling the platform presenter. +Preview operations can request immediate renders, while capture renders may +skip presentation. macOS and Linux swap the SDL OpenGL window directly. +Windows swaps directly when layered presentation is inactive and otherwise +reads back the frame for `UpdateLayeredWindow`. The preferences UI owns a +separate SDL/OpenGL window and is rendered and presented independently from +the pet window. + +The C runtime calls the ABI declared in `include/bongo_cat/model.h`. The Live2D +bridge and Cubism implementation live in `src/live2d` and use C++17 only when +the Cubism SDK is enabled; the rest of the native runtime uses C11. Cubism +types remain behind opaque C handles, while `src/live2d/live2d_stub.c` provides the diagnostic backend when the SDK is unavailable. ```mermaid -%%{init: {"flowchart": {"curve": "catmullRom", "htmlLabels": true, "nodeSpacing": 24, "rankSpacing": 38}}}%% flowchart TB - User(["User Input
Keyboard / Mouse / Gamepad"]) - ModelSources(["External Model Sources
Standalone .model3.json / Mver package / Mver image patch"]) - Desktop(["Desktop Output
Transparent pet window / Preferences window"]) + Input(["Keyboard / mouse / gamepad"]) + BuiltIn(["Built-in model assets"]) + Sources(["External model sources
Mver, Tauri, .model3.json, image patches"]) + Desktop(["Pet window and preferences window"]) - subgraph Platform["Platform Backends"] - direction LR - Win["Windows
Win32 hooks / DirectInput / layered-window support"] - Mac["macOS
Cocoa / ApplicationServices / Quartz event tap"] - Linux["Linux
X11 / XInput2 / XFixes"] - end - - subgraph Native["BongoCat Native Runtime"] + subgraph Runtime["BongoCat native runtime"] direction TB - - subgraph Orchestration["Runtime Orchestration / C11"] - direction LR - Entry["Process Entry
src/main.c calls bongo_cat_app_run"] - Lifecycle["Application Lifecycle
startup / storage paths / configuration / shutdown"] - EventLoop["SDL3 Event and Frame Loop
wait / dispatch / drain input / update / render"] - AppState[("BongoCatApp State
settings / session / catalogs / platform and runtime state")] - - Entry --> Lifecycle --> EventLoop - Lifecycle <--> AppState - EventLoop <--> AppState - end - - subgraph InputShell["Input and Desktop Shell"] - direction LR - GlobalInput["Global Keyboard and Pointer Capture
Win32 hooks / Quartz event tap / XInput2"] - InputQueue[("Atomic Input State
ordered edge queue / release-edge recovery / coalesced pointer position")] - Gamepad["SDL3 Gamepad Events
active-device selection / buttons / axes"] - InputMap["Input Dispatch
shortcuts / model parameters / pointer mapping"] - Shell["Desktop Shell
window / tray / context menu / drag and resize / click-through / multi-pet coordination"] - - GlobalInput --> InputQueue --> InputMap - Gamepad --> InputMap - EventLoop --> Shell - EventLoop --> InputMap - InputMap --> AppState - Shell --> AppState - end - - subgraph ModelPipeline["Model Discovery, Adaptation, and Cataloging"] - direction LR - BuiltIn["Built-in Models
resources/assets/models"] - Discover["Source Discovery
standalone model3 / Mver package / image patch"] - Validate["Validation and Identity
manifest references / bounded paths / SHA-256 digest / metadata"] - Adapt["Runtime Adapter Generation
preview assets / input overlays / projection and binding metadata"] - Installed[("Installed Packages
models_root / directly copyable Mver trees")] - Nearby[("Runtime Adapters
generated outside models_root / cache_root/model-adapters")] - Catalog["Model and Behavior Catalogs
models / motions / expressions / sounds / effects"] - - Discover --> Validate --> Adapt - Adapt --> Installed --> Catalog - Adapt --> Nearby --> Catalog - BuiltIn --> Catalog - Catalog --> AppState - end - - subgraph Presentation["Animation, Composition, and UI"] - direction LR - Live2DBridge["C ABI / C++17 Bridge"] - Cubism["Live2D Cubism Runtime
model / motion / expression / physics / pose / parameter overrides"] - Images["Image Pipeline
stb decode and resize / alpha masks and mipmaps / OpenGL texture upload"] - Overlay["Input Overlay Runtime
background / pointer / key images / effects"] - Audio["Audio Playback
miniaudio engine / asynchronous file decoding"] - Composite["OpenGL Frame Composition
clear and background / Live2D model / pointer, keys, and effects"] - Preferences["Nuklear Preferences UI
Catime theme / localization / settings / model management"] - Present["Platform Presentation
Windows: UpdateLayeredWindow or GL swap
macOS and Linux: SDL_GL_SwapWindow"] - - Live2DBridge --> Cubism - Cubism --> Composite --> Present - Images --> Cubism - Images --> Overlay --> Composite - AppState <--> Preferences - end - - EventLoop --> Live2DBridge - EventLoop --> Composite - EventLoop --> Preferences - AppState --> Live2DBridge - AppState --> Composite - InputMap --> Live2DBridge - InputMap --> Overlay - InputMap --> Audio - Catalog --> Live2DBridge - Catalog --> Overlay - Catalog --> Audio - Shell --> Preferences - end - - subgraph Dependencies["Native Dependencies"] - direction LR - SDL["SDL3"] - OpenGL["OpenGL"] - GLEW["GLEW
Cubism OpenGL function loading"] - CubismSDK["Live2D Cubism SDK"] - YYJSON["yyjson"] - STB["stb_image / stb_image_resize2 / stb_image_write"] - Miniaudio["miniaudio"] - Nuklear["Nuklear"] + Entry["src/main.c
bongo_cat_app_run"] + Startup["Startup and initialization
configuration, storage, window, platform"] + Loop["SDL3 main loop
wait, dispatch, update, render"] + Shutdown["Shutdown
flush state, stop services, release resources"] + InputQueue[("Atomic input state
edge queue and coalesced pointer position")] + InputDispatch["Input dispatch
shortcuts, pointer mapping, model parameters"] + State[("BongoCatApp state
settings, session, catalogs, runtime handles")] + Import["Model discovery and import
validate, normalize to Mver, install/cache"] + Catalog[("Model and behavior catalogs")] + Live2D["Live2D C ABI
Cubism SDK or diagnostic stub"] + Overlay["Overlay and audio"] + Preferences["Preferences and desktop shell
Nuklear UI, tray, window actions"] + Compose["OpenGL frame composition"] + Present["Platform presentation"] + + Entry --> Startup --> Loop + Loop --> Shutdown + Loop --> InputDispatch --> State + Loop <--> State + State --> Live2D + State --> Overlay + State <--> Preferences + Loop --> Preferences + Catalog --> State + State --> Compose + Live2D --> Compose + Overlay --> Compose + Compose --> Present + Loop --> Compose end - subgraph Toolchain["Build, Verification, and Distribution"] + subgraph Platform["Platform backends"] direction LR - CMake["CMake
pinned dependencies / platform sources / asset staging and embedding"] - CTest["CTest
core / i18n / UI / app state / model import / motion state / Windows capture"] - Audits["Static and Runtime Checks
line policy / platform API allowlist / smoke and visual audits"] - Packaging["Distribution
Windows ZIP and NSIS / macOS ZIP / Linux TGZ / Microsoft Store MSIX"] - - CMake --> CTest - CMake --> Audits - CMake --> Packaging + Global["Global keyboard / pointer capture
Windows, macOS, Linux"] + SDL["SDL3 events
window and gamepad events"] end - User --> GlobalInput - User --> Gamepad - User --> Shell - ModelSources --> Discover - - Win --> GlobalInput - Mac --> GlobalInput - Linux --> GlobalInput - Win --> Shell - Mac --> Shell - Linux --> Shell - Win --> Present - Mac --> Present - Linux --> Present - + Input --> Global --> InputQueue --> InputDispatch + Input --> SDL --> Loop + BuiltIn --> Catalog + Sources --> Import --> Catalog Present --> Desktop Preferences --> Desktop - - SDL -.-> EventLoop - SDL -.-> Gamepad - SDL -.-> Shell - SDL -.-> Present - OpenGL -.-> Cubism - OpenGL -.-> Composite - OpenGL -.-> Preferences - GLEW -.-> Cubism - CubismSDK -.-> Cubism - YYJSON -.-> Lifecycle - YYJSON -.-> Validate - STB -.-> Images - Miniaudio -.-> Audio - Nuklear -.-> Preferences - - CMake -.-> Entry - CTest -.-> AppState - CTest -.-> Preferences - CTest -.-> Live2DBridge - CTest -.-> Present - Audits -.-> EventLoop - Audits -.-> GlobalInput - Audits -.-> Composite - - classDef external fill:#fff4cc,stroke:#b7791f,color:#3d2b00,stroke-width:1.5px - classDef platform fill:#e8f1ff,stroke:#4a78b8,color:#14263d,stroke-width:1.5px - classDef runtime fill:#ffe8f0,stroke:#d95f8d,color:#3d1725,stroke-width:1.5px - classDef state fill:#f3eaff,stroke:#8a63c7,color:#2c1b46,stroke-width:1.5px - classDef input fill:#e7f8ff,stroke:#3f8eaa,color:#12323d,stroke-width:1.5px - classDef model fill:#eaf8e8,stroke:#57945a,color:#18361a,stroke-width:1.5px - classDef render fill:#f3ecff,stroke:#8064b3,color:#281b40,stroke-width:1.5px - classDef dependency fill:#f4f4f5,stroke:#71717a,color:#27272a,stroke-width:1.2px - classDef tooling fill:#fff0dd,stroke:#c47b2c,color:#42270d,stroke-width:1.5px - - class User,ModelSources,Desktop external - class Win,Mac,Linux platform - class Entry,Lifecycle,EventLoop runtime - class AppState,InputQueue,Installed,Nearby state - class GlobalInput,Gamepad,InputMap,Shell input - class BuiltIn,Discover,Validate,Adapt,Catalog model - class Live2DBridge,Cubism,Images,Overlay,Audio,Composite,Preferences,Present render - class SDL,OpenGL,GLEW,CubismSDK,YYJSON,STB,Miniaudio,Nuklear dependency - class CMake,CTest,Audits,Packaging tooling - - style Native fill:#fffafd,stroke:#d95f8d,stroke-width:2px - style Platform fill:#f8fbff,stroke:#7da1cf,stroke-width:1.5px - style Orchestration fill:#fff7fa,stroke:#ed9eb8,stroke-width:1px - style InputShell fill:#f5fcff,stroke:#86bfd3,stroke-width:1px - style ModelPipeline fill:#f7fcf5,stroke:#91bd8f,stroke-width:1px - style Presentation fill:#faf7ff,stroke:#ad98cf,stroke-width:1px - style Dependencies fill:#fafafa,stroke:#a1a1aa,stroke-width:1px - style Toolchain fill:#fffaf3,stroke:#dda866,stroke-width:1.5px ``` # Here are a few things you might be curious about: @@ -336,4 +222,3 @@ By vladelaina\ Made with ❤️ & ⌨️
- From 122d8f64a6967092c267caeb8677e8ac19a5b621 Mon Sep 17 00:00:00 2001 From: yuanziyi2022 Date: Tue, 1 Sep 2026 01:10:14 +0800 Subject: [PATCH 168/334] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=B8=AD=E6=96=87?= =?UTF-8?q?=E7=89=88README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README-zh_cn.md | 308 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 README-zh_cn.md diff --git a/README-zh_cn.md b/README-zh_cn.md new file mode 100644 index 00000000..9a73fc5b --- /dev/null +++ b/README-zh_cn.md @@ -0,0 +1,308 @@ + + + + + + + + + +

+ 💘C/C++ × SDL3 × OpenGL, 搅匀,捣碎!嘣~ Bongo Cat!!! +

+ + +
+ +
+ + +> [!TIP] +> 本演示使用的模型来自[宇痕冫](https://space.bilibili.com/348616056). +> +> 🎁寻找**免费**模型?访问我们的官网: [bongocat.pet](https://bongocat.pet/models) + + +

+ +

+ +## 📥 下载 + + + + + +- GitHub Releases + + 从 [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest) 下载最新版本。 + +## 项目状态 + + +## 许可证 + +BongoCat 源代码和原生运行时的许可证为 [AGPL-3.0-only](LICENSE) + +默认内置模型模式(`standard`)仍采用 MIT 许可证。`resources/assets/models/standard`、`keyboard` 和 `gamepad` 中的捆绑模型资产受单独的 [MIT 许可声明](LICENSE-MIT) 管辖。该 MIT 许可证仅适用于模型资产及其附带的美术作品;它不会重新授权 BongoCat 源代码或原生运行时。 + + +## 技术架构 + +> 当前原生版本基于 C/C++、SDL3 和 OpenGL 构建。下图可能看起来有点复杂,但实际上并不难理解。 + +### 运行时所有权与帧调度 + +原生运行时在所有权边界上有意进行了划分: + +```text +平台输入监听器 +(键盘 / 指针) + | + v + C11 输入状态 + (原子边沿队列 + 合并的指针路径) + | + v + 主线程应用 <----- SDL3 事件 + | + v + 模型参数、覆盖层和 UI 状态 + | + v + Cubism 更新 -> OpenGL 合成 -> 平台呈现 +``` + +平台输入监听器从不直接操作模型。Windows 低级钩子、macOS Quartz 事件监听和 Linux XInput2 将带时间戳的键盘和鼠标按键转换发布到应用程序拥有的输入状态。在 Windows 上,当导入的模型请求相对移动时,通过平台指针接口对 DirectInput 进行采样;SDL3 游戏手柄事件在主线程上标准化。指针坐标使用独立的原子合并路径,因此高频运动不会取代用于按键和按钮边沿的有界队列。 + +每次循环迭代等待 SDL 或原生唤醒、下一个调度程序截止时间或待处理的 UI/窗口工作。然后它排空排队的事件和释放恢复,应用输入派生的参数和覆盖层状态,并使用经过的时间推进模型。在普通的宠物窗口路径中,当应用程序被标记为“脏”时,会呈现一帧;显式的预览、调整大小和捕获操作可能会请求额外的渲染。呈现是平台特定的:在 macOS 和 Linux 上使用 SDL_GL_SwapWindow,在 Windows 上,当分层呈现未激活时也使用它;活动的 Windows 分层呈现使用 UpdateLayeredWindow。 + +调度器不需要固定的模拟步长。启用 Cubism 后,下一个模型更新从配置的最大 FPS(默认为 60 FPS)派生,并且运行时接收自上次更新以来经过的时间。经过的时间间隔限制在 250 毫秒以内,并细分为最多八个子步骤以保证稳定性。如果更新未产生视觉变化,则除非另一个状态转换请求渲染,否则不会再次呈现普通宠物窗口。 + +C 运行时调用 include/bongo_cat/model.h 中声明的 C ABI,其桥接和模型实现位于 src/live2d。当启用 Cubism SDK 时,该实现为 C++17,因为 SDK 公开了 C++ API;其余原生 C 源使用 C11。Cubism SDK 类型保留在不透明的 C 句柄和 C 兼容结构后面,而当 SDK 不可用时,src/live2d/live2d_stub.c 提供诊断后端。 + +```mermaid +%%{init: {"flowchart": {"curve": "catmullRom", "htmlLabels": true, "nodeSpacing": 24, "rankSpacing": 38}}}%% +flowchart TB + User(["用户输入
键盘 / 鼠标 / 游戏手柄"]) + ModelSources(["外部模型源
独立 .model3.json / Mver 包 / Mver 图像补丁"]) + Desktop(["桌面输出
透明宠物窗口 / 偏好设置窗口"]) + + subgraph Platform["平台后端"] + direction LR + Win["Windows
Win32 钩子 / DirectInput / 分层窗口支持"] + Mac["macOS
Cocoa / ApplicationServices / Quartz 事件监听"] + Linux["Linux
X11 / XInput2 / XFixes"] + end + + subgraph Native["BongoCat 原生运行时"] + direction TB + + subgraph Orchestration["运行时编排 / C11"] + direction LR + Entry["进程入口
src/main.c 调用 bongo_cat_app_run"] + Lifecycle["应用生命周期
启动 / 存储路径 / 配置 / 关闭"] + EventLoop["SDL3 事件与帧循环
等待 / 分发 / 排空输入 / 更新 / 渲染"] + AppState[("BongoCatApp 状态
设置 / 会话 / 目录 / 平台和运行时状态")] + + Entry --> Lifecycle --> EventLoop + Lifecycle <--> AppState + EventLoop <--> AppState + end + + subgraph InputShell["输入与桌面外壳"] + direction LR + GlobalInput["全局键盘和指针捕获
Win32 钩子 / Quartz 事件监听 / XInput2"] + InputQueue[("原子输入状态
有序边沿队列 / 释放边沿恢复 / 合并的指针位置")] + Gamepad["SDL3 游戏手柄事件
活动设备选择 / 按钮 / 轴"] + InputMap["输入分发
快捷键 / 模型参数 / 指针映射"] + Shell["桌面外壳
窗口 / 托盘 / 上下文菜单 / 拖拽和调整大小 / 穿透点击 / 多宠物协调"] + + GlobalInput --> InputQueue --> InputMap + Gamepad --> InputMap + EventLoop --> Shell + EventLoop --> InputMap + InputMap --> AppState + Shell --> AppState + end + + subgraph ModelPipeline["模型发现、适配与编目"] + direction LR + BuiltIn["内置模型
resources/assets/models"] + Discover["源发现
独立 model3 / Mver 包 / 图像补丁"] + Validate["验证与身份
清单引用 / 有界路径 / SHA-256 摘要 / 元数据"] + Adapt["运行时适配器生成
预览资产 / 输入覆盖层 / 投影和绑定元数据"] + Installed[("已安装包
models_root / 可直接复制的 Mver 树")] + Nearby[("运行时适配器
生成在 models_root 之外 / cache_root/model-adapters")] + Catalog["模型与行为目录
模型 / 动作 / 表情 / 音效 / 特效"] + + Discover --> Validate --> Adapt + Adapt --> Installed --> Catalog + Adapt --> Nearby --> Catalog + BuiltIn --> Catalog + Catalog --> AppState + end + + subgraph Presentation["动画、合成与 UI"] + direction LR + Live2DBridge["C ABI / C++17 桥接"] + Cubism["Live2D Cubism 运行时
模型 / 动作 / 表情 / 物理 / 姿势 / 参数覆盖"] + Images["图像管线
stb 解码与缩放 / Alpha 蒙版与 Mipmap / OpenGL 纹理上传"] + Overlay["输入覆盖层运行时
背景 / 指针 / 按键图像 / 特效"] + Audio["音频播放
miniaudio 引擎 / 异步文件解码"] + Composite["OpenGL 帧合成
清除和背景 / Live2D 模型 / 指针、按键和特效"] + Preferences["Nuklear 偏好设置 UI
Catime 主题 / 本地化 / 设置 / 模型管理"] + Present["平台呈现
Windows: UpdateLayeredWindow 或 GL 交换
macOS 和 Linux: SDL_GL_SwapWindow"] + + Live2DBridge --> Cubism + Cubism --> Composite --> Present + Images --> Cubism + Images --> Overlay --> Composite + AppState <--> Preferences + end + + EventLoop --> Live2DBridge + EventLoop --> Composite + EventLoop --> Preferences + AppState --> Live2DBridge + AppState --> Composite + InputMap --> Live2DBridge + InputMap --> Overlay + InputMap --> Audio + Catalog --> Live2DBridge + Catalog --> Overlay + Catalog --> Audio + Shell --> Preferences + end + + subgraph Dependencies["原生依赖"] + direction LR + SDL["SDL3"] + OpenGL["OpenGL"] + GLEW["GLEW
Cubism OpenGL 函数加载"] + CubismSDK["Live2D Cubism SDK"] + YYJSON["yyjson"] + STB["stb_image / stb_image_resize2 / stb_image_write"] + Miniaudio["miniaudio"] + Nuklear["Nuklear"] + end + + subgraph Toolchain["构建、验证与分发"] + direction LR + CMake["CMake
固定依赖 / 平台源 / 资产暂存与嵌入"] + CTest["CTest
核心 / i18n / UI / 应用状态 / 模型导入 / 动作状态 / Windows 捕获"] + Audits["静态与运行时检查
行策略 / 平台 API 允许列表 / 烟雾与视觉审计"] + Packaging["分发
Windows ZIP 和 NSIS / macOS ZIP / Linux TGZ / Microsoft Store MSIX"] + + CMake --> CTest + CMake --> Audits + CMake --> Packaging + end + + User --> GlobalInput + User --> Gamepad + User --> Shell + ModelSources --> Discover + + Win --> GlobalInput + Mac --> GlobalInput + Linux --> GlobalInput + Win --> Shell + Mac --> Shell + Linux --> Shell + Win --> Present + Mac --> Present + Linux --> Present + + Present --> Desktop + Preferences --> Desktop + + SDL -.-> EventLoop + SDL -.-> Gamepad + SDL -.-> Shell + SDL -.-> Present + OpenGL -.-> Cubism + OpenGL -.-> Composite + OpenGL -.-> Preferences + GLEW -.-> Cubism + CubismSDK -.-> Cubism + YYJSON -.-> Lifecycle + YYJSON -.-> Validate + STB -.-> Images + Miniaudio -.-> Audio + Nuklear -.-> Preferences + + CMake -.-> Entry + CTest -.-> AppState + CTest -.-> Preferences + CTest -.-> Live2DBridge + CTest -.-> Present + Audits -.-> EventLoop + Audits -.-> GlobalInput + Audits -.-> Composite + + classDef external fill:#fff4cc,stroke:#b7791f,color:#3d2b00,stroke-width:1.5px + classDef platform fill:#e8f1ff,stroke:#4a78b8,color:#14263d,stroke-width:1.5px + classDef runtime fill:#ffe8f0,stroke:#d95f8d,color:#3d1725,stroke-width:1.5px + classDef state fill:#f3eaff,stroke:#8a63c7,color:#2c1b46,stroke-width:1.5px + classDef input fill:#e7f8ff,stroke:#3f8eaa,color:#12323d,stroke-width:1.5px + classDef model fill:#eaf8e8,stroke:#57945a,color:#18361a,stroke-width:1.5px + classDef render fill:#f3ecff,stroke:#8064b3,color:#281b40,stroke-width:1.5px + classDef dependency fill:#f4f4f5,stroke:#71717a,color:#27272a,stroke-width:1.2px + classDef tooling fill:#fff0dd,stroke:#c47b2c,color:#42270d,stroke-width:1.5px + + class User,ModelSources,Desktop external + class Win,Mac,Linux platform + class Entry,Lifecycle,EventLoop runtime + class AppState,InputQueue,Installed,Nearby state + class GlobalInput,Gamepad,InputMap,Shell input + class BuiltIn,Discover,Validate,Adapt,Catalog model + class Live2DBridge,Cubism,Images,Overlay,Audio,Composite,Preferences,Present render + class SDL,OpenGL,GLEW,CubismSDK,YYJSON,STB,Miniaudio,Nuklear dependency + class CMake,CTest,Audits,Packaging tooling + + style Native fill:#fffafd,stroke:#d95f8d,stroke-width:2px + style Platform fill:#f8fbff,stroke:#7da1cf,stroke-width:1.5px + style Orchestration fill:#fff7fa,stroke:#ed9eb8,stroke-width:1px + style InputShell fill:#f5fcff,stroke:#86bfd3,stroke-width:1px + style ModelPipeline fill:#f7fcf5,stroke:#91bd8f,stroke-width:1px + style Presentation fill:#faf7ff,stroke:#ad98cf,stroke-width:1px + style Dependencies fill:#fafafa,stroke:#a1a1aa,stroke-width:1px + style Toolchain fill:#fffaf3,stroke:#dda866,stroke-width:1.5px +``` + +# 以下是你可能好奇的几点: + +1. 为什么选择 OpenGL 而非 Vulkan + + + + +## 特别感谢❤️ + + + Contribution Leaderboard + + + + + +--- + +
+ +版权所有 © 2026 - BongoCat +作者 vladelaina +用 ❤️ 和 ⌨️ 制作 + +
+ From 78446042c9dd440201c4343e7c2c854b545cbd3c Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 1 Sep 2026 09:03:12 +0800 Subject: [PATCH 169/334] =?UTF-8?q?=F0=9F=8C=9F=20chore:=20improve=20proje?= =?UTF-8?q?ct=20setup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/README.md b/README.md index c74787f4..0aa8d07f 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,112 @@ Download the latest release from [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest). +## Build From Source + +BongoCat uses CMake and requires a C11 compiler, a C++17 compiler, CMake 3.24 +or newer, and desktop OpenGL development files. SDL3, yyjson, stb, miniaudio, +and Nuklear are downloaded at configure time by default, so the first +configuration needs network access. + +Run the commands below from the project root (the directory containing +`CMakeLists.txt`). + +### Platform prerequisites + +- **Windows:** Visual Studio 2022 with the Desktop C++ workload and CMake. + Use the MSVC generator; MinGW can build the diagnostic backend but is not + supported for the Cubism SDK. +- **macOS:** Xcode Command Line Tools, CMake, and Ninja. Select an architecture + with `CMAKE_OSX_ARCHITECTURES` when it differs from the host default. +- **Linux (Debian/Ubuntu):** GCC or Clang, Ninja, and the OpenGL/X11 headers: + + ```bash + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build \ + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev + ``` + +### Configure and build + +On Linux and macOS, use a single-configuration generator such as Ninja: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --parallel +``` + +On Windows, run from a Visual Studio 2022 developer shell (or another shell +where MSVC is available): + +```powershell +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --config Release --parallel +``` + +The executable is written to `build/BongoCat` on Linux, to +`build/BongoCat.app/Contents/MacOS/BongoCat` on macOS, and to +`build/Release/BongoCat.exe` for Visual Studio builds. + +### Tests + +CTest targets are enabled by default. Run them after building: + +```bash +ctest --test-dir build --output-on-failure +``` + +For a multi-configuration generator such as Visual Studio, select the build +configuration explicitly: + +```powershell +ctest --test-dir build -C Release --output-on-failure +``` + +### Live2D / Cubism SDK (optional) + +If the Cubism SDK is not present, CMake emits a warning and builds the +diagnostic backend. This backend is intended for startup and platform +diagnostics; it does not provide Live2D model rendering. To build the full +runtime, install a compatible Cubism SDK for Native and either place it at +`vendor/CubismSdkForNative` or pass its location explicitly: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ + -DBONGO_CAT_REQUIRE_CUBISM=ON +``` + +The SDK must contain its Core library, Framework sources, and the OpenGL GLEW +third-party tree in the layout expected by `cmake/Cubism.cmake`. Windows +Cubism builds require Visual Studio 2022. `BONGO_CAT_REQUIRE_CUBISM=ON` makes +configuration fail instead of silently selecting the diagnostic backend. + +### CMake options + +| Option | Default | Description | +| --- | --- | --- | +| `BONGO_CAT_FETCH_DEPS` | `ON` | Download the pinned third-party dependencies with CMake `FetchContent`. Set `OFF` only when SDL3, yyjson, stb, miniaudio, and Nuklear are already available to CMake. | +| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Path to the Cubism SDK for Native. | +| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | Fail configuration when a usable Cubism SDK is unavailable. | +| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | Treat native compiler warnings as errors. | + +For an offline build with `BONGO_CAT_FETCH_DEPS=OFF`, provide CMake package +configurations for SDL3 (including `SDL3-static`) and yyjson, plus the include +directories for stb, Nuklear, and miniaudio when they are not discoverable: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=OFF \ + -DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ + -DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ + -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio +``` + ## Project Status From c7aa9c69b861a7aa2a8d681aad82448f26f69a2f Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 1 Sep 2026 10:58:28 +0800 Subject: [PATCH 170/334] =?UTF-8?q?=F0=9F=8C=9F=20chore:=20maintain=20proj?= =?UTF-8?q?ect=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0aa8d07f..b6ed8467 100644 --- a/README.md +++ b/README.md @@ -303,12 +303,19 @@ flowchart TB Preferences --> Desktop ``` -# Here are a few things you might be curious about: - -1. Why OpenGL instead of Vulkan - - - +## FAQ + +### Why OpenGL instead of Vulkan? + +We chose OpenGL not because Vulkan is bad, but because BongoCat does not need +that level of complexity. The app mainly renders one Live2D model, a few UI +layers, and a transparent desktop window. OpenGL already handles that +comfortably, and it works naturally with SDL3 and Cubism's OpenGL renderer. +Moving to Vulkan would mean maintaining much more rendering and synchronization +code across three desktop platforms, without a noticeable improvement for +users. For BongoCat's current workload, OpenGL keeps the renderer smaller, +easier to debug, and easier to maintain while still delivering the performance +we need. ## Special Thanks❤️ From 4f92e955d106edd1d47132ed81f863b03db681c2 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 1 Sep 2026 11:41:43 +0800 Subject: [PATCH 171/334] =?UTF-8?q?=F0=9F=8C=9F=20chore:=20clean=20up=20pr?= =?UTF-8?q?oject=20details?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index b6ed8467..22f3205a 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Download the latest release from [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest). -## Build From Source +## 🛠️ Build From Source BongoCat uses CMake and requires a C11 compiler, a C++17 compiler, CMake 3.24 or newer, and desktop OpenGL development files. SDL3, yyjson, stb, miniaudio, @@ -56,7 +56,7 @@ configuration needs network access. Run the commands below from the project root (the directory containing `CMakeLists.txt`). -### Platform prerequisites +### 📋 Platform Prerequisites - **Windows:** Visual Studio 2022 with the Desktop C++ workload and CMake. Use the MSVC generator; MinGW can build the diagnostic backend but is not @@ -71,7 +71,7 @@ Run the commands below from the project root (the directory containing libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev ``` -### Configure and build +### 🔧 Configure and Build On Linux and macOS, use a single-configuration generator such as Ninja: @@ -95,7 +95,7 @@ The executable is written to `build/BongoCat` on Linux, to `build/BongoCat.app/Contents/MacOS/BongoCat` on macOS, and to `build/Release/BongoCat.exe` for Visual Studio builds. -### Tests +### 🧪 Tests CTest targets are enabled by default. Run them after building: @@ -110,7 +110,7 @@ configuration explicitly: ctest --test-dir build -C Release --output-on-failure ``` -### Live2D / Cubism SDK (optional) +### 🎭 Live2D / Cubism SDK (Optional) If the Cubism SDK is not present, CMake emits a warning and builds the diagnostic backend. This backend is intended for startup and platform @@ -130,7 +130,7 @@ third-party tree in the layout expected by `cmake/Cubism.cmake`. Windows Cubism builds require Visual Studio 2022. `BONGO_CAT_REQUIRE_CUBISM=ON` makes configuration fail instead of silently selecting the diagnostic backend. -### CMake options +### ⚙️ CMake Options | Option | Default | Description | | --- | --- | --- | @@ -152,10 +152,10 @@ cmake -S . -B build -G Ninja \ -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio ``` -## Project Status +## 📌 Project Status -## License +## 📜 License The BongoCat source code and native runtime are licensed under [AGPL-3.0-only](LICENSE). @@ -167,11 +167,11 @@ That MIT license applies to the model assets and their accompanying artwork only; it does not relicense the BongoCat source code or native runtime. -## Technical Architecture +## 🧭 Technical Architecture > The current native version is built on C/C++, SDL3, and OpenGL. The diagram below focuses on the runtime data flow; build and packaging details live in CMake. -### Runtime ownership and frame scheduling +### 🔄 Runtime Ownership and Frame Scheduling Each process owns one `BongoCatApp` and one main-thread event and render loop. Platform listeners stop at the input boundary: @@ -303,9 +303,9 @@ flowchart TB Preferences --> Desktop ``` -## FAQ +## ❓ FAQ -### Why OpenGL instead of Vulkan? +### 🖼️ Why OpenGL instead of Vulkan? We chose OpenGL not because Vulkan is bad, but because BongoCat does not need that level of complexity. The app mainly renders one Live2D model, a few UI @@ -317,7 +317,7 @@ users. For BongoCat's current workload, OpenGL keeps the renderer smaller, easier to debug, and easier to maintain while still delivering the performance we need. -## Special Thanks❤️ +## 🙏 Special Thanks Contribution Leaderboard From b8b858f0aa6324a1624f7705f4f1e664a039c4eb Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 1 Sep 2026 11:44:55 +0800 Subject: [PATCH 172/334] =?UTF-8?q?=F0=9F=8C=9F=20chore:=20clean=20up=20pr?= =?UTF-8?q?oject=20details?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 22f3205a..e6697cb0 100644 --- a/README.md +++ b/README.md @@ -305,6 +305,15 @@ flowchart TB ## ❓ FAQ +### 🔒 Does BongoCat record my keyboard or mouse input? + +No. BongoCat processes keyboard and mouse input locally to drive animations +and shortcuts. It does not record or upload your keystrokes, mouse actions, or +other interaction data. Configuration is stored locally as well, and the app +contains no ads, analytics tools, or user-tracking code. When an update check +is performed, it only requests public release metadata; it does not send input, +configuration, or usage data. + ### 🖼️ Why OpenGL instead of Vulkan? We chose OpenGL not because Vulkan is bad, but because BongoCat does not need From cc9493c8de656d2cde4085abd8c122c9a0e01e74 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 1 Sep 2026 11:53:04 +0800 Subject: [PATCH 173/334] chore: update publisher metadata --- cmake/Packaging.cmake | 2 +- cmake/windows_resources.rc.in | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmake/Packaging.cmake b/cmake/Packaging.cmake index 0a4747ad..d66983f1 100644 --- a/cmake/Packaging.cmake +++ b/cmake/Packaging.cmake @@ -21,7 +21,7 @@ file(GENERATE OUTPUT "${CMAKE_BINARY_DIR}/bongocat-package-name.txt" CONTENT "${BONGO_CAT_PACKAGE_NAME}\n") set(CPACK_PACKAGE_NAME "${BONGO_CAT_PACKAGE_PRODUCT}") -set(CPACK_PACKAGE_VENDOR "BongoCat") +set(CPACK_PACKAGE_VENDOR "vladelaina") set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "${PROJECT_DESCRIPTION}") set(CPACK_PACKAGE_HOMEPAGE_URL "${PROJECT_HOMEPAGE_URL}") set(CPACK_PACKAGE_VERSION "${PROJECT_VERSION}") diff --git a/cmake/windows_resources.rc.in b/cmake/windows_resources.rc.in index 29a3853c..86375334 100644 --- a/cmake/windows_resources.rc.in +++ b/cmake/windows_resources.rc.in @@ -21,7 +21,7 @@ BEGIN BEGIN BLOCK "040904b0" BEGIN - VALUE "CompanyName", "BongoCat\0" + VALUE "CompanyName", "vladelaina\0" VALUE "FileDescription", "@PROJECT_DESCRIPTION@\0" VALUE "FileVersion", "@PROJECT_VERSION@\0" VALUE "InternalName", "BongoCat\0" From 640626ece406f21da3fe26ae3ade4e7f4879e8b7 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 1 Sep 2026 11:54:05 +0800 Subject: [PATCH 174/334] fix: refresh model covers from restored state --- cmake/RuntimeSources.cmake | 1 + include/bongo_cat/app.h | 3 + include/bongo_cat/model.h | 2 + src/live2d/cubism_bridge.cpp | 5 + src/live2d/cubism_expression.cpp | 23 ++ src/live2d/cubism_model.hpp | 3 + src/live2d/cubism_model_update.cpp | 11 + src/live2d/live2d_stub.c | 3 + src/runtime/lifecycle/app_loop.c | 42 ++- .../model/import/model_import_adapter.c | 23 -- src/runtime/model/model_behavior_state.c | 14 + src/runtime/model/model_cover.c | 249 ++++++------------ src/runtime/model/model_cover.h | 8 +- src/runtime/model/model_cover_paths.h | 6 - src/runtime/model/model_cover_queue.c | 159 +++++++++++ src/runtime/model/model_files.c | 29 +- src/ui/preferences/preferences_model_cover.c | 4 + tests/core/test_app_state.c | 2 + 18 files changed, 377 insertions(+), 210 deletions(-) create mode 100644 src/runtime/model/model_cover_queue.c diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 24e02302..c4e22c28 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -77,6 +77,7 @@ set(BONGO_CAT_RUNTIME_MODEL_SOURCES src/runtime/model/model_catalog_refresh.c src/runtime/model/model_catalog_selection.c src/runtime/model/model_cover.c + src/runtime/model/model_cover_queue.c src/runtime/model/model_files.c src/runtime/model/model_geometry.c src/runtime/model/multi_pet.c diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index 03c83f43..f05f83ab 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -71,6 +71,8 @@ typedef struct BongoCatApp { char smoke_viewer_trace[BONGO_CAT_PATH_CAP]; char pending_model_cover_ids[BONGO_CAT_MODEL_COVER_PENDING_CAP][BONGO_CAT_ID_CAP]; char pending_model_cover_paths[BONGO_CAT_MODEL_COVER_PENDING_CAP][BONGO_CAT_PATH_CAP]; + uint64_t pending_model_cover_retry_ns[BONGO_CAT_MODEL_COVER_PENDING_CAP]; + unsigned pending_model_cover_attempts[BONGO_CAT_MODEL_COVER_PENDING_CAP]; size_t pending_model_cover_count; char loaded_model[BONGO_CAT_ID_CAP]; char loading_model[BONGO_CAT_ID_CAP]; @@ -190,6 +192,7 @@ bool bongo_cat_app_set_model_active(BongoCatApp *app, const char *id, void bongo_cat_app_set_multiple_pets(BongoCatApp *app, bool enabled); bool bongo_cat_app_run_behavior(BongoCatApp *app, const BongoCatBehaviorEntry *behavior); +size_t bongo_cat_app_selected_motion_count(const BongoCatApp *app); void bongo_cat_app_capture_behavior_state(BongoCatApp *app); void bongo_cat_app_forget_behavior_state(BongoCatApp *app, const char *model_id); diff --git a/include/bongo_cat/model.h b/include/bongo_cat/model.h index c570dfbc..6bede629 100644 --- a/include/bongo_cat/model.h +++ b/include/bongo_cat/model.h @@ -152,6 +152,8 @@ void bongo_cat_live2d_set_dragging(BongoCatLive2D *live2d, float x, float y); void bongo_cat_live2d_set_centered_dragging(BongoCatLive2D *live2d, float x, float y); void bongo_cat_live2d_prepare_viewer_audit(BongoCatLive2D *live2d); +/* Synchronize drawables from the current state before a cover-only frame. */ +bool bongo_cat_live2d_prepare_cover_capture(BongoCatLive2D *live2d); bool bongo_cat_live2d_set_parameter(BongoCatLive2D *live2d, const char *id, float value); bool bongo_cat_live2d_parameter(BongoCatLive2D *live2d, const char *id, BongoCatParameterRange *range); diff --git a/src/live2d/cubism_bridge.cpp b/src/live2d/cubism_bridge.cpp index 65c26e2c..f2082909 100644 --- a/src/live2d/cubism_bridge.cpp +++ b/src/live2d/cubism_bridge.cpp @@ -219,6 +219,11 @@ extern "C" void bongo_cat_live2d_set_centered_dragging(BongoCatLive2D *runtime, extern "C" void bongo_cat_live2d_prepare_viewer_audit(BongoCatLive2D *runtime) { if (runtime && runtime->model) runtime->model->prepare_viewer_audit(); } +extern "C" bool bongo_cat_live2d_prepare_cover_capture( + BongoCatLive2D *runtime) { + return runtime && runtime->model && + runtime->model->prepare_cover_capture(); +} extern "C" bool bongo_cat_live2d_set_parameter(BongoCatLive2D *runtime, const char *id, float value) { return runtime && runtime->model && runtime->model->set_parameter(id, value); } diff --git a/src/live2d/cubism_expression.cpp b/src/live2d/cubism_expression.cpp index 27828cb5..09b25718 100644 --- a/src/live2d/cubism_expression.cpp +++ b/src/live2d/cubism_expression.cpp @@ -2,12 +2,14 @@ #include #include +#include namespace bongo_cat { bool NativeModel::set_expression(int index) { if (index == -1) { expression_index_ = -1; + expression_frame_pending_ = false; auto *entries = _expressionManager->GetCubismMotionQueueEntries(); expression_clearing_ = entries && entries->GetSize() > 0; if (!expression_clearing_) { @@ -33,9 +35,30 @@ bool NativeModel::set_expression(int index) { if (handle == Csm::InvalidMotionQueueEntryHandleValue) return false; expression_clearing_ = false; expression_index_ = index; + expression_frame_pending_ = true; return true; } +void NativeModel::settle_pending_expression_for_cover() { + if (!expression_frame_pending_ || !_model || expression_index_ < 0 || + (size_t)expression_index_ >= expression_names_.size()) return; + auto found = expressions_.find(expression_names_[(size_t)expression_index_]); + if (found == expressions_.end()) { + expression_frame_pending_ = false; + return; + } + float fade_seconds = found->second->GetFadeInTime(); + if (!std::isfinite(fade_seconds) || fade_seconds < 0.0f) + fade_seconds = 0.0f; + /* Only the expression clock is advanced. The model baseline already + contains restored persistent motions, while idle effects stay put. */ + _model->LoadParameters(); + _expressionManager->UpdateMotion(_model, 0.0f); + if (fade_seconds > 0.0f) + _expressionManager->UpdateMotion(_model, fade_seconds); + expression_frame_pending_ = false; +} + void NativeModel::expire_expression_fade() { if (!expression_clearing_) return; auto *entries = _expressionManager->GetCubismMotionQueueEntries(); diff --git a/src/live2d/cubism_model.hpp b/src/live2d/cubism_model.hpp index 975e188b..be64d6ae 100644 --- a/src/live2d/cubism_model.hpp +++ b/src/live2d/cubism_model.hpp @@ -44,6 +44,7 @@ class NativeModel final : public Csm::CubismUserModel { void set_render_options(const BongoCatLive2DRenderOptions &options); void set_dragging(float x, float y, bool angle_z = false); void prepare_viewer_audit(); + bool prepare_cover_capture(); bool set_parameter(const char *id, float value); bool parameter(const char *id, float *minimum, float *maximum, float *value); bool start_motion(const char *group, int index); @@ -119,6 +120,7 @@ class NativeModel final : public Csm::CubismUserModel { void expire_motion_runs(); void clear_motion_runs(); void expire_expression_fade(); + void settle_pending_expression_for_cover(); void capture_parameter_baseline(); void save_parameters(); void add_parameter_override_updater(); @@ -165,6 +167,7 @@ class NativeModel final : public Csm::CubismUserModel { int viewport_height_ = 354; int expression_index_ = -1; bool expression_clearing_ = false; + bool expression_frame_pending_ = false; BongoCatLive2DFrame frame_{}; BongoCatLive2DVisualState visual_state_{}; bool visual_state_ready_ = false; diff --git a/src/live2d/cubism_model_update.cpp b/src/live2d/cubism_model_update.cpp index 9e9d533f..0933bd50 100644 --- a/src/live2d/cubism_model_update.cpp +++ b/src/live2d/cubism_model_update.cpp @@ -65,6 +65,7 @@ bool NativeModel::update(float delta_seconds) { expire_motion_runs(); save_parameters(); _updateScheduler.OnLateUpdate(_model, delta_seconds); + expression_frame_pending_ = false; expire_expression_fade(); apply_parameter_overrides(); _opacity = _model->GetModelOpacity(); @@ -156,6 +157,16 @@ void NativeModel::prepare_viewer_audit() { save_parameters(); } +bool NativeModel::prepare_cover_capture() { + if (!_model) return false; + settle_pending_expression_for_cover(); + apply_parameter_overrides(); + /* Persistent motions are already stored in the Cubism baseline. Updating + drawables does not advance idle motion, physics, breathing, or blinking. */ + _model->Update(); + return true; +} + bool NativeModel::set_parameter(const char *id, float value) { if (!_model || !id) return false; Csm::CubismIdHandle handle = Csm::CubismFramework::GetIdManager()->GetId(id); diff --git a/src/live2d/live2d_stub.c b/src/live2d/live2d_stub.c index 96827140..6da0a11e 100644 --- a/src/live2d/live2d_stub.c +++ b/src/live2d/live2d_stub.c @@ -100,6 +100,9 @@ void bongo_cat_live2d_set_dragging(BongoCatLive2D *live2d, float x, float y) { void bongo_cat_live2d_set_centered_dragging(BongoCatLive2D *live2d, float x, float y) { (void)live2d; (void)x; (void)y; } void bongo_cat_live2d_prepare_viewer_audit(BongoCatLive2D *live2d) { (void)live2d; } +bool bongo_cat_live2d_prepare_cover_capture(BongoCatLive2D *live2d) { + return live2d && live2d->loaded; +} bool bongo_cat_live2d_set_parameter(BongoCatLive2D *live2d, const char *id, float value) { (void)live2d; (void)id; (void)value; return false; } diff --git a/src/runtime/lifecycle/app_loop.c b/src/runtime/lifecycle/app_loop.c index 4386bb01..ddb4898d 100644 --- a/src/runtime/lifecycle/app_loop.c +++ b/src/runtime/lifecycle/app_loop.c @@ -50,6 +50,9 @@ static bool render(BongoCatApp *app, bool present) { glViewport(content_x, content_y, content_width, content_height); bongo_cat_overlay_draw_background(app->overlay, app->settings.model.mirror); + bool cover_requested = !present && bongo_cat_model_cover_pending(app); + bool cover_ready = !cover_requested || + bongo_cat_live2d_prepare_cover_capture(app->live2d); glViewport(0, 0, width, height); bongo_cat_live2d_set_mirror(app->live2d, app->settings.model.mirror); bongo_cat_live2d_draw(app->live2d); @@ -64,10 +67,20 @@ static bool render(BongoCatApp *app, bool present) { bongo_cat_overlay_draw_effect(app->overlay, app->settings.model.mirror); bongo_cat_overlay_draw_pointer_after_keys(app->overlay); glViewport(0, 0, width, height); - bongo_cat_model_cover_capture(app, width, height); + bool cover_handled = true; + if (cover_requested) { + if (!cover_ready) { + bongo_cat_model_cover_defer(app, "model-state-preparation"); + cover_handled = false; + } else if (!bongo_cat_model_cover_capture(app, width, height)) { + bongo_cat_model_cover_defer(app, "framebuffer-capture"); + cover_handled = false; + } + } + if (cover_requested) app->dirty = true; if (!present) { app->dirty = true; - return true; + return cover_handled; } bongo_cat_frame_audit(app, width, height); bongo_cat_window_capture_pointer_hit(app); @@ -95,14 +108,33 @@ void bongo_cat_app_render_now(BongoCatApp *app) { bool bongo_cat_app_capture_pending_model_cover(BongoCatApp *app) { if (!app || !app->window || !bongo_cat_model_cover_pending(app)) return false; + uint64_t now = SDL_GetTicksNS(); + if (!bongo_cat_model_cover_capture_due(app, now)) return false; if (app->window_minimized) { SDL_Log("Pending model cover deferred while window is minimized: %s", bongo_cat_model_cover_pending_path(app)); + bongo_cat_model_cover_defer(app, "window-minimized"); return false; } - SDL_Log("Capturing pending model cover: visible=%d path=%s", - app->session.window.visible, bongo_cat_model_cover_pending_path(app)); - return render(app, false); + SDL_Log("[runtime] Capturing pending model cover: model=%s loading=%s " + "visible=%d dirty=%d motions=%zu expression=%d path=%s", + app->loaded_model, + app->loading_model[0] ? app->loading_model : "none", + app->session.window.visible, app->dirty, + bongo_cat_app_selected_motion_count(app), + bongo_cat_live2d_expression(app->live2d), + bongo_cat_model_cover_pending_path(app)); + SDL_Window *previous_window = SDL_GL_GetCurrentWindow(); + SDL_GLContext previous_context = SDL_GL_GetCurrentContext(); + bool restore_context = previous_window && previous_context && + (previous_window != app->window || previous_context != app->gl_context); + bool captured = render(app, false); + if (restore_context && + !SDL_GL_MakeCurrent(previous_window, previous_context)) + SDL_LogError(SDL_LOG_CATEGORY_VIDEO, + "Cannot restore the OpenGL context after model cover capture: %s", + SDL_GetError()); + return captured; } static void take_instance_wake(BongoCatApp *app) { diff --git a/src/runtime/model/import/model_import_adapter.c b/src/runtime/model/import/model_import_adapter.c index e10e56cc..d617defb 100644 --- a/src/runtime/model/import/model_import_adapter.c +++ b/src/runtime/model/import/model_import_adapter.c @@ -1,5 +1,4 @@ #include "model_import.h" -#include "../model_cover_paths.h" #include "mver/model_import_mver.h" #include "model_storage.h" #include "runtime.h" @@ -47,24 +46,6 @@ static bool preview_file_exists(const char *directory, const char *name) { bongo_cat_path_is_file(path); } -static void mark_preview_fallback(const char *resources) { - char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), resources, - BONGO_CAT_MODEL_COVER_FALLBACK_NAME)) return; - FILE *file = bongo_cat_file_open(path, "wb"); - if (file) fclose(file); -} - -static void clear_preview_markers(const char *resources) { - char path[BONGO_CAT_PATH_CAP]; - if (bongo_cat_path_join(path, sizeof(path), resources, - BONGO_CAT_MODEL_COVER_FALLBACK_NAME)) - bongo_cat_file_remove(path); - if (bongo_cat_path_join(path, sizeof(path), resources, - BONGO_CAT_MODEL_COVER_GENERATED_NAME)) - bongo_cat_file_remove(path); -} - static bool copy_preview(const BongoCatImportCandidate *candidate, const char *target, BongoCatError *error) { char source_resources[BONGO_CAT_PATH_CAP], target_resources[BONGO_CAT_PATH_CAP]; @@ -84,16 +65,12 @@ static bool copy_preview(const BongoCatImportCandidate *candidate, "cover.png", "cat.png", "bg.png", "mousebg.png", "tabletbg.png"}; const char *backgrounds[] = { "background.png", "bg.png", "mousebg.png", "tabletbg.png"}; - bool authored_cover = preview_file_exists(source_resources, "cover.png") || - preview_file_exists(candidate->assets, "cover.png"); bool ok = (preview_file_exists(target_resources, "cover.png") || copy_preview_file(source_resources, candidate->assets, covers, 5, target_resources, "cover.png")) && (preview_file_exists(target_resources, "background.png") || copy_preview_file(source_resources, candidate->assets, backgrounds, 4, target_resources, "background.png")); - if (ok && !authored_cover) mark_preview_fallback(target_resources); - else if (ok) clear_preview_markers(target_resources); if (!ok) bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, "Cannot copy model preview assets: %s", SDL_GetError()); return ok; diff --git a/src/runtime/model/model_behavior_state.c b/src/runtime/model/model_behavior_state.c index 032ef181..ba0f9334 100644 --- a/src/runtime/model/model_behavior_state.c +++ b/src/runtime/model/model_behavior_state.c @@ -17,6 +17,20 @@ static bool behavior_active(const BongoCatApp *app, entry->index == expression; } +size_t bongo_cat_app_selected_motion_count(const BongoCatApp *app) { + if (!app || !app->live2d) return 0; + size_t count = 0; + for (size_t i = 0; i < app->behaviors.count; ++i) { + const BongoCatBehaviorEntry *entry = &app->behaviors.entries[i]; + if (entry->kind == BONGO_CAT_BEHAVIOR_MOTION && + bongo_cat_live2d_motion_visible(app->live2d, + entry->group, entry->index) && + bongo_cat_live2d_motion_selected(app->live2d, + entry->group, entry->index)) count++; + } + return count; +} + static void remove_model_state(BongoCatSessionState *session, const char *model_id) { size_t count = session->active_behavior_count; diff --git a/src/runtime/model/model_cover.c b/src/runtime/model/model_cover.c index 6f1e8098..eb08103c 100644 --- a/src/runtime/model/model_cover.c +++ b/src/runtime/model/model_cover.c @@ -3,13 +3,11 @@ #include "preferences_model_cover.h" #include "runtime.h" #include "bongo_cat/file.h" -#include "bongo_cat/path.h" #include #include #include #include -#include #include typedef struct CoverWriter { @@ -17,119 +15,6 @@ typedef struct CoverWriter { bool ok; } CoverWriter; -static bool cover_path(char *path, size_t capacity, - const char *adapter, const char *relative) { - return adapter && adapter[0] && bongo_cat_path_join(path, capacity, - adapter, relative); -} - -static bool marker_exists(const char *adapter, const char *relative) { - char path[BONGO_CAT_PATH_CAP]; - return cover_path(path, sizeof(path), adapter, relative) && - bongo_cat_path_is_file(path); -} - -static bool write_marker(const char *adapter, const char *relative) { - char path[BONGO_CAT_PATH_CAP]; - if (!cover_path(path, sizeof(path), adapter, relative)) return false; - FILE *file = bongo_cat_file_open(path, "wb"); - if (!file) return false; - return fclose(file) == 0; -} - -static void remove_marker(const char *adapter, const char *relative) { - char path[BONGO_CAT_PATH_CAP]; - if (cover_path(path, sizeof(path), adapter, relative)) - bongo_cat_file_remove(path); -} - -static bool task_matches_loaded(const BongoCatApp *app, size_t index) { - return app && index < app->pending_model_cover_count && - app->loaded_model[0] && - strcmp(app->pending_model_cover_ids[index], app->loaded_model) == 0; -} - -static void discard_stale_tasks(BongoCatApp *app) { - if (!app) return; - size_t first = 0; - while (first < app->pending_model_cover_count && - !task_matches_loaded(app, first)) { - SDL_Log("Discarding stale model cover task: model=%s current=%s", - app->pending_model_cover_ids[first], app->loaded_model[0] ? - app->loaded_model : "none"); - first++; - } - if (!first) return; - size_t remaining = app->pending_model_cover_count - first; - memmove(app->pending_model_cover_ids, - app->pending_model_cover_ids + first, - remaining * sizeof(app->pending_model_cover_ids[0])); - memmove(app->pending_model_cover_paths, - app->pending_model_cover_paths + first, - remaining * sizeof(app->pending_model_cover_paths[0])); - app->pending_model_cover_count = remaining; -} - -static void remove_first_task(BongoCatApp *app) { - if (!app || !app->pending_model_cover_count) return; - size_t remaining = --app->pending_model_cover_count; - if (remaining) { - memmove(app->pending_model_cover_ids, - app->pending_model_cover_ids + 1, - remaining * sizeof(app->pending_model_cover_ids[0])); - memmove(app->pending_model_cover_paths, - app->pending_model_cover_paths + 1, - remaining * sizeof(app->pending_model_cover_paths[0])); - } - app->pending_model_cover_ids[app->pending_model_cover_count][0] = '\0'; - app->pending_model_cover_paths[app->pending_model_cover_count][0] = '\0'; -} - -void bongo_cat_model_cover_schedule(BongoCatApp *app, - const BongoCatModelEntry *entry) { - if (!app || !entry || !entry->adapter_directory[0]) return; - bool fallback = marker_exists(entry->adapter_directory, - BONGO_CAT_MODEL_COVER_FALLBACK_MARKER); - bool generated = marker_exists(entry->adapter_directory, - BONGO_CAT_MODEL_COVER_GENERATED_MARKER); - if (!fallback && !generated) return; - char path[BONGO_CAT_PATH_CAP]; - if (!cover_path(path, sizeof(path), entry->adapter_directory, - BONGO_CAT_MODEL_COVER_FILE)) return; - for (size_t i = 0; i < app->pending_model_cover_count; ++i) - if (strcmp(app->pending_model_cover_ids[i], entry->id) == 0 || - strcmp(app->pending_model_cover_paths[i], path) == 0) return; - if (app->pending_model_cover_count >= BONGO_CAT_MODEL_COVER_PENDING_CAP) { - SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, - "Model cover queue is full; dropping refresh: id=%s", entry->id); - return; - } - size_t index = app->pending_model_cover_count++; - snprintf(app->pending_model_cover_ids[index], - sizeof(app->pending_model_cover_ids[index]), "%s", entry->id); - snprintf(app->pending_model_cover_paths[index], - sizeof(app->pending_model_cover_paths[index]), "%s", path); - SDL_Log("Scheduling model cover refresh: id=%s source=%s path=%s", - entry->id, generated ? "generated" : "fallback", path); - app->dirty = true; -} - -bool bongo_cat_model_cover_pending(const BongoCatApp *app) { - if (!app || !app->loaded_model[0]) return false; - for (size_t i = 0; i < app->pending_model_cover_count; ++i) - if (strcmp(app->pending_model_cover_ids[i], app->loaded_model) == 0) - return true; - return false; -} - -const char *bongo_cat_model_cover_pending_path(const BongoCatApp *app) { - if (!app || !app->loaded_model[0]) return NULL; - for (size_t i = 0; i < app->pending_model_cover_count; ++i) - if (strcmp(app->pending_model_cover_ids[i], app->loaded_model) == 0) - return app->pending_model_cover_paths[i]; - return NULL; -} - void bongo_cat_model_cover_capture_before_switch(BongoCatApp *app) { if (!app || !app->loaded_model[0]) return; const BongoCatModelEntry *entry = bongo_cat_models_find(&app->models, @@ -149,84 +34,104 @@ static void cover_write(void *context, void *data, int size) { writer->ok = false; } -void bongo_cat_model_cover_capture(BongoCatApp *app, int width, int height) { - if (!app || !app->pending_model_cover_count) return; - discard_stale_tasks(app); - if (!app->pending_model_cover_count) return; - uint64_t started_ns = SDL_GetTicksNS(); - char path[BONGO_CAT_PATH_CAP]; - snprintf(path, sizeof(path), "%s", app->pending_model_cover_paths[0]); - remove_first_task(app); - /* Keep the desktop's native pixel size while bounding readback memory. */ - if (width < 2 || height < 2 || width > 4096 || height > 4096) { - SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, - "Model cover refresh skipped for unsupported size %dx%d: %s", - width, height, path); - return; - } - size_t pitch = (size_t)width * 4; - size_t bytes = pitch * (size_t)height; - if (pitch / 4 != (size_t)width || bytes / pitch != (size_t)height) { - SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, - "Model cover refresh skipped due to size overflow: %s", path); - return; +static void flip_cover_pixels(unsigned char *pixels, int height, size_t pitch) { + for (int y = 0; y < height / 2; ++y) { + unsigned char *top = pixels + (size_t)y * pitch; + unsigned char *bottom = pixels + (size_t)(height - y - 1) * pitch; + for (size_t i = 0; i < pitch; ++i) { + unsigned char value = top[i]; + top[i] = bottom[i]; + bottom[i] = value; + } } +} + +static unsigned char *read_cover_pixels(const char *path, int width, + int height, size_t pitch, size_t bytes) { unsigned char *pixels = malloc(bytes); if (!pixels) { SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, "Model cover refresh allocation failed for %zu bytes: %s", bytes, path); - return; + return NULL; } - GLenum before = GL_NO_ERROR; - GLenum stale; - while ((stale = glGetError()) != GL_NO_ERROR) before = stale; + while (glGetError() != GL_NO_ERROR) {} glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels); - GLenum after = glGetError(); - if (after != GL_NO_ERROR) { + GLenum error = glGetError(); + if (error != GL_NO_ERROR) { SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, - "Model cover framebuffer readback failed: path=%s " - "gl_error_before=0x%x gl_error_after=0x%x", path, before, after); + "Model cover framebuffer readback failed: path=%s gl_error=0x%x", + path, error); free(pixels); - return; + return NULL; } - for (int y = 0; y < height / 2; ++y) { - unsigned char *top = pixels + (size_t)y * pitch; - unsigned char *bottom = pixels + (size_t)(height - y - 1) * pitch; - for (size_t i = 0; i < pitch; ++i) { - unsigned char value = top[i]; - top[i] = bottom[i]; - bottom[i] = value; - } - } - FILE *file = bongo_cat_file_open(path, "wb"); + flip_cover_pixels(pixels, height, pitch); + return pixels; +} + +static bool write_cover_png(const char *temporary, const char *path, + const unsigned char *pixels, int width, int height, size_t pitch) { + FILE *file = bongo_cat_file_open(temporary, "wb"); CoverWriter writer = {file, file != NULL}; bool ok = file && stbi_write_png_to_func(cover_write, &writer, width, height, 4, pixels, (int)pitch) && writer.ok; if (file && fclose(file) != 0) ok = false; if (!ok) { - bongo_cat_file_remove(path); + bongo_cat_file_remove(temporary); SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, - "Model cover could not be written: %s", path); - free(pixels); - return; + "Model cover could not be encoded: %s", path); + return false; + } + if (bongo_cat_file_replace(temporary, path, false)) return true; + bongo_cat_file_remove(temporary); + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Model cover temporary file could not replace target: %s", path); + return false; +} + +bool bongo_cat_model_cover_capture(BongoCatApp *app, int width, int height) { + if (!app || !bongo_cat_model_cover_pending(app)) return false; + uint64_t started_ns = SDL_GetTicksNS(); + const char *pending_path = bongo_cat_model_cover_pending_path(app); + if (!pending_path) return false; + char path[BONGO_CAT_PATH_CAP]; + int path_length = snprintf(path, sizeof(path), "%s", pending_path); + if (path_length < 0 || (size_t)path_length >= sizeof(path)) return false; + if (width < 2 || height < 2) return false; + /* Keep the desktop's native pixel size while bounding readback memory. */ + if (width > 4096 || height > 4096) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Model cover refresh abandoned for unsupported size %dx%d: %s", + width, height, path); + bongo_cat_model_cover_finish(app); + return true; + } + size_t pitch = (size_t)width * 4; + size_t bytes = pitch * (size_t)height; + if (pitch / 4 != (size_t)width || bytes / pitch != (size_t)height) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Model cover refresh skipped due to size overflow: %s", path); + return false; } - char adapter[BONGO_CAT_PATH_CAP]; - snprintf(adapter, sizeof(adapter), "%s", path); - char *separator = strrchr(adapter, '/'); - if (!separator) separator = strrchr(adapter, '\\'); - if (separator) *separator = '\0'; - bool marked_generated = write_marker(adapter, - BONGO_CAT_MODEL_COVER_GENERATED_NAME); - if (marked_generated) - remove_marker(adapter, BONGO_CAT_MODEL_COVER_FALLBACK_NAME); - else + char temporary[BONGO_CAT_PATH_CAP]; + int temporary_length = snprintf(temporary, sizeof(temporary), "%s.tmp.%s", + path, app->secondary_pet ? "secondary" : "primary"); + if (temporary_length < 0 || (size_t)temporary_length >= sizeof(temporary)) { SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, - "Model cover written but generated marker could not be created: %s", - adapter); + "Model cover temporary path is too long: %s", path); + bongo_cat_model_cover_finish(app); + return true; + } + unsigned char *pixels = read_cover_pixels(path, width, height, pitch, bytes); + if (!pixels) return false; + bool written = write_cover_png(temporary, path, pixels, + width, height, pitch); + free(pixels); + if (!written) return false; + bongo_cat_model_cover_finish(app); if (app->preferences) bongo_cat_preferences_model_cover_reload(app, path); SDL_Log("Model cover refreshed: path=%s (%dx%d) elapsed_ms=%.2f", path, width, height, (double)(SDL_GetTicksNS() - started_ns) / 1000000.0); - free(pixels); + return true; } diff --git a/src/runtime/model/model_cover.h b/src/runtime/model/model_cover.h index 27bd93d1..ab96e375 100644 --- a/src/runtime/model/model_cover.h +++ b/src/runtime/model/model_cover.h @@ -4,11 +4,17 @@ #include "bongo_cat/app.h" #include "model_cover_paths.h" +/* Pending cover queue and retry policy. */ void bongo_cat_model_cover_schedule(BongoCatApp *app, const BongoCatModelEntry *entry); bool bongo_cat_model_cover_pending(const BongoCatApp *app); +bool bongo_cat_model_cover_capture_due(const BongoCatApp *app, uint64_t now); const char *bongo_cat_model_cover_pending_path(const BongoCatApp *app); +void bongo_cat_model_cover_defer(BongoCatApp *app, const char *reason); +void bongo_cat_model_cover_finish(BongoCatApp *app); + +/* Capture workflow and framebuffer readback. */ void bongo_cat_model_cover_capture_before_switch(BongoCatApp *app); -void bongo_cat_model_cover_capture(BongoCatApp *app, int width, int height); +bool bongo_cat_model_cover_capture(BongoCatApp *app, int width, int height); #endif diff --git a/src/runtime/model/model_cover_paths.h b/src/runtime/model/model_cover_paths.h index 82953020..ee543522 100644 --- a/src/runtime/model/model_cover_paths.h +++ b/src/runtime/model/model_cover_paths.h @@ -3,11 +3,5 @@ #define BONGO_CAT_MODEL_COVER_RESOURCES "resources" #define BONGO_CAT_MODEL_COVER_FILE BONGO_CAT_MODEL_COVER_RESOURCES "/cover.png" -#define BONGO_CAT_MODEL_COVER_FALLBACK_NAME ".bongo-cat-cover-fallback" -#define BONGO_CAT_MODEL_COVER_GENERATED_NAME ".bongo-cat-cover-generated" -#define BONGO_CAT_MODEL_COVER_FALLBACK_MARKER \ - BONGO_CAT_MODEL_COVER_RESOURCES "/" BONGO_CAT_MODEL_COVER_FALLBACK_NAME -#define BONGO_CAT_MODEL_COVER_GENERATED_MARKER \ - BONGO_CAT_MODEL_COVER_RESOURCES "/" BONGO_CAT_MODEL_COVER_GENERATED_NAME #endif diff --git a/src/runtime/model/model_cover_queue.c b/src/runtime/model/model_cover_queue.c new file mode 100644 index 00000000..1ebb456c --- /dev/null +++ b/src/runtime/model/model_cover_queue.c @@ -0,0 +1,159 @@ +#include "model_cover.h" + +#include "bongo_cat/path.h" + +#include +#include +#include + +enum { + COVER_RETRY_INITIAL_MS = 250, + COVER_RETRY_MAX_MS = 5000 +}; + +static size_t find_task(const BongoCatApp *app, const char *model_id) { + if (!app || !model_id || !model_id[0]) return SIZE_MAX; + for (size_t i = 0; i < app->pending_model_cover_count; ++i) + if (strcmp(app->pending_model_cover_ids[i], model_id) == 0) + return i; + return SIZE_MAX; +} + +static void remove_task_at(BongoCatApp *app, size_t index) { + if (!app || index >= app->pending_model_cover_count) return; + size_t remaining = app->pending_model_cover_count - index - 1; + if (remaining) { + memmove(app->pending_model_cover_ids + index, + app->pending_model_cover_ids + index + 1, + remaining * sizeof(app->pending_model_cover_ids[0])); + memmove(app->pending_model_cover_paths + index, + app->pending_model_cover_paths + index + 1, + remaining * sizeof(app->pending_model_cover_paths[0])); + memmove(app->pending_model_cover_retry_ns + index, + app->pending_model_cover_retry_ns + index + 1, + remaining * sizeof(app->pending_model_cover_retry_ns[0])); + memmove(app->pending_model_cover_attempts + index, + app->pending_model_cover_attempts + index + 1, + remaining * sizeof(app->pending_model_cover_attempts[0])); + } + app->pending_model_cover_count--; + app->pending_model_cover_ids[app->pending_model_cover_count][0] = '\0'; + app->pending_model_cover_paths[app->pending_model_cover_count][0] = '\0'; + app->pending_model_cover_retry_ns[app->pending_model_cover_count] = 0; + app->pending_model_cover_attempts[app->pending_model_cover_count] = 0; +} + +static void discard_removed_tasks(BongoCatApp *app) { + if (!app || !app->models.count) return; + for (size_t i = 0; i < app->pending_model_cover_count;) { + if (bongo_cat_models_find(&app->models, + app->pending_model_cover_ids[i])) { + ++i; + continue; + } + SDL_Log("Discarding model cover task for removed model: id=%s", + app->pending_model_cover_ids[i]); + remove_task_at(app, i); + } +} + +static bool discard_oldest_task(BongoCatApp *app, + const char *keep_model_id) { + if (!app) return false; + size_t candidate = SIZE_MAX; + for (size_t i = 0; i < app->pending_model_cover_count; ++i) { + if (strcmp(app->pending_model_cover_ids[i], keep_model_id) == 0) + continue; + if (app->pending_model_cover_attempts[i]) { + candidate = i; + break; + } + if (candidate == SIZE_MAX) candidate = i; + } + if (candidate == SIZE_MAX) return false; + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Discarding superseded model cover task: id=%s attempts=%u", + app->pending_model_cover_ids[candidate], + app->pending_model_cover_attempts[candidate]); + remove_task_at(app, candidate); + return true; +} + +void bongo_cat_model_cover_schedule(BongoCatApp *app, + const BongoCatModelEntry *entry) { + if (!app || !entry || !entry->adapter_directory[0]) return; + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), entry->adapter_directory, + BONGO_CAT_MODEL_COVER_FILE)) return; + discard_removed_tasks(app); + for (size_t i = 0; i < app->pending_model_cover_count; ++i) { + if (strcmp(app->pending_model_cover_ids[i], entry->id) != 0 && + strcmp(app->pending_model_cover_paths[i], path) != 0) continue; + /* A model use is a fresh request, so clear any previous backoff. */ + snprintf(app->pending_model_cover_ids[i], + sizeof(app->pending_model_cover_ids[i]), "%s", entry->id); + snprintf(app->pending_model_cover_paths[i], + sizeof(app->pending_model_cover_paths[i]), "%s", path); + app->pending_model_cover_retry_ns[i] = 0; + app->pending_model_cover_attempts[i] = 0; + app->dirty = true; + SDL_Log("Rescheduling model cover refresh: id=%s path=%s", + entry->id, path); + return; + } + if (app->pending_model_cover_count >= BONGO_CAT_MODEL_COVER_PENDING_CAP && + !discard_oldest_task(app, entry->id)) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Model cover queue is full; dropping refresh: id=%s", entry->id); + return; + } + size_t index = app->pending_model_cover_count++; + snprintf(app->pending_model_cover_ids[index], + sizeof(app->pending_model_cover_ids[index]), "%s", entry->id); + snprintf(app->pending_model_cover_paths[index], + sizeof(app->pending_model_cover_paths[index]), "%s", path); + app->pending_model_cover_retry_ns[index] = 0; + app->pending_model_cover_attempts[index] = 0; + SDL_Log("Scheduling model cover refresh: id=%s source=runtime path=%s", + entry->id, path); + app->dirty = true; +} + +bool bongo_cat_model_cover_pending(const BongoCatApp *app) { + return find_task(app, app ? app->loaded_model : NULL) != SIZE_MAX; +} + +bool bongo_cat_model_cover_capture_due(const BongoCatApp *app, uint64_t now) { + size_t index = find_task(app, app ? app->loaded_model : NULL); + return index != SIZE_MAX && + (!app->pending_model_cover_retry_ns[index] || + now >= app->pending_model_cover_retry_ns[index]); +} + +const char *bongo_cat_model_cover_pending_path(const BongoCatApp *app) { + size_t index = find_task(app, app ? app->loaded_model : NULL); + return index == SIZE_MAX ? NULL : app->pending_model_cover_paths[index]; +} + +void bongo_cat_model_cover_defer(BongoCatApp *app, const char *reason) { + if (!app) return; + size_t index = find_task(app, app->loaded_model); + if (index == SIZE_MAX) return; + unsigned attempts = ++app->pending_model_cover_attempts[index]; + unsigned shift = attempts > 5 ? 5 : attempts - 1; + uint64_t delay_ms = (uint64_t)COVER_RETRY_INITIAL_MS << shift; + if (delay_ms > COVER_RETRY_MAX_MS) delay_ms = COVER_RETRY_MAX_MS; + app->pending_model_cover_retry_ns[index] = + SDL_GetTicksNS() + delay_ms * 1000000ull; + app->dirty = true; + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Model cover refresh deferred: model=%s attempt=%u retry_ms=%llu " + "reason=%s path=%s", app->loaded_model, attempts, + (unsigned long long)delay_ms, reason && reason[0] ? reason : "unknown", + app->pending_model_cover_paths[index]); +} + +void bongo_cat_model_cover_finish(BongoCatApp *app) { + size_t index = find_task(app, app ? app->loaded_model : NULL); + if (index != SIZE_MAX) remove_task_at(app, index); +} diff --git a/src/runtime/model/model_files.c b/src/runtime/model/model_files.c index c1732b2a..2c6784a8 100644 --- a/src/runtime/model/model_files.c +++ b/src/runtime/model/model_files.c @@ -36,6 +36,19 @@ static void request_model_frame(BongoCatApp *app, bool reveal) { app->dirty = true; } +static size_t saved_behavior_count(const BongoCatApp *app, + const char *model_id) { + if (!app || !model_id) return 0; + size_t limit = app->session.active_behavior_count; + if (limit > BONGO_CAT_BEHAVIOR_BINDING_CAP) + limit = BONGO_CAT_BEHAVIOR_BINDING_CAP; + size_t count = 0; + for (size_t i = 0; i < limit; ++i) + if (strcmp(app->session.active_behaviors[i].model_id, model_id) == 0) + count++; + return count; +} + static void model_runtime_stage(BongoCatApp *app, const char *stage, const BongoCatModelEntry *entry) { char state[BONGO_CAT_ID_CAP + 32]; @@ -229,7 +242,13 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, bongo_cat_overlay_clear(app->overlay); } snprintf(app->loaded_model, sizeof(app->loaded_model), "%s", entry->id); + size_t saved_behaviors = saved_behavior_count(app, entry->id); bongo_cat_app_restore_behavior_state(app, entry->id); + SDL_Log("[runtime] Behavior restore summary: model=%s saved=%zu " + "motions=%zu expression=%d", entry->id, saved_behaviors, + bongo_cat_app_selected_motion_count(app), + bongo_cat_live2d_expression(app->live2d)); + bongo_cat_model_cover_schedule(app, entry); bongo_cat_random_expression_reset(app); app->pointer_known = false; bool geometry_changed = bongo_cat_model_apply_aspect(app, &render_options, @@ -243,7 +262,13 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, commit_model(app, entry, true, replacing_model); bongo_cat_app_reapply_input(app); bongo_cat_app_apply_mouse(app); - bongo_cat_model_cover_schedule(app, entry); + SDL_Log("[runtime] Model cover load phase: id=%s phase=%s", + entry->id, replacing_model ? "switch" : "startup"); + /* Finish the handoff before the independent readback so startup and model + switches both capture the restored state, never the initial state. */ + app->loading_model[0] = '\0'; + app->model_load_runtime_stage = 0; + bongo_cat_app_capture_pending_model_cover(app); /* Do not leave the previous frame cropped during the UI completion pass. */ if (replacing_model) bongo_cat_app_render_now(app); if (restore_context && previous_window && previous_context && @@ -258,8 +283,6 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, (void *)SDL_GL_GetCurrentWindow(), (void *)SDL_GL_GetCurrentContext(), (unsigned)glGetError()); model_runtime_stage(app, "completed", entry); - app->loading_model[0] = '\0'; - app->model_load_runtime_stage = 0; model_running_stage(app, entry->id); return true; } diff --git a/src/ui/preferences/preferences_model_cover.c b/src/ui/preferences/preferences_model_cover.c index 4680d456..3a528c09 100644 --- a/src/ui/preferences/preferences_model_cover.c +++ b/src/ui/preferences/preferences_model_cover.c @@ -54,10 +54,14 @@ bool bongo_cat_preferences_model_cover_reload(BongoCatApp *app, slot->image.height = height; slot->generation = cover_generation; glDeleteTextures(1, &previous); + if (app->preferences) + bongo_cat_preferences_invalidate(app->preferences); SDL_Log("Model cover cache reloaded: %s (%dx%d)", path, width, height); return true; } + if (app->preferences) + bongo_cat_preferences_invalidate(app->preferences); return true; } diff --git a/tests/core/test_app_state.c b/tests/core/test_app_state.c index 1227257f..0cfb6d01 100644 --- a/tests/core/test_app_state.c +++ b/tests/core/test_app_state.c @@ -149,6 +149,7 @@ static void check_behavior_state(BongoCatApp *app) { active_motions[1] = true; active_motions[2] = true; active_expression = 3; + CHECK(bongo_cat_app_selected_motion_count(app) == 2); bongo_cat_app_capture_behavior_state(app); CHECK(app->session.active_behavior_count == 3); CHECK(strcmp(app->session.active_behaviors[0].model_id, @@ -164,6 +165,7 @@ static void check_behavior_state(BongoCatApp *app) { bongo_cat_app_restore_behavior_state(app, "model-a"); CHECK(active_motions[1]); CHECK(!active_motions[2]); + CHECK(bongo_cat_app_selected_motion_count(app) == 1); CHECK(active_expression == 3); CHECK(restored_motion_count == 1); From 411e5da69b25abf3c8f50b1470d1b33f4235cce4 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 1 Sep 2026 12:00:18 +0800 Subject: [PATCH 175/334] Update README.md --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index e6697cb0..083ca99f 100644 --- a/README.md +++ b/README.md @@ -326,6 +326,12 @@ users. For BongoCat's current workload, OpenGL keeps the renderer smaller, easier to debug, and easier to maintain while still delivering the performance we need. + + +## Project Status +![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") + + ## 🙏 Special Thanks From 708ab53367b528b5d90c009705164b99daa2f37c Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 1 Sep 2026 12:17:41 +0800 Subject: [PATCH 176/334] Update README.md --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 083ca99f..5478324b 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,4 @@ - - - -
Catime @@ -18,6 +14,10 @@

💘C/C++ × SDL3 × OpenGL, stir it up, mash it together! Bong~ Bongo Cat!!!

+

+ Select language ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español +

+
From 083ce096265cbd4744301513a51006249917a04a Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 1 Sep 2026 12:22:41 +0800 Subject: [PATCH 177/334] Fix duplicate language selection links in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5478324b..1e1cb401 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ 💘C/C++ × SDL3 × OpenGL, stir it up, mash it together! Bong~ Bongo Cat!!!

- Select language ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español +English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español

From 7c12958eda728753a1c7c98bc8192c5661b90622 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 1 Sep 2026 12:17:05 +0800 Subject: [PATCH 178/334] =?UTF-8?q?=F0=9F=8C=9F=20chore:=20streamline=20pr?= =?UTF-8?q?oject=20updates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 12 +++++++++++- docs/README.de-DE.md | 0 docs/README.es-ES.md | 0 docs/README.fr-FR.md | 0 docs/README.ja-JP.md | 0 docs/README.ko-KR.md | 0 docs/README.pt-BR.md | 0 docs/README.ru-RU.md | 0 docs/README.zh-CN.md | 0 docs/README.zh-Hant.md | 0 10 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 docs/README.de-DE.md create mode 100644 docs/README.es-ES.md create mode 100644 docs/README.fr-FR.md create mode 100644 docs/README.ja-JP.md create mode 100644 docs/README.ko-KR.md create mode 100644 docs/README.pt-BR.md create mode 100644 docs/README.ru-RU.md create mode 100644 docs/README.zh-CN.md create mode 100644 docs/README.zh-Hant.md diff --git a/.gitignore b/.gitignore index 083150da..591e29a6 100644 --- a/.gitignore +++ b/.gitignore @@ -53,7 +53,17 @@ Desktop.ini # Local reference files /.reference/ -/docs/ +/docs/* +!/docs/ +!/docs/README.zh-CN.md +!/docs/README.zh-Hant.md +!/docs/README.fr-FR.md +!/docs/README.de-DE.md +!/docs/README.ja-JP.md +!/docs/README.ko-KR.md +!/docs/README.pt-BR.md +!/docs/README.ru-RU.md +!/docs/README.es-ES.md # Local Windows audit tools /cmake/*.ps1 diff --git a/docs/README.de-DE.md b/docs/README.de-DE.md new file mode 100644 index 00000000..e69de29b diff --git a/docs/README.es-ES.md b/docs/README.es-ES.md new file mode 100644 index 00000000..e69de29b diff --git a/docs/README.fr-FR.md b/docs/README.fr-FR.md new file mode 100644 index 00000000..e69de29b diff --git a/docs/README.ja-JP.md b/docs/README.ja-JP.md new file mode 100644 index 00000000..e69de29b diff --git a/docs/README.ko-KR.md b/docs/README.ko-KR.md new file mode 100644 index 00000000..e69de29b diff --git a/docs/README.pt-BR.md b/docs/README.pt-BR.md new file mode 100644 index 00000000..e69de29b diff --git a/docs/README.ru-RU.md b/docs/README.ru-RU.md new file mode 100644 index 00000000..e69de29b diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md new file mode 100644 index 00000000..e69de29b diff --git a/docs/README.zh-Hant.md b/docs/README.zh-Hant.md new file mode 100644 index 00000000..e69de29b From aa6b8f83df7fffd7a2371a46c621235c7c815c71 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 1 Sep 2026 14:31:24 +0800 Subject: [PATCH 179/334] =?UTF-8?q?=F0=9F=8C=9F=20chore:=20clean=20up=20pr?= =?UTF-8?q?oject=20details?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README-zh_cn.md | 308 ------------------------------------------- docs/README.zh-CN.md | 230 ++++++++++++++++++++++++++++++++ 2 files changed, 230 insertions(+), 308 deletions(-) delete mode 100644 README-zh_cn.md diff --git a/README-zh_cn.md b/README-zh_cn.md deleted file mode 100644 index 9a73fc5b..00000000 --- a/README-zh_cn.md +++ /dev/null @@ -1,308 +0,0 @@ - - - - - -
- - Catime - - -

- BongoCat -

-
- - - -

- 💘C/C++ × SDL3 × OpenGL, 搅匀,捣碎!嘣~ Bongo Cat!!! -

- - -
- -
- - -> [!TIP] -> 本演示使用的模型来自[宇痕冫](https://space.bilibili.com/348616056). -> -> 🎁寻找**免费**模型?访问我们的官网: [bongocat.pet](https://bongocat.pet/models) - - -

- -

- -## 📥 下载 - - - - - -- GitHub Releases - - 从 [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest) 下载最新版本。 - -## 项目状态 - - -## 许可证 - -BongoCat 源代码和原生运行时的许可证为 [AGPL-3.0-only](LICENSE) - -默认内置模型模式(`standard`)仍采用 MIT 许可证。`resources/assets/models/standard`、`keyboard` 和 `gamepad` 中的捆绑模型资产受单独的 [MIT 许可声明](LICENSE-MIT) 管辖。该 MIT 许可证仅适用于模型资产及其附带的美术作品;它不会重新授权 BongoCat 源代码或原生运行时。 - - -## 技术架构 - -> 当前原生版本基于 C/C++、SDL3 和 OpenGL 构建。下图可能看起来有点复杂,但实际上并不难理解。 - -### 运行时所有权与帧调度 - -原生运行时在所有权边界上有意进行了划分: - -```text -平台输入监听器 -(键盘 / 指针) - | - v - C11 输入状态 - (原子边沿队列 + 合并的指针路径) - | - v - 主线程应用 <----- SDL3 事件 - | - v - 模型参数、覆盖层和 UI 状态 - | - v - Cubism 更新 -> OpenGL 合成 -> 平台呈现 -``` - -平台输入监听器从不直接操作模型。Windows 低级钩子、macOS Quartz 事件监听和 Linux XInput2 将带时间戳的键盘和鼠标按键转换发布到应用程序拥有的输入状态。在 Windows 上,当导入的模型请求相对移动时,通过平台指针接口对 DirectInput 进行采样;SDL3 游戏手柄事件在主线程上标准化。指针坐标使用独立的原子合并路径,因此高频运动不会取代用于按键和按钮边沿的有界队列。 - -每次循环迭代等待 SDL 或原生唤醒、下一个调度程序截止时间或待处理的 UI/窗口工作。然后它排空排队的事件和释放恢复,应用输入派生的参数和覆盖层状态,并使用经过的时间推进模型。在普通的宠物窗口路径中,当应用程序被标记为“脏”时,会呈现一帧;显式的预览、调整大小和捕获操作可能会请求额外的渲染。呈现是平台特定的:在 macOS 和 Linux 上使用 SDL_GL_SwapWindow,在 Windows 上,当分层呈现未激活时也使用它;活动的 Windows 分层呈现使用 UpdateLayeredWindow。 - -调度器不需要固定的模拟步长。启用 Cubism 后,下一个模型更新从配置的最大 FPS(默认为 60 FPS)派生,并且运行时接收自上次更新以来经过的时间。经过的时间间隔限制在 250 毫秒以内,并细分为最多八个子步骤以保证稳定性。如果更新未产生视觉变化,则除非另一个状态转换请求渲染,否则不会再次呈现普通宠物窗口。 - -C 运行时调用 include/bongo_cat/model.h 中声明的 C ABI,其桥接和模型实现位于 src/live2d。当启用 Cubism SDK 时,该实现为 C++17,因为 SDK 公开了 C++ API;其余原生 C 源使用 C11。Cubism SDK 类型保留在不透明的 C 句柄和 C 兼容结构后面,而当 SDK 不可用时,src/live2d/live2d_stub.c 提供诊断后端。 - -```mermaid -%%{init: {"flowchart": {"curve": "catmullRom", "htmlLabels": true, "nodeSpacing": 24, "rankSpacing": 38}}}%% -flowchart TB - User(["用户输入
键盘 / 鼠标 / 游戏手柄"]) - ModelSources(["外部模型源
独立 .model3.json / Mver 包 / Mver 图像补丁"]) - Desktop(["桌面输出
透明宠物窗口 / 偏好设置窗口"]) - - subgraph Platform["平台后端"] - direction LR - Win["Windows
Win32 钩子 / DirectInput / 分层窗口支持"] - Mac["macOS
Cocoa / ApplicationServices / Quartz 事件监听"] - Linux["Linux
X11 / XInput2 / XFixes"] - end - - subgraph Native["BongoCat 原生运行时"] - direction TB - - subgraph Orchestration["运行时编排 / C11"] - direction LR - Entry["进程入口
src/main.c 调用 bongo_cat_app_run"] - Lifecycle["应用生命周期
启动 / 存储路径 / 配置 / 关闭"] - EventLoop["SDL3 事件与帧循环
等待 / 分发 / 排空输入 / 更新 / 渲染"] - AppState[("BongoCatApp 状态
设置 / 会话 / 目录 / 平台和运行时状态")] - - Entry --> Lifecycle --> EventLoop - Lifecycle <--> AppState - EventLoop <--> AppState - end - - subgraph InputShell["输入与桌面外壳"] - direction LR - GlobalInput["全局键盘和指针捕获
Win32 钩子 / Quartz 事件监听 / XInput2"] - InputQueue[("原子输入状态
有序边沿队列 / 释放边沿恢复 / 合并的指针位置")] - Gamepad["SDL3 游戏手柄事件
活动设备选择 / 按钮 / 轴"] - InputMap["输入分发
快捷键 / 模型参数 / 指针映射"] - Shell["桌面外壳
窗口 / 托盘 / 上下文菜单 / 拖拽和调整大小 / 穿透点击 / 多宠物协调"] - - GlobalInput --> InputQueue --> InputMap - Gamepad --> InputMap - EventLoop --> Shell - EventLoop --> InputMap - InputMap --> AppState - Shell --> AppState - end - - subgraph ModelPipeline["模型发现、适配与编目"] - direction LR - BuiltIn["内置模型
resources/assets/models"] - Discover["源发现
独立 model3 / Mver 包 / 图像补丁"] - Validate["验证与身份
清单引用 / 有界路径 / SHA-256 摘要 / 元数据"] - Adapt["运行时适配器生成
预览资产 / 输入覆盖层 / 投影和绑定元数据"] - Installed[("已安装包
models_root / 可直接复制的 Mver 树")] - Nearby[("运行时适配器
生成在 models_root 之外 / cache_root/model-adapters")] - Catalog["模型与行为目录
模型 / 动作 / 表情 / 音效 / 特效"] - - Discover --> Validate --> Adapt - Adapt --> Installed --> Catalog - Adapt --> Nearby --> Catalog - BuiltIn --> Catalog - Catalog --> AppState - end - - subgraph Presentation["动画、合成与 UI"] - direction LR - Live2DBridge["C ABI / C++17 桥接"] - Cubism["Live2D Cubism 运行时
模型 / 动作 / 表情 / 物理 / 姿势 / 参数覆盖"] - Images["图像管线
stb 解码与缩放 / Alpha 蒙版与 Mipmap / OpenGL 纹理上传"] - Overlay["输入覆盖层运行时
背景 / 指针 / 按键图像 / 特效"] - Audio["音频播放
miniaudio 引擎 / 异步文件解码"] - Composite["OpenGL 帧合成
清除和背景 / Live2D 模型 / 指针、按键和特效"] - Preferences["Nuklear 偏好设置 UI
Catime 主题 / 本地化 / 设置 / 模型管理"] - Present["平台呈现
Windows: UpdateLayeredWindow 或 GL 交换
macOS 和 Linux: SDL_GL_SwapWindow"] - - Live2DBridge --> Cubism - Cubism --> Composite --> Present - Images --> Cubism - Images --> Overlay --> Composite - AppState <--> Preferences - end - - EventLoop --> Live2DBridge - EventLoop --> Composite - EventLoop --> Preferences - AppState --> Live2DBridge - AppState --> Composite - InputMap --> Live2DBridge - InputMap --> Overlay - InputMap --> Audio - Catalog --> Live2DBridge - Catalog --> Overlay - Catalog --> Audio - Shell --> Preferences - end - - subgraph Dependencies["原生依赖"] - direction LR - SDL["SDL3"] - OpenGL["OpenGL"] - GLEW["GLEW
Cubism OpenGL 函数加载"] - CubismSDK["Live2D Cubism SDK"] - YYJSON["yyjson"] - STB["stb_image / stb_image_resize2 / stb_image_write"] - Miniaudio["miniaudio"] - Nuklear["Nuklear"] - end - - subgraph Toolchain["构建、验证与分发"] - direction LR - CMake["CMake
固定依赖 / 平台源 / 资产暂存与嵌入"] - CTest["CTest
核心 / i18n / UI / 应用状态 / 模型导入 / 动作状态 / Windows 捕获"] - Audits["静态与运行时检查
行策略 / 平台 API 允许列表 / 烟雾与视觉审计"] - Packaging["分发
Windows ZIP 和 NSIS / macOS ZIP / Linux TGZ / Microsoft Store MSIX"] - - CMake --> CTest - CMake --> Audits - CMake --> Packaging - end - - User --> GlobalInput - User --> Gamepad - User --> Shell - ModelSources --> Discover - - Win --> GlobalInput - Mac --> GlobalInput - Linux --> GlobalInput - Win --> Shell - Mac --> Shell - Linux --> Shell - Win --> Present - Mac --> Present - Linux --> Present - - Present --> Desktop - Preferences --> Desktop - - SDL -.-> EventLoop - SDL -.-> Gamepad - SDL -.-> Shell - SDL -.-> Present - OpenGL -.-> Cubism - OpenGL -.-> Composite - OpenGL -.-> Preferences - GLEW -.-> Cubism - CubismSDK -.-> Cubism - YYJSON -.-> Lifecycle - YYJSON -.-> Validate - STB -.-> Images - Miniaudio -.-> Audio - Nuklear -.-> Preferences - - CMake -.-> Entry - CTest -.-> AppState - CTest -.-> Preferences - CTest -.-> Live2DBridge - CTest -.-> Present - Audits -.-> EventLoop - Audits -.-> GlobalInput - Audits -.-> Composite - - classDef external fill:#fff4cc,stroke:#b7791f,color:#3d2b00,stroke-width:1.5px - classDef platform fill:#e8f1ff,stroke:#4a78b8,color:#14263d,stroke-width:1.5px - classDef runtime fill:#ffe8f0,stroke:#d95f8d,color:#3d1725,stroke-width:1.5px - classDef state fill:#f3eaff,stroke:#8a63c7,color:#2c1b46,stroke-width:1.5px - classDef input fill:#e7f8ff,stroke:#3f8eaa,color:#12323d,stroke-width:1.5px - classDef model fill:#eaf8e8,stroke:#57945a,color:#18361a,stroke-width:1.5px - classDef render fill:#f3ecff,stroke:#8064b3,color:#281b40,stroke-width:1.5px - classDef dependency fill:#f4f4f5,stroke:#71717a,color:#27272a,stroke-width:1.2px - classDef tooling fill:#fff0dd,stroke:#c47b2c,color:#42270d,stroke-width:1.5px - - class User,ModelSources,Desktop external - class Win,Mac,Linux platform - class Entry,Lifecycle,EventLoop runtime - class AppState,InputQueue,Installed,Nearby state - class GlobalInput,Gamepad,InputMap,Shell input - class BuiltIn,Discover,Validate,Adapt,Catalog model - class Live2DBridge,Cubism,Images,Overlay,Audio,Composite,Preferences,Present render - class SDL,OpenGL,GLEW,CubismSDK,YYJSON,STB,Miniaudio,Nuklear dependency - class CMake,CTest,Audits,Packaging tooling - - style Native fill:#fffafd,stroke:#d95f8d,stroke-width:2px - style Platform fill:#f8fbff,stroke:#7da1cf,stroke-width:1.5px - style Orchestration fill:#fff7fa,stroke:#ed9eb8,stroke-width:1px - style InputShell fill:#f5fcff,stroke:#86bfd3,stroke-width:1px - style ModelPipeline fill:#f7fcf5,stroke:#91bd8f,stroke-width:1px - style Presentation fill:#faf7ff,stroke:#ad98cf,stroke-width:1px - style Dependencies fill:#fafafa,stroke:#a1a1aa,stroke-width:1px - style Toolchain fill:#fffaf3,stroke:#dda866,stroke-width:1.5px -``` - -# 以下是你可能好奇的几点: - -1. 为什么选择 OpenGL 而非 Vulkan - - - - -## 特别感谢❤️ - - - Contribution Leaderboard - - - - - ---- - -
- -版权所有 © 2026 - BongoCat -作者 vladelaina -用 ❤️ 和 ⌨️ 制作 - -
- diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index e69de29b..90fc1307 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -0,0 +1,230 @@ + + +

💘 C/C++ × SDL3 × OpenGL,搅拌在一起,尽情敲击!Bong~ Bongo Cat!!!

+

+ 选择语言 ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español +

+ +
+ +> [!TIP] +> 演示中使用的模型来自 [宇痕冫](https://space.bilibili.com/348616056)。 +> +> 🎁 想找**免费**模型?欢迎访问官网:[bongocat.pet](https://bongocat.pet/models) + +

+ +## 📥 下载 + + + +- GitHub Releases + + 从 [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest) 下载最新版本。 + +## 🛠️ 从源码构建 + +BongoCat 使用 CMake,需要 C11 编译器、C++17 编译器、CMake 3.24 或更高版本,以及桌面 OpenGL 开发文件。默认情况下,SDL3、yyjson、stb、miniaudio 和 Nuklear 会在配置阶段自动下载,因此首次配置需要网络连接。 + +请在项目根目录(包含 `CMakeLists.txt` 的目录)运行以下命令。 + +### 📋 平台前置条件 + +- **Windows:** Visual Studio 2022(安装“使用 C++ 的桌面开发”工作负载)和 CMake。请使用 MSVC 生成器;MinGW 可构建诊断后端,但不支持 Cubism SDK。 +- **macOS:** Xcode Command Line Tools、CMake 和 Ninja。如果目标架构与主机默认架构不同,请通过 `CMAKE_OSX_ARCHITECTURES` 指定。 +- **Linux(Debian/Ubuntu):** GCC 或 Clang、Ninja,以及 OpenGL/X11 头文件: + + ```bash + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build \ + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev + ``` + +### 🔧 配置与构建 + +在 Linux 和 macOS 上,请使用 Ninja 这样的单配置生成器: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --parallel +``` + +在 Windows 上,请从 Visual Studio 2022 开发者命令行(或 MSVC 可用的其他命令行)运行: + +```powershell +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --config Release --parallel +``` + +可执行文件位于:Linux 的 `build/BongoCat`,macOS 的 `build/BongoCat.app/Contents/MacOS/BongoCat`,Visual Studio 构建的 Windows 版本为 `build/Release/BongoCat.exe`。 + +### 🧪 测试 + +CTest 目标默认启用。构建后运行: + +```bash +ctest --test-dir build --output-on-failure +``` + +对于 Visual Studio 这类多配置生成器,请明确指定构建配置: + +```powershell +ctest --test-dir build -C Release --output-on-failure +``` + +### 🎭 Live2D / Cubism SDK(可选) + +如果未找到 Cubism SDK,CMake 会发出警告并构建诊断后端。该后端用于启动和平台诊断,不提供 Live2D 模型渲染。要构建完整运行时,请安装兼容的 Cubism SDK for Native,将其放置在 `vendor/CubismSdkForNative`,或显式传入路径: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ + -DBONGO_CAT_REQUIRE_CUBISM=ON +``` + +SDK 必须包含 Core 库、Framework 源码,以及 `cmake/Cubism.cmake` 所要求布局中的 OpenGL GLEW 第三方目录。Windows Cubism 构建需要 Visual Studio 2022。`BONGO_CAT_REQUIRE_CUBISM=ON` 会在 SDK 不可用时使配置失败,而不是静默选择诊断后端。 + +### ⚙️ CMake 选项 + +| 选项 | 默认值 | 说明 | +| --- | --- | --- | +| `BONGO_CAT_FETCH_DEPS` | `ON` | 使用 CMake `FetchContent` 下载固定版本的第三方依赖。仅当 SDL3、yyjson、stb、miniaudio 和 Nuklear 已可供 CMake 使用时才设为 `OFF`。 | +| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Cubism SDK for Native 的路径。 | +| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | SDK 不可用时使配置失败。 | +| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | 将本地编译器警告视为错误。 | + +离线构建时将 `BONGO_CAT_FETCH_DEPS=OFF`,并提供 SDL3(包括 `SDL3-static`)和 yyjson 的 CMake 包配置;如果 stb、Nuklear 和 miniaudio 无法自动发现,还需提供其包含目录: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=OFF \ + -DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ + -DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ + -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio +``` + +## 📌 项目状态 + +![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") + +## 📜 许可证 + +BongoCat 源代码和本地运行时采用 [AGPL-3.0-only](../LICENSE) 许可证。 + +默认内置模型模式(`standard`)仍采用 MIT 许可证。`resources/assets/models/standard`、`keyboard` 和 `gamepad` 中捆绑的模型资源受单独的 [MIT 许可证声明](../LICENSE-MIT) 覆盖。该 MIT 许可证仅适用于模型资源及其配套美术作品,不会改变 BongoCat 源代码或本地运行时的许可证。 + +## 🧭 技术架构 + +当前原生版本基于 C/C++、SDL3 和 OpenGL 构建。下图重点展示运行时数据流;构建与打包细节请参阅 CMake 文件。 + +### 🔄 运行时所有权与帧调度 + +每个进程拥有一个 `BongoCatApp` 和一个主线程事件/渲染循环。平台监听器在输入边界处停止: + +```text +平台监听器(键盘/指针) + | + v + C11 输入状态(原子边沿队列 + 合并后的指针位置) + | + v + 主线程应用 <----- SDL3 事件 + | + v + 模型参数、覆盖层和 UI 状态 + | + v + 模型更新 -> OpenGL 合成 -> 平台呈现 +``` + +Windows 低级钩子、macOS Quartz 事件 tap 和 Linux XInput2 监听器运行在主循环之外。它们将带时间戳的按键和鼠标按钮边沿发布到有界原子队列,并通过独立的合并槽发布指针坐标;成功发布后会推送原生 SDL 唤醒事件,从而避免高频移动事件挤出有序的按键和按钮边沿。在 Windows 上,只有当模型请求相对移动时,才会通过平台指针接口使用 DirectInput。SDL3 窗口、偏好设置和手柄事件在主线程处理,手柄事件会先标准化再传递给模型参数或快捷键。任何平台监听器都不会直接调用 Live2D、覆盖层或 UI 代码。 + +`bongo_cat_app_run` 负责更新、关闭和次级进程参数,确保主进程的单实例所有权,分配应用状态,执行初始化,进入 `bongo_cat_app_loop`,随后按定义顺序刷新状态并销毁资源。初始化会加载配置和存储路径、定位资源、创建 SDL/OpenGL 宠物窗口、初始化平台后端、创建 Live2D/覆盖层/音频服务、扫描内置/已安装/附近的模型来源,并加载可用模型。`BongoCatApp` 持有设置、会话状态、模型和行为目录、平台句柄及运行时服务句柄。 + +已安装的模型包使用 Mver 作为规范格式。导入流程会解析选中的文件或目录,发现并验证候选项,生成包身份指纹,将 Tauri 来源转换为 Mver,应用图像补丁,并将规范化包提交到 `models_root`。随后生成运行时适配器并刷新目录。附近来源只在不安装源目录的情况下被发现;其适配器和检查结果缓存在 `models_root` 之外的 `cache_root` 下。 + +每次主循环迭代都会等待 SDL/原生唤醒,或等待最早的帧、UI、动画和指针命中截止时间(最长 250 毫秒)。循环分发排队的 SDL 事件,清空原子输入队列并执行释放恢复,更新窗口和模型刷新状态,然后应用输入参数。启用 Cubism 时,模型截止时间遵循 `settings.model.max_fps`(默认为 60 FPS);诊断构建使用 100 毫秒的后备间隔。模型经过的时间最多计为 250 毫秒,并拆分为最多八个子步,每个子步目标不超过 1/30 秒。 + +常规宠物路径仅在窗口可见、未最小化且标记为脏时渲染。每帧先清空背景,绘制模型,再合成指针、按键和效果覆盖层,最后调用平台呈现器。预览操作可以请求立即渲染,截图渲染可以跳过呈现。macOS 和 Linux 直接交换 SDL OpenGL 窗口;Windows 在未启用分层呈现时直接交换,否则读取帧缓冲并调用 `UpdateLayeredWindow`。偏好设置 UI 拥有独立的 SDL/OpenGL 窗口,并单独渲染和呈现。 + +C 运行时调用 `include/bongo_cat/model.h` 中声明的 ABI。Live2D 桥接和 Cubism 实现在 `src/live2d` 中,仅在启用 Cubism SDK 时使用 C++17;其余本地运行时使用 C11。Cubism 类型保持在不透明 C 句柄之后;当 SDK 不可用时,`src/live2d/live2d_stub.c` 提供诊断后端。 + +```mermaid +flowchart TB + Input(["键盘 / 鼠标 / 手柄"]) + BuiltIn(["内置模型资源"]) + Sources(["外部模型来源
Mver、Tauri、.model3.json、图像补丁"]) + Desktop(["宠物窗口和偏好设置窗口"]) + subgraph Runtime["BongoCat 原生运行时"] + direction TB + Entry["src/main.c
bongo_cat_app_run"] + Startup["启动和初始化
配置、存储、窗口、平台"] + Loop["SDL3 主循环
等待、分发、更新、渲染"] + Shutdown["关闭
刷新状态、停止服务、释放资源"] + InputQueue[("原子输入状态
边沿队列和合并指针位置")] + InputDispatch["输入分发
快捷键、指针映射、模型参数"] + State[("BongoCatApp 状态
设置、会话、目录、运行时句柄")] + Import["模型发现和导入
验证、规范化为 Mver、安装/缓存"] + Catalog[("模型和行为目录")] + Live2D["Live2D C ABI
Cubism SDK 或诊断存根"] + Overlay["覆盖层和音频"] + Preferences["偏好设置和桌面外壳
Nuklear UI、托盘、窗口操作"] + Compose["OpenGL 帧合成"] + Present["平台呈现"] + Entry --> Startup --> Loop + Loop --> Shutdown + Loop --> InputDispatch --> State + Loop <--> State + State --> Live2D + State --> Overlay + State <--> Preferences + Loop --> Preferences + Catalog --> State + State --> Compose + Live2D --> Compose + Overlay --> Compose + Compose --> Present + Loop --> Compose + end + subgraph Platform["平台后端"] + direction LR + Global["全局键盘/指针捕获
Windows、macOS、Linux"] + SDL["SDL3 事件
窗口和手柄事件"] + end + Input --> Global --> InputQueue --> InputDispatch + Input --> SDL --> Loop + BuiltIn --> Catalog + Sources --> Import --> Catalog + Present --> Desktop + Preferences --> Desktop +``` + +## ❓ 常见问题 + +### 🔒 BongoCat 会记录我的键盘或鼠标输入吗? + +不会。BongoCat 在本地处理键盘和鼠标输入,用于驱动动画和快捷键。它不会记录或上传按键、鼠标操作或其他交互数据。配置也只保存在本地,应用不包含广告、分析工具或用户跟踪代码。执行更新检查时只会请求公开的版本元数据,不会发送输入、配置或使用数据。 + +### 🖼️ 为什么使用 OpenGL 而不是 Vulkan? + +这不是因为 Vulkan 不好,而是 BongoCat 不需要那种程度的复杂性。应用主要渲染一个 Live2D 模型、少量 UI 图层和透明桌面窗口,OpenGL 已能轻松满足需求,并且能自然地与 SDL3 及 Cubism 的 OpenGL 渲染器配合。迁移到 Vulkan 将需要在三个桌面平台维护更多渲染和同步代码,却不会为用户带来明显提升。对于 BongoCat 当前的工作负载,OpenGL 让渲染器更精简、更易调试和维护,同时仍能提供所需性能。 + +## 🙏 特别感谢 + +贡献排行榜 + +
+版权所有 © 2026 - **BongoCat**
+By vladelaina
+Made with ❤️ & ⌨️ +
From 80e5e51420571fa93bd4567758f6b4af325f6859 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 1 Sep 2026 14:43:24 +0800 Subject: [PATCH 180/334] release: add download links to GitHub releases --- .github/workflows/release.yml | 14 ++++++++++++++ include/bongo_cat/app.h | 2 ++ src/runtime/model/model_behavior_state.c | 13 +++++++++++++ src/runtime/model/model_files.c | 15 +-------------- 4 files changed, 30 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d40e941..e16f1c85 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -257,6 +257,20 @@ jobs: uses: softprops/action-gh-release@v2 with: name: BongoCat v${{ steps.version.outputs.version }} + body: | + ## Download + + + + + + ### GitHub Downloads + + - [Windows x64 installer](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/BongoCat-${{ steps.version.outputs.version }}-windows-x64-setup.exe) + - [Windows x64 portable](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/BongoCat-${{ steps.version.outputs.version }}-windows-x64-portable.exe) + - [Windows x86 installer](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/BongoCat-${{ steps.version.outputs.version }}-windows-x86-setup.exe) + - [Windows x86 portable](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/BongoCat-${{ steps.version.outputs.version }}-windows-x86-portable.exe) + generate_release_notes: true fail_on_unmatched_files: true make_latest: true diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index f05f83ab..ec55375c 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -194,6 +194,8 @@ bool bongo_cat_app_run_behavior(BongoCatApp *app, const BongoCatBehaviorEntry *behavior); size_t bongo_cat_app_selected_motion_count(const BongoCatApp *app); void bongo_cat_app_capture_behavior_state(BongoCatApp *app); +size_t bongo_cat_app_saved_behavior_count(const BongoCatApp *app, + const char *model_id); void bongo_cat_app_forget_behavior_state(BongoCatApp *app, const char *model_id); void bongo_cat_app_restore_behavior_state(BongoCatApp *app, diff --git a/src/runtime/model/model_behavior_state.c b/src/runtime/model/model_behavior_state.c index ba0f9334..9b4ff8de 100644 --- a/src/runtime/model/model_behavior_state.c +++ b/src/runtime/model/model_behavior_state.c @@ -86,6 +86,19 @@ void bongo_cat_app_capture_behavior_state(BongoCatApp *app) { } } +size_t bongo_cat_app_saved_behavior_count(const BongoCatApp *app, + const char *model_id) { + if (!app || !model_id) return 0; + size_t limit = app->session.active_behavior_count; + if (limit > BONGO_CAT_BEHAVIOR_BINDING_CAP) + limit = BONGO_CAT_BEHAVIOR_BINDING_CAP; + size_t count = 0; + for (size_t i = 0; i < limit; ++i) + if (strcmp(app->session.active_behaviors[i].model_id, model_id) == 0) + count++; + return count; +} + static const BongoCatBehaviorEntry *find_behavior(const BongoCatApp *app, const char *id) { for (size_t i = 0; i < app->behaviors.count; ++i) diff --git a/src/runtime/model/model_files.c b/src/runtime/model/model_files.c index 2c6784a8..30759fdc 100644 --- a/src/runtime/model/model_files.c +++ b/src/runtime/model/model_files.c @@ -36,19 +36,6 @@ static void request_model_frame(BongoCatApp *app, bool reveal) { app->dirty = true; } -static size_t saved_behavior_count(const BongoCatApp *app, - const char *model_id) { - if (!app || !model_id) return 0; - size_t limit = app->session.active_behavior_count; - if (limit > BONGO_CAT_BEHAVIOR_BINDING_CAP) - limit = BONGO_CAT_BEHAVIOR_BINDING_CAP; - size_t count = 0; - for (size_t i = 0; i < limit; ++i) - if (strcmp(app->session.active_behaviors[i].model_id, model_id) == 0) - count++; - return count; -} - static void model_runtime_stage(BongoCatApp *app, const char *stage, const BongoCatModelEntry *entry) { char state[BONGO_CAT_ID_CAP + 32]; @@ -242,7 +229,7 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, bongo_cat_overlay_clear(app->overlay); } snprintf(app->loaded_model, sizeof(app->loaded_model), "%s", entry->id); - size_t saved_behaviors = saved_behavior_count(app, entry->id); + size_t saved_behaviors = bongo_cat_app_saved_behavior_count(app, entry->id); bongo_cat_app_restore_behavior_state(app, entry->id); SDL_Log("[runtime] Behavior restore summary: model=%s saved=%zu " "motions=%zu expression=%d", entry->id, saved_behaviors, From 10994e8104c9ea0af73d5ca5d09bb6c715859c45 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 1 Sep 2026 14:49:19 +0800 Subject: [PATCH 181/334] Update README.md --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 1e1cb401..85ac2133 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,13 @@

English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español

+

+ + + + + +

From d7a0f6cfa326cbe274539b750ab3fa38e9575abd Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 1 Sep 2026 14:53:02 +0800 Subject: [PATCH 182/334] =?UTF-8?q?=F0=9F=8C=9F=20chore:=20adjust=20projec?= =?UTF-8?q?t=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + docs/wechat.png | Bin 0 -> 181337 bytes 2 files changed, 1 insertion(+) create mode 100644 docs/wechat.png diff --git a/.gitignore b/.gitignore index 591e29a6..ef09b35a 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,7 @@ Desktop.ini !/docs/README.pt-BR.md !/docs/README.ru-RU.md !/docs/README.es-ES.md +!/docs/wechat.png # Local Windows audit tools /cmake/*.ps1 diff --git a/docs/wechat.png b/docs/wechat.png new file mode 100644 index 0000000000000000000000000000000000000000..1375811ca066e8d0a3730ba65d2c0d155d6b5bb6 GIT binary patch literal 181337 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
MugJuOQ+k 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�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 VRl}F?^QuJn%gbJs$&|Y8^MA)15EK9a literal 0 HcmV?d00001 From 7fb927ca17ae65cd9cb4742d3abc3f62ad01dfe9 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 1 Sep 2026 15:07:37 +0800 Subject: [PATCH 183/334] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 85ac2133..ef72bdb8 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ - +

From 20ba54a04f25d85f70262bf2a15f5df98f2b62fd Mon Sep 17 00:00:00 2001 From: Erina <159221636+ChinaWeJen@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:25:45 +0800 Subject: [PATCH 184/334] Create README in Traditional Chinese Add Traditional Chinese README with project details and setup instructions. --- docs/README.zh-Hant.md | 282 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 282 insertions(+) diff --git a/docs/README.zh-Hant.md b/docs/README.zh-Hant.md index e69de29b..1b043ac1 100644 --- a/docs/README.zh-Hant.md +++ b/docs/README.zh-Hant.md @@ -0,0 +1,282 @@ +```markdown +
+ + Catime + + +

+ BongoCat +

+
+ + + +

+ 💘C/C++ × SDL3 × OpenGL,攪拌均勻,搗碎融合!Bong~ Bongo Cat!!! +

+

+English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español +

+

+ + + + + +

+ + + +
+ +
+ + +> [!TIP] +> 本示範所使用的模型來自 [宇痕冫](https://space.bilibili.com/348616056)。 +> +> 🎁 尋找**免費**模型?請瀏覽我們的官方網站:[bongocat.pet](https://bongocat.pet/models) + + +

+ +

+ +## 📥 下載 + + + + + +- GitHub Releases + + 從 [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest) 下載最新版本。 + +## 🛠️ 從原始碼建置 + +BongoCat 使用 CMake,需要 C11 編譯器、C++17 編譯器、CMake 3.24 或更新版本,以及桌面 OpenGL 開發檔案。SDL3、yyjson、stb、miniaudio 和 Nuklear 預設在建置配置時下載,因此首次配置需要網路連線。 + +請在專案根目錄(包含 `CMakeLists.txt` 的目錄)執行以下命令。 + +### 📋 平台先決條件 + +- **Windows:** Visual Studio 2022 並包含「桌面 C++」工作負載和 CMake。請使用 MSVC 產生器;MinGW 可建置診斷後端,但不支援 Cubism SDK。 +- **macOS:** Xcode Command Line Tools、CMake 和 Ninja。若目標架構與主機預設不同,請以 `CMAKE_OSX_ARCHITECTURES` 指定。 +- **Linux(Debian/Ubuntu):** GCC 或 Clang、Ninja,以及 OpenGL/X11 標頭檔: + + ```bash + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build \ + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev + ``` + +### 🔧 配置與建置 + +在 Linux 和 macOS 上,使用單一配置產生器(如 Ninja): + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --parallel +``` + +在 Windows 上,請從 Visual Studio 2022 開發人員命令提示字元(或任何可使用 MSVC 的 Shell)執行: + +```powershell +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --config Release --parallel +``` + +執行檔產出位置: +- Linux:`build/BongoCat` +- macOS:`build/BongoCat.app/Contents/MacOS/BongoCat` +- Visual Studio 建置:`build/Release/BongoCat.exe` + +### 🧪 測試 + +CTest 目標預設啟用。建置完成後執行: + +```bash +ctest --test-dir build --output-on-failure +``` + +若使用多配置產生器(如 Visual Studio),請明確指定建置配置: + +```powershell +ctest --test-dir build -C Release --output-on-failure +``` + +### 🎭 Live2D / Cubism SDK(選用) + +若未提供 Cubism SDK,CMake 會發出警告並建置診斷後端。此後端僅供啟動和平台診斷之用,不提供 Live2D 模型渲染。若要建置完整執行時期,請安裝相容的 Cubism SDK for Native,並將其放置在 `vendor/CubismSdkForNative`,或明確傳遞路徑: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ + -DBONGO_CAT_REQUIRE_CUBISM=ON +``` + +SDK 必須包含 Core 函式庫、Framework 原始碼,以及 `cmake/Cubism.cmake` 所預期的 OpenGL GLEW 第三方目錄結構。Windows 上的 Cubism 建置需要 Visual Studio 2022。設定 `BONGO_CAT_REQUIRE_CUBISM=ON` 會在無法使用 Cubism SDK 時使配置失敗,而非靜默改用診斷後端。 + +### ⚙️ CMake 選項 + +| 選項 | 預設值 | 說明 | +| --- | --- | --- | +| `BONGO_CAT_FETCH_DEPS` | `ON` | 使用 CMake `FetchContent` 下載固定的第三方依賴項。僅在 SDL3、yyjson、stb、miniaudio 和 Nuklear 已可被 CMake 找到時設為 `OFF`。 | +| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Cubism SDK for Native 的路徑。 | +| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | 當無法使用 Cubism SDK 時使配置失敗。 | +| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | 將原生編譯器警告視為錯誤。 | + +若要進行離線建置,設定 `BONGO_CAT_FETCH_DEPS=OFF`,並提供 SDL3(含 `SDL3-static`)和 yyjson 的 CMake 套件配置,以及 stb、Nuklear、miniaudio 的包含路徑(若無法自動找到): + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=OFF \ + -DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ + -DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ + -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio +``` + +## 📌 專案狀態 + + +## 📜 授權條款 + +BongoCat 原始碼和原生執行時期採用 [AGPL-3.0-only](LICENSE) 授權。 + +預設內建模型模式(`standard`)仍以 MIT 授權釋出。`resources/assets/models/standard`、`keyboard` 和 `gamepad` 中的模型資產,則受個別的 [MIT 授權聲明](LICENSE-MIT) 規範。該 MIT 授權僅適用於模型資產及其附屬藝術作品,不適用於 BongoCat 原始碼或原生執行時期。 + + +## 🧭 技術架構 + +> 目前原生版本基於 C/C++、SDL3 和 OpenGL 建構。下圖著重於執行時期資料流;建置與打包細節請參閱 CMake。 + +### 🔄 執行時期所有權與影格排程 + +每個行程擁有一個 `BongoCatApp` 和一個主執行緒事件與渲染迴圈。平台監聽器僅作用於輸入邊界: + +```text +平台監聽器 +(鍵盤/指標) + | + v + C11 輸入狀態 +(原子邊緣佇列 + 合併指標位置) + | + v + 主執行緒應用程式 <----- SDL3 事件 + | + v + 模型參數、覆疊層與 UI 狀態 + | + v + 模型更新 -> OpenGL 合成 -> 平台呈現 +``` + +Windows 低階鉤子、macOS Quartz 事件監聽,以及 Linux XInput2 監聽器均執行於主迴圈之外。它們將帶時間戳記的按鍵和滑鼠按鈕邊緣事件發佈至有界原子佇列,並透過獨立的合併槽發佈指標座標;成功發佈後會觸發原生 SDL 喚醒事件。如此可避免高頻率滑鼠移動干擾按鍵與按鈕邊緣的順序。在 Windows 上,DirectInput 僅在模型要求相對移動時,透過平台指標介面使用。SDL3 視窗、偏好設定和遊戲控制器事件則在主執行緒處理,遊戲控制器事件在傳遞給模型參數或快捷鍵之前會先正規化。平台監聽器不會直接呼叫 Live2D、覆疊層或 UI 程式碼。 + +`bongo_cat_app_run` 負責處理更新、關閉和輔助行程參數,為主要行程強制執行單一實例所有權,分配應用程式狀態,執行初始化,進入 `bongo_cat_app_loop`,然後依序清除狀態並釋放資源。初始化會載入配置和儲存路徑、定位資產、建立 SDL/OpenGL 寵物視窗、初始化平台後端、建立 Live2D、覆疊層和音訊服務、掃描內建/已安裝/鄰近模型來源,並載入可用的模型。`BongoCatApp` 擁有設定、工作階段狀態、模型與行為目錄、平台控制代碼,以及執行時期服務控制代碼。 + +已安裝的模型套件使用 Mver 作為標準格式。匯入流程會解析選取的檔案或目錄、探索並驗證候選項目、指紋辨識套件識別、將 Tauri 來源轉換為 Mver、套用影像修補,並將標準化套件提交至 `models_root`,接著產生執行時期轉接器並重新整理目錄。鄰近來源則在不安裝其原始碼樹的情況下被探索;其轉接器和檢查結果會快取在 `models_root` 之外的 `cache_root` 下。 + +每次主迴圈迭代會等待 SDL/原生喚醒事件,或最早待處理的影格、UI、動畫或指標點擊期限(最長等待 250 毫秒)。它會分派佇列中的 SDL 事件、清空原子輸入佇列與釋放復原、更新視窗與模型重新整理狀態,並套用輸入衍生參數。啟用 Cubism 時,模型期限遵循 `settings.model.max_fps`(預設 60 FPS);診斷版本則使用 100 毫秒的備用間隔。模型經過時間上限為 250 毫秒,並分割為最多八個子步驟,每個子步驟不超過 1/30 秒。 + +一般寵物路徑僅在視窗可見、未最小化且標記為髒污時才進行渲染。每個影格會清除背景、繪製模型,並合成指標、按鍵與特效覆疊層,最後呼叫平台呈現器。預覽操作可要求立即渲染,而擷取渲染則可能略過呈現。macOS 和 Linux 直接交換 SDL OpenGL 視窗。Windows 在圖層呈現未啟用時直接交換,否則會讀回影格供 `UpdateLayeredWindow` 使用。偏好設定 UI 擁有獨立的 SDL/OpenGL 視窗,且獨立於寵物視窗進行渲染和呈現。 + +C 執行時期會呼叫 `include/bongo_cat/model.h` 中宣告的 ABI。Live2D 橋接與 Cubism 實作位於 `src/live2d`,且僅在啟用 Cubism SDK 時使用 C++17;其餘原生執行時期則使用 C11。Cubism 類型保留在不透明的 C 控制代碼之後,而 `src/live2d/live2d_stub.c` 則在 SDK 不可用時提供診斷後端。 + + +```mermaid +flowchart TB + Input(["鍵盤 / 滑鼠 / 遊戲控制器"]) + BuiltIn(["內建模型資產"]) + Sources(["外部模型來源
Mver、Tauri、.model3.json、影像修補"]) + Desktop(["寵物視窗與偏好設定視窗"]) + + subgraph Runtime["BongoCat 原生執行時期"] + direction TB + Entry["src/main.c
bongo_cat_app_run"] + Startup["啟動與初始化
配置、儲存、視窗、平台"] + Loop["SDL3 主迴圈
等待、分派、更新、渲染"] + Shutdown["關閉
清除狀態、停止服務、釋放資源"] + InputQueue[("原子輸入狀態
邊緣佇列與合併指標位置")] + InputDispatch["輸入分派
快捷鍵、指標對應、模型參數"] + State[("BongoCatApp 狀態
設定、工作階段、目錄、執行時期控制代碼")] + Import["模型探索與匯入
驗證、正規化為 Mver、安裝/快取"] + Catalog[("模型與行為目錄")] + Live2D["Live2D C ABI
Cubism SDK 或診斷存根"] + Overlay["覆疊層與音訊"] + Preferences["偏好設定與桌面殼層
Nuklear UI、系統匣、視窗動作"] + Compose["OpenGL 影格合成"] + Present["平台呈現"] + + Entry --> Startup --> Loop + Loop --> Shutdown + Loop --> InputDispatch --> State + Loop <--> State + State --> Live2D + State --> Overlay + State <--> Preferences + Loop --> Preferences + Catalog --> State + State --> Compose + Live2D --> Compose + Overlay --> Compose + Compose --> Present + Loop --> Compose + end + + subgraph Platform["平台後端"] + direction LR + Global["全域鍵盤/指標擷取
Windows、macOS、Linux"] + SDL["SDL3 事件
視窗與遊戲控制器事件"] + end + + Input --> Global --> InputQueue --> InputDispatch + Input --> SDL --> Loop + BuiltIn --> Catalog + Sources --> Import --> Catalog + Present --> Desktop + Preferences --> Desktop +``` + +## ❓ 常見問答 + +### 🔒 BongoCat 會記錄我的鍵盤或滑鼠輸入嗎? + +不會。BongoCat 僅在本地處理鍵盤和滑鼠輸入,以驅動動畫和快捷鍵。它不會記錄或上傳您的按鍵、滑鼠動作或其他互動資料。配置也僅儲存在本地,且應用程式不含廣告、分析工具或使用者追蹤程式碼。執行更新檢查時,僅會請求公開的發行版本元資料;不會傳送輸入、配置或使用資料。 + +### 🖼️ 為什麼選擇 OpenGL 而非 Vulkan? + +我們選擇 OpenGL 並非因為 Vulkan 不好,而是 BongoCat 不需要那樣的複雜度。本應用主要渲染一個 Live2D 模型、少數 UI 層,以及一個透明桌面視窗。OpenGL 已能輕鬆應付,且與 SDL3 和 Cubism 的 OpenGL 渲染器自然整合。轉向 Vulkan 意味著要在三個桌面平台上維護更多渲染和同步程式碼,但對使用者而言並不會有明顯改善。就 BongoCat 目前的工作負載而言,OpenGL 讓渲染器更小巧、更容易除錯和維護,同時仍能提供我們所需的效能。 + + + +## 專案狀態 +![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") + + +## 🙏 特別感謝 + + + 貢獻者排行榜 + + + + + +--- + +
+ +Copyright © 2026 - **BongoCat**\ +由 vladelaina 製作\ +以 ❤️ 和 ⌨️ 打造 + +
+``` From 483c3815751009c327dd12a967e1e0fb9a59dc7c Mon Sep 17 00:00:00 2001 From: xxtui-main Date: Tue, 1 Sep 2026 15:36:56 +0800 Subject: [PATCH 185/334] docs: add French README translation Add French README translation --- docs/README.fr-FR.md | 384 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 384 insertions(+) diff --git a/docs/README.fr-FR.md b/docs/README.fr-FR.md index e69de29b..92732f33 100644 --- a/docs/README.fr-FR.md +++ b/docs/README.fr-FR.md @@ -0,0 +1,384 @@ +
+ + Catime + + +

+ BongoCat +

+
+ + +

+ 💘C/C++ × SDL3 × OpenGL, mélangez le tout, assemblez-le ! Bong~ Bongo Cat !!! +

+

+English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español +

+

+ + + + + +

+ + +
+ +
+ + +> [!TIP] +> Le modèle présenté dans cette démonstration provient de [宇痕冫](https://space.bilibili.com/348616056). +> +> 🎁Vous cherchez des modèles **gratuits** ? Rendez-vous sur notre site officiel : [bongocat.pet](https://bongocat.pet/models) + +

+ +

+ +## 📥 Téléchargement + + + + + +- GitHub Releases + + Téléchargez la dernière version depuis [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest). + +## 🛠️ Compilation depuis les sources + +BongoCat utilise CMake et nécessite un compilateur C11, un compilateur C++17, +CMake 3.24 ou une version plus récente, ainsi que les fichiers de développement +OpenGL pour environnement de bureau. SDL3, yyjson, stb, miniaudio et Nuklear +sont téléchargés par défaut lors de la configuration ; la première configuration +nécessite donc un accès au réseau. + +Exécutez les commandes ci-dessous depuis la racine du projet (le répertoire +contenant `CMakeLists.txt`). + +### 📋 Prérequis par plateforme + +- **Windows :** Visual Studio 2022 avec la charge de travail Desktop C++ et CMake. + Utilisez le générateur MSVC ; MinGW peut compiler le backend de diagnostic, + mais il n'est pas pris en charge par le SDK Cubism. +- **macOS :** Xcode Command Line Tools, CMake et Ninja. Définissez une architecture + avec `CMAKE_OSX_ARCHITECTURES` lorsqu'elle diffère de l'architecture par défaut + de la machine hôte. +- **Linux (Debian/Ubuntu) :** GCC ou Clang, Ninja ainsi que les en-têtes OpenGL/X11 : + ```bash + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build \ + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev + ``` + +### 🔧 Configuration et compilation + +Sous Linux et macOS, utilisez un générateur à configuration unique tel que Ninja : + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --parallel +``` + +Sous Windows, exécutez les commandes depuis un shell de développement +Visual Studio 2022 (ou un autre shell dans lequel MSVC est disponible) : + +```powershell +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --config Release --parallel +``` + +L'exécutable est généré dans `build/BongoCat` sous Linux, +dans `build/BongoCat.app/Contents/MacOS/BongoCat` sous macOS, +et dans `build/Release/BongoCat.exe` pour les compilations Visual Studio. + +### 🧪 Tests + +Les cibles CTest sont activées par défaut. Exécutez-les après la compilation : + +```bash +ctest --test-dir build --output-on-failure +``` + +Pour un générateur multi-configuration tel que Visual Studio, +sélectionnez explicitement la configuration de compilation : + +```powershell +ctest --test-dir build -C Release --output-on-failure +``` + +### 🎭 Live2D / SDK Cubism (facultatif) + +Si le SDK Cubism n'est pas présent, CMake affiche un avertissement et compile +le backend de diagnostic. Ce backend est destiné au démarrage et aux diagnostics +de plateforme ; il ne permet pas le rendu des modèles Live2D. Pour compiler +le runtime complet, installez une version compatible du Cubism SDK for Native +et placez-la dans `vendor/CubismSdkForNative`, ou indiquez explicitement son +emplacement : + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ + -DBONGO_CAT_REQUIRE_CUBISM=ON +``` + +Le SDK doit contenir sa bibliothèque Core, les sources du Framework ainsi que +l'arborescence tierce OpenGL GLEW dans la structure attendue par +`cmake/Cubism.cmake`. Les compilations Cubism sous Windows nécessitent +Visual Studio 2022. `BONGO_CAT_REQUIRE_CUBISM=ON` fait échouer la configuration +au lieu de sélectionner silencieusement le backend de diagnostic. + +### ⚙️ Options CMake + +| Option | Valeur par défaut | Description | +| --- | --- | --- | +| `BONGO_CAT_FETCH_DEPS` | `ON` | Télécharge les dépendances tierces aux versions épinglées via `FetchContent` de CMake. Définissez cette option sur `OFF` uniquement si SDL3, yyjson, stb, miniaudio et Nuklear sont déjà accessibles à CMake. | +| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Chemin vers le Cubism SDK for Native. | +| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | Fait échouer la configuration lorsqu'aucun SDK Cubism utilisable n'est disponible. | +| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | Traite les avertissements du compilateur natif comme des erreurs. | + +Pour une compilation hors ligne avec `BONGO_CAT_FETCH_DEPS=OFF`, fournissez +les configurations de paquets CMake pour SDL3 (y compris `SDL3-static`) et yyjson, +ainsi que les répertoires d'inclusion de stb, Nuklear et miniaudio lorsqu'ils ne +peuvent pas être détectés automatiquement : + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=OFF \ + -DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ + -DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ + -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio +``` + +## 📌 État du projet + + +## 📜 Licence + +Le code source et le runtime natif de BongoCat sont distribués sous licence +[AGPL-3.0-only](LICENSE). + +Le mode de modèle intégré par défaut (`standard`) reste sous licence MIT. +Les ressources de modèles fournies dans `resources/assets/models/standard`, +`keyboard` et `gamepad` sont couvertes par la [licence MIT distincte](LICENSE-MIT). +Cette licence MIT s'applique uniquement aux ressources des modèles et aux +éléments graphiques qui les accompagnent ; elle ne modifie pas la licence du +code source ni du runtime natif de BongoCat. + +## 🧭 Architecture technique + +> La version native actuelle repose sur C/C++, SDL3 et OpenGL. Le diagramme ci-dessous se concentre sur le flux de données à l'exécution ; les détails de compilation et de packaging sont gérés par CMake. + +### 🔄 Gestion du runtime et ordonnancement des frames + +Chaque processus possède une instance de `BongoCatApp` ainsi qu'une boucle +principale d'événements et de rendu exécutée sur le thread principal. +Les écouteurs propres à chaque plateforme s'arrêtent à la frontière du +système d'entrée : + +```text +Écouteurs de plateforme +(clavier / pointeur) + | + v + État des entrées C11 + (file atomique de transitions + position du pointeur coalescée) + | + v + application sur le thread principal <----- événements SDL3 + | + v + paramètres du modèle, overlay et état de l'interface + | + v + mise à jour du modèle -> composition OpenGL -> présentation plateforme +``` + +Les hooks de bas niveau de Windows, l'event tap Quartz de macOS et l'écouteur +XInput2 de Linux s'exécutent en dehors de la boucle principale. Ils publient +les transitions horodatées des touches et des boutons de souris dans une file +atomique bornée, tandis que les coordonnées du pointeur sont publiées dans un +emplacement séparé utilisant la coalescence ; lorsqu'une publication réussit, +un événement SDL natif de réveil est envoyé. Cela empêche les mouvements à +haute fréquence d'évincer les transitions ordonnées des touches et des boutons. +Sous Windows, DirectInput est uniquement utilisé via l'interface de pointeur +propre à la plateforme lorsqu'un modèle demande un déplacement relatif. + +Les événements SDL3 liés aux fenêtres, aux préférences et aux manettes sont +traités sur le thread principal, où les événements de manette sont normalisés +avant d'atteindre les paramètres du modèle ou les raccourcis. Aucun écouteur +de plateforme n'appelle directement le code Live2D, celui de l'overlay ou +celui de l'interface utilisateur. + +`bongo_cat_app_run` gère l'arrêt lié aux mises à jour ainsi que les arguments +des processus secondaires, impose une instance unique pour le processus +principal, alloue l'état de l'application, exécute l'initialisation, entre dans +`bongo_cat_app_loop`, puis enregistre l'état et libère les ressources dans un +ordre défini. + +L'initialisation charge la configuration et les chemins de stockage, localise +les ressources, crée la fenêtre SDL/OpenGL de l'animal de compagnie, initialise +le backend de la plateforme, crée les services Live2D, d'overlay et audio, +analyse les sources de modèles intégrées, installées ou locales, puis charge +un modèle utilisable. `BongoCatApp` possède les paramètres, l'état de session, +les catalogues de modèles et de comportements, les handles de plateforme ainsi +que les handles des services d'exécution. + +Les paquets de modèles installés utilisent Mver comme format canonique. +Le processus d'importation résout le fichier ou le répertoire sélectionné, +découvre et valide les candidats, calcule l'empreinte d'identité du paquet, +convertit les sources Tauri vers Mver, applique les correctifs d'image et +enregistre le paquet normalisé sous `models_root`. Il génère ensuite +l'adaptateur d'exécution et actualise les catalogues. + +Les sources locales voisines sont découvertes sans installer leur arborescence +source ; leurs adaptateurs et résultats d'inspection sont mis en cache en dehors +de `models_root`, sous `cache_root`. + +À chaque itération de la boucle principale, l'application attend un réveil +SDL/natif ou la première échéance en attente concernant une frame, l'interface +utilisateur, une animation ou la détection du pointeur, avec un temps d'attente +maximal de 250 ms. Elle distribue ensuite les événements SDL en attente, vide +la file atomique des entrées et traite la récupération des relâchements, met à +jour l'état de la fenêtre et du rafraîchissement des modèles, puis applique les +paramètres dérivés des entrées. + +Lorsque Cubism est activé, l'échéance de mise à jour du modèle suit +`settings.model.max_fps` (60 FPS par défaut) ; les compilations de diagnostic +utilisent un intervalle de secours de 100 ms. Le temps écoulé du modèle est +plafonné à 250 ms et divisé en un maximum de huit sous-étapes, avec pour +objectif de ne pas dépasser 1/30 s par sous-étape. + +Dans le fonctionnement normal de l'animal de compagnie, le rendu n'est effectué +que lorsque la fenêtre est visible, non réduite et marquée comme nécessitant +un rafraîchissement. Une frame efface l'arrière-plan, dessine le modèle et +compose les overlays du pointeur, des touches et des effets avant d'appeler +le système de présentation de la plateforme. + +Les opérations d'aperçu peuvent demander un rendu immédiat, tandis que les +rendus destinés à une capture peuvent ignorer la présentation. macOS et Linux +effectuent directement le swap de la fenêtre OpenGL SDL. Windows effectue +également directement le swap lorsque la présentation en couches est inactive ; +dans le cas contraire, la frame est relue pour `UpdateLayeredWindow`. + +L'interface des préférences possède une fenêtre SDL/OpenGL distincte, rendue et +présentée indépendamment de la fenêtre de l'animal de compagnie. + +Le runtime C appelle l'ABI déclarée dans `include/bongo_cat/model.h`. +Le pont Live2D et l'implémentation Cubism se trouvent dans `src/live2d` et +n'utilisent C++17 que lorsque le SDK Cubism est activé ; le reste du runtime +natif utilise C11. Les types Cubism restent masqués derrière des handles C +opaques, tandis que `src/live2d/live2d_stub.c` fournit le backend de diagnostic +lorsque le SDK n'est pas disponible. + +```mermaid +flowchart TB + Input(["Clavier / souris / manette"]) + BuiltIn(["Ressources des modèles intégrés"]) + Sources(["Sources de modèles externes
Mver, Tauri, .model3.json, correctifs d'image"]) + Desktop(["Fenêtre de l'animal et fenêtre des préférences"]) + subgraph Runtime["Runtime natif BongoCat"] + direction TB + Entry["src/main.c
bongo_cat_app_run"] + Startup["Démarrage et initialisation
configuration, stockage, fenêtre, plateforme"] + Loop["Boucle principale SDL3
attente, distribution, mise à jour, rendu"] + Shutdown["Arrêt
enregistrement de l'état, arrêt des services, libération des ressources"] + InputQueue[("État atomique des entrées
file de transitions et position du pointeur coalescée")] + InputDispatch["Distribution des entrées
raccourcis, mappage du pointeur, paramètres du modèle"] + State[("État BongoCatApp
paramètres, session, catalogues, handles d'exécution")] + Import["Découverte et importation des modèles
validation, normalisation vers Mver, installation/cache"] + Catalog[("Catalogues des modèles et comportements")] + Live2D["ABI C de Live2D
SDK Cubism ou stub de diagnostic"] + Overlay["Overlay et audio"] + Preferences["Préférences et shell de bureau
interface Nuklear, zone de notification, actions de fenêtre"] + Compose["Composition de la frame OpenGL"] + Present["Présentation plateforme"] + Entry --> Startup --> Loop + Loop --> Shutdown + Loop --> InputDispatch --> State + Loop <--> State + State --> Live2D + State --> Overlay + State <--> Preferences + Loop --> Preferences + Catalog --> State + State --> Compose + Live2D --> Compose + Overlay --> Compose + Compose --> Present + Loop --> Compose + end + subgraph Platform["Backends de plateforme"] + direction LR + Global["Capture globale du clavier / pointeur
Windows, macOS, Linux"] + SDL["Événements SDL3
événements de fenêtre et de manette"] + end + + Input --> Global --> InputQueue --> InputDispatch + Input --> SDL --> Loop + BuiltIn --> Catalog + Sources --> Import --> Catalog + Present --> Desktop + Preferences --> Desktop +``` + +## ❓ FAQ + +### 🔒 BongoCat enregistre-t-il les entrées de mon clavier ou de ma souris ? + +Non. BongoCat traite localement les entrées du clavier et de la souris afin de +piloter les animations et les raccourcis. Il n'enregistre ni ne téléverse vos +frappes au clavier, vos actions de souris ou toute autre donnée d'interaction. + +La configuration est également stockée localement, et l'application ne contient +ni publicité, ni outil d'analyse, ni code de suivi des utilisateurs. Lorsqu'une +vérification de mise à jour est effectuée, l'application demande uniquement les +métadonnées publiques des versions ; elle n'envoie aucune donnée d'entrée, +de configuration ou d'utilisation. + +### 🖼️ Pourquoi OpenGL plutôt que Vulkan ? + +Nous avons choisi OpenGL non pas parce que Vulkan serait mauvais, mais parce que +BongoCat n'a tout simplement pas besoin de ce niveau de complexité. +L'application affiche principalement un modèle Live2D, quelques couches +d'interface et une fenêtre de bureau transparente. OpenGL prend déjà très bien +en charge cette charge de travail et s'intègre naturellement à SDL3 ainsi qu'au +renderer OpenGL de Cubism. + +Passer à Vulkan impliquerait de maintenir beaucoup plus de code de rendu et de +synchronisation sur trois plateformes de bureau, sans amélioration perceptible +pour les utilisateurs. Pour la charge de travail actuelle de BongoCat, OpenGL +permet de conserver un renderer plus compact, plus simple à déboguer et plus +facile à maintenir, tout en fournissant les performances dont nous avons besoin. + +## État du projet + +![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Image d'analyse Repobeats") + +## 🙏 Remerciements + + + Classement des contributeurs + + + + + +--- + +
+ +Copyright © 2026 - **BongoCat**\ +Par vladelaina\ +Fait avec ❤️ & ⌨️ + +
From b0fb8a1f47f6c51c2a656a4defaae50713a484b0 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 1 Sep 2026 16:25:15 +0800 Subject: [PATCH 186/334] Update README.md --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ef72bdb8..ff2378bb 100644 --- a/README.md +++ b/README.md @@ -340,11 +340,13 @@ we need. ## 🙏 Special Thanks +> [!TIP] +> Every step BongoCat takes is powered by the spirit of open source. We sincerely thank all our community contributors for their selfless contributions (listed below in chronological order by contribution date). It is your support that makes desktop companionship more free and genuine.❤️‍🔥 - - Contribution Leaderboard - + + + From 4b54534827d823e0f549807bbd3b8ef2466d0d10 Mon Sep 17 00:00:00 2001 From: mua Date: Tue, 1 Sep 2026 17:20:19 +0800 Subject: [PATCH 187/334] 260901 260901 --- docs/README.de-DE.md | 230 +++++++++++++++++++++++++++++++++++++++++++ docs/README.es-ES.md | 230 +++++++++++++++++++++++++++++++++++++++++++ docs/README.ja-JP.md | 230 +++++++++++++++++++++++++++++++++++++++++++ docs/README.ko-KR.md | 230 +++++++++++++++++++++++++++++++++++++++++++ docs/README.pt-BR.md | 230 +++++++++++++++++++++++++++++++++++++++++++ docs/README.ru-RU.md | 230 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 1380 insertions(+) diff --git a/docs/README.de-DE.md b/docs/README.de-DE.md index e69de29b..d3eb9ef4 100644 --- a/docs/README.de-DE.md +++ b/docs/README.de-DE.md @@ -0,0 +1,230 @@ + + +

💘 C/C++ × SDL3 × OpenGL, zusammenrühren, drauflos trommeln! Bong~ Bongo Cat!!!

+

+ Sprache wählen ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español +

+ +
+ +> [!TIP] +> Das in der Demo verwendete Modell stammt von [宇痕冫](https://space.bilibili.com/348616056). +> +> 🎁 Auf der Suche nach **kostenlosen** Modellen? Besuche unsere offizielle Website: [bongocat.pet](https://bongocat.pet/models) + +

+ +## 📥 Download + + + +- GitHub Releases + + Lade die neueste Version von den [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest) herunter. + +## 🛠️ Aus dem Quellcode erstellen + +BongoCat verwendet CMake und benötigt einen C11-Compiler, einen C++17-Compiler, CMake 3.24 oder neuer sowie die OpenGL-Entwicklungsdateien für den Desktop. Standardmäßig werden SDL3, yyjson, stb, miniaudio und Nuklear während der Konfigurationsphase automatisch heruntergeladen, daher ist für die erste Konfiguration eine Internetverbindung erforderlich. + +Führe die folgenden Befehle im Projektstammverzeichnis aus (dem Verzeichnis, das `CMakeLists.txt` enthält). + +### 📋 Plattform-Voraussetzungen + +- **Windows:** Visual Studio 2022 (mit der Workload „Desktopentwicklung mit C++“) und CMake. Verwende den MSVC-Generator; MinGW kann das Diagnose-Backend bauen, unterstützt jedoch nicht das Cubism SDK. +- **macOS:** Xcode Command Line Tools, CMake und Ninja. Weicht die Zielarchitektur von der Standardarchitektur des Hosts ab, gib sie über `CMAKE_OSX_ARCHITECTURES` an. +- **Linux (Debian/Ubuntu):** GCC oder Clang, Ninja sowie die OpenGL/X11-Header: + + ```bash + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build \ + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev + ``` + +### 🔧 Konfiguration und Build + +Verwende unter Linux und macOS einen Einzelkonfigurations-Generator wie Ninja: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --parallel +``` + +Führe die Befehle unter Windows von der Visual Studio 2022 Developer Command Prompt aus (oder einer anderen Shell, in der MSVC verfügbar ist): + +```powershell +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --config Release --parallel +``` + +Die ausführbare Datei befindet sich unter `build/BongoCat` auf Linux, unter `build/BongoCat.app/Contents/MacOS/BongoCat` auf macOS und unter `build/Release/BongoCat.exe` für Visual-Studio-Builds auf Windows. + +### 🧪 Tests + +Die CTest-Ziele sind standardmäßig aktiviert. Führe nach dem Build Folgendes aus: + +```bash +ctest --test-dir build --output-on-failure +``` + +Gib bei einem Mehrkonfigurations-Generator wie Visual Studio die Build-Konfiguration explizit an: + +```powershell +ctest --test-dir build -C Release --output-on-failure +``` + +### 🎭 Live2D / Cubism SDK (optional) + +Wird kein Cubism SDK gefunden, gibt CMake eine Warnung aus und baut das Diagnose-Backend. Dieses Backend dient dem Start und der Plattformdiagnose; es bietet kein Rendering von Live2D-Modellen. Um die vollständige Laufzeit zu bauen, installiere eine kompatible Version des Cubism SDK for Native, platziere sie unter `vendor/CubismSdkForNative` oder gib den Pfad explizit an: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ + -DBONGO_CAT_REQUIRE_CUBISM=ON +``` + +Das SDK muss die Core-Bibliothek, die Framework-Quellen sowie das OpenGL-GLEW-Drittanbieterverzeichnis in der von `cmake/Cubism.cmake` erwarteten Struktur enthalten. Cubism-Builds unter Windows erfordern Visual Studio 2022. Mit `BONGO_CAT_REQUIRE_CUBISM=ON` schlägt die Konfiguration fehl, wenn kein SDK verfügbar ist, anstatt stillschweigend das Diagnose-Backend zu wählen. + +### ⚙️ CMake-Optionen + +| Option | Standard | Beschreibung | +| --- | --- | --- | +| `BONGO_CAT_FETCH_DEPS` | `ON` | Lädt Drittanbieter-Abhängigkeiten in festgelegten Versionen über CMake `FetchContent` herunter. Setze es nur auf `OFF`, wenn SDL3, yyjson, stb, miniaudio und Nuklear bereits für CMake verfügbar sind. | +| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Pfad zum Cubism SDK for Native. | +| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | Lässt die Konfiguration fehlschlagen, wenn kein SDK verfügbar ist. | +| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | Behandelt Warnungen des nativen Compilers als Fehler. | + +Setze für Offline-Builds `BONGO_CAT_FETCH_DEPS=OFF` und stelle die CMake-Paketkonfigurationen für SDL3 (einschließlich `SDL3-static`) und yyjson bereit; falls stb, Nuklear und miniaudio nicht automatisch gefunden werden können, gib zusätzlich deren Include-Verzeichnisse an: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=OFF \ + -DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ + -DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ + -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio +``` + +## 📌 Projektstatus + +![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") + +## 📜 Lizenz + +Der Quellcode und die native Laufzeit von BongoCat stehen unter der [AGPL-3.0-only](../LICENSE)-Lizenz. + +Das standardmäßig eingebaute Modell (`standard`) bleibt unter der MIT-Lizenz. Die in `resources/assets/models/standard`, `keyboard` und `gamepad` gebündelten Modellressourcen fallen unter die separate [MIT-Lizenz-Deklaration](../LICENSE-MIT). Diese MIT-Lizenz gilt ausschließlich für die Modellressourcen und die zugehörigen Grafiken; sie ändert nichts an der Lizenz des BongoCat-Quellcodes oder der nativen Laufzeit. + +## 🧭 Technische Architektur + +Die aktuelle native Version basiert auf C/C++, SDL3 und OpenGL. Das folgende Diagramm zeigt den Datenfluss zur Laufzeit; Details zu Build und Packaging findest du in den CMake-Dateien. + +### 🔄 Runtime-Eigentum und Frame-Planung + +Jeder Prozess besitzt eine `BongoCatApp`-Instanz und eine Ereignis-/Render-Schleife auf dem Hauptthread. Die Plattform-Listener enden an der Eingabegrenze: + +```text +Plattform-Listener (Tastatur/Zeiger) + | + v + C11-Eingabezustand (atomare Flankenwarteschlange + zusammengeführte Zeigerposition) + | + v + Anwendung auf dem Hauptthread <----- SDL3-Ereignisse + | + v + Modellparameter, Overlays und UI-Zustand + | + v + Modell-Update -> OpenGL-Komposition -> Plattform-Präsentation +``` + +Die Windows-Low-Level-Hooks, der macOS-Quartz-Ereignis-Tap und die Linux-XInput2-Listener laufen außerhalb der Hauptschleife. Sie veröffentlichen mit Zeitstempel versehene Tastatur- und Maustasten-Flanken in eine begrenzte atomare Warteschlange und veröffentlichen Zeigerkoordinaten über einen separaten Zusammenführungsslot; nach einer erfolgreichen Veröffentlichung wird ein natives SDL-Aufweckereignis ausgelöst, sodass hochfrequente Bewegungsereignisse die geordneten Tastatur- und Tasten-Flanken nicht verdrängen. Unter Windows wird DirectInput nur dann über die Plattform-Zeigerschnittstelle verwendet, wenn das Modell relative Bewegungen anfordert. SDL3-Fenster-, Präferenz- und Gamepad-Ereignisse werden auf dem Hauptthread verarbeitet; Gamepad-Ereignisse werden vor der Weitergabe an Modellparameter oder Tastenkürzel normalisiert. Kein Plattform-Listener ruft direkt Live2D-, Overlay- oder UI-Code auf. + +`bongo_cat_app_run` ist für Update-, Shutdown- und Sekundärprozessparameter verantwortlich, erzwingt den Besitz einer einzelnen Instanz des Hauptprozesses, weist den Anwendungszustand zu, führt die Initialisierung aus, betritt `bongo_cat_app_loop` und aktualisiert anschließend in definierter Reihenfolge den Zustand und gibt Ressourcen frei. Die Initialisierung lädt Konfiguration und Speicherpfade, findet Ressourcen, erstellt das SDL/OpenGL-Pet-Fenster, initialisiert die Plattform-Backends, erstellt Live2D-/Overlay-/Audio-Dienste, scannt eingebaute/installierte/nah gelegene Modellquellen und lädt verfügbare Modelle. `BongoCatApp` hält Einstellungen, Sitzungszustand, Modell- und Verhaltenskataloge, Plattform-Handles sowie Laufzeit-Dienst-Handles. + +Installierte Modellpakete verwenden Mver als kanonisches Format. Der Importprozess analysiert die ausgewählte Datei oder das Verzeichnis, findet und validiert Kandidaten, erzeugt einen Paketidentitäts-Fingerabdruck, konvertiert Tauri-Quellen zu Mver, wendet Bild-Patches an und übermittelt das normalisierte Paket an `models_root`. Anschließend werden Laufzeit-Adapter erzeugt und der Katalog aktualisiert. Nahe Quellen werden nur entdeckt, ohne deren Quellverzeichnis zu installieren; ihre Adapter und Prüfergebnisse werden unter `cache_root` außerhalb von `models_root` zwischengespeichert. + +Jede Iteration der Hauptschleife wartet auf SDL-/native Aufweckereignisse oder auf die früheste Fälligkeit von Frame-, UI-, Animations- und Zeiger-Treffertests (maximal 250 ms). Die Schleife verteilt gepufferte SDL-Ereignisse, leert die atomare Eingabewarteschlange und führt Freigabe-Wiederherstellung durch, aktualisiert Fenster- und Modell-Aktualisierungsstatus und wendet anschließend Eingabeparameter an. Bei aktiviertem Cubism folgt die Modellfrist `settings.model.max_fps` (Standard 60 FPS); Diagnose-Builds verwenden ein Fallback-Intervall von 100 ms. Die verstrichene Modellzeit wird auf maximal 250 ms begrenzt und in bis zu acht Teilschritte aufgeteilt, die jeweils höchstens 1/30 Sekunde dauern. + +Der reguläre Pet-Pfad rendert nur, wenn das Fenster sichtbar, nicht minimiert und als schmutzig markiert ist. Pro Frame wird zunächst der Hintergrund gelöscht, das Modell gezeichnet, dann werden Zeiger-, Tasten- und Effekt-Overlays komponiert und schließlich der Plattform-Präsentator aufgerufen. Vorschauvorgänge können sofortiges Rendern anfordern, Screenshot-Rendering kann die Präsentation überspringen. macOS und Linux tauschen das SDL-OpenGL-Fenster direkt; Windows tauscht direkt, wenn keine geschichtete Darstellung aktiviert ist, andernfalls wird der Framebuffer ausgelesen und `UpdateLayeredWindow` aufgerufen. Die Präferenz-UI besitzt ein eigenes SDL/OpenGL-Fenster und wird separat gerendert und präsentiert. + +Die C-Laufzeit ruft die in `include/bongo_cat/model.h` deklarierte ABI auf. Die Live2D-Brücke und die Cubism-Implementierung befinden sich in `src/live2d` und verwenden C++17 nur, wenn das Cubism SDK aktiviert ist; der restliche native Laufzeitcode verwendet C11. Cubism-Typen bleiben hinter undurchsichtigen C-Handles; wenn das SDK nicht verfügbar ist, stellt `src/live2d/live2d_stub.c` das Diagnose-Backend bereit. + +```mermaid +flowchart TB + Input(["Tastatur / Maus / Gamepad"]) + BuiltIn(["Eingebaute Modellressourcen"]) + Sources(["Externe Modellquellen
Mver, Tauri, .model3.json, Bild-Patches"]) + Desktop(["Pet-Fenster und Präferenzfenster"]) + subgraph Runtime["BongoCat Native Runtime"] + direction TB + Entry["src/main.c
bongo_cat_app_run"] + Startup["Start und Initialisierung
Konfiguration, Speicher, Fenster, Plattform"] + Loop["SDL3-Hauptschleife
Warten, Verteilen, Aktualisieren, Rendern"] + Shutdown["Herunterfahren
Zustand aktualisieren, Dienste stoppen, Ressourcen freigeben"] + InputQueue[("Atomarer Eingabezustand
Flankenwarteschlange und zusammengeführte Zeigerposition")] + InputDispatch["Eingabeverteilung
Tastenkürzel, Zeigerzuordnung, Modellparameter"] + State[("BongoCatApp-Zustand
Einstellungen, Sitzung, Katalog, Laufzeit-Handles")] + Import["Modellerkennung und -import
Validierung, Normalisierung zu Mver, Installation/Cache"] + Catalog[("Modell- und Verhaltenskatalog")] + Live2D["Live2D C ABI
Cubism SDK oder Diagnose-Stub"] + Overlay["Overlays und Audio"] + Preferences["Präferenzen und Desktop-Shell
Nuklear-UI, Tray, Fensteraktionen"] + Compose["OpenGL-Frame-Komposition"] + Present["Plattform-Präsentation"] + Entry --> Startup --> Loop + Loop --> Shutdown + Loop --> InputDispatch --> State + Loop <--> State + State --> Live2D + State --> Overlay + State <--> Preferences + Loop --> Preferences + Catalog --> State + State --> Compose + Live2D --> Compose + Overlay --> Compose + Compose --> Present + Loop --> Compose + end + subgraph Platform["Plattform-Backends"] + direction LR + Global["Globale Tastatur/Zeiger-Erfassung
Windows, macOS, Linux"] + SDL["SDL3-Ereignisse
Fenster- und Gamepad-Ereignisse"] + end + Input --> Global --> InputQueue --> InputDispatch + Input --> SDL --> Loop + BuiltIn --> Catalog + Sources --> Import --> Catalog + Present --> Desktop + Preferences --> Desktop +``` + +## ❓ Häufig gestellte Fragen + +### 🔒 Zeichnet BongoCat meine Tastatur- oder Mauseingaben auf? + +Nein. BongoCat verarbeitet Tastatur- und Mauseingaben lokal, um Animationen und Tastenkürzel anzutreiben. Es zeichnet weder Tastenanschläge, Mausaktionen noch andere Interaktionsdaten auf oder lädt sie hoch. Auch die Konfiguration wird nur lokal gespeichert; die Anwendung enthält keine Werbung, Analyse-Tools oder Code zur Nutzerverfolgung. Bei Update-Checks werden nur öffentliche Versionsmetadaten abgefragt; es werden keine Eingabe-, Konfigurations- oder Nutzungsdaten gesendet. + +### 🖼️ Warum OpenGL und nicht Vulkan? + +Nicht weil Vulkan schlecht ist, sondern weil BongoCat diese Komplexität nicht benötigt. Die Anwendung rendert hauptsächlich ein Live2D-Modell, einige UI-Ebenen und ein transparentes Desktop-Fenster – das bewältigt OpenGL mühelos, und es harmoniert auf natürliche Weise mit SDL3 und dem OpenGL-Renderer von Cubism. Eine Migration zu Vulkan würde mehr Render- und Synchronisationscode auf drei Desktop-Plattformen erfordern, ohne dem Nutzer einen spürbaren Vorteil zu bringen. Für die aktuellen Arbeitslasten von BongoCat macht OpenGL den Renderer schlanker, besser debugbar und wartbarer und liefert weiterhin die benötigte Leistung. + +## 🙏 Besonderer Dank + +Beiträger-Rangliste + +
+Copyright © 2026 - **BongoCat**
+By vladelaina
+Made with ❤️ & ⌨️ +
diff --git a/docs/README.es-ES.md b/docs/README.es-ES.md index e69de29b..bff6f02e 100644 --- a/docs/README.es-ES.md +++ b/docs/README.es-ES.md @@ -0,0 +1,230 @@ + + +

💘 C/C++ × SDL3 × OpenGL, mézclalo todo, ¡a tocar! ¡Bong~ Bongo Cat!!!

+

+ Elige el idioma ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español +

+ +
+ +> [!TIP] +> El modelo usado en la demo proviene de [宇痕冫](https://space.bilibili.com/348616056). +> +> 🎁 ¿Buscas modelos **gratuitos**? Visita nuestro sitio web oficial: [bongocat.pet](https://bongocat.pet/models) + +

+ +## 📥 Descarga + + + +- GitHub Releases + + Descarga la última versión desde [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest). + +## 🛠️ Compilar desde el código fuente + +BongoCat usa CMake y requiere un compilador C11, un compilador C++17, CMake 3.24 o superior y los archivos de desarrollo de OpenGL para escritorio. Por defecto, SDL3, yyjson, stb, miniaudio y Nuklear se descargan automáticamente durante la configuración, por lo que la primera configuración requiere conexión a internet. + +Ejecuta los siguientes comandos en la raíz del proyecto (el directorio que contiene `CMakeLists.txt`). + +### 📋 Requisitos por plataforma + +- **Windows:** Visual Studio 2022 (con la carga de trabajo «Desarrollo para escritorio con C++») y CMake. Usa el generador MSVC; MinGW puede compilar el backend de diagnóstico, pero no es compatible con el SDK de Cubism. +- **macOS:** Xcode Command Line Tools, CMake y Ninja. Si la arquitectura de destino difiere de la predeterminada del host, especifícala mediante `CMAKE_OSX_ARCHITECTURES`. +- **Linux (Debian/Ubuntu):** GCC o Clang, Ninja y los encabezados de OpenGL/X11: + + ```bash + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build \ + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev + ``` + +### 🔧 Configuración y compilación + +En Linux y macOS, usa un generador de configuración única como Ninja: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --parallel +``` + +En Windows, ejecuta los comandos desde el Símbolo del sistema para desarrolladores de Visual Studio 2022 (o cualquier shell donde MSVC esté disponible): + +```powershell +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --config Release --parallel +``` + +El ejecutable se encuentra en `build/BongoCat` en Linux, en `build/BongoCat.app/Contents/MacOS/BongoCat` en macOS y en `build/Release/BongoCat.exe` para las compilaciones de Visual Studio en Windows. + +### 🧪 Pruebas + +Los objetivos de CTest están habilitados por defecto. Ejecuta lo siguiente después de compilar: + +```bash +ctest --test-dir build --output-on-failure +``` + +Para un generador de múltiples configuraciones como Visual Studio, especifica explícitamente la configuración de compilación: + +```powershell +ctest --test-dir build -C Release --output-on-failure +``` + +### 🎭 Live2D / SDK de Cubism (opcional) + +Si no se encuentra el SDK de Cubism, CMake muestra una advertencia y compila el backend de diagnóstico. Este backend está pensado para el arranque y el diagnóstico de plataforma; no proporciona renderizado de modelos Live2D. Para compilar el runtime completo, instala una versión compatible del Cubism SDK for Native, colócalo en `vendor/CubismSdkForNative` o indica explícitamente su ruta: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ + -DBONGO_CAT_REQUIRE_CUBISM=ON +``` + +El SDK debe incluir la biblioteca Core, el código fuente de Framework y el directorio de terceros de OpenGL GLEW en la estructura esperada por `cmake/Cubism.cmake`. Las compilaciones de Cubism en Windows requieren Visual Studio 2022. Con `BONGO_CAT_REQUIRE_CUBISM=ON`, la configuración falla si el SDK no está disponible, en lugar de seleccionar silenciosamente el backend de diagnóstico. + +### ⚙️ Opciones de CMake + +| Opción | Valor por defecto | Descripción | +| --- | --- | --- | +| `BONGO_CAT_FETCH_DEPS` | `ON` | Descarga dependencias de terceros en versiones fijadas mediante `FetchContent` de CMake. Establécelo en `OFF` solo si SDL3, yyjson, stb, miniaudio y Nuklear ya están disponibles para CMake. | +| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Ruta del Cubism SDK for Native. | +| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | Hace fallar la configuración cuando no hay SDK disponible. | +| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | Trata las advertencias del compilador nativo como errores. | + +Para una compilación sin conexión, establece `BONGO_CAT_FETCH_DEPS=OFF` y proporciona las configuraciones de paquetes de CMake para SDL3 (incluido `SDL3-static`) y yyjson; si stb, Nuklear y miniaudio no se pueden detectar automáticamente, indica también sus directorios de inclusión: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=OFF \ + -DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ + -DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ + -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio +``` + +## 📌 Estado del proyecto + +![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") + +## 📜 Licencia + +El código fuente y el runtime nativo de BongoCat están bajo la licencia [AGPL-3.0-only](../LICENSE). + +El modo de modelo integrado por defecto (`standard`) permanece bajo la licencia MIT. Los recursos de modelo incluidos en `resources/assets/models/standard`, `keyboard` y `gamepad` están cubiertos por la [declaración de licencia MIT](../LICENSE-MIT) independiente. Esta licencia MIT se aplica únicamente a los recursos de los modelos y sus ilustraciones asociadas; no cambia la licencia del código fuente ni del runtime nativo de BongoCat. + +## 🧭 Arquitectura técnica + +La versión nativa actual se basa en C/C++, SDL3 y OpenGL. El siguiente diagrama destaca el flujo de datos en tiempo de ejecución; consulta los archivos de CMake para los detalles de compilación y empaquetado. + +### 🔄 Propiedad del runtime y planificación de fotogramas + +Cada proceso posee una instancia de `BongoCatApp` y un bucle de eventos/renderizado en el hilo principal. Los listeners de plataforma se detienen en el límite de entrada: + +```text +Listeners de plataforma (teclado/ puntero) + | + v + Estado de entrada C11 (cola atómica de flancos + posición de puntero fusionada) + | + v + aplicación en el hilo principal <----- eventos SDL3 + | + v + parámetros del modelo, superposiciones y estado de la interfaz + | + v + actualización del modelo -> composición OpenGL -> presentación de la plataforma +``` + +Los hooks de bajo nivel de Windows, el tap de eventos Quartz de macOS y los listeners XInput2 de Linux se ejecutan fuera del bucle principal. Publican pulsaciones de teclas y flancos de botones del ratón con marca de tiempo en una cola atómica acotada, y publican las coordenadas del puntero a través de una ranura de fusión independiente; tras una publicación correcta, se impulsa un evento de activación SDL nativo, lo que evita que los eventos de movimiento de alta frecuencia desplacen los flancos ordenados de teclas y botones. En Windows, DirectInput solo se usa a través de la interfaz de puntero de la plataforma cuando el modelo solicita movimiento relativo. Los eventos de ventana, preferencias y gamepad de SDL3 se procesan en el hilo principal; los eventos de gamepad se normalizan antes de pasarse a los parámetros del modelo o a los atajos. Ningún listener de plataforma llama directamente al código de Live2D, superposiciones o interfaz. + +`bongo_cat_app_run` gestiona los parámetros de actualización, cierre y procesos secundarios, hace cumplir la propiedad de una única instancia del proceso principal, asigna el estado de la aplicación, realiza la inicialización, entra en `bongo_cat_app_loop` y luego actualiza el estado y libera los recursos en el orden definido. La inicialización carga la configuración y las rutas de almacenamiento, localiza los recursos, crea la ventana de mascota SDL/OpenGL, inicializa los backends de plataforma, crea los servicios de Live2D/superposiciones/audio, escanea las fuentes de modelos integrados/instalados/cercanos y carga los modelos disponibles. `BongoCatApp` conserva la configuración, el estado de la sesión, los catálogos de modelos y comportamientos, los identificadores de plataforma y los identificadores de servicios en tiempo de ejecución. + +Los paquetes de modelos instalados usan Mver como formato canónico. El proceso de importación analiza el archivo o directorio seleccionado, descubre y valida los candidatos, genera una huella de identidad del paquete, convierte las fuentes Tauri a Mver, aplica parches de imagen y envía el paquete normalizado a `models_root`. Después se generan los adaptadores de tiempo de ejecución y se actualiza el catálogo. Las fuentes cercanas solo se descubren sin instalar su árbol de origen; sus adaptadores y resultados de comprobación se almacenan en caché bajo `cache_root`, fuera de `models_root`. + +Cada iteración del bucle principal espera activaciones SDL/nativas o el vencimiento más temprano de los plazos de fotograma, interfaz, animación y acierto de puntero (máximo 250 ms). El bucle distribuye los eventos SDL en cola, vacía la cola de entrada atómica y realiza la recuperación de liberaciones, actualiza el estado de la ventana y de actualización del modelo y luego aplica los parámetros de entrada. Con Cubism habilitado, el plazo del modelo sigue `settings.model.max_fps` (predeterminado 60 FPS); las compilaciones de diagnóstico usan un intervalo de reserva de 100 ms. El tiempo transcurrido del modelo se cuenta como máximo 250 ms y se divide en hasta ocho subpasos, cada uno con un objetivo de no más de 1/30 de segundo. + +La ruta normal de la mascota solo se renderiza cuando la ventana es visible, no está minimizada y está marcada como sucia. Cada fotograma primero limpia el fondo, dibuja el modelo y luego compone las superposiciones de puntero, teclas y efectos, y finalmente llama al presentador de la plataforma. Las operaciones de vista previa pueden solicitar renderizado inmediato; el renderizado de capturas de pantalla puede omitir la presentación. macOS y Linux intercambian directamente la ventana SDL OpenGL; Windows la intercambia directamente cuando la presentación en capas no está habilitada; de lo contrario, lee el búfer de fotogramas y llama a `UpdateLayeredWindow`. La interfaz de preferencias tiene su propia ventana SDL/OpenGL y se renderiza y presenta por separado. + +El runtime C llama a la ABI declarada en `include/bongo_cat/model.h`. El puente Live2D y la implementación de Cubism están en `src/live2d` y solo usan C++17 cuando el SDK de Cubism está habilitado; el resto del runtime nativo usa C11. Los tipos de Cubism se mantienen detrás de identificadores C opacos; cuando el SDK no está disponible, `src/live2d/live2d_stub.c` proporciona el backend de diagnóstico. + +```mermaid +flowchart TB + Input(["Teclado / Ratón / Gamepad"]) + BuiltIn(["Recursos de modelos integrados"]) + Sources(["Fuentes de modelos externos
Mver, Tauri, .model3.json, parches de imagen"]) + Desktop(["Ventana de mascota y ventana de preferencias"]) + subgraph Runtime["Runtime nativo de BongoCat"] + direction TB + Entry["src/main.c
bongo_cat_app_run"] + Startup["Arranque e inicialización
Configuración, almacenamiento, ventana, plataforma"] + Loop["Bucle principal SDL3
Esperar, distribuir, actualizar, renderizar"] + Shutdown["Apagado
Actualizar estado, detener servicios, liberar recursos"] + InputQueue[("Estado de entrada atómico
Cola de flancos y posición de puntero fusionada")] + InputDispatch["Distribución de entrada
Atajos, mapeo de puntero, parámetros del modelo"] + State[("Estado de BongoCatApp
Configuración, sesión, catálogo, identificadores de runtime")] + Import["Descubrimiento e importación de modelos
Validación, normalización a Mver, instalación/caché"] + Catalog[("Catálogo de modelos y comportamientos")] + Live2D["Live2D C ABI
SDK de Cubism o stub de diagnóstico"] + Overlay["Superposiciones y audio"] + Preferences["Preferencias y shell de escritorio
UI de Nuklear, bandeja, acciones de ventana"] + Compose["Composición de fotogramas OpenGL"] + Present["Presentación de la plataforma"] + Entry --> Startup --> Loop + Loop --> Shutdown + Loop --> InputDispatch --> State + Loop <--> State + State --> Live2D + State --> Overlay + State <--> Preferences + Loop --> Preferences + Catalog --> State + State --> Compose + Live2D --> Compose + Overlay --> Compose + Compose --> Present + Loop --> Compose + end + subgraph Platform["Backends de plataforma"] + direction LR + Global["Captura global de teclado/puntero
Windows, macOS, Linux"] + SDL["Eventos SDL3
Eventos de ventana y gamepad"] + end + Input --> Global --> InputQueue --> InputDispatch + Input --> SDL --> Loop + BuiltIn --> Catalog + Sources --> Import --> Catalog + Present --> Desktop + Preferences --> Desktop +``` + +## ❓ Preguntas frecuentes + +### 🔒 ¿BongoCat registra mis entradas de teclado o ratón? + +No. BongoCat procesa las entradas de teclado y ratón localmente para impulsar animaciones y atajos. No registra ni sube pulsaciones de teclas, acciones del ratón ni otros datos de interacción. La configuración también se guarda solo localmente; la aplicación no incluye anuncios, herramientas de análisis ni código de seguimiento de usuarios. Al comprobar actualizaciones solo se solicitan los metadatos públicos de versión; no se envían datos de entrada, configuración ni uso. + +### 🖼️ ¿Por qué OpenGL y no Vulkan? + +No porque Vulkan sea malo, sino porque BongoCat no necesita ese nivel de complejidad. La aplicación renderiza principalmente un modelo Live2D, algunas capas de interfaz y una ventana de escritorio transparente; OpenGL lo cubre con facilidad y se integra de forma natural con SDL3 y el renderizador OpenGL de Cubism. Migrar a Vulkan implicaría mantener más código de renderizado y sincronización en tres plataformas de escritorio, sin un beneficio notable para el usuario. Para la carga de trabajo actual de BongoCat, OpenGL hace que el renderizador sea más ligero, más fácil de depurar y mantener, y sigue ofreciendo el rendimiento necesario. + +## 🙏 Agradecimientos especiales + +Clasificación de contribuyentes + +
+Derechos de autor © 2026 - **BongoCat**
+By vladelaina
+Made with ❤️ & ⌨️ +
diff --git a/docs/README.ja-JP.md b/docs/README.ja-JP.md index e69de29b..ed52aabe 100644 --- a/docs/README.ja-JP.md +++ b/docs/README.ja-JP.md @@ -0,0 +1,230 @@ + + +

💘 C/C++ × SDL3 × OpenGL、ぜんぶ混ぜて、思いっきり叩こう!Bong~ Bongo Cat!!!

+

+ 言語を選択 ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español +

+ +
+ +> [!TIP] +> デモで使用しているモデルは [宇痕冫](https://space.bilibili.com/348616056) によるものです。 +> +> 🎁 **無料**モデルをお探しですか?公式サイトへどうぞ:[bongocat.pet](https://bongocat.pet/models) + +

+ +## 📥 ダウンロード + + + +- GitHub Releases + + 最新版は [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest) からダウンロードできます。 + +## 🛠️ ソースコードからビルド + +BongoCat は CMake を使用し、C11 コンパイラ、C++17 コンパイラ、CMake 3.24 以降、デスクトップ用 OpenGL 開発ファイルが必要です。デフォルトでは SDL3、yyjson、stb、miniaudio、Nuklear が構成時に自動的にダウンロードされるため、初回構成にはネットワーク接続が必要です。 + +プロジェクトのルートディレクトリ(`CMakeLists.txt` を含むディレクトリ)で以下のコマンドを実行してください。 + +### 📋 プラットフォーム別の前提条件 + +- **Windows:** Visual Studio 2022(「C++ によるデスクトップ開発」ワークロードをインストール)と CMake。MSVC ジェネレーターを使用してください。MinGW は診断バックエンドをビルドできますが、Cubism SDK には対応していません。 +- **macOS:** Xcode Command Line Tools、CMake、Ninja。ターゲットアーキテクチャがホストの既定と異なる場合は、`CMAKE_OSX_ARCHITECTURES` で指定してください。 +- **Linux(Debian/Ubuntu):** GCC または Clang、Ninja、および OpenGL/X11 ヘッダー: + + ```bash + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build \ + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev + ``` + +### 🔧 構成とビルド + +Linux と macOS では、Ninja のような単一構成ジェネレーターを使用してください: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --parallel +``` + +Windows では、Visual Studio 2022 の開発者コマンドプロンプト(または MSVC が利用可能なシェル)からコマンドを実行してください: + +```powershell +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --config Release --parallel +``` + +実行ファイルは、Linux では `build/BongoCat`、macOS では `build/BongoCat.app/Contents/MacOS/BongoCat`、Visual Studio ビルドの Windows では `build/Release/BongoCat.exe` に生成されます。 + +### 🧪 テスト + +CTest ターゲットはデフォルトで有効です。ビルド後に以下を実行してください: + +```bash +ctest --test-dir build --output-on-failure +``` + +Visual Studio のようなマルチ構成ジェネレーターでは、ビルド構成を明示的に指定してください: + +```powershell +ctest --test-dir build -C Release --output-on-failure +``` + +### 🎭 Live2D / Cubism SDK(オプション) + +Cubism SDK が見つからない場合、CMake は警告を出して診断バックエンドをビルドします。このバックエンドは起動とプラットフォーム診断用で、Live2D モデルのレンダリングは提供しません。完全なランタイムをビルドするには、互換性のある Cubism SDK for Native をインストールし、`vendor/CubismSdkForNative` に配置するか、パスを明示的に指定してください: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ + -DBONGO_CAT_REQUIRE_CUBISM=ON +``` + +SDK には Core ライブラリ、Framework のソース、`cmake/Cubism.cmake` が期待する構成の OpenGL GLEW サードパーティディレクトリが含まれている必要があります。Windows の Cubism ビルドには Visual Studio 2022 が必要です。`BONGO_CAT_REQUIRE_CUBISM=ON` を設定すると、SDK が利用できない場合に構成を失敗させ、診断バックエンドを黙って選択しなくなります。 + +### ⚙️ CMake オプション + +| オプション | デフォルト値 | 説明 | +| --- | --- | --- | +| `BONGO_CAT_FETCH_DEPS` | `ON` | CMake の `FetchContent` を使用して固定バージョンのサードパーティ依存関係をダウンロードします。SDL3、yyjson、stb、miniaudio、Nuklear がすでに CMake で利用できる場合にのみ `OFF` に設定してください。 | +| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Cubism SDK for Native のパス。 | +| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | SDK が利用できない場合に構成を失敗させます。 | +| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | ネイティブコンパイラの警告をエラーとして扱います。 | + +オフラインビルドでは `BONGO_CAT_FETCH_DEPS=OFF` に設定し、SDL3(`SDL3-static` を含む)と yyjson の CMake パッケージ構成を提供してください。stb、Nuklear、miniaudio を自動検出できない場合は、そのインクルードディレクトリも指定してください: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=OFF \ + -DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ + -DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ + -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio +``` + +## 📌 プロジェクトの状態 + +![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") + +## 📜 ライセンス + +BongoCat のソースコードとネイティブランタイムは [AGPL-3.0-only](../LICENSE) ライセンスの下で提供されます。 + +既定の組み込みモデルモード(`standard`)は引き続き MIT ライセンスです。`resources/assets/models/standard`、`keyboard`、`gamepad` に同梱されているモデルリソースは、別途の [MIT ライセンス宣言](../LICENSE-MIT) の対象です。この MIT ライセンスはモデルリソースと付随するアートワークにのみ適用され、BongoCat のソースコードやネイティブランタイムのライセンスは変更されません。 + +## 🧭 技術アーキテクチャ + +現在のネイティブ版は C/C++、SDL3、OpenGL で構築されています。以下の図はランタイムのデータフローを重点的に示しています。ビルドとパッケージングの詳細は CMake ファイルを参照してください。 + +### 🔄 ランタイムの所有権とフレームスケジューリング + +各プロセスは `BongoCatApp` インスタンスと、メインスレッドのイベント/レンダリングループを 1 つ持ちます。プラットフォームリスナーは入力境界で停止します: + +```text +プラットフォームリスナー(キーボード/ポインター) + | + v + C11 入力状態(アトミックエッジキュー+マージされたポインター位置) + | + v + メインスレッドのアプリケーション <----- SDL3 イベント + | + v + モデルパラメーター、オーバーレイ、UI 状態 + | + v + モデル更新 -> OpenGL 合成 -> プラットフォーム表示 +``` + +Windows の低レベルフック、macOS の Quartz イベントタップ、Linux の XInput2 リスナーはメインループの外で実行されます。これらはタイムスタンプ付きのキー押下とマウスボタンのエッジを有界アトミックキューに公開し、独立したマージスロットでポインター座標を公開します。公開に成功するとネイティブの SDL ウェイクイベントがプッシュされ、高頻度の移動イベントが順序付けられたキーやボタンのエッジを押し出さないようにします。Windows では、モデルが相対移動を要求した場合にのみ、プラットフォームのポインターインターフェースを通じて DirectInput が使用されます。SDL3 のウィンドウ、設定、ゲームパッドイベントはメインスレッドで処理され、ゲームパッドイベントはモデルパラメーターやショートカットに渡す前に正規化されます。どのプラットフォームリスナーも Live2D、オーバーレイ、UI コードを直接呼び出しません。 + +`bongo_cat_app_run` は更新、シャットダウン、二次プロセスのパラメーターを処理し、メインプロセスの単一インスタンス所有権を強制し、アプリケーション状態を割り当て、初期化を実行し、`bongo_cat_app_loop` に入り、その後定義された順序で状態をフラッシュしてリソースを破棄します。初期化では設定とストレージパスを読み込み、リソースを特定し、SDL/OpenGL ペットウィンドウを作成し、プラットフォームバックエンドを初期化し、Live2D/オーバーレイ/オーディオサービスを作成し、組み込み/インストール済み/近隣のモデルソースをスキャンし、利用可能なモデルを読み込みます。`BongoCatApp` は設定、セッション状態、モデルと動作のカタログ、プラットフォームハンドル、ランタイムサービスのハンドルを保持します。 + +インストール済みのモデルパッケージは Mver を正規形式として使用します。インポート処理は選択したファイルまたはディレクトリを解析し、候補を発見・検証し、パッケージの ID フィンガープリントを生成し、Tauri ソースを Mver に変換し、画像パッチを適用し、正規化されたパッケージを `models_root` にコミットします。その後、ランタイムアダプターが生成され、カタログが更新されます。近隣ソースはソースツリーをインストールせずに発見されるだけです。そのアダプターと検査結果は `models_root` の外の `cache_root` にキャッシュされます。 + +メインループの各反復は、SDL/ネイティブのウェイクイベント、または最も早いフレーム、UI、アニメーション、ポインターのヒット期限(最大 250 ミリ秒)を待ちます。ループはキューに入った SDL イベントを分配し、アトミック入力キューを空にして解放回復を実行し、ウィンドウとモデルの更新状態を更新してから、入力パラメーターを適用します。Cubism が有効な場合、モデルの期限は `settings.model.max_fps`(既定 60 FPS)に従います。診断ビルドは 100 ミリ秒のフォールバック間隔を使用します。モデルの経過時間は最大 250 ミリ秒でカウントされ、それぞれ 1/30 秒以下を目標とする最大 8 つのサブステップに分割されます。 + +通常のペットパスは、ウィンドウが表示され、最小化されておらず、ダーティとマークされている場合にのみレンダリングされます。各フレームはまず背景をクリアし、モデルを描画してから、ポインター、キー、エフェクトのオーバーレイを合成し、最後にプラットフォームのプレゼンターを呼び出します。プレビュー操作は即時レンダリングを要求でき、スクリーンショットのレンダリングは表示をスキップできます。macOS と Linux は SDL OpenGL ウィンドウを直接交換します。Windows はレイヤード表示が有効でない場合は直接交換し、それ以外はフレームバッファを読み取って `UpdateLayeredWindow` を呼び出します。設定 UI は独自の SDL/OpenGL ウィンドウを持ち、個別にレンダリング・表示されます。 + +C ランタイムは `include/bongo_cat/model.h` で宣言されている ABI を呼び出します。Live2D ブリッジと Cubism 実装は `src/live2d` にあり、Cubism SDK が有効な場合のみ C++17 を使用します。残りのネイティブランタイムは C11 を使用します。Cubism 型は不透明な C ハンドルの背後に保持されます。SDK が利用できない場合は、`src/live2d/live2d_stub.c` が診断バックエンドを提供します。 + +```mermaid +flowchart TB + Input(["キーボード / マウス / ゲームパッド"]) + BuiltIn(["組み込みモデルリソース"]) + Sources(["外部モデルソース
Mver、Tauri、.model3.json、画像パッチ"]) + Desktop(["ペットウィンドウと設定ウィンドウ"]) + subgraph Runtime["BongoCat ネイティブランタイム"] + direction TB + Entry["src/main.c
bongo_cat_app_run"] + Startup["起動と初期化
構成、ストレージ、ウィンドウ、プラットフォーム"] + Loop["SDL3 メインループ
待機、分配、更新、レンダリング"] + Shutdown["シャットダウン
状態のフラッシュ、サービスの停止、リソースの解放"] + InputQueue[("アトミック入力状態
エッジキューとマージされたポインター位置")] + InputDispatch["入力分配
ショートカット、ポインター割り当て、モデルパラメーター"] + State[("BongoCatApp 状態
設定、セッション、カタログ、ランタイムハンドル")] + Import["モデルの発見とインポート
検証、Mver への正規化、インストール/キャッシュ"] + Catalog[("モデルと動作のカタログ")] + Live2D["Live2D C ABI
Cubism SDK または診断スタブ"] + Overlay["オーバーレイとオーディオ"] + Preferences["設定とデスクトップシェル
Nuklear UI、トレイ、ウィンドウ操作"] + Compose["OpenGL フレーム合成"] + Present["プラットフォーム表示"] + Entry --> Startup --> Loop + Loop --> Shutdown + Loop --> InputDispatch --> State + Loop <--> State + State --> Live2D + State --> Overlay + State <--> Preferences + Loop --> Preferences + Catalog --> State + State --> Compose + Live2D --> Compose + Overlay --> Compose + Compose --> Present + Loop --> Compose + end + subgraph Platform["プラットフォームバックエンド"] + direction LR + Global["グローバルなキーボード/ポインターキャプチャ
Windows、macOS、Linux"] + SDL["SDL3 イベント
ウィンドウとゲームパッドのイベント"] + end + Input --> Global --> InputQueue --> InputDispatch + Input --> SDL --> Loop + BuiltIn --> Catalog + Sources --> Import --> Catalog + Present --> Desktop + Preferences --> Desktop +``` + +## ❓ よくある質問 + +### 🔒 BongoCat はキーボードやマウスの入力を記録しますか? + +いいえ。BongoCat はキーボードとマウスの入力をローカルで処理し、アニメーションとショートカットを駆動します。キーストローク、マウス操作、その他の操作データを記録したりアップロードしたりすることはありません。構成もローカルのみに保存され、アプリには広告、分析ツール、ユーザー追跡コードは含まれていません。更新チェックの実行時には公開バージョンのメタデータのみが要求され、入力、構成、使用データは送信されません。 + +### 🖼️ Vulkan ではなく OpenGL を使うのはなぜですか? + +Vulkan が悪いからではなく、BongoCat にはその複雑さが必要ないからです。このアプリは主に Live2D モデル 1 つ、少数の UI レイヤー、透明なデスクトップウィンドウをレンダリングします。OpenGL で十分にまかなえ、SDL3 や Cubism の OpenGL レンダラーとも自然に連携します。Vulkan への移行は 3 つのデスクトッププラットフォームでより多くのレンダリング・同期コードを維持することになり、ユーザーにとっての明確なメリットはありません。BongoCat の現在のワークロードでは、OpenGL によってレンダラーがより軽量で、デバッグや保守も容易になり、必要なパフォーマンスも引き続き提供できます。 + +## 🙏 スペシャルサンクス + +コントリビューターランキング + +
+著作権 © 2026 - **BongoCat**
+By vladelaina
+Made with ❤️ & ⌨️ +
diff --git a/docs/README.ko-KR.md b/docs/README.ko-KR.md index e69de29b..7721cbf0 100644 --- a/docs/README.ko-KR.md +++ b/docs/README.ko-KR.md @@ -0,0 +1,230 @@ + + +

💘 C/C++ × SDL3 × OpenGL, 다 섞어서 마음껏 두드려 보자! Bong~ Bongo Cat!!!

+

+ 언어 선택 ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español +

+ +
+ +> [!TIP] +> 데모에 사용된 모델은 [宇痕冫](https://space.bilibili.com/348616056) 님의 것입니다. +> +> 🎁 **무료** 모델을 찾고 계신가요? 공식 웹사이트를 방문하세요: [bongocat.pet](https://bongocat.pet/models) + +

+ +## 📥 다운로드 + + + +- GitHub Releases + + 최신 버전은 [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest)에서 다운로드하세요. + +## 🛠️ 소스 코드에서 빌드 + +BongoCat은 CMake를 사용하며 C11 컴파일러, C++17 컴파일러, CMake 3.24 이상, 데스크톱용 OpenGL 개발 파일이 필요합니다. 기본적으로 SDL3, yyjson, stb, miniaudio, Nuklear는 구성 단계에서 자동으로 다운로드되므로 첫 구성에는 네트워크 연결이 필요합니다. + +프로젝트 루트 디렉터리(`CMakeLists.txt`가 포함된 디렉터리)에서 다음 명령을 실행하세요. + +### 📋 플랫폼별 사전 요구 사항 + +- **Windows:** Visual Studio 2022(‘C++를 사용한 데스크톱 개발’ 워크로드 설치)와 CMake. MSVC 생성기를 사용하세요. MinGW는 진단 백엔드를 빌드할 수 있지만 Cubism SDK는 지원하지 않습니다. +- **macOS:** Xcode Command Line Tools, CMake, Ninja. 대상 아키텍처가 호스트 기본 아키텍처와 다른 경우 `CMAKE_OSX_ARCHITECTURES`로 지정하세요. +- **Linux(Debian/Ubuntu):** GCC 또는 Clang, Ninja, OpenGL/X11 헤더: + + ```bash + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build \ + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev + ``` + +### 🔧 구성 및 빌드 + +Linux와 macOS에서는 Ninja 같은 단일 구성 생성기를 사용하세요: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --parallel +``` + +Windows에서는 Visual Studio 2022 개발자 명령 프롬프트(또는 MSVC를 사용할 수 있는 셸)에서 명령을 실행하세요: + +```powershell +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --config Release --parallel +``` + +실행 파일은 Linux의 `build/BongoCat`, macOS의 `build/BongoCat.app/Contents/MacOS/BongoCat`, Visual Studio 빌드의 Windows에서는 `build/Release/BongoCat.exe`에 생성됩니다. + +### 🧪 테스트 + +CTest 대상은 기본적으로 활성화되어 있습니다. 빌드 후 다음을 실행하세요: + +```bash +ctest --test-dir build --output-on-failure +``` + +Visual Studio 같은 다중 구성 생성기에서는 빌드 구성을 명시적으로 지정하세요: + +```powershell +ctest --test-dir build -C Release --output-on-failure +``` + +### 🎭 Live2D / Cubism SDK(선택 사항) + +Cubism SDK를 찾을 수 없으면 CMake는 경고를 표시하고 진단 백엔드를 빌드합니다. 이 백엔드는 시작 및 플랫폼 진단용이며 Live2D 모델 렌더링을 제공하지 않습니다. 전체 런타임을 빌드하려면 호환되는 Cubism SDK for Native를 설치하여 `vendor/CubismSdkForNative`에 배치하거나 경로를 명시적으로 전달하세요: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ + -DBONGO_CAT_REQUIRE_CUBISM=ON +``` + +SDK에는 Core 라이브러리, Framework 소스, 그리고 `cmake/Cubism.cmake`가 기대하는 구조의 OpenGL GLEW 서드파티 디렉터리가 포함되어야 합니다. Windows Cubism 빌드에는 Visual Studio 2022가 필요합니다. `BONGO_CAT_REQUIRE_CUBISM=ON`을 설정하면 SDK를 사용할 수 없을 때 진단 백엔드를 조용히 선택하는 대신 구성이 실패합니다. + +### ⚙️ CMake 옵션 + +| 옵션 | 기본값 | 설명 | +| --- | --- | --- | +| `BONGO_CAT_FETCH_DEPS` | `ON` | CMake `FetchContent`를 사용하여 고정 버전의 서드파티 종속성을 다운로드합니다. SDL3, yyjson, stb, miniaudio, Nuklear가 이미 CMake에서 사용 가능한 경우에만 `OFF`로 설정하세요. | +| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Cubism SDK for Native의 경로. | +| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | SDK를 사용할 수 없을 때 구성이 실패하도록 합니다. | +| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | 네이티브 컴파일러 경고를 오류로 처리합니다. | + +오프라인 빌드에서는 `BONGO_CAT_FETCH_DEPS=OFF`로 설정하고 SDL3(`SDL3-static` 포함)와 yyjson의 CMake 패키지 구성을 제공하세요. stb, Nuklear, miniaudio를 자동으로 찾을 수 없는 경우 해당 포함 디렉터리도 지정하세요: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=OFF \ + -DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ + -DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ + -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio +``` + +## 📌 프로젝트 상태 + +![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") + +## 📜 라이선스 + +BongoCat의 소스 코드와 네이티브 런타임은 [AGPL-3.0-only](../LICENSE) 라이선스로 제공됩니다. + +기본 내장 모델 모드(`standard`)는 여전히 MIT 라이선스입니다. `resources/assets/models/standard`, `keyboard`, `gamepad`에 포함된 모델 리소스는 별도의 [MIT 라이선스 고지](../LICENSE-MIT)에 따라 적용됩니다. 이 MIT 라이선스는 모델 리소스와 관련 아트워크에만 적용되며 BongoCat 소스 코드나 네이티브 런타임의 라이선스는 변경하지 않습니다. + +## 🧭 기술 아키텍처 + +현재 네이티브 버전은 C/C++, SDL3, OpenGL로 구축되었습니다. 아래 다이어그램은 런타임 데이터 흐름을 중점적으로 보여줍니다. 빌드 및 패키징 세부 사항은 CMake 파일을 참조하세요. + +### 🔄 런타임 소유권 및 프레임 스케줄링 + +각 프로세스는 `BongoCatApp` 인스턴스와 메인 스레드 이벤트/렌더링 루프를 하나씩 소유합니다. 플랫폼 리스너는 입력 경계에서 멈춥니다: + +```text +플랫폼 리스너(키보드/포인터) + | + v + C11 입력 상태(원자적 에지 큐 + 병합된 포인터 위치) + | + v + 메인 스레드 애플리케이션 <----- SDL3 이벤트 + | + v + 모델 매개변수, 오버레이, UI 상태 + | + v + 모델 업데이트 -> OpenGL 합성 -> 플랫폼 표시 +``` + +Windows 로우 레벨 훅, macOS Quartz 이벤트 탭, Linux XInput2 리스너는 메인 루프 밖에서 실행됩니다. 이들은 타임스탬프가 있는 키 입력과 마우스 버튼 에지를 유계 원자 큐에 게시하고, 별도의 병합 슬롯을 통해 포인터 좌표를 게시합니다. 게시에 성공하면 네이티브 SDL 웨이크 이벤트가 푸시되어 고주파 이동 이벤트가 정렬된 키와 버튼 에지를 밀어내지 않도록 합니다. Windows에서는 모델이 상대 이동을 요청할 때만 플랫폼 포인터 인터페이스를 통해 DirectInput을 사용합니다. SDL3 창, 설정, 게임패드 이벤트는 메인 스레드에서 처리되며, 게임패드 이벤트는 모델 매개변수나 단축키에 전달되기 전에 정규화됩니다. 어떤 플랫폼 리스너도 Live2D, 오버레이, UI 코드를 직접 호출하지 않습니다. + +`bongo_cat_app_run`은 업데이트, 종료, 보조 프로세스 매개변수를 처리하고 메인 프로세스의 단일 인스턴스 소유권을 강제하며 애플리케이션 상태를 할당하고 초기화를 수행한 후 `bongo_cat_app_loop`에 들어가 정의된 순서대로 상태를 플러시하고 리소스를 파괴합니다. 초기화는 구성과 저장 경로를 로드하고, 리소스를 찾고, SDL/OpenGL 펫 창을 만들고, 플랫폼 백엔드를 초기화하고, Live2D/오버레이/오디오 서비스를 만들고, 내장/설치/인근 모델 소스를 스캔하고, 사용 가능한 모델을 로드합니다. `BongoCatApp`은 설정, 세션 상태, 모델 및 동작 카탈로그, 플랫폼 핸들, 런타임 서비스 핸들을 보유합니다. + +설치된 모델 패키지는 Mver을 표준 형식으로 사용합니다. 가져오기 프로세스는 선택한 파일이나 디렉터리를 구문 분석하고 후보를 발견·검증하며 패키지 ID 지문을 생성하고 Tauri 소스를 Mver로 변환하며 이미지 패치를 적용하고 정규화된 패키지를 `models_root`에 커밋합니다. 그런 다음 런타임 어댑터가 생성되고 카탈로그가 새로 고쳐집니다. 인근 소스는 소스 트리를 설치하지 않고 발견만 됩니다. 해당 어댑터와 검사 결과는 `models_root` 밖의 `cache_root` 아래에 캐시됩니다. + +메인 루프의 각 반복은 SDL/네이티브 웨이크 이벤트 또는 가장 이른 프레임, UI, 애니메이션, 포인터 히트 마감(최대 250ms)을 기다립니다. 루프는 대기 중인 SDL 이벤트를 분배하고 원자 입력 큐를 비우고 해제 복구를 수행하며 창과 모델 새로 고침 상태를 업데이트한 다음 입력 매개변수를 적용합니다. Cubism이 활성화된 경우 모델 마감은 `settings.model.max_fps`(기본 60 FPS)를 따릅니다. 진단 빌드는 100ms 폴백 간격을 사용합니다. 모델 경과 시간은 최대 250ms로 계산되며 각각 1/30초 이하를 목표로 하는 최대 8개의 하위 단계로 나뉩니다. + +일반 펫 경로는 창이 표시되고, 최소화되지 않았으며, 더티로 표시된 경우에만 렌더링됩니다. 각 프레임은 먼저 배경을 지우고 모델을 그린 다음 포인터, 키, 효과 오버레이를 합성하고 마지막으로 플랫폼 프리젠터를 호출합니다. 미리 보기 작업은 즉시 렌더링을 요청할 수 있고 스크린샷 렌더링은 표시를 건너뛸 수 있습니다. macOS와 Linux는 SDL OpenGL 창을 직접 교환합니다. Windows는 레이어드 표시가 활성화되지 않은 경우 직접 교환하고 그렇지 않으면 프레임 버퍼를 읽어 `UpdateLayeredWindow`를 호출합니다. 설정 UI는 자체 SDL/OpenGL 창을 가지며 별도로 렌더링되고 표시됩니다. + +C 런타임은 `include/bongo_cat/model.h`에 선언된 ABI를 호출합니다. Live2D 브리지와 Cubism 구현은 `src/live2d`에 있으며 Cubism SDK가 활성화된 경우에만 C++17을 사용합니다. 나머지 네이티브 런타임은 C11을 사용합니다. Cubism 유형은 불투명한 C 핸들 뒤에 유지됩니다. SDK를 사용할 수 없으면 `src/live2d/live2d_stub.c`가 진단 백엔드를 제공합니다. + +```mermaid +flowchart TB + Input(["키보드 / 마우스 / 게임패드"]) + BuiltIn(["내장 모델 리소스"]) + Sources(["외부 모델 소스
Mver, Tauri, .model3.json, 이미지 패치"]) + Desktop(["펫 창과 설정 창"]) + subgraph Runtime["BongoCat 네이티브 런타임"] + direction TB + Entry["src/main.c
bongo_cat_app_run"] + Startup["시작 및 초기화
구성, 저장소, 창, 플랫폼"] + Loop["SDL3 메인 루프
대기, 분배, 업데이트, 렌더링"] + Shutdown["종료
상태 플러시, 서비스 중지, 리소스 해제"] + InputQueue[("원자 입력 상태
에지 큐와 병합된 포인터 위치")] + InputDispatch["입력 분배
단축키, 포인터 매핑, 모델 매개변수"] + State[("BongoCatApp 상태
설정, 세션, 카탈로그, 런타임 핸들")] + Import["모델 발견 및 가져오기
검증, Mver로 정규화, 설치/캐시"] + Catalog[("모델 및 동작 카탈로그")] + Live2D["Live2D C ABI
Cubism SDK 또는 진단 스텁"] + Overlay["오버레이 및 오디오"] + Preferences["설정 및 데스크톱 셸
Nuklear UI, 트레이, 창 작업"] + Compose["OpenGL 프레임 합성"] + Present["플랫폼 표시"] + Entry --> Startup --> Loop + Loop --> Shutdown + Loop --> InputDispatch --> State + Loop <--> State + State --> Live2D + State --> Overlay + State <--> Preferences + Loop --> Preferences + Catalog --> State + State --> Compose + Live2D --> Compose + Overlay --> Compose + Compose --> Present + Loop --> Compose + end + subgraph Platform["플랫폼 백엔드"] + direction LR + Global["전역 키보드/포인터 캡처
Windows, macOS, Linux"] + SDL["SDL3 이벤트
창 및 게임패드 이벤트"] + end + Input --> Global --> InputQueue --> InputDispatch + Input --> SDL --> Loop + BuiltIn --> Catalog + Sources --> Import --> Catalog + Present --> Desktop + Preferences --> Desktop +``` + +## ❓ 자주 묻는 질문 + +### 🔒 BongoCat이 키보드나 마우스 입력을 기록하나요? + +아니요. BongoCat은 키보드와 마우스 입력을 로컬에서 처리하여 애니메이션과 단축키를 구동합니다. 키 입력, 마우스 동작 또는 기타 상호 작용 데이터를 기록하거나 업로드하지 않습니다. 구성도 로컬에만 저장되며 앱에는 광고, 분석 도구, 사용자 추적 코드가 포함되어 있지 않습니다. 업데이트 확인 시 공개 버전 메타데이터만 요청하며 입력, 구성, 사용 데이터는 전송하지 않습니다. + +### 🖼️ Vulkan 대신 OpenGL을 사용하는 이유는 무엇인가요? + +Vulkan이 나쁘기 때문이 아니라 BongoCat이 그 정도의 복잡성을 필요로 하지 않기 때문입니다. 이 앱은 주로 Live2D 모델 하나, 소수의 UI 레이어, 투명한 데스크톱 창을 렌더링합니다. OpenGL은 이를 쉽게 처리하며 SDL3 및 Cubism의 OpenGL 렌더러와 자연스럽게 어울립니다. Vulkan으로의 마이그레이션은 세 개의 데스크톱 플랫폼에서 더 많은 렌더링 및 동기화 코드를 유지해야 하며 사용자에게 눈에 띄는 이점이 없습니다. BongoCat의 현재 워크로드에서 OpenGL은 렌더러를 더 가볍고 디버깅과 유지 관리가 쉽게 만들면서도 필요한 성능을 계속 제공합니다. + +## 🙏 특별 감사 + +기여자 랭킹 + +
+저작권 © 2026 - **BongoCat**
+By vladelaina
+Made with ❤️ & ⌨️ +
diff --git a/docs/README.pt-BR.md b/docs/README.pt-BR.md index e69de29b..d425d7b9 100644 --- a/docs/README.pt-BR.md +++ b/docs/README.pt-BR.md @@ -0,0 +1,230 @@ + + +

💘 C/C++ × SDL3 × OpenGL, misture tudo e bata à vontade! Bong~ Bongo Cat!!!

+

+ Escolha o idioma ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español +

+ +
+ +> [!TIP] +> O modelo usado na demonstração é de [宇痕冫](https://space.bilibili.com/348616056). +> +> 🎁 Procurando modelos **gratuitos**? Visite nosso site oficial: [bongocat.pet](https://bongocat.pet/models) + +

+ +## 📥 Download + + + +- GitHub Releases + + Baixe a versão mais recente nas [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest). + +## 🛠️ Compilar a partir do código-fonte + +O BongoCat usa CMake e requer um compilador C11, um compilador C++17, CMake 3.24 ou superior e os arquivos de desenvolvimento de OpenGL para desktop. Por padrão, SDL3, yyjson, stb, miniaudio e Nuklear são baixados automaticamente durante a configuração, portanto a primeira configuração requer conexão com a internet. + +Execute os comandos abaixo na raiz do projeto (o diretório que contém `CMakeLists.txt`). + +### 📋 Pré-requisitos por plataforma + +- **Windows:** Visual Studio 2022 (com a carga de trabalho ‘Desenvolvimento para desktop com C++’) e CMake. Use o gerador MSVC; o MinGW pode compilar o backend de diagnóstico, mas não suporta o SDK do Cubism. +- **macOS:** Xcode Command Line Tools, CMake e Ninja. Se a arquitetura de destino for diferente da padrão do host, especifique-a por meio de `CMAKE_OSX_ARCHITECTURES`. +- **Linux (Debian/Ubuntu):** GCC ou Clang, Ninja e os cabeçalhos de OpenGL/X11: + + ```bash + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build \ + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev + ``` + +### 🔧 Configuração e compilação + +No Linux e no macOS, use um gerador de configuração única como o Ninja: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --parallel +``` + +No Windows, execute os comandos no Prompt de Comando do Desenvolvedor do Visual Studio 2022 (ou em qualquer shell em que o MSVC esteja disponível): + +```powershell +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --config Release --parallel +``` + +O executável fica em `build/BongoCat` no Linux, em `build/BongoCat.app/Contents/MacOS/BongoCat` no macOS e em `build/Release/BongoCat.exe` para compilações do Visual Studio no Windows. + +### 🧪 Testes + +Os alvos do CTest estão habilitados por padrão. Execute o seguinte após compilar: + +```bash +ctest --test-dir build --output-on-failure +``` + +Para um gerador de múltiplas configurações, como o Visual Studio, especifique explicitamente a configuração de compilação: + +```powershell +ctest --test-dir build -C Release --output-on-failure +``` + +### 🎭 Live2D / SDK do Cubism (opcional) + +Se o SDK do Cubism não for encontrado, o CMake emitirá um aviso e compilará o backend de diagnóstico. Esse backend é destinado à inicialização e ao diagnóstico de plataforma; ele não fornece renderização de modelos Live2D. Para compilar o runtime completo, instale uma versão compatível do Cubism SDK for Native, coloque-a em `vendor/CubismSdkForNative` ou informe o caminho explicitamente: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ + -DBONGO_CAT_REQUIRE_CUBISM=ON +``` + +O SDK deve incluir a biblioteca Core, o código-fonte do Framework e o diretório de terceiros do OpenGL GLEW na estrutura esperada por `cmake/Cubism.cmake`. As compilações de Cubism no Windows exigem o Visual Studio 2022. Com `BONGO_CAT_REQUIRE_CUBISM=ON`, a configuração falha quando o SDK não está disponível, em vez de selecionar silenciosamente o backend de diagnóstico. + +### ⚙️ Opções do CMake + +| Opção | Valor padrão | Descrição | +| --- | --- | --- | +| `BONGO_CAT_FETCH_DEPS` | `ON` | Baixa dependências de terceiros em versões fixadas via `FetchContent` do CMake. Defina como `OFF` apenas se SDL3, yyjson, stb, miniaudio e Nuklear já estiverem disponíveis para o CMake. | +| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Caminho para o Cubism SDK for Native. | +| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | Faz a configuração falhar quando não há SDK disponível. | +| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | Trata avisos do compilador nativo como erros. | + +Para uma compilação offline, defina `BONGO_CAT_FETCH_DEPS=OFF` e forneça as configurações de pacote do CMake para SDL3 (incluindo `SDL3-static`) e yyjson; se stb, Nuklear e miniaudio não puderem ser detectados automaticamente, informe também seus diretórios de inclusão: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=OFF \ + -DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ + -DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ + -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio +``` + +## 📌 Status do projeto + +![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") + +## 📜 Licença + +O código-fonte e o runtime nativo do BongoCat estão licenciados sob [AGPL-3.0-only](../LICENSE). + +O modo de modelo integrado padrão (`standard`) permanece sob a licença MIT. Os recursos de modelo incluídos em `resources/assets/models/standard`, `keyboard` e `gamepad` são cobertos pela [declaração de licença MIT](../LICENSE-MIT) separada. Essa licença MIT se aplica apenas aos recursos dos modelos e às artes que os acompanham; ela não altera a licença do código-fonte nem do runtime nativo do BongoCat. + +## 🧭 Arquitetura técnica + +A versão nativa atual é construída com C/C++, SDL3 e OpenGL. O diagrama abaixo destaca o fluxo de dados em tempo de execução; consulte os arquivos do CMake para obter detalhes de compilação e empacotamento. + +### 🔄 Propriedade do runtime e agendamento de quadros + +Cada processo possui uma instância de `BongoCatApp` e um loop de eventos/renderização no thread principal. Os listeners de plataforma param na fronteira de entrada: + +```text +Listeners de plataforma (teclado/ponteiro) + | + v + Estado de entrada C11 (fila atômica de bordas + posição do ponteiro mesclada) + | + v + aplicação no thread principal <----- eventos SDL3 + | + v + parâmetros do modelo, sobreposições e estado da interface + | + v + atualização do modelo -> composição OpenGL -> apresentação da plataforma +``` + +Os hooks de baixo nível do Windows, o tap de eventos Quartz do macOS e os listeners XInput2 do Linux são executados fora do loop principal. Eles publicam bordas de teclas e botões do mouse com carimbo de data/hora em uma fila atômica limitada e publicam as coordenadas do ponteiro por meio de um slot de mesclagem independente; após uma publicação bem-sucedida, um evento de ativação SDL nativo é enviado, evitando que eventos de movimento de alta frequência desloquem as bordas ordenadas de teclas e botões. No Windows, o DirectInput é usado pela interface de ponteiro da plataforma apenas quando o modelo solicita movimento relativo. Os eventos de janela, preferências e gamepad do SDL3 são processados no thread principal; os eventos de gamepad são normalizados antes de serem passados aos parâmetros do modelo ou atalhos. Nenhum listener de plataforma chama diretamente o código de Live2D, sobreposições ou interface. + +`bongo_cat_app_run` trata dos parâmetros de atualização, encerramento e processos secundários, impõe a propriedade de instância única do processo principal, aloca o estado do aplicativo, executa a inicialização, entra em `bongo_cat_app_loop` e, em seguida, atualiza o estado e destrói os recursos na ordem definida. A inicialização carrega a configuração e os caminhos de armazenamento, localiza os recursos, cria a janela de mascote SDL/OpenGL, inicializa os backends de plataforma, cria os serviços de Live2D/sobreposições/áudio, verifica fontes de modelos integradas/instaladas/próximas e carrega os modelos disponíveis. `BongoCatApp` mantém as configurações, o estado da sessão, os catálogos de modelos e comportamentos, os identificadores de plataforma e os identificadores de serviços de runtime. + +Os pacotes de modelos instalados usam Mver como formato canônico. O processo de importação analisa o arquivo ou diretório selecionado, descobre e valida candidatos, gera uma impressão digital de identidade do pacote, converte fontes Tauri em Mver, aplica patches de imagem e envia o pacote normalizado para `models_root`. Em seguida, os adaptadores de runtime são gerados e o catálogo é atualizado. Fontes próximas são descobertas apenas sem instalar a árvore de origem; seus adaptadores e resultados de verificação são armazenados em cache sob `cache_root`, fora de `models_root`. + +Cada iteração do loop principal espera ativações SDL/nativas ou o vencimento mais cedo dos prazos de quadro, interface, animação e acerto do ponteiro (máximo de 250 ms). O loop distribui os eventos SDL enfileirados, esvazia a fila de entrada atômica e executa a recuperação de liberação, atualiza o estado da janela e da atualização do modelo e, em seguida, aplica os parâmetros de entrada. Com o Cubism habilitado, o prazo do modelo segue `settings.model.max_fps` (padrão de 60 FPS); builds de diagnóstico usam um intervalo de reserva de 100 ms. O tempo decorrido do modelo é contado no máximo como 250 ms e dividido em até oito subetapas, cada uma com o objetivo de não mais que 1/30 de segundo. + +O caminho normal da mascote é renderizado apenas quando a janela está visível, não minimizada e marcada como suja. Cada quadro primeiro limpa o fundo, desenha o modelo e então compõe as sobreposições de ponteiro, teclas e efeitos, e finalmente chama o apresentador da plataforma. As operações de visualização podem solicitar renderização imediata; a renderização de capturas de tela pode pular a apresentação. macOS e Linux trocam diretamente a janela SDL OpenGL; o Windows a troca diretamente quando a apresentação em camadas não está habilitada; caso contrário, lê o buffer de quadros e chama `UpdateLayeredWindow`. A interface de preferências tem sua própria janela SDL/OpenGL e é renderizada e apresentada separadamente. + +O runtime C chama a ABI declarada em `include/bongo_cat/model.h`. A ponte Live2D e a implementação do Cubism estão em `src/live2d` e usam C++17 apenas quando o SDK do Cubism está habilitado; o restante do runtime nativo usa C11. Os tipos do Cubism são mantidos atrás de identificadores C opacos; quando o SDK não está disponível, `src/live2d/live2d_stub.c` fornece o backend de diagnóstico. + +```mermaid +flowchart TB + Input(["Teclado / Mouse / Gamepad"]) + BuiltIn(["Recursos de modelos integrados"]) + Sources(["Fontes de modelos externos
Mver, Tauri, .model3.json, patches de imagem"]) + Desktop(["Janela de mascote e janela de preferências"]) + subgraph Runtime["Runtime nativo do BongoCat"] + direction TB + Entry["src/main.c
bongo_cat_app_run"] + Startup["Inicialização e inicialização
Configuração, armazenamento, janela, plataforma"] + Loop["Loop principal SDL3
Aguardar, distribuir, atualizar, renderizar"] + Shutdown["Encerramento
Atualizar estado, parar serviços, liberar recursos"] + InputQueue[("Estado de entrada atômico
Fila de bordas e posição do ponteiro mesclada")] + InputDispatch["Distribuição de entrada
Atalhos, mapeamento do ponteiro, parâmetros do modelo"] + State[("Estado do BongoCatApp
Configurações, sessão, catálogo, identificadores de runtime")] + Import["Descoberta e importação de modelos
Validação, normalização para Mver, instalação/cache"] + Catalog[("Catálogo de modelos e comportamentos")] + Live2D["Live2D C ABI
SDK do Cubism ou stub de diagnóstico"] + Overlay["Sobreposições e áudio"] + Preferences["Preferências e shell de desktop
UI Nuklear, bandeja, ações de janela"] + Compose["Composição de quadros OpenGL"] + Present["Apresentação da plataforma"] + Entry --> Startup --> Loop + Loop --> Shutdown + Loop --> InputDispatch --> State + Loop <--> State + State --> Live2D + State --> Overlay + State <--> Preferences + Loop --> Preferences + Catalog --> State + State --> Compose + Live2D --> Compose + Overlay --> Compose + Compose --> Present + Loop --> Compose + end + subgraph Platform["Backends de plataforma"] + direction LR + Global["Captura global de teclado/ponteiro
Windows, macOS, Linux"] + SDL["Eventos SDL3
Eventos de janela e gamepad"] + end + Input --> Global --> InputQueue --> InputDispatch + Input --> SDL --> Loop + BuiltIn --> Catalog + Sources --> Import --> Catalog + Present --> Desktop + Preferences --> Desktop +``` + +## ❓ Perguntas frequentes + +### 🔒 O BongoCat registra minhas entradas de teclado ou mouse? + +Não. O BongoCat processa entradas de teclado e mouse localmente para impulsionar animações e atalhos. Ele não registra nem envia teclas pressionadas, ações do mouse ou outros dados de interação. A configuração também é salva apenas localmente; o aplicativo não contém anúncios, ferramentas de análise nem código de rastreamento de usuários. Ao verificar atualizações, apenas metadados públicos de versão são solicitados; nenhum dado de entrada, configuração ou uso é enviado. + +### 🖼️ Por que OpenGL em vez de Vulkan? + +Não porque Vulkan seja ruim, mas porque o BongoCat não precisa desse nível de complexidade. O aplicativo renderiza principalmente um modelo Live2D, algumas camadas de interface e uma janela de desktop transparente; o OpenGL dá conta disso facilmente e se integra naturalmente com o SDL3 e o renderizador OpenGL do Cubism. Migrar para Vulkan exigiria manter mais código de renderização e sincronização em três plataformas de desktop, sem benefício perceptível para o usuário. Para a carga de trabalho atual do BongoCat, o OpenGL torna o renderizador mais enxuto, mais fácil de depurar e manter, continuando a oferecer o desempenho necessário. + +## 🙏 Agradecimentos especiais + +Ranking de contribuidores + +
+Copyright © 2026 - **BongoCat**
+By vladelaina
+Made with ❤️ & ⌨️ +
diff --git a/docs/README.ru-RU.md b/docs/README.ru-RU.md index e69de29b..e1d7f266 100644 --- a/docs/README.ru-RU.md +++ b/docs/README.ru-RU.md @@ -0,0 +1,230 @@ + + +

💘 C/C++ × SDL3 × OpenGL — смешай всё и барабань в своё удовольствие! Бонг~ Bongo Cat!!!

+

+ Выберите язык ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español +

+ +
+ +> [!TIP] +> Модель, использованная в демо, принадлежит [宇痕冫](https://space.bilibili.com/348616056). +> +> 🎁 Ищете **бесплатные** модели? Загляните на наш официальный сайт: [bongocat.pet](https://bongocat.pet/models) + +

+ +## 📥 Загрузка + + + +- GitHub Releases + + Скачайте последнюю версию из [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest). + +## 🛠️ Сборка из исходного кода + +BongoCat использует CMake и требует компилятор C11, компилятор C++17, CMake 3.24 или новее, а также файлы разработки OpenGL для настольных систем. По умолчанию SDL3, yyjson, stb, miniaudio и Nuklear загружаются автоматически на этапе конфигурации, поэтому для первой настройки требуется подключение к сети. + +Выполните следующие команды в корневом каталоге проекта (каталоге, содержащем `CMakeLists.txt`). + +### 📋 Предварительные требования по платформам + +- **Windows:** Visual Studio 2022 (с рабочей нагрузкой «Разработка классических приложений на C++») и CMake. Используйте генератор MSVC; MinGW может собрать диагностический бэкенд, но не поддерживает Cubism SDK. +- **macOS:** Xcode Command Line Tools, CMake и Ninja. Если целевая архитектура отличается от архитектуры хоста по умолчанию, укажите её через `CMAKE_OSX_ARCHITECTURES`. +- **Linux (Debian/Ubuntu):** GCC или Clang, Ninja, а также заголовочные файлы OpenGL/X11: + + ```bash + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build \ + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev + ``` + +### 🔧 Настройка и сборка + +В Linux и macOS используйте генератор с одной конфигурацией, например Ninja: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --parallel +``` + +В Windows выполняйте команды из командной строки разработчика Visual Studio 2022 (или любой другой оболочки, где доступен MSVC): + +```powershell +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --config Release --parallel +``` + +Исполняемый файл находится в `build/BongoCat` в Linux, в `build/BongoCat.app/Contents/MacOS/BongoCat` в macOS и в `build/Release/BongoCat.exe` для сборок Visual Studio в Windows. + +### 🧪 Тестирование + +Цели CTest включены по умолчанию. После сборки выполните: + +```bash +ctest --test-dir build --output-on-failure +``` + +Для генератора с несколькими конфигурациями, такого как Visual Studio, явно укажите конфигурацию сборки: + +```powershell +ctest --test-dir build -C Release --output-on-failure +``` + +### 🎭 Live2D / Cubism SDK (необязательно) + +Если Cubism SDK не найден, CMake выдаст предупреждение и соберёт диагностический бэкенд. Этот бэкенд предназначен для запуска и диагностики платформы; он не обеспечивает рендеринг моделей Live2D. Чтобы собрать полный рантайм, установите совместимую версию Cubism SDK for Native, поместите её в `vendor/CubismSdkForNative` или явно укажите путь: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ + -DBONGO_CAT_REQUIRE_CUBISM=ON +``` + +SDK должен включать библиотеку Core, исходный код Framework и каталог сторонних библиотек OpenGL GLEW в структуре, ожидаемой `cmake/Cubism.cmake`. Сборки Cubism в Windows требуют Visual Studio 2022. При `BONGO_CAT_REQUIRE_CUBISM=ON` конфигурация завершится ошибкой, если SDK недоступен, а не будет молча использовать диагностический бэкенд. + +### ⚙️ Параметры CMake + +| Параметр | По умолчанию | Описание | +| --- | --- | --- | +| `BONGO_CAT_FETCH_DEPS` | `ON` | Загружает сторонние зависимости фиксированных версий через `FetchContent` CMake. Установите `OFF`, только если SDL3, yyjson, stb, miniaudio и Nuklear уже доступны для CMake. | +| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Путь к Cubism SDK for Native. | +| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | Завершает конфигурацию ошибкой, если SDK недоступен. | +| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | Обрабатывает предупреждения нативного компилятора как ошибки. | + +Для автономной сборки установите `BONGO_CAT_FETCH_DEPS=OFF` и предоставьте конфигурации пакетов CMake для SDL3 (включая `SDL3-static`) и yyjson; если stb, Nuklear и miniaudio не могут быть найдены автоматически, укажите также их каталоги включения: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=OFF \ + -DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ + -DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ + -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio +``` + +## 📌 Статус проекта + +![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") + +## 📜 Лицензия + +Исходный код и нативный рантайм BongoCat распространяются под лицензией [AGPL-3.0-only](../LICENSE). + +Встроенный по умолчанию режим модели (`standard`) остаётся под лицензией MIT. Ресурсы моделей, входящие в `resources/assets/models/standard`, `keyboard` и `gamepad`, покрываются отдельным [объявлением о лицензии MIT](../LICENSE-MIT). Эта лицензия MIT распространяется только на ресурсы моделей и сопутствующие художественные материалы; она не меняет лицензию исходного кода или нативного рантайма BongoCat. + +## 🧭 Техническая архитектура + +Текущая нативная версия построена на C/C++, SDL3 и OpenGL. Диаграмма ниже акцентирует поток данных во время выполнения; подробности сборки и упаковки см. в файлах CMake. + +### 🔄 Владение рантаймом и планирование кадров + +Каждый процесс владеет экземпляром `BongoCatApp` и циклом событий/рендеринга в главном потоке. Платформенные слушатели останавливаются на границе ввода: + +```text +Платформенные слушатели (клавиатура/указатель) + | + v + Состояние ввода C11 (атомарная очередь фронтов + объединённая позиция указателя) + | + v + приложение в главном потоке <----- события SDL3 + | + v + параметры модели, оверлеи и состояние интерфейса + | + v + обновление модели -> композиция OpenGL -> представление на платформе +``` + +Низкоуровневые хуки Windows, обработчик событий Quartz в macOS и слушатели XInput2 в Linux работают вне главного цикла. Они публикуют нажатия клавиш и фронты кнопок мыши с отметками времени в ограниченную атомарную очередь и публикуют координаты указателя через отдельный слот объединения; после успешной публикации отправляется нативное событие пробуждения SDL, что не позволяет высокочастотным событиям движения вытеснять упорядоченные фронты клавиш и кнопок. В Windows DirectInput используется через интерфейс указателя платформы только тогда, когда модель запрашивает относительное движение. События окна, настроек и геймпада SDL3 обрабатываются в главном потоке; события геймпада нормализуются перед передачей параметрам модели или сочетаниям клавиш. Ни один платформенный слушатель не вызывает напрямую код Live2D, оверлеев или интерфейса. + +`bongo_cat_app_run` обрабатывает параметры обновления, завершения и вторичных процессов, обеспечивает владение единственным экземпляром главного процесса, выделяет состояние приложения, выполняет инициализацию, входит в `bongo_cat_app_loop`, а затем обновляет состояние и уничтожает ресурсы в определённом порядке. Инициализация загружает конфигурацию и пути хранения, находит ресурсы, создаёт окно питомца SDL/OpenGL, инициализирует платформенные бэкенды, создаёт сервисы Live2D/оверлеев/аудио, сканирует встроенные/установленные/близлежащие источники моделей и загружает доступные модели. `BongoCatApp` хранит настройки, состояние сеанса, каталоги моделей и поведения, дескрипторы платформы и дескрипторы сервисов рантайма. + +Установленные пакеты моделей используют Mver в качестве канонического формата. Процесс импорта анализирует выбранный файл или каталог, обнаруживает и проверяет кандидатов, формирует отпечаток идентичности пакета, преобразует источники Tauri в Mver, применяет патчи изображений и отправляет нормализованный пакет в `models_root`. Затем создаются адаптеры рантайма и обновляется каталог. Близлежащие источники обнаруживаются без установки их дерева исходников; их адаптеры и результаты проверок кэшируются в `cache_root` за пределами `models_root`. + +Каждая итерация главного цикла ожидает пробуждения SDL/нативных событий или самого раннего срока кадра, интерфейса, анимации и попадания указателя (максимум 250 мс). Цикл распределяет поставленные в очередь события SDL, очищает атомарную очередь ввода и выполняет восстановление освобождений, обновляет состояние окна и обновления модели, а затем применяет входные параметры. При включённом Cubism срок модели следует `settings.model.max_fps` (по умолчанию 60 FPS); диагностические сборки используют резервный интервал 100 мс. Прошедшее время модели учитывается максимум 250 мс и делится на до восьми подшагов, каждый из которых не превышает 1/30 секунды. + +Обычный путь питомца рендерится только тогда, когда окно видимо, не свёрнуто и помечено как изменённое. Каждый кадр сначала очищает фон, рисует модель, затем компонует оверлеи указателя, клавиш и эффектов и, наконец, вызывает презентер платформы. Операции предпросмотра могут запросить немедленный рендеринг; рендеринг скриншотов может пропустить представление. macOS и Linux напрямую обменивают окно SDL OpenGL; Windows обменивает его напрямую, если многослойное представление не включено, в противном случае считывает кадровый буфер и вызывает `UpdateLayeredWindow`. Интерфейс настроек имеет собственное окно SDL/OpenGL и рендерится и представляется отдельно. + +Среда выполнения C вызывает ABI, объявленный в `include/bongo_cat/model.h`. Мост Live2D и реализация Cubism находятся в `src/live2d` и используют C++17 только при включённом Cubism SDK; остальной нативный рантайм использует C11. Типы Cubism остаются за непрозрачными дескрипторами C; когда SDK недоступен, `src/live2d/live2d_stub.c` предоставляет диагностический бэкенд. + +```mermaid +flowchart TB + Input(["Клавиатура / Мышь / Геймпад"]) + BuiltIn(["Встроенные ресурсы моделей"]) + Sources(["Внешние источники моделей
Mver, Tauri, .model3.json, патчи изображений"]) + Desktop(["Окно питомца и окно настроек"]) + subgraph Runtime["Нативный рантайм BongoCat"] + direction TB + Entry["src/main.c
bongo_cat_app_run"] + Startup["Запуск и инициализация
Конфигурация, хранилище, окно, платформа"] + Loop["Главный цикл SDL3
Ожидание, распределение, обновление, рендеринг"] + Shutdown["Завершение
Обновить состояние, остановить сервисы, освободить ресурсы"] + InputQueue[("Атомарное состояние ввода
Очередь фронтов и объединённая позиция указателя")] + InputDispatch["Распределение ввода
Сочетания клавиш, сопоставление указателя, параметры модели"] + State[("Состояние BongoCatApp
Настройки, сеанс, каталог, дескрипторы рантайма")] + Import["Обнаружение и импорт моделей
Проверка, нормализация в Mver, установка/кэш"] + Catalog[("Каталог моделей и поведений")] + Live2D["Live2D C ABI
Cubism SDK или диагностический заглушка"] + Overlay["Оверлеи и аудио"] + Preferences["Настройки и оболочка рабочего стола
UI Nuklear, трей, операции с окном"] + Compose["Композиция кадров OpenGL"] + Present["Представление на платформе"] + Entry --> Startup --> Loop + Loop --> Shutdown + Loop --> InputDispatch --> State + Loop <--> State + State --> Live2D + State --> Overlay + State <--> Preferences + Loop --> Preferences + Catalog --> State + State --> Compose + Live2D --> Compose + Overlay --> Compose + Compose --> Present + Loop --> Compose + end + subgraph Platform["Платформенные бэкенды"] + direction LR + Global["Глобальный захват клавиатуры/указателя
Windows, macOS, Linux"] + SDL["События SDL3
События окна и геймпада"] + end + Input --> Global --> InputQueue --> InputDispatch + Input --> SDL --> Loop + BuiltIn --> Catalog + Sources --> Import --> Catalog + Present --> Desktop + Preferences --> Desktop +``` + +## ❓ Часто задаваемые вопросы + +### 🔒 Записывает ли BongoCat мой ввод с клавиатуры или мыши? + +Нет. BongoCat обрабатывает ввод с клавиатуры и мыши локально для управления анимациями и сочетаниями клавиш. Он не записывает и не загружает нажатия клавиш, действия мыши или другие данные взаимодействия. Конфигурация также сохраняется только локально; приложение не содержит рекламы, аналитических инструментов или кода отслеживания пользователей. При проверке обновлений запрашиваются только публичные метаданные версии; никакие данные ввода, конфигурации или использования не отправляются. + +### 🖼️ Почему OpenGL, а не Vulkan? + +Не потому что Vulkan плох, а потому что BongoCat не нуждается в такой сложности. Приложение в основном рендерит одну модель Live2D, несколько слоёв интерфейса и прозрачное окно рабочего стола; OpenGL легко справляется с этим и естественно сочетается с SDL3 и рендерером OpenGL от Cubism. Переход на Vulkan потребовал бы поддержки большего объёма кода рендеринга и синхронизации на трёх настольных платформах без заметного преимущества для пользователя. Для текущей нагрузки BongoCat OpenGL делает рендерер более компактным, лёгким в отладке и обслуживании, сохраняя при этом необходимую производительность. + +## 🙏 Особая благодарность + +Рейтинг контрибьюторов + +
+Авторские права © 2026 - **BongoCat**
+By vladelaina
+Made with ❤️ & ⌨️ +
From ad09b7a1737572fb26fe315711099af465835beb Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 1 Sep 2026 17:42:09 +0800 Subject: [PATCH 188/334] Update README.md --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index ff2378bb..cf57c033 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,11 @@ > > 🎁Looking for **free** models? Visit our official website: [bongocat.pet](https://bongocat.pet/models) +

+ + + +

From 76db5863c6587800b47f7bd12e497e2be71c197f Mon Sep 17 00:00:00 2001 From: yuanziyi2022 Date: Tue, 1 Sep 2026 17:43:58 +0800 Subject: [PATCH 189/334] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E7=B9=81=E4=BD=93REA?= =?UTF-8?q?DME?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/README.zh-Hant.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/README.zh-Hant.md b/docs/README.zh-Hant.md index 1b043ac1..f14e2989 100644 --- a/docs/README.zh-Hant.md +++ b/docs/README.zh-Hant.md @@ -1,4 +1,3 @@ -```markdown

Catime @@ -21,7 +20,7 @@ - +

@@ -279,4 +278,3 @@ Copyright © 2026 - **BongoCat**\ 以 ❤️ 和 ⌨️ 打造
-``` From cd497b235920b61c298316332fd3a73eacdd0f8d Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 1 Sep 2026 17:46:46 +0800 Subject: [PATCH 190/334] Update README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cf57c033..7f1e387f 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,8 @@ > [!TIP] > The model featured in this demonstration is from [宇痕冫](https://space.bilibili.com/348616056). > -> 🎁Looking for **free** models? Visit our official website: [bongocat.pet](https://bongocat.pet/models) +> 🎁 Looking for **free** models? We work with talented model creators to bring you a wide variety of free models, while continuously exploring more fun desktop experiences! Visit our official website: [bongocat.pet](https://bongocat.pet/models) +

From df91b1ad48bfe150c770718976c4375296e27ad0 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 1 Sep 2026 17:49:44 +0800 Subject: [PATCH 191/334] Clear translated README files --- docs/README.es-ES.md | 230 ------------------------------------------- docs/README.ja-JP.md | 230 ------------------------------------------- docs/README.ko-KR.md | 230 ------------------------------------------- docs/README.pt-BR.md | 230 ------------------------------------------- docs/README.ru-RU.md | 230 ------------------------------------------- 5 files changed, 1150 deletions(-) diff --git a/docs/README.es-ES.md b/docs/README.es-ES.md index bff6f02e..e69de29b 100644 --- a/docs/README.es-ES.md +++ b/docs/README.es-ES.md @@ -1,230 +0,0 @@ -

- -

💘 C/C++ × SDL3 × OpenGL, mézclalo todo, ¡a tocar! ¡Bong~ Bongo Cat!!!

-

- Elige el idioma ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español -

- -
- -> [!TIP] -> El modelo usado en la demo proviene de [宇痕冫](https://space.bilibili.com/348616056). -> -> 🎁 ¿Buscas modelos **gratuitos**? Visita nuestro sitio web oficial: [bongocat.pet](https://bongocat.pet/models) - -

- -## 📥 Descarga - - - -- GitHub Releases - - Descarga la última versión desde [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest). - -## 🛠️ Compilar desde el código fuente - -BongoCat usa CMake y requiere un compilador C11, un compilador C++17, CMake 3.24 o superior y los archivos de desarrollo de OpenGL para escritorio. Por defecto, SDL3, yyjson, stb, miniaudio y Nuklear se descargan automáticamente durante la configuración, por lo que la primera configuración requiere conexión a internet. - -Ejecuta los siguientes comandos en la raíz del proyecto (el directorio que contiene `CMakeLists.txt`). - -### 📋 Requisitos por plataforma - -- **Windows:** Visual Studio 2022 (con la carga de trabajo «Desarrollo para escritorio con C++») y CMake. Usa el generador MSVC; MinGW puede compilar el backend de diagnóstico, pero no es compatible con el SDK de Cubism. -- **macOS:** Xcode Command Line Tools, CMake y Ninja. Si la arquitectura de destino difiere de la predeterminada del host, especifícala mediante `CMAKE_OSX_ARCHITECTURES`. -- **Linux (Debian/Ubuntu):** GCC o Clang, Ninja y los encabezados de OpenGL/X11: - - ```bash - sudo apt-get update - sudo apt-get install -y build-essential cmake ninja-build \ - libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev - ``` - -### 🔧 Configuración y compilación - -En Linux y macOS, usa un generador de configuración única como Ninja: - -```bash -cmake -S . -B build -G Ninja \ - -DCMAKE_BUILD_TYPE=Release \ - -DBONGO_CAT_FETCH_DEPS=ON -cmake --build build --parallel -``` - -En Windows, ejecuta los comandos desde el Símbolo del sistema para desarrolladores de Visual Studio 2022 (o cualquier shell donde MSVC esté disponible): - -```powershell -cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` - -DBONGO_CAT_FETCH_DEPS=ON -cmake --build build --config Release --parallel -``` - -El ejecutable se encuentra en `build/BongoCat` en Linux, en `build/BongoCat.app/Contents/MacOS/BongoCat` en macOS y en `build/Release/BongoCat.exe` para las compilaciones de Visual Studio en Windows. - -### 🧪 Pruebas - -Los objetivos de CTest están habilitados por defecto. Ejecuta lo siguiente después de compilar: - -```bash -ctest --test-dir build --output-on-failure -``` - -Para un generador de múltiples configuraciones como Visual Studio, especifica explícitamente la configuración de compilación: - -```powershell -ctest --test-dir build -C Release --output-on-failure -``` - -### 🎭 Live2D / SDK de Cubism (opcional) - -Si no se encuentra el SDK de Cubism, CMake muestra una advertencia y compila el backend de diagnóstico. Este backend está pensado para el arranque y el diagnóstico de plataforma; no proporciona renderizado de modelos Live2D. Para compilar el runtime completo, instala una versión compatible del Cubism SDK for Native, colócalo en `vendor/CubismSdkForNative` o indica explícitamente su ruta: - -```bash -cmake -S . -B build -G Ninja \ - -DCMAKE_BUILD_TYPE=Release \ - -DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ - -DBONGO_CAT_REQUIRE_CUBISM=ON -``` - -El SDK debe incluir la biblioteca Core, el código fuente de Framework y el directorio de terceros de OpenGL GLEW en la estructura esperada por `cmake/Cubism.cmake`. Las compilaciones de Cubism en Windows requieren Visual Studio 2022. Con `BONGO_CAT_REQUIRE_CUBISM=ON`, la configuración falla si el SDK no está disponible, en lugar de seleccionar silenciosamente el backend de diagnóstico. - -### ⚙️ Opciones de CMake - -| Opción | Valor por defecto | Descripción | -| --- | --- | --- | -| `BONGO_CAT_FETCH_DEPS` | `ON` | Descarga dependencias de terceros en versiones fijadas mediante `FetchContent` de CMake. Establécelo en `OFF` solo si SDL3, yyjson, stb, miniaudio y Nuklear ya están disponibles para CMake. | -| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Ruta del Cubism SDK for Native. | -| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | Hace fallar la configuración cuando no hay SDK disponible. | -| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | Trata las advertencias del compilador nativo como errores. | - -Para una compilación sin conexión, establece `BONGO_CAT_FETCH_DEPS=OFF` y proporciona las configuraciones de paquetes de CMake para SDL3 (incluido `SDL3-static`) y yyjson; si stb, Nuklear y miniaudio no se pueden detectar automáticamente, indica también sus directorios de inclusión: - -```bash -cmake -S . -B build -G Ninja \ - -DCMAKE_BUILD_TYPE=Release \ - -DBONGO_CAT_FETCH_DEPS=OFF \ - -DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ - -DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ - -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio -``` - -## 📌 Estado del proyecto - -![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") - -## 📜 Licencia - -El código fuente y el runtime nativo de BongoCat están bajo la licencia [AGPL-3.0-only](../LICENSE). - -El modo de modelo integrado por defecto (`standard`) permanece bajo la licencia MIT. Los recursos de modelo incluidos en `resources/assets/models/standard`, `keyboard` y `gamepad` están cubiertos por la [declaración de licencia MIT](../LICENSE-MIT) independiente. Esta licencia MIT se aplica únicamente a los recursos de los modelos y sus ilustraciones asociadas; no cambia la licencia del código fuente ni del runtime nativo de BongoCat. - -## 🧭 Arquitectura técnica - -La versión nativa actual se basa en C/C++, SDL3 y OpenGL. El siguiente diagrama destaca el flujo de datos en tiempo de ejecución; consulta los archivos de CMake para los detalles de compilación y empaquetado. - -### 🔄 Propiedad del runtime y planificación de fotogramas - -Cada proceso posee una instancia de `BongoCatApp` y un bucle de eventos/renderizado en el hilo principal. Los listeners de plataforma se detienen en el límite de entrada: - -```text -Listeners de plataforma (teclado/ puntero) - | - v - Estado de entrada C11 (cola atómica de flancos + posición de puntero fusionada) - | - v - aplicación en el hilo principal <----- eventos SDL3 - | - v - parámetros del modelo, superposiciones y estado de la interfaz - | - v - actualización del modelo -> composición OpenGL -> presentación de la plataforma -``` - -Los hooks de bajo nivel de Windows, el tap de eventos Quartz de macOS y los listeners XInput2 de Linux se ejecutan fuera del bucle principal. Publican pulsaciones de teclas y flancos de botones del ratón con marca de tiempo en una cola atómica acotada, y publican las coordenadas del puntero a través de una ranura de fusión independiente; tras una publicación correcta, se impulsa un evento de activación SDL nativo, lo que evita que los eventos de movimiento de alta frecuencia desplacen los flancos ordenados de teclas y botones. En Windows, DirectInput solo se usa a través de la interfaz de puntero de la plataforma cuando el modelo solicita movimiento relativo. Los eventos de ventana, preferencias y gamepad de SDL3 se procesan en el hilo principal; los eventos de gamepad se normalizan antes de pasarse a los parámetros del modelo o a los atajos. Ningún listener de plataforma llama directamente al código de Live2D, superposiciones o interfaz. - -`bongo_cat_app_run` gestiona los parámetros de actualización, cierre y procesos secundarios, hace cumplir la propiedad de una única instancia del proceso principal, asigna el estado de la aplicación, realiza la inicialización, entra en `bongo_cat_app_loop` y luego actualiza el estado y libera los recursos en el orden definido. La inicialización carga la configuración y las rutas de almacenamiento, localiza los recursos, crea la ventana de mascota SDL/OpenGL, inicializa los backends de plataforma, crea los servicios de Live2D/superposiciones/audio, escanea las fuentes de modelos integrados/instalados/cercanos y carga los modelos disponibles. `BongoCatApp` conserva la configuración, el estado de la sesión, los catálogos de modelos y comportamientos, los identificadores de plataforma y los identificadores de servicios en tiempo de ejecución. - -Los paquetes de modelos instalados usan Mver como formato canónico. El proceso de importación analiza el archivo o directorio seleccionado, descubre y valida los candidatos, genera una huella de identidad del paquete, convierte las fuentes Tauri a Mver, aplica parches de imagen y envía el paquete normalizado a `models_root`. Después se generan los adaptadores de tiempo de ejecución y se actualiza el catálogo. Las fuentes cercanas solo se descubren sin instalar su árbol de origen; sus adaptadores y resultados de comprobación se almacenan en caché bajo `cache_root`, fuera de `models_root`. - -Cada iteración del bucle principal espera activaciones SDL/nativas o el vencimiento más temprano de los plazos de fotograma, interfaz, animación y acierto de puntero (máximo 250 ms). El bucle distribuye los eventos SDL en cola, vacía la cola de entrada atómica y realiza la recuperación de liberaciones, actualiza el estado de la ventana y de actualización del modelo y luego aplica los parámetros de entrada. Con Cubism habilitado, el plazo del modelo sigue `settings.model.max_fps` (predeterminado 60 FPS); las compilaciones de diagnóstico usan un intervalo de reserva de 100 ms. El tiempo transcurrido del modelo se cuenta como máximo 250 ms y se divide en hasta ocho subpasos, cada uno con un objetivo de no más de 1/30 de segundo. - -La ruta normal de la mascota solo se renderiza cuando la ventana es visible, no está minimizada y está marcada como sucia. Cada fotograma primero limpia el fondo, dibuja el modelo y luego compone las superposiciones de puntero, teclas y efectos, y finalmente llama al presentador de la plataforma. Las operaciones de vista previa pueden solicitar renderizado inmediato; el renderizado de capturas de pantalla puede omitir la presentación. macOS y Linux intercambian directamente la ventana SDL OpenGL; Windows la intercambia directamente cuando la presentación en capas no está habilitada; de lo contrario, lee el búfer de fotogramas y llama a `UpdateLayeredWindow`. La interfaz de preferencias tiene su propia ventana SDL/OpenGL y se renderiza y presenta por separado. - -El runtime C llama a la ABI declarada en `include/bongo_cat/model.h`. El puente Live2D y la implementación de Cubism están en `src/live2d` y solo usan C++17 cuando el SDK de Cubism está habilitado; el resto del runtime nativo usa C11. Los tipos de Cubism se mantienen detrás de identificadores C opacos; cuando el SDK no está disponible, `src/live2d/live2d_stub.c` proporciona el backend de diagnóstico. - -```mermaid -flowchart TB - Input(["Teclado / Ratón / Gamepad"]) - BuiltIn(["Recursos de modelos integrados"]) - Sources(["Fuentes de modelos externos
Mver, Tauri, .model3.json, parches de imagen"]) - Desktop(["Ventana de mascota y ventana de preferencias"]) - subgraph Runtime["Runtime nativo de BongoCat"] - direction TB - Entry["src/main.c
bongo_cat_app_run"] - Startup["Arranque e inicialización
Configuración, almacenamiento, ventana, plataforma"] - Loop["Bucle principal SDL3
Esperar, distribuir, actualizar, renderizar"] - Shutdown["Apagado
Actualizar estado, detener servicios, liberar recursos"] - InputQueue[("Estado de entrada atómico
Cola de flancos y posición de puntero fusionada")] - InputDispatch["Distribución de entrada
Atajos, mapeo de puntero, parámetros del modelo"] - State[("Estado de BongoCatApp
Configuración, sesión, catálogo, identificadores de runtime")] - Import["Descubrimiento e importación de modelos
Validación, normalización a Mver, instalación/caché"] - Catalog[("Catálogo de modelos y comportamientos")] - Live2D["Live2D C ABI
SDK de Cubism o stub de diagnóstico"] - Overlay["Superposiciones y audio"] - Preferences["Preferencias y shell de escritorio
UI de Nuklear, bandeja, acciones de ventana"] - Compose["Composición de fotogramas OpenGL"] - Present["Presentación de la plataforma"] - Entry --> Startup --> Loop - Loop --> Shutdown - Loop --> InputDispatch --> State - Loop <--> State - State --> Live2D - State --> Overlay - State <--> Preferences - Loop --> Preferences - Catalog --> State - State --> Compose - Live2D --> Compose - Overlay --> Compose - Compose --> Present - Loop --> Compose - end - subgraph Platform["Backends de plataforma"] - direction LR - Global["Captura global de teclado/puntero
Windows, macOS, Linux"] - SDL["Eventos SDL3
Eventos de ventana y gamepad"] - end - Input --> Global --> InputQueue --> InputDispatch - Input --> SDL --> Loop - BuiltIn --> Catalog - Sources --> Import --> Catalog - Present --> Desktop - Preferences --> Desktop -``` - -## ❓ Preguntas frecuentes - -### 🔒 ¿BongoCat registra mis entradas de teclado o ratón? - -No. BongoCat procesa las entradas de teclado y ratón localmente para impulsar animaciones y atajos. No registra ni sube pulsaciones de teclas, acciones del ratón ni otros datos de interacción. La configuración también se guarda solo localmente; la aplicación no incluye anuncios, herramientas de análisis ni código de seguimiento de usuarios. Al comprobar actualizaciones solo se solicitan los metadatos públicos de versión; no se envían datos de entrada, configuración ni uso. - -### 🖼️ ¿Por qué OpenGL y no Vulkan? - -No porque Vulkan sea malo, sino porque BongoCat no necesita ese nivel de complejidad. La aplicación renderiza principalmente un modelo Live2D, algunas capas de interfaz y una ventana de escritorio transparente; OpenGL lo cubre con facilidad y se integra de forma natural con SDL3 y el renderizador OpenGL de Cubism. Migrar a Vulkan implicaría mantener más código de renderizado y sincronización en tres plataformas de escritorio, sin un beneficio notable para el usuario. Para la carga de trabajo actual de BongoCat, OpenGL hace que el renderizador sea más ligero, más fácil de depurar y mantener, y sigue ofreciendo el rendimiento necesario. - -## 🙏 Agradecimientos especiales - -Clasificación de contribuyentes - -
-Derechos de autor © 2026 - **BongoCat**
-By vladelaina
-Made with ❤️ & ⌨️ -
diff --git a/docs/README.ja-JP.md b/docs/README.ja-JP.md index ed52aabe..e69de29b 100644 --- a/docs/README.ja-JP.md +++ b/docs/README.ja-JP.md @@ -1,230 +0,0 @@ - - -

💘 C/C++ × SDL3 × OpenGL、ぜんぶ混ぜて、思いっきり叩こう!Bong~ Bongo Cat!!!

-

- 言語を選択 ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español -

- -
- -> [!TIP] -> デモで使用しているモデルは [宇痕冫](https://space.bilibili.com/348616056) によるものです。 -> -> 🎁 **無料**モデルをお探しですか?公式サイトへどうぞ:[bongocat.pet](https://bongocat.pet/models) - -

- -## 📥 ダウンロード - - - -- GitHub Releases - - 最新版は [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest) からダウンロードできます。 - -## 🛠️ ソースコードからビルド - -BongoCat は CMake を使用し、C11 コンパイラ、C++17 コンパイラ、CMake 3.24 以降、デスクトップ用 OpenGL 開発ファイルが必要です。デフォルトでは SDL3、yyjson、stb、miniaudio、Nuklear が構成時に自動的にダウンロードされるため、初回構成にはネットワーク接続が必要です。 - -プロジェクトのルートディレクトリ(`CMakeLists.txt` を含むディレクトリ)で以下のコマンドを実行してください。 - -### 📋 プラットフォーム別の前提条件 - -- **Windows:** Visual Studio 2022(「C++ によるデスクトップ開発」ワークロードをインストール)と CMake。MSVC ジェネレーターを使用してください。MinGW は診断バックエンドをビルドできますが、Cubism SDK には対応していません。 -- **macOS:** Xcode Command Line Tools、CMake、Ninja。ターゲットアーキテクチャがホストの既定と異なる場合は、`CMAKE_OSX_ARCHITECTURES` で指定してください。 -- **Linux(Debian/Ubuntu):** GCC または Clang、Ninja、および OpenGL/X11 ヘッダー: - - ```bash - sudo apt-get update - sudo apt-get install -y build-essential cmake ninja-build \ - libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev - ``` - -### 🔧 構成とビルド - -Linux と macOS では、Ninja のような単一構成ジェネレーターを使用してください: - -```bash -cmake -S . -B build -G Ninja \ - -DCMAKE_BUILD_TYPE=Release \ - -DBONGO_CAT_FETCH_DEPS=ON -cmake --build build --parallel -``` - -Windows では、Visual Studio 2022 の開発者コマンドプロンプト(または MSVC が利用可能なシェル)からコマンドを実行してください: - -```powershell -cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` - -DBONGO_CAT_FETCH_DEPS=ON -cmake --build build --config Release --parallel -``` - -実行ファイルは、Linux では `build/BongoCat`、macOS では `build/BongoCat.app/Contents/MacOS/BongoCat`、Visual Studio ビルドの Windows では `build/Release/BongoCat.exe` に生成されます。 - -### 🧪 テスト - -CTest ターゲットはデフォルトで有効です。ビルド後に以下を実行してください: - -```bash -ctest --test-dir build --output-on-failure -``` - -Visual Studio のようなマルチ構成ジェネレーターでは、ビルド構成を明示的に指定してください: - -```powershell -ctest --test-dir build -C Release --output-on-failure -``` - -### 🎭 Live2D / Cubism SDK(オプション) - -Cubism SDK が見つからない場合、CMake は警告を出して診断バックエンドをビルドします。このバックエンドは起動とプラットフォーム診断用で、Live2D モデルのレンダリングは提供しません。完全なランタイムをビルドするには、互換性のある Cubism SDK for Native をインストールし、`vendor/CubismSdkForNative` に配置するか、パスを明示的に指定してください: - -```bash -cmake -S . -B build -G Ninja \ - -DCMAKE_BUILD_TYPE=Release \ - -DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ - -DBONGO_CAT_REQUIRE_CUBISM=ON -``` - -SDK には Core ライブラリ、Framework のソース、`cmake/Cubism.cmake` が期待する構成の OpenGL GLEW サードパーティディレクトリが含まれている必要があります。Windows の Cubism ビルドには Visual Studio 2022 が必要です。`BONGO_CAT_REQUIRE_CUBISM=ON` を設定すると、SDK が利用できない場合に構成を失敗させ、診断バックエンドを黙って選択しなくなります。 - -### ⚙️ CMake オプション - -| オプション | デフォルト値 | 説明 | -| --- | --- | --- | -| `BONGO_CAT_FETCH_DEPS` | `ON` | CMake の `FetchContent` を使用して固定バージョンのサードパーティ依存関係をダウンロードします。SDL3、yyjson、stb、miniaudio、Nuklear がすでに CMake で利用できる場合にのみ `OFF` に設定してください。 | -| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Cubism SDK for Native のパス。 | -| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | SDK が利用できない場合に構成を失敗させます。 | -| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | ネイティブコンパイラの警告をエラーとして扱います。 | - -オフラインビルドでは `BONGO_CAT_FETCH_DEPS=OFF` に設定し、SDL3(`SDL3-static` を含む)と yyjson の CMake パッケージ構成を提供してください。stb、Nuklear、miniaudio を自動検出できない場合は、そのインクルードディレクトリも指定してください: - -```bash -cmake -S . -B build -G Ninja \ - -DCMAKE_BUILD_TYPE=Release \ - -DBONGO_CAT_FETCH_DEPS=OFF \ - -DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ - -DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ - -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio -``` - -## 📌 プロジェクトの状態 - -![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") - -## 📜 ライセンス - -BongoCat のソースコードとネイティブランタイムは [AGPL-3.0-only](../LICENSE) ライセンスの下で提供されます。 - -既定の組み込みモデルモード(`standard`)は引き続き MIT ライセンスです。`resources/assets/models/standard`、`keyboard`、`gamepad` に同梱されているモデルリソースは、別途の [MIT ライセンス宣言](../LICENSE-MIT) の対象です。この MIT ライセンスはモデルリソースと付随するアートワークにのみ適用され、BongoCat のソースコードやネイティブランタイムのライセンスは変更されません。 - -## 🧭 技術アーキテクチャ - -現在のネイティブ版は C/C++、SDL3、OpenGL で構築されています。以下の図はランタイムのデータフローを重点的に示しています。ビルドとパッケージングの詳細は CMake ファイルを参照してください。 - -### 🔄 ランタイムの所有権とフレームスケジューリング - -各プロセスは `BongoCatApp` インスタンスと、メインスレッドのイベント/レンダリングループを 1 つ持ちます。プラットフォームリスナーは入力境界で停止します: - -```text -プラットフォームリスナー(キーボード/ポインター) - | - v - C11 入力状態(アトミックエッジキュー+マージされたポインター位置) - | - v - メインスレッドのアプリケーション <----- SDL3 イベント - | - v - モデルパラメーター、オーバーレイ、UI 状態 - | - v - モデル更新 -> OpenGL 合成 -> プラットフォーム表示 -``` - -Windows の低レベルフック、macOS の Quartz イベントタップ、Linux の XInput2 リスナーはメインループの外で実行されます。これらはタイムスタンプ付きのキー押下とマウスボタンのエッジを有界アトミックキューに公開し、独立したマージスロットでポインター座標を公開します。公開に成功するとネイティブの SDL ウェイクイベントがプッシュされ、高頻度の移動イベントが順序付けられたキーやボタンのエッジを押し出さないようにします。Windows では、モデルが相対移動を要求した場合にのみ、プラットフォームのポインターインターフェースを通じて DirectInput が使用されます。SDL3 のウィンドウ、設定、ゲームパッドイベントはメインスレッドで処理され、ゲームパッドイベントはモデルパラメーターやショートカットに渡す前に正規化されます。どのプラットフォームリスナーも Live2D、オーバーレイ、UI コードを直接呼び出しません。 - -`bongo_cat_app_run` は更新、シャットダウン、二次プロセスのパラメーターを処理し、メインプロセスの単一インスタンス所有権を強制し、アプリケーション状態を割り当て、初期化を実行し、`bongo_cat_app_loop` に入り、その後定義された順序で状態をフラッシュしてリソースを破棄します。初期化では設定とストレージパスを読み込み、リソースを特定し、SDL/OpenGL ペットウィンドウを作成し、プラットフォームバックエンドを初期化し、Live2D/オーバーレイ/オーディオサービスを作成し、組み込み/インストール済み/近隣のモデルソースをスキャンし、利用可能なモデルを読み込みます。`BongoCatApp` は設定、セッション状態、モデルと動作のカタログ、プラットフォームハンドル、ランタイムサービスのハンドルを保持します。 - -インストール済みのモデルパッケージは Mver を正規形式として使用します。インポート処理は選択したファイルまたはディレクトリを解析し、候補を発見・検証し、パッケージの ID フィンガープリントを生成し、Tauri ソースを Mver に変換し、画像パッチを適用し、正規化されたパッケージを `models_root` にコミットします。その後、ランタイムアダプターが生成され、カタログが更新されます。近隣ソースはソースツリーをインストールせずに発見されるだけです。そのアダプターと検査結果は `models_root` の外の `cache_root` にキャッシュされます。 - -メインループの各反復は、SDL/ネイティブのウェイクイベント、または最も早いフレーム、UI、アニメーション、ポインターのヒット期限(最大 250 ミリ秒)を待ちます。ループはキューに入った SDL イベントを分配し、アトミック入力キューを空にして解放回復を実行し、ウィンドウとモデルの更新状態を更新してから、入力パラメーターを適用します。Cubism が有効な場合、モデルの期限は `settings.model.max_fps`(既定 60 FPS)に従います。診断ビルドは 100 ミリ秒のフォールバック間隔を使用します。モデルの経過時間は最大 250 ミリ秒でカウントされ、それぞれ 1/30 秒以下を目標とする最大 8 つのサブステップに分割されます。 - -通常のペットパスは、ウィンドウが表示され、最小化されておらず、ダーティとマークされている場合にのみレンダリングされます。各フレームはまず背景をクリアし、モデルを描画してから、ポインター、キー、エフェクトのオーバーレイを合成し、最後にプラットフォームのプレゼンターを呼び出します。プレビュー操作は即時レンダリングを要求でき、スクリーンショットのレンダリングは表示をスキップできます。macOS と Linux は SDL OpenGL ウィンドウを直接交換します。Windows はレイヤード表示が有効でない場合は直接交換し、それ以外はフレームバッファを読み取って `UpdateLayeredWindow` を呼び出します。設定 UI は独自の SDL/OpenGL ウィンドウを持ち、個別にレンダリング・表示されます。 - -C ランタイムは `include/bongo_cat/model.h` で宣言されている ABI を呼び出します。Live2D ブリッジと Cubism 実装は `src/live2d` にあり、Cubism SDK が有効な場合のみ C++17 を使用します。残りのネイティブランタイムは C11 を使用します。Cubism 型は不透明な C ハンドルの背後に保持されます。SDK が利用できない場合は、`src/live2d/live2d_stub.c` が診断バックエンドを提供します。 - -```mermaid -flowchart TB - Input(["キーボード / マウス / ゲームパッド"]) - BuiltIn(["組み込みモデルリソース"]) - Sources(["外部モデルソース
Mver、Tauri、.model3.json、画像パッチ"]) - Desktop(["ペットウィンドウと設定ウィンドウ"]) - subgraph Runtime["BongoCat ネイティブランタイム"] - direction TB - Entry["src/main.c
bongo_cat_app_run"] - Startup["起動と初期化
構成、ストレージ、ウィンドウ、プラットフォーム"] - Loop["SDL3 メインループ
待機、分配、更新、レンダリング"] - Shutdown["シャットダウン
状態のフラッシュ、サービスの停止、リソースの解放"] - InputQueue[("アトミック入力状態
エッジキューとマージされたポインター位置")] - InputDispatch["入力分配
ショートカット、ポインター割り当て、モデルパラメーター"] - State[("BongoCatApp 状態
設定、セッション、カタログ、ランタイムハンドル")] - Import["モデルの発見とインポート
検証、Mver への正規化、インストール/キャッシュ"] - Catalog[("モデルと動作のカタログ")] - Live2D["Live2D C ABI
Cubism SDK または診断スタブ"] - Overlay["オーバーレイとオーディオ"] - Preferences["設定とデスクトップシェル
Nuklear UI、トレイ、ウィンドウ操作"] - Compose["OpenGL フレーム合成"] - Present["プラットフォーム表示"] - Entry --> Startup --> Loop - Loop --> Shutdown - Loop --> InputDispatch --> State - Loop <--> State - State --> Live2D - State --> Overlay - State <--> Preferences - Loop --> Preferences - Catalog --> State - State --> Compose - Live2D --> Compose - Overlay --> Compose - Compose --> Present - Loop --> Compose - end - subgraph Platform["プラットフォームバックエンド"] - direction LR - Global["グローバルなキーボード/ポインターキャプチャ
Windows、macOS、Linux"] - SDL["SDL3 イベント
ウィンドウとゲームパッドのイベント"] - end - Input --> Global --> InputQueue --> InputDispatch - Input --> SDL --> Loop - BuiltIn --> Catalog - Sources --> Import --> Catalog - Present --> Desktop - Preferences --> Desktop -``` - -## ❓ よくある質問 - -### 🔒 BongoCat はキーボードやマウスの入力を記録しますか? - -いいえ。BongoCat はキーボードとマウスの入力をローカルで処理し、アニメーションとショートカットを駆動します。キーストローク、マウス操作、その他の操作データを記録したりアップロードしたりすることはありません。構成もローカルのみに保存され、アプリには広告、分析ツール、ユーザー追跡コードは含まれていません。更新チェックの実行時には公開バージョンのメタデータのみが要求され、入力、構成、使用データは送信されません。 - -### 🖼️ Vulkan ではなく OpenGL を使うのはなぜですか? - -Vulkan が悪いからではなく、BongoCat にはその複雑さが必要ないからです。このアプリは主に Live2D モデル 1 つ、少数の UI レイヤー、透明なデスクトップウィンドウをレンダリングします。OpenGL で十分にまかなえ、SDL3 や Cubism の OpenGL レンダラーとも自然に連携します。Vulkan への移行は 3 つのデスクトッププラットフォームでより多くのレンダリング・同期コードを維持することになり、ユーザーにとっての明確なメリットはありません。BongoCat の現在のワークロードでは、OpenGL によってレンダラーがより軽量で、デバッグや保守も容易になり、必要なパフォーマンスも引き続き提供できます。 - -## 🙏 スペシャルサンクス - -コントリビューターランキング - -
-著作権 © 2026 - **BongoCat**
-By vladelaina
-Made with ❤️ & ⌨️ -
diff --git a/docs/README.ko-KR.md b/docs/README.ko-KR.md index 7721cbf0..e69de29b 100644 --- a/docs/README.ko-KR.md +++ b/docs/README.ko-KR.md @@ -1,230 +0,0 @@ - - -

💘 C/C++ × SDL3 × OpenGL, 다 섞어서 마음껏 두드려 보자! Bong~ Bongo Cat!!!

-

- 언어 선택 ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español -

- -
- -> [!TIP] -> 데모에 사용된 모델은 [宇痕冫](https://space.bilibili.com/348616056) 님의 것입니다. -> -> 🎁 **무료** 모델을 찾고 계신가요? 공식 웹사이트를 방문하세요: [bongocat.pet](https://bongocat.pet/models) - -

- -## 📥 다운로드 - - - -- GitHub Releases - - 최신 버전은 [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest)에서 다운로드하세요. - -## 🛠️ 소스 코드에서 빌드 - -BongoCat은 CMake를 사용하며 C11 컴파일러, C++17 컴파일러, CMake 3.24 이상, 데스크톱용 OpenGL 개발 파일이 필요합니다. 기본적으로 SDL3, yyjson, stb, miniaudio, Nuklear는 구성 단계에서 자동으로 다운로드되므로 첫 구성에는 네트워크 연결이 필요합니다. - -프로젝트 루트 디렉터리(`CMakeLists.txt`가 포함된 디렉터리)에서 다음 명령을 실행하세요. - -### 📋 플랫폼별 사전 요구 사항 - -- **Windows:** Visual Studio 2022(‘C++를 사용한 데스크톱 개발’ 워크로드 설치)와 CMake. MSVC 생성기를 사용하세요. MinGW는 진단 백엔드를 빌드할 수 있지만 Cubism SDK는 지원하지 않습니다. -- **macOS:** Xcode Command Line Tools, CMake, Ninja. 대상 아키텍처가 호스트 기본 아키텍처와 다른 경우 `CMAKE_OSX_ARCHITECTURES`로 지정하세요. -- **Linux(Debian/Ubuntu):** GCC 또는 Clang, Ninja, OpenGL/X11 헤더: - - ```bash - sudo apt-get update - sudo apt-get install -y build-essential cmake ninja-build \ - libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev - ``` - -### 🔧 구성 및 빌드 - -Linux와 macOS에서는 Ninja 같은 단일 구성 생성기를 사용하세요: - -```bash -cmake -S . -B build -G Ninja \ - -DCMAKE_BUILD_TYPE=Release \ - -DBONGO_CAT_FETCH_DEPS=ON -cmake --build build --parallel -``` - -Windows에서는 Visual Studio 2022 개발자 명령 프롬프트(또는 MSVC를 사용할 수 있는 셸)에서 명령을 실행하세요: - -```powershell -cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` - -DBONGO_CAT_FETCH_DEPS=ON -cmake --build build --config Release --parallel -``` - -실행 파일은 Linux의 `build/BongoCat`, macOS의 `build/BongoCat.app/Contents/MacOS/BongoCat`, Visual Studio 빌드의 Windows에서는 `build/Release/BongoCat.exe`에 생성됩니다. - -### 🧪 테스트 - -CTest 대상은 기본적으로 활성화되어 있습니다. 빌드 후 다음을 실행하세요: - -```bash -ctest --test-dir build --output-on-failure -``` - -Visual Studio 같은 다중 구성 생성기에서는 빌드 구성을 명시적으로 지정하세요: - -```powershell -ctest --test-dir build -C Release --output-on-failure -``` - -### 🎭 Live2D / Cubism SDK(선택 사항) - -Cubism SDK를 찾을 수 없으면 CMake는 경고를 표시하고 진단 백엔드를 빌드합니다. 이 백엔드는 시작 및 플랫폼 진단용이며 Live2D 모델 렌더링을 제공하지 않습니다. 전체 런타임을 빌드하려면 호환되는 Cubism SDK for Native를 설치하여 `vendor/CubismSdkForNative`에 배치하거나 경로를 명시적으로 전달하세요: - -```bash -cmake -S . -B build -G Ninja \ - -DCMAKE_BUILD_TYPE=Release \ - -DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ - -DBONGO_CAT_REQUIRE_CUBISM=ON -``` - -SDK에는 Core 라이브러리, Framework 소스, 그리고 `cmake/Cubism.cmake`가 기대하는 구조의 OpenGL GLEW 서드파티 디렉터리가 포함되어야 합니다. Windows Cubism 빌드에는 Visual Studio 2022가 필요합니다. `BONGO_CAT_REQUIRE_CUBISM=ON`을 설정하면 SDK를 사용할 수 없을 때 진단 백엔드를 조용히 선택하는 대신 구성이 실패합니다. - -### ⚙️ CMake 옵션 - -| 옵션 | 기본값 | 설명 | -| --- | --- | --- | -| `BONGO_CAT_FETCH_DEPS` | `ON` | CMake `FetchContent`를 사용하여 고정 버전의 서드파티 종속성을 다운로드합니다. SDL3, yyjson, stb, miniaudio, Nuklear가 이미 CMake에서 사용 가능한 경우에만 `OFF`로 설정하세요. | -| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Cubism SDK for Native의 경로. | -| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | SDK를 사용할 수 없을 때 구성이 실패하도록 합니다. | -| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | 네이티브 컴파일러 경고를 오류로 처리합니다. | - -오프라인 빌드에서는 `BONGO_CAT_FETCH_DEPS=OFF`로 설정하고 SDL3(`SDL3-static` 포함)와 yyjson의 CMake 패키지 구성을 제공하세요. stb, Nuklear, miniaudio를 자동으로 찾을 수 없는 경우 해당 포함 디렉터리도 지정하세요: - -```bash -cmake -S . -B build -G Ninja \ - -DCMAKE_BUILD_TYPE=Release \ - -DBONGO_CAT_FETCH_DEPS=OFF \ - -DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ - -DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ - -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio -``` - -## 📌 프로젝트 상태 - -![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") - -## 📜 라이선스 - -BongoCat의 소스 코드와 네이티브 런타임은 [AGPL-3.0-only](../LICENSE) 라이선스로 제공됩니다. - -기본 내장 모델 모드(`standard`)는 여전히 MIT 라이선스입니다. `resources/assets/models/standard`, `keyboard`, `gamepad`에 포함된 모델 리소스는 별도의 [MIT 라이선스 고지](../LICENSE-MIT)에 따라 적용됩니다. 이 MIT 라이선스는 모델 리소스와 관련 아트워크에만 적용되며 BongoCat 소스 코드나 네이티브 런타임의 라이선스는 변경하지 않습니다. - -## 🧭 기술 아키텍처 - -현재 네이티브 버전은 C/C++, SDL3, OpenGL로 구축되었습니다. 아래 다이어그램은 런타임 데이터 흐름을 중점적으로 보여줍니다. 빌드 및 패키징 세부 사항은 CMake 파일을 참조하세요. - -### 🔄 런타임 소유권 및 프레임 스케줄링 - -각 프로세스는 `BongoCatApp` 인스턴스와 메인 스레드 이벤트/렌더링 루프를 하나씩 소유합니다. 플랫폼 리스너는 입력 경계에서 멈춥니다: - -```text -플랫폼 리스너(키보드/포인터) - | - v - C11 입력 상태(원자적 에지 큐 + 병합된 포인터 위치) - | - v - 메인 스레드 애플리케이션 <----- SDL3 이벤트 - | - v - 모델 매개변수, 오버레이, UI 상태 - | - v - 모델 업데이트 -> OpenGL 합성 -> 플랫폼 표시 -``` - -Windows 로우 레벨 훅, macOS Quartz 이벤트 탭, Linux XInput2 리스너는 메인 루프 밖에서 실행됩니다. 이들은 타임스탬프가 있는 키 입력과 마우스 버튼 에지를 유계 원자 큐에 게시하고, 별도의 병합 슬롯을 통해 포인터 좌표를 게시합니다. 게시에 성공하면 네이티브 SDL 웨이크 이벤트가 푸시되어 고주파 이동 이벤트가 정렬된 키와 버튼 에지를 밀어내지 않도록 합니다. Windows에서는 모델이 상대 이동을 요청할 때만 플랫폼 포인터 인터페이스를 통해 DirectInput을 사용합니다. SDL3 창, 설정, 게임패드 이벤트는 메인 스레드에서 처리되며, 게임패드 이벤트는 모델 매개변수나 단축키에 전달되기 전에 정규화됩니다. 어떤 플랫폼 리스너도 Live2D, 오버레이, UI 코드를 직접 호출하지 않습니다. - -`bongo_cat_app_run`은 업데이트, 종료, 보조 프로세스 매개변수를 처리하고 메인 프로세스의 단일 인스턴스 소유권을 강제하며 애플리케이션 상태를 할당하고 초기화를 수행한 후 `bongo_cat_app_loop`에 들어가 정의된 순서대로 상태를 플러시하고 리소스를 파괴합니다. 초기화는 구성과 저장 경로를 로드하고, 리소스를 찾고, SDL/OpenGL 펫 창을 만들고, 플랫폼 백엔드를 초기화하고, Live2D/오버레이/오디오 서비스를 만들고, 내장/설치/인근 모델 소스를 스캔하고, 사용 가능한 모델을 로드합니다. `BongoCatApp`은 설정, 세션 상태, 모델 및 동작 카탈로그, 플랫폼 핸들, 런타임 서비스 핸들을 보유합니다. - -설치된 모델 패키지는 Mver을 표준 형식으로 사용합니다. 가져오기 프로세스는 선택한 파일이나 디렉터리를 구문 분석하고 후보를 발견·검증하며 패키지 ID 지문을 생성하고 Tauri 소스를 Mver로 변환하며 이미지 패치를 적용하고 정규화된 패키지를 `models_root`에 커밋합니다. 그런 다음 런타임 어댑터가 생성되고 카탈로그가 새로 고쳐집니다. 인근 소스는 소스 트리를 설치하지 않고 발견만 됩니다. 해당 어댑터와 검사 결과는 `models_root` 밖의 `cache_root` 아래에 캐시됩니다. - -메인 루프의 각 반복은 SDL/네이티브 웨이크 이벤트 또는 가장 이른 프레임, UI, 애니메이션, 포인터 히트 마감(최대 250ms)을 기다립니다. 루프는 대기 중인 SDL 이벤트를 분배하고 원자 입력 큐를 비우고 해제 복구를 수행하며 창과 모델 새로 고침 상태를 업데이트한 다음 입력 매개변수를 적용합니다. Cubism이 활성화된 경우 모델 마감은 `settings.model.max_fps`(기본 60 FPS)를 따릅니다. 진단 빌드는 100ms 폴백 간격을 사용합니다. 모델 경과 시간은 최대 250ms로 계산되며 각각 1/30초 이하를 목표로 하는 최대 8개의 하위 단계로 나뉩니다. - -일반 펫 경로는 창이 표시되고, 최소화되지 않았으며, 더티로 표시된 경우에만 렌더링됩니다. 각 프레임은 먼저 배경을 지우고 모델을 그린 다음 포인터, 키, 효과 오버레이를 합성하고 마지막으로 플랫폼 프리젠터를 호출합니다. 미리 보기 작업은 즉시 렌더링을 요청할 수 있고 스크린샷 렌더링은 표시를 건너뛸 수 있습니다. macOS와 Linux는 SDL OpenGL 창을 직접 교환합니다. Windows는 레이어드 표시가 활성화되지 않은 경우 직접 교환하고 그렇지 않으면 프레임 버퍼를 읽어 `UpdateLayeredWindow`를 호출합니다. 설정 UI는 자체 SDL/OpenGL 창을 가지며 별도로 렌더링되고 표시됩니다. - -C 런타임은 `include/bongo_cat/model.h`에 선언된 ABI를 호출합니다. Live2D 브리지와 Cubism 구현은 `src/live2d`에 있으며 Cubism SDK가 활성화된 경우에만 C++17을 사용합니다. 나머지 네이티브 런타임은 C11을 사용합니다. Cubism 유형은 불투명한 C 핸들 뒤에 유지됩니다. SDK를 사용할 수 없으면 `src/live2d/live2d_stub.c`가 진단 백엔드를 제공합니다. - -```mermaid -flowchart TB - Input(["키보드 / 마우스 / 게임패드"]) - BuiltIn(["내장 모델 리소스"]) - Sources(["외부 모델 소스
Mver, Tauri, .model3.json, 이미지 패치"]) - Desktop(["펫 창과 설정 창"]) - subgraph Runtime["BongoCat 네이티브 런타임"] - direction TB - Entry["src/main.c
bongo_cat_app_run"] - Startup["시작 및 초기화
구성, 저장소, 창, 플랫폼"] - Loop["SDL3 메인 루프
대기, 분배, 업데이트, 렌더링"] - Shutdown["종료
상태 플러시, 서비스 중지, 리소스 해제"] - InputQueue[("원자 입력 상태
에지 큐와 병합된 포인터 위치")] - InputDispatch["입력 분배
단축키, 포인터 매핑, 모델 매개변수"] - State[("BongoCatApp 상태
설정, 세션, 카탈로그, 런타임 핸들")] - Import["모델 발견 및 가져오기
검증, Mver로 정규화, 설치/캐시"] - Catalog[("모델 및 동작 카탈로그")] - Live2D["Live2D C ABI
Cubism SDK 또는 진단 스텁"] - Overlay["오버레이 및 오디오"] - Preferences["설정 및 데스크톱 셸
Nuklear UI, 트레이, 창 작업"] - Compose["OpenGL 프레임 합성"] - Present["플랫폼 표시"] - Entry --> Startup --> Loop - Loop --> Shutdown - Loop --> InputDispatch --> State - Loop <--> State - State --> Live2D - State --> Overlay - State <--> Preferences - Loop --> Preferences - Catalog --> State - State --> Compose - Live2D --> Compose - Overlay --> Compose - Compose --> Present - Loop --> Compose - end - subgraph Platform["플랫폼 백엔드"] - direction LR - Global["전역 키보드/포인터 캡처
Windows, macOS, Linux"] - SDL["SDL3 이벤트
창 및 게임패드 이벤트"] - end - Input --> Global --> InputQueue --> InputDispatch - Input --> SDL --> Loop - BuiltIn --> Catalog - Sources --> Import --> Catalog - Present --> Desktop - Preferences --> Desktop -``` - -## ❓ 자주 묻는 질문 - -### 🔒 BongoCat이 키보드나 마우스 입력을 기록하나요? - -아니요. BongoCat은 키보드와 마우스 입력을 로컬에서 처리하여 애니메이션과 단축키를 구동합니다. 키 입력, 마우스 동작 또는 기타 상호 작용 데이터를 기록하거나 업로드하지 않습니다. 구성도 로컬에만 저장되며 앱에는 광고, 분석 도구, 사용자 추적 코드가 포함되어 있지 않습니다. 업데이트 확인 시 공개 버전 메타데이터만 요청하며 입력, 구성, 사용 데이터는 전송하지 않습니다. - -### 🖼️ Vulkan 대신 OpenGL을 사용하는 이유는 무엇인가요? - -Vulkan이 나쁘기 때문이 아니라 BongoCat이 그 정도의 복잡성을 필요로 하지 않기 때문입니다. 이 앱은 주로 Live2D 모델 하나, 소수의 UI 레이어, 투명한 데스크톱 창을 렌더링합니다. OpenGL은 이를 쉽게 처리하며 SDL3 및 Cubism의 OpenGL 렌더러와 자연스럽게 어울립니다. Vulkan으로의 마이그레이션은 세 개의 데스크톱 플랫폼에서 더 많은 렌더링 및 동기화 코드를 유지해야 하며 사용자에게 눈에 띄는 이점이 없습니다. BongoCat의 현재 워크로드에서 OpenGL은 렌더러를 더 가볍고 디버깅과 유지 관리가 쉽게 만들면서도 필요한 성능을 계속 제공합니다. - -## 🙏 특별 감사 - -기여자 랭킹 - -
-저작권 © 2026 - **BongoCat**
-By vladelaina
-Made with ❤️ & ⌨️ -
diff --git a/docs/README.pt-BR.md b/docs/README.pt-BR.md index d425d7b9..e69de29b 100644 --- a/docs/README.pt-BR.md +++ b/docs/README.pt-BR.md @@ -1,230 +0,0 @@ - - -

💘 C/C++ × SDL3 × OpenGL, misture tudo e bata à vontade! Bong~ Bongo Cat!!!

-

- Escolha o idioma ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español -

- -
- -> [!TIP] -> O modelo usado na demonstração é de [宇痕冫](https://space.bilibili.com/348616056). -> -> 🎁 Procurando modelos **gratuitos**? Visite nosso site oficial: [bongocat.pet](https://bongocat.pet/models) - -

- -## 📥 Download - - - -- GitHub Releases - - Baixe a versão mais recente nas [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest). - -## 🛠️ Compilar a partir do código-fonte - -O BongoCat usa CMake e requer um compilador C11, um compilador C++17, CMake 3.24 ou superior e os arquivos de desenvolvimento de OpenGL para desktop. Por padrão, SDL3, yyjson, stb, miniaudio e Nuklear são baixados automaticamente durante a configuração, portanto a primeira configuração requer conexão com a internet. - -Execute os comandos abaixo na raiz do projeto (o diretório que contém `CMakeLists.txt`). - -### 📋 Pré-requisitos por plataforma - -- **Windows:** Visual Studio 2022 (com a carga de trabalho ‘Desenvolvimento para desktop com C++’) e CMake. Use o gerador MSVC; o MinGW pode compilar o backend de diagnóstico, mas não suporta o SDK do Cubism. -- **macOS:** Xcode Command Line Tools, CMake e Ninja. Se a arquitetura de destino for diferente da padrão do host, especifique-a por meio de `CMAKE_OSX_ARCHITECTURES`. -- **Linux (Debian/Ubuntu):** GCC ou Clang, Ninja e os cabeçalhos de OpenGL/X11: - - ```bash - sudo apt-get update - sudo apt-get install -y build-essential cmake ninja-build \ - libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev - ``` - -### 🔧 Configuração e compilação - -No Linux e no macOS, use um gerador de configuração única como o Ninja: - -```bash -cmake -S . -B build -G Ninja \ - -DCMAKE_BUILD_TYPE=Release \ - -DBONGO_CAT_FETCH_DEPS=ON -cmake --build build --parallel -``` - -No Windows, execute os comandos no Prompt de Comando do Desenvolvedor do Visual Studio 2022 (ou em qualquer shell em que o MSVC esteja disponível): - -```powershell -cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` - -DBONGO_CAT_FETCH_DEPS=ON -cmake --build build --config Release --parallel -``` - -O executável fica em `build/BongoCat` no Linux, em `build/BongoCat.app/Contents/MacOS/BongoCat` no macOS e em `build/Release/BongoCat.exe` para compilações do Visual Studio no Windows. - -### 🧪 Testes - -Os alvos do CTest estão habilitados por padrão. Execute o seguinte após compilar: - -```bash -ctest --test-dir build --output-on-failure -``` - -Para um gerador de múltiplas configurações, como o Visual Studio, especifique explicitamente a configuração de compilação: - -```powershell -ctest --test-dir build -C Release --output-on-failure -``` - -### 🎭 Live2D / SDK do Cubism (opcional) - -Se o SDK do Cubism não for encontrado, o CMake emitirá um aviso e compilará o backend de diagnóstico. Esse backend é destinado à inicialização e ao diagnóstico de plataforma; ele não fornece renderização de modelos Live2D. Para compilar o runtime completo, instale uma versão compatível do Cubism SDK for Native, coloque-a em `vendor/CubismSdkForNative` ou informe o caminho explicitamente: - -```bash -cmake -S . -B build -G Ninja \ - -DCMAKE_BUILD_TYPE=Release \ - -DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ - -DBONGO_CAT_REQUIRE_CUBISM=ON -``` - -O SDK deve incluir a biblioteca Core, o código-fonte do Framework e o diretório de terceiros do OpenGL GLEW na estrutura esperada por `cmake/Cubism.cmake`. As compilações de Cubism no Windows exigem o Visual Studio 2022. Com `BONGO_CAT_REQUIRE_CUBISM=ON`, a configuração falha quando o SDK não está disponível, em vez de selecionar silenciosamente o backend de diagnóstico. - -### ⚙️ Opções do CMake - -| Opção | Valor padrão | Descrição | -| --- | --- | --- | -| `BONGO_CAT_FETCH_DEPS` | `ON` | Baixa dependências de terceiros em versões fixadas via `FetchContent` do CMake. Defina como `OFF` apenas se SDL3, yyjson, stb, miniaudio e Nuklear já estiverem disponíveis para o CMake. | -| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Caminho para o Cubism SDK for Native. | -| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | Faz a configuração falhar quando não há SDK disponível. | -| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | Trata avisos do compilador nativo como erros. | - -Para uma compilação offline, defina `BONGO_CAT_FETCH_DEPS=OFF` e forneça as configurações de pacote do CMake para SDL3 (incluindo `SDL3-static`) e yyjson; se stb, Nuklear e miniaudio não puderem ser detectados automaticamente, informe também seus diretórios de inclusão: - -```bash -cmake -S . -B build -G Ninja \ - -DCMAKE_BUILD_TYPE=Release \ - -DBONGO_CAT_FETCH_DEPS=OFF \ - -DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ - -DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ - -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio -``` - -## 📌 Status do projeto - -![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") - -## 📜 Licença - -O código-fonte e o runtime nativo do BongoCat estão licenciados sob [AGPL-3.0-only](../LICENSE). - -O modo de modelo integrado padrão (`standard`) permanece sob a licença MIT. Os recursos de modelo incluídos em `resources/assets/models/standard`, `keyboard` e `gamepad` são cobertos pela [declaração de licença MIT](../LICENSE-MIT) separada. Essa licença MIT se aplica apenas aos recursos dos modelos e às artes que os acompanham; ela não altera a licença do código-fonte nem do runtime nativo do BongoCat. - -## 🧭 Arquitetura técnica - -A versão nativa atual é construída com C/C++, SDL3 e OpenGL. O diagrama abaixo destaca o fluxo de dados em tempo de execução; consulte os arquivos do CMake para obter detalhes de compilação e empacotamento. - -### 🔄 Propriedade do runtime e agendamento de quadros - -Cada processo possui uma instância de `BongoCatApp` e um loop de eventos/renderização no thread principal. Os listeners de plataforma param na fronteira de entrada: - -```text -Listeners de plataforma (teclado/ponteiro) - | - v - Estado de entrada C11 (fila atômica de bordas + posição do ponteiro mesclada) - | - v - aplicação no thread principal <----- eventos SDL3 - | - v - parâmetros do modelo, sobreposições e estado da interface - | - v - atualização do modelo -> composição OpenGL -> apresentação da plataforma -``` - -Os hooks de baixo nível do Windows, o tap de eventos Quartz do macOS e os listeners XInput2 do Linux são executados fora do loop principal. Eles publicam bordas de teclas e botões do mouse com carimbo de data/hora em uma fila atômica limitada e publicam as coordenadas do ponteiro por meio de um slot de mesclagem independente; após uma publicação bem-sucedida, um evento de ativação SDL nativo é enviado, evitando que eventos de movimento de alta frequência desloquem as bordas ordenadas de teclas e botões. No Windows, o DirectInput é usado pela interface de ponteiro da plataforma apenas quando o modelo solicita movimento relativo. Os eventos de janela, preferências e gamepad do SDL3 são processados no thread principal; os eventos de gamepad são normalizados antes de serem passados aos parâmetros do modelo ou atalhos. Nenhum listener de plataforma chama diretamente o código de Live2D, sobreposições ou interface. - -`bongo_cat_app_run` trata dos parâmetros de atualização, encerramento e processos secundários, impõe a propriedade de instância única do processo principal, aloca o estado do aplicativo, executa a inicialização, entra em `bongo_cat_app_loop` e, em seguida, atualiza o estado e destrói os recursos na ordem definida. A inicialização carrega a configuração e os caminhos de armazenamento, localiza os recursos, cria a janela de mascote SDL/OpenGL, inicializa os backends de plataforma, cria os serviços de Live2D/sobreposições/áudio, verifica fontes de modelos integradas/instaladas/próximas e carrega os modelos disponíveis. `BongoCatApp` mantém as configurações, o estado da sessão, os catálogos de modelos e comportamentos, os identificadores de plataforma e os identificadores de serviços de runtime. - -Os pacotes de modelos instalados usam Mver como formato canônico. O processo de importação analisa o arquivo ou diretório selecionado, descobre e valida candidatos, gera uma impressão digital de identidade do pacote, converte fontes Tauri em Mver, aplica patches de imagem e envia o pacote normalizado para `models_root`. Em seguida, os adaptadores de runtime são gerados e o catálogo é atualizado. Fontes próximas são descobertas apenas sem instalar a árvore de origem; seus adaptadores e resultados de verificação são armazenados em cache sob `cache_root`, fora de `models_root`. - -Cada iteração do loop principal espera ativações SDL/nativas ou o vencimento mais cedo dos prazos de quadro, interface, animação e acerto do ponteiro (máximo de 250 ms). O loop distribui os eventos SDL enfileirados, esvazia a fila de entrada atômica e executa a recuperação de liberação, atualiza o estado da janela e da atualização do modelo e, em seguida, aplica os parâmetros de entrada. Com o Cubism habilitado, o prazo do modelo segue `settings.model.max_fps` (padrão de 60 FPS); builds de diagnóstico usam um intervalo de reserva de 100 ms. O tempo decorrido do modelo é contado no máximo como 250 ms e dividido em até oito subetapas, cada uma com o objetivo de não mais que 1/30 de segundo. - -O caminho normal da mascote é renderizado apenas quando a janela está visível, não minimizada e marcada como suja. Cada quadro primeiro limpa o fundo, desenha o modelo e então compõe as sobreposições de ponteiro, teclas e efeitos, e finalmente chama o apresentador da plataforma. As operações de visualização podem solicitar renderização imediata; a renderização de capturas de tela pode pular a apresentação. macOS e Linux trocam diretamente a janela SDL OpenGL; o Windows a troca diretamente quando a apresentação em camadas não está habilitada; caso contrário, lê o buffer de quadros e chama `UpdateLayeredWindow`. A interface de preferências tem sua própria janela SDL/OpenGL e é renderizada e apresentada separadamente. - -O runtime C chama a ABI declarada em `include/bongo_cat/model.h`. A ponte Live2D e a implementação do Cubism estão em `src/live2d` e usam C++17 apenas quando o SDK do Cubism está habilitado; o restante do runtime nativo usa C11. Os tipos do Cubism são mantidos atrás de identificadores C opacos; quando o SDK não está disponível, `src/live2d/live2d_stub.c` fornece o backend de diagnóstico. - -```mermaid -flowchart TB - Input(["Teclado / Mouse / Gamepad"]) - BuiltIn(["Recursos de modelos integrados"]) - Sources(["Fontes de modelos externos
Mver, Tauri, .model3.json, patches de imagem"]) - Desktop(["Janela de mascote e janela de preferências"]) - subgraph Runtime["Runtime nativo do BongoCat"] - direction TB - Entry["src/main.c
bongo_cat_app_run"] - Startup["Inicialização e inicialização
Configuração, armazenamento, janela, plataforma"] - Loop["Loop principal SDL3
Aguardar, distribuir, atualizar, renderizar"] - Shutdown["Encerramento
Atualizar estado, parar serviços, liberar recursos"] - InputQueue[("Estado de entrada atômico
Fila de bordas e posição do ponteiro mesclada")] - InputDispatch["Distribuição de entrada
Atalhos, mapeamento do ponteiro, parâmetros do modelo"] - State[("Estado do BongoCatApp
Configurações, sessão, catálogo, identificadores de runtime")] - Import["Descoberta e importação de modelos
Validação, normalização para Mver, instalação/cache"] - Catalog[("Catálogo de modelos e comportamentos")] - Live2D["Live2D C ABI
SDK do Cubism ou stub de diagnóstico"] - Overlay["Sobreposições e áudio"] - Preferences["Preferências e shell de desktop
UI Nuklear, bandeja, ações de janela"] - Compose["Composição de quadros OpenGL"] - Present["Apresentação da plataforma"] - Entry --> Startup --> Loop - Loop --> Shutdown - Loop --> InputDispatch --> State - Loop <--> State - State --> Live2D - State --> Overlay - State <--> Preferences - Loop --> Preferences - Catalog --> State - State --> Compose - Live2D --> Compose - Overlay --> Compose - Compose --> Present - Loop --> Compose - end - subgraph Platform["Backends de plataforma"] - direction LR - Global["Captura global de teclado/ponteiro
Windows, macOS, Linux"] - SDL["Eventos SDL3
Eventos de janela e gamepad"] - end - Input --> Global --> InputQueue --> InputDispatch - Input --> SDL --> Loop - BuiltIn --> Catalog - Sources --> Import --> Catalog - Present --> Desktop - Preferences --> Desktop -``` - -## ❓ Perguntas frequentes - -### 🔒 O BongoCat registra minhas entradas de teclado ou mouse? - -Não. O BongoCat processa entradas de teclado e mouse localmente para impulsionar animações e atalhos. Ele não registra nem envia teclas pressionadas, ações do mouse ou outros dados de interação. A configuração também é salva apenas localmente; o aplicativo não contém anúncios, ferramentas de análise nem código de rastreamento de usuários. Ao verificar atualizações, apenas metadados públicos de versão são solicitados; nenhum dado de entrada, configuração ou uso é enviado. - -### 🖼️ Por que OpenGL em vez de Vulkan? - -Não porque Vulkan seja ruim, mas porque o BongoCat não precisa desse nível de complexidade. O aplicativo renderiza principalmente um modelo Live2D, algumas camadas de interface e uma janela de desktop transparente; o OpenGL dá conta disso facilmente e se integra naturalmente com o SDL3 e o renderizador OpenGL do Cubism. Migrar para Vulkan exigiria manter mais código de renderização e sincronização em três plataformas de desktop, sem benefício perceptível para o usuário. Para a carga de trabalho atual do BongoCat, o OpenGL torna o renderizador mais enxuto, mais fácil de depurar e manter, continuando a oferecer o desempenho necessário. - -## 🙏 Agradecimentos especiais - -Ranking de contribuidores - -
-Copyright © 2026 - **BongoCat**
-By vladelaina
-Made with ❤️ & ⌨️ -
diff --git a/docs/README.ru-RU.md b/docs/README.ru-RU.md index e1d7f266..e69de29b 100644 --- a/docs/README.ru-RU.md +++ b/docs/README.ru-RU.md @@ -1,230 +0,0 @@ - - -

💘 C/C++ × SDL3 × OpenGL — смешай всё и барабань в своё удовольствие! Бонг~ Bongo Cat!!!

-

- Выберите язык ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español -

- -
- -> [!TIP] -> Модель, использованная в демо, принадлежит [宇痕冫](https://space.bilibili.com/348616056). -> -> 🎁 Ищете **бесплатные** модели? Загляните на наш официальный сайт: [bongocat.pet](https://bongocat.pet/models) - -

- -## 📥 Загрузка - - - -- GitHub Releases - - Скачайте последнюю версию из [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest). - -## 🛠️ Сборка из исходного кода - -BongoCat использует CMake и требует компилятор C11, компилятор C++17, CMake 3.24 или новее, а также файлы разработки OpenGL для настольных систем. По умолчанию SDL3, yyjson, stb, miniaudio и Nuklear загружаются автоматически на этапе конфигурации, поэтому для первой настройки требуется подключение к сети. - -Выполните следующие команды в корневом каталоге проекта (каталоге, содержащем `CMakeLists.txt`). - -### 📋 Предварительные требования по платформам - -- **Windows:** Visual Studio 2022 (с рабочей нагрузкой «Разработка классических приложений на C++») и CMake. Используйте генератор MSVC; MinGW может собрать диагностический бэкенд, но не поддерживает Cubism SDK. -- **macOS:** Xcode Command Line Tools, CMake и Ninja. Если целевая архитектура отличается от архитектуры хоста по умолчанию, укажите её через `CMAKE_OSX_ARCHITECTURES`. -- **Linux (Debian/Ubuntu):** GCC или Clang, Ninja, а также заголовочные файлы OpenGL/X11: - - ```bash - sudo apt-get update - sudo apt-get install -y build-essential cmake ninja-build \ - libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev - ``` - -### 🔧 Настройка и сборка - -В Linux и macOS используйте генератор с одной конфигурацией, например Ninja: - -```bash -cmake -S . -B build -G Ninja \ - -DCMAKE_BUILD_TYPE=Release \ - -DBONGO_CAT_FETCH_DEPS=ON -cmake --build build --parallel -``` - -В Windows выполняйте команды из командной строки разработчика Visual Studio 2022 (или любой другой оболочки, где доступен MSVC): - -```powershell -cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` - -DBONGO_CAT_FETCH_DEPS=ON -cmake --build build --config Release --parallel -``` - -Исполняемый файл находится в `build/BongoCat` в Linux, в `build/BongoCat.app/Contents/MacOS/BongoCat` в macOS и в `build/Release/BongoCat.exe` для сборок Visual Studio в Windows. - -### 🧪 Тестирование - -Цели CTest включены по умолчанию. После сборки выполните: - -```bash -ctest --test-dir build --output-on-failure -``` - -Для генератора с несколькими конфигурациями, такого как Visual Studio, явно укажите конфигурацию сборки: - -```powershell -ctest --test-dir build -C Release --output-on-failure -``` - -### 🎭 Live2D / Cubism SDK (необязательно) - -Если Cubism SDK не найден, CMake выдаст предупреждение и соберёт диагностический бэкенд. Этот бэкенд предназначен для запуска и диагностики платформы; он не обеспечивает рендеринг моделей Live2D. Чтобы собрать полный рантайм, установите совместимую версию Cubism SDK for Native, поместите её в `vendor/CubismSdkForNative` или явно укажите путь: - -```bash -cmake -S . -B build -G Ninja \ - -DCMAKE_BUILD_TYPE=Release \ - -DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ - -DBONGO_CAT_REQUIRE_CUBISM=ON -``` - -SDK должен включать библиотеку Core, исходный код Framework и каталог сторонних библиотек OpenGL GLEW в структуре, ожидаемой `cmake/Cubism.cmake`. Сборки Cubism в Windows требуют Visual Studio 2022. При `BONGO_CAT_REQUIRE_CUBISM=ON` конфигурация завершится ошибкой, если SDK недоступен, а не будет молча использовать диагностический бэкенд. - -### ⚙️ Параметры CMake - -| Параметр | По умолчанию | Описание | -| --- | --- | --- | -| `BONGO_CAT_FETCH_DEPS` | `ON` | Загружает сторонние зависимости фиксированных версий через `FetchContent` CMake. Установите `OFF`, только если SDL3, yyjson, stb, miniaudio и Nuklear уже доступны для CMake. | -| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Путь к Cubism SDK for Native. | -| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | Завершает конфигурацию ошибкой, если SDK недоступен. | -| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | Обрабатывает предупреждения нативного компилятора как ошибки. | - -Для автономной сборки установите `BONGO_CAT_FETCH_DEPS=OFF` и предоставьте конфигурации пакетов CMake для SDL3 (включая `SDL3-static`) и yyjson; если stb, Nuklear и miniaudio не могут быть найдены автоматически, укажите также их каталоги включения: - -```bash -cmake -S . -B build -G Ninja \ - -DCMAKE_BUILD_TYPE=Release \ - -DBONGO_CAT_FETCH_DEPS=OFF \ - -DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ - -DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ - -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio -``` - -## 📌 Статус проекта - -![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") - -## 📜 Лицензия - -Исходный код и нативный рантайм BongoCat распространяются под лицензией [AGPL-3.0-only](../LICENSE). - -Встроенный по умолчанию режим модели (`standard`) остаётся под лицензией MIT. Ресурсы моделей, входящие в `resources/assets/models/standard`, `keyboard` и `gamepad`, покрываются отдельным [объявлением о лицензии MIT](../LICENSE-MIT). Эта лицензия MIT распространяется только на ресурсы моделей и сопутствующие художественные материалы; она не меняет лицензию исходного кода или нативного рантайма BongoCat. - -## 🧭 Техническая архитектура - -Текущая нативная версия построена на C/C++, SDL3 и OpenGL. Диаграмма ниже акцентирует поток данных во время выполнения; подробности сборки и упаковки см. в файлах CMake. - -### 🔄 Владение рантаймом и планирование кадров - -Каждый процесс владеет экземпляром `BongoCatApp` и циклом событий/рендеринга в главном потоке. Платформенные слушатели останавливаются на границе ввода: - -```text -Платформенные слушатели (клавиатура/указатель) - | - v - Состояние ввода C11 (атомарная очередь фронтов + объединённая позиция указателя) - | - v - приложение в главном потоке <----- события SDL3 - | - v - параметры модели, оверлеи и состояние интерфейса - | - v - обновление модели -> композиция OpenGL -> представление на платформе -``` - -Низкоуровневые хуки Windows, обработчик событий Quartz в macOS и слушатели XInput2 в Linux работают вне главного цикла. Они публикуют нажатия клавиш и фронты кнопок мыши с отметками времени в ограниченную атомарную очередь и публикуют координаты указателя через отдельный слот объединения; после успешной публикации отправляется нативное событие пробуждения SDL, что не позволяет высокочастотным событиям движения вытеснять упорядоченные фронты клавиш и кнопок. В Windows DirectInput используется через интерфейс указателя платформы только тогда, когда модель запрашивает относительное движение. События окна, настроек и геймпада SDL3 обрабатываются в главном потоке; события геймпада нормализуются перед передачей параметрам модели или сочетаниям клавиш. Ни один платформенный слушатель не вызывает напрямую код Live2D, оверлеев или интерфейса. - -`bongo_cat_app_run` обрабатывает параметры обновления, завершения и вторичных процессов, обеспечивает владение единственным экземпляром главного процесса, выделяет состояние приложения, выполняет инициализацию, входит в `bongo_cat_app_loop`, а затем обновляет состояние и уничтожает ресурсы в определённом порядке. Инициализация загружает конфигурацию и пути хранения, находит ресурсы, создаёт окно питомца SDL/OpenGL, инициализирует платформенные бэкенды, создаёт сервисы Live2D/оверлеев/аудио, сканирует встроенные/установленные/близлежащие источники моделей и загружает доступные модели. `BongoCatApp` хранит настройки, состояние сеанса, каталоги моделей и поведения, дескрипторы платформы и дескрипторы сервисов рантайма. - -Установленные пакеты моделей используют Mver в качестве канонического формата. Процесс импорта анализирует выбранный файл или каталог, обнаруживает и проверяет кандидатов, формирует отпечаток идентичности пакета, преобразует источники Tauri в Mver, применяет патчи изображений и отправляет нормализованный пакет в `models_root`. Затем создаются адаптеры рантайма и обновляется каталог. Близлежащие источники обнаруживаются без установки их дерева исходников; их адаптеры и результаты проверок кэшируются в `cache_root` за пределами `models_root`. - -Каждая итерация главного цикла ожидает пробуждения SDL/нативных событий или самого раннего срока кадра, интерфейса, анимации и попадания указателя (максимум 250 мс). Цикл распределяет поставленные в очередь события SDL, очищает атомарную очередь ввода и выполняет восстановление освобождений, обновляет состояние окна и обновления модели, а затем применяет входные параметры. При включённом Cubism срок модели следует `settings.model.max_fps` (по умолчанию 60 FPS); диагностические сборки используют резервный интервал 100 мс. Прошедшее время модели учитывается максимум 250 мс и делится на до восьми подшагов, каждый из которых не превышает 1/30 секунды. - -Обычный путь питомца рендерится только тогда, когда окно видимо, не свёрнуто и помечено как изменённое. Каждый кадр сначала очищает фон, рисует модель, затем компонует оверлеи указателя, клавиш и эффектов и, наконец, вызывает презентер платформы. Операции предпросмотра могут запросить немедленный рендеринг; рендеринг скриншотов может пропустить представление. macOS и Linux напрямую обменивают окно SDL OpenGL; Windows обменивает его напрямую, если многослойное представление не включено, в противном случае считывает кадровый буфер и вызывает `UpdateLayeredWindow`. Интерфейс настроек имеет собственное окно SDL/OpenGL и рендерится и представляется отдельно. - -Среда выполнения C вызывает ABI, объявленный в `include/bongo_cat/model.h`. Мост Live2D и реализация Cubism находятся в `src/live2d` и используют C++17 только при включённом Cubism SDK; остальной нативный рантайм использует C11. Типы Cubism остаются за непрозрачными дескрипторами C; когда SDK недоступен, `src/live2d/live2d_stub.c` предоставляет диагностический бэкенд. - -```mermaid -flowchart TB - Input(["Клавиатура / Мышь / Геймпад"]) - BuiltIn(["Встроенные ресурсы моделей"]) - Sources(["Внешние источники моделей
Mver, Tauri, .model3.json, патчи изображений"]) - Desktop(["Окно питомца и окно настроек"]) - subgraph Runtime["Нативный рантайм BongoCat"] - direction TB - Entry["src/main.c
bongo_cat_app_run"] - Startup["Запуск и инициализация
Конфигурация, хранилище, окно, платформа"] - Loop["Главный цикл SDL3
Ожидание, распределение, обновление, рендеринг"] - Shutdown["Завершение
Обновить состояние, остановить сервисы, освободить ресурсы"] - InputQueue[("Атомарное состояние ввода
Очередь фронтов и объединённая позиция указателя")] - InputDispatch["Распределение ввода
Сочетания клавиш, сопоставление указателя, параметры модели"] - State[("Состояние BongoCatApp
Настройки, сеанс, каталог, дескрипторы рантайма")] - Import["Обнаружение и импорт моделей
Проверка, нормализация в Mver, установка/кэш"] - Catalog[("Каталог моделей и поведений")] - Live2D["Live2D C ABI
Cubism SDK или диагностический заглушка"] - Overlay["Оверлеи и аудио"] - Preferences["Настройки и оболочка рабочего стола
UI Nuklear, трей, операции с окном"] - Compose["Композиция кадров OpenGL"] - Present["Представление на платформе"] - Entry --> Startup --> Loop - Loop --> Shutdown - Loop --> InputDispatch --> State - Loop <--> State - State --> Live2D - State --> Overlay - State <--> Preferences - Loop --> Preferences - Catalog --> State - State --> Compose - Live2D --> Compose - Overlay --> Compose - Compose --> Present - Loop --> Compose - end - subgraph Platform["Платформенные бэкенды"] - direction LR - Global["Глобальный захват клавиатуры/указателя
Windows, macOS, Linux"] - SDL["События SDL3
События окна и геймпада"] - end - Input --> Global --> InputQueue --> InputDispatch - Input --> SDL --> Loop - BuiltIn --> Catalog - Sources --> Import --> Catalog - Present --> Desktop - Preferences --> Desktop -``` - -## ❓ Часто задаваемые вопросы - -### 🔒 Записывает ли BongoCat мой ввод с клавиатуры или мыши? - -Нет. BongoCat обрабатывает ввод с клавиатуры и мыши локально для управления анимациями и сочетаниями клавиш. Он не записывает и не загружает нажатия клавиш, действия мыши или другие данные взаимодействия. Конфигурация также сохраняется только локально; приложение не содержит рекламы, аналитических инструментов или кода отслеживания пользователей. При проверке обновлений запрашиваются только публичные метаданные версии; никакие данные ввода, конфигурации или использования не отправляются. - -### 🖼️ Почему OpenGL, а не Vulkan? - -Не потому что Vulkan плох, а потому что BongoCat не нуждается в такой сложности. Приложение в основном рендерит одну модель Live2D, несколько слоёв интерфейса и прозрачное окно рабочего стола; OpenGL легко справляется с этим и естественно сочетается с SDL3 и рендерером OpenGL от Cubism. Переход на Vulkan потребовал бы поддержки большего объёма кода рендеринга и синхронизации на трёх настольных платформах без заметного преимущества для пользователя. Для текущей нагрузки BongoCat OpenGL делает рендерер более компактным, лёгким в отладке и обслуживании, сохраняя при этом необходимую производительность. - -## 🙏 Особая благодарность - -Рейтинг контрибьюторов - -
-Авторские права © 2026 - **BongoCat**
-By vladelaina
-Made with ❤️ & ⌨️ -
From 503acf22638d461892d14e3429dc32449d961196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=AF=E7=8E=84=E5=A4=9C=E4=B8=8D=E6=98=AF=E7=8E=84?= =?UTF-8?q?=E6=9C=88?= <107749059+sakuraxy2004@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:19:33 +0800 Subject: [PATCH 192/334] =?UTF-8?q?Create=20Japanese=20README=20for=20Bong?= =?UTF-8?q?oCat.=20=20=20Ciallo=EF=BD=9E(=E2=88=A0=E3=83=BB=CF=89<=20)?= =?UTF-8?q?=E2=8C=92=E2=98=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added Japanese README with installation and usage instructions. Ciallo~(∠・ω< )⌒★ --- docs/README.ja-JP.md | 238 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) diff --git a/docs/README.ja-JP.md b/docs/README.ja-JP.md index e69de29b..67e274c5 100644 --- a/docs/README.ja-JP.md +++ b/docs/README.ja-JP.md @@ -0,0 +1,238 @@ +# 💘 C/C++ × SDL3 × OpenGL を混ぜて、思う存分叩け!Bong~ Bongo Cat!!! + +言語を選択 ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español + +> [!TIP] +> デモで使用されているモデルは [宇痕冫](https://space.bilibili.com/348616056) から提供されています。 +> +> 🎁 **無料**のモデルをお探しですか?公式サイトをご覧ください:[bongocat.pet](https://bongocat.pet/models) + +## 📥 ダウンロード + +[GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest) から最新バージョンをダウンロードしてください。 + +## 🛠️ ソースからのビルド + +BongoCat は CMake を使用しており、C11 コンパイラ、C++17 コンパイラ、CMake 3.24 以降、およびデスクトップ用 OpenGL 開発ファイルが必要です。デフォルトでは SDL3、yyjson、stb、miniaudio、Nuklear が構成フェーズで自動的にダウンロードされるため、初回の構成にはインターネット接続が必要です。 + +プロジェクトのルートディレクトリ(`CMakeLists.txt` を含むディレクトリ)で以下のコマンドを実行してください。 + +### 📋 プラットフォーム別の前提条件 + +- **Windows:** Visual Studio 2022(「C++ によるデスクトップ開発」ワークロードをインストール)と CMake。MSVC ジェネレータを使用してください。MinGW では診断バックエンドをビルドできますが、Cubism SDK には対応していません。 +- **macOS:** Xcode Command Line Tools、CMake、Ninja。対象アーキテクチャがホストのデフォルトと異なる場合は、`CMAKE_OSX_ARCHITECTURES` で指定してください。 +- **Linux(Debian/Ubuntu):** GCC または Clang、Ninja、および OpenGL/X11 ヘッダー: + +```bash +sudo apt-get update +sudo apt-get install -y build-essential cmake ninja-build \ +libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev +``` + +### 🔧 設定とビルド + +Linux と macOS では、Ninja のような単一構成ジェネレータを使用してください: + +```bash +cmake -S . -B build -G Ninja \ +-DCMAKE_BUILD_TYPE=Release \ +-DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --parallel +``` + +Windows では、Visual Studio 2022 の開発者コマンドプロンプト(または MSVC が利用可能な他のコマンドライン)から実行してください: + +```powershell +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` +-DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --config Release --parallel +``` + +実行可能ファイルの場所:Linux では `build/BongoCat`、macOS では `build/BongoCat.app/Contents/MacOS/BongoCat`、Visual Studio ビルドの Windows では `build/Release/BongoCat.exe`。 + +### 🧪 テスト + +CTest ターゲットはデフォルトで有効です。ビルド後に実行: + +```bash +ctest --test-dir build --output-on-failure +``` + +Visual Studio のような複数構成ジェネレータの場合は、ビルド構成を明示的に指定してください: + +```powershell +ctest --test-dir build -C Release --output-on-failure +``` + +### 🎭 Live2D / Cubism SDK(任意) + +Cubism SDK が見つからない場合、CMake は警告を表示して診断バックエンドをビルドします。このバックエンドは起動およびプラットフォーム診断用であり、Live2D モデルのレンダリングは提供しません。完全なランタイムをビルドするには、互換性のある Cubism SDK for Native をインストールし、`vendor/CubismSdkForNative` に配置するか、パスを明示的に渡してください: + +```bash +cmake -S . -B build -G Ninja \ +-DCMAKE_BUILD_TYPE=Release \ +-DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ +-DBONGO_CAT_REQUIRE_CUBISM=ON +``` + +SDK には Core ライブラリ、Framework ソース、および `cmake/Cubism.cmake` で要求されるレイアウトの OpenGL GLEW サードパーティディレクトリを含める必要があります。Windows の Cubism ビルドには Visual Studio 2022 が必要です。`BONGO_CAT_REQUIRE_CUBISM=ON` は、SDK が利用できない場合に診断バックエンドを静かに選択するのではなく、構成を失敗させます。 + +### ⚙️ CMake オプション + +| オプション | デフォルト値 | 説明 | +| --- | --- | --- | +| `BONGO_CAT_FETCH_DEPS` | `ON` | CMake `FetchContent` を使用して、固定バージョンのサードパーティ依存関係をダウンロードします。SDL3、yyjson、stb、miniaudio、Nuklear が CMake で利用可能な場合にのみ `OFF` に設定してください。 | +| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Cubism SDK for Native のパス。 | +| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | SDK が利用できない場合に構成を失敗させます。 | +| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | ローカルコンパイラの警告をエラーとして扱います。 | + +オフラインビルドでは `BONGO_CAT_FETCH_DEPS=OFF` を設定し、SDL3(`SDL3-static` を含む)と yyjson の CMake パッケージ設定を提供してください。stb、Nuklear、miniaudio を自動的に発見できない場合は、それらのインクルードディレクトリも提供してください: + +```bash +cmake -S . -B build -G Ninja \ +-DCMAKE_BUILD_TYPE=Release \ +-DBONGO_CAT_FETCH_DEPS=OFF \ +-DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ +-DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ +-DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio +``` + +## 📌 プロジェクトの状態 + +![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") + +## 📜 ライセンス + +BongoCat のソースコードとローカルランタイムは [AGPL-3.0-only](../LICENSE) ライセンスです。 + +デフォルトの内蔵モデルモード(`standard`)は引き続き MIT ライセンスです。`resources/assets/models/standard`、`keyboard`、`gamepad` にバンドルされているモデルリソースは、別途の [MIT ライセンス宣言](../LICENSE-MIT) の対象です。この MIT ライセンスはモデルリソースおよびその付随するアートワークのみに適用され、BongoCat のソースコードやローカルランタイムのライセンスを変更するものではありません。 + +## 🧭 技術的なアーキテクチャ + +現在のネイティブバージョンは C/C++、SDL3、OpenGL で構築されています。以下の図はランタイムのデータフローに重点を置いています。ビルドとパッケージングの詳細は CMake ファイルを参照してください。 + +### 🔄 ランタイムの所有権とフレームスケジューリング + +各プロセスは 1 つの `BongoCatApp` と 1 つのメインスレッドのイベント/レンダリングループを所有します。プラットフォームリスナーは入力の境界で停止します: + +```text +プラットフォームリスナー(キーボード/ポインタ) +| +v +C11 入力状態(アトミックなエッジキュー + マージ済みポインタ位置) +| +v +メインスレッドアプリ <----- SDL3 イベント +| +v +モデルパラメータ、オーバーレイ、UI 状態 +| +v +モデル更新 -> OpenGL 合成 -> プラットフォーム表示 +``` + +Windows の低レベルフック、macOS の Quartz イベントタップ、Linux の XInput2 リスナーはメインループの外で動作します。これらはタイムスタンプ付きのキーとマウスボタンのエッジを有界アトミックキューに公開し、別個のマージスロットを介してポインタ座標を公開します。公開に成功するとネイティブの SDL ウェイクアップイベントをプッシュし、高頻度の移動イベントが順序付けられたキーおよびボタンのエッジを押し出すのを防ぎます。Windows では、モデルが相対移動を要求した場合にのみ、プラットフォームポインタインターフェースを介して DirectInput が使用されます。SDL3 のウィンドウ、設定、ゲームパッドイベントはメインスレッドで処理され、ゲームパッドイベントはモデルパラメータまたはショートカットに渡す前に正規化されます。どのプラットフォームリスナーも Live2D、オーバーレイ、UI コードを直接呼び出すことはありません。 + +`bongo_cat_app_run` は更新、シャットダウン、サブプロセスパラメータを処理し、メインプロセスのシングルインスタンス所有権を確保し、アプリ状態を割り当て、初期化を実行し、`bongo_cat_app_loop` に入り、その後定義された順序で状態をフラッシュしてリソースを破棄します。初期化は設定とストレージパスを読み込み、リソースを探し、SDL/OpenGL のペットウィンドウを作成し、プラットフォームバックエンドを初期化し、Live2D/オーバーレイ/オーディオサービスを作成し、内蔵/インストール済み/近傍のモデルソースをスキャンして、利用可能なモデルを読み込みます。`BongoCatApp` は設定、セッション状態、モデルと動作のカタログ、プラットフォームハンドル、ランタイムサービスのハンドルを保持します。 + +インストール済みモデルパッケージは Mver を正規形式として使用します。インポートプロセスは選択したファイルまたはディレクトリを解析し、候補を発見して検証し、パッケージの同一性フィンガープリントを生成し、Tauri ソースを Mver に変換し、画像パッチを適用し、正規化されたパッケージを `models_root` にコミットします。その後、ランタイムアダプタを生成してカタログを更新します。近傍ソースはソースディレクトリをインストールせずにのみ発見されます。そのアダプタと検証結果は `models_root` の外にある `cache_root` にキャッシュされます。 + +各メインループの反復は、SDL/ネイティブのウェイクアップ、または最も早いフレーム、UI、アニメーション、ポインタヒットの期限(最大 250 ミリ秒)を待ちます。ループはキューに入れられた SDL イベントをディスパッチし、アトミックな入力キューを空にして解放/復元を実行し、ウィンドウとモデルの更新状態を更新してから、入力パラメータを適用します。Cubism が有効な場合、モデルの期限は `settings.model.max_fps`(デフォルト 60 FPS)に従います。診断ビルドでは 100 ミリ秒のフォールバック間隔を使用します。モデルの経過時間は最大 250 ミリ秒として扱われ、各サブステップの目標が 1/30 秒以下になるように、最大 8 つのサブステップに分割されます。 + +通常のペットパスは、ウィンドウが表示され、最小化されておらず、ダーティとマークされている場合にのみレンダリングされます。各フレームは最初に背景をクリアし、モデルを描画し、それからポインタ、キー、エフェクトのオーバーレイを合成し、最後にプラットフォームレンダラーを呼び出します。プレビュー操作は即時レンダリングを要求できます。スクリーンショットレンダリングは表示をスキップできます。macOS と Linux は SDL OpenGL ウィンドウを直接スワップします。Windows はレイヤードプレゼンテーションが有効でない場合は直接スワップし、有効な場合はフレームバッファを読み取って `UpdateLayeredWindow` を呼び出します。設定 UI は独立した SDL/OpenGL ウィンドウを持ち、個別にレンダリングおよび表示されます。 + +C ランタイムは `include/bongo_cat/model.h` で宣言された ABI を呼び出します。Live2D ブリッジと Cubism 実装は `src/live2d` にあり、Cubism SDK が有効な場合のみ C++17 を使用します。残りのネイティブランタイムは C11 を使用します。Cubism 型は不透明な C ハンドルの背後に保持されます。SDK が利用できない場合、`src/live2d/live2d_stub.c` が診断バックエンドを提供します。 + +```mermaid +flowchart TB +Input(["キーボード / マウス / ゲームパッド"]) +BuiltIn(["内蔵モデルリソース"]) +Sources(["外部モデルソース + +Mver、Tauri、.model3.json、画像パッチ"]) +Desktop(["ペットウィンドウと設定ウィンドウ"]) +subgraph Runtime["BongoCat ネイティブランタイム"] +direction TB +Entry["src/main.c + +bongo_cat_app_run"] +Startup["起動と初期化 + +設定、ストレージ、ウィンドウ、プラットフォーム"] +Loop["SDL3 メインループ + +待機、配信、更新、レンダリング"] +Shutdown["シャットダウン + +状態のフラッシュ、サービス停止、リソース解放"] +InputQueue[("アトミック入力状態 + +エッジキューとマージ済みポインタ位置")] +InputDispatch["入力ディスパッチ + +ショートカット、ポインタマッピング、モデルパラメータ"] +State[("BongoCatApp 状態 + +設定、セッション、カタログ、ランタイムハンドル")] +Import["モデルの発見とインポート + +検証、Mver への正規化、インストール/キャッシュ"] +Catalog[("モデルと動作のカタログ")] +Live2D["Live2D C ABI + +Cubism SDK または診断スタブ"] +Overlay["オーバーレイとオーディオ"] +Preferences["設定とデスクトップシェル + +Nuklear UI、トレイ、ウィンドウ操作"] +Compose["OpenGL フレーム合成"] +Present["プラットフォーム表示"] +Entry --> Startup --> Loop +Loop --> Shutdown +Loop --> InputDispatch --> State +Loop <--> State +State --> Live2D +State --> Overlay +State <--> Preferences +Loop --> Preferences +Catalog --> State +State --> Compose +Live2D --> Compose +Overlay --> Compose +Compose --> Present +Loop --> Compose +end +subgraph Platform["プラットフォームバックエンド"] +direction LR +Global["グローバルなキーボード/ポインタキャプチャ + +Windows、macOS、Linux"] +SDL["SDL3 イベント + +ウィンドウとゲームパッドのイベント"] +end +Input --> Global --> InputQueue --> InputDispatch +Input --> SDL --> Loop +BuiltIn --> Catalog +Sources --> Import --> Catalog +Present --> Desktop +Preferences --> Desktop +``` + +## ❓ よくある質問 + +### 🔒 BongoCat は私のキーボードやマウスの入力を記録しますか? + +いいえ。BongoCat はキーボードとマウスの入力をローカルで処理し、アニメーションとショートカットを駆動します。キーストローク、マウス操作、その他のインタラクションデータを記録またはアップロードすることはありません。設定もローカルにのみ保存され、アプリには広告、分析ツール、ユーザートラッキングコードは含まれていません。更新チェックを実行するときは公開されたバージョンメタデータのみを要求し、入力、設定、使用データを送信することはありません。 + +### 🖼️ なぜ Vulkan ではなく OpenGL を使うのですか? + +これは Vulkan が良くないからではなく、BongoCat がそのレベルの複雑さを必要としないからです。このアプリは主に 1 つの Live2D モデル、少数の UI レイヤー、透明なデスクトップウィンドウをレンダリングします。OpenGL で十分にまかなえ、SDL3 および Cubism の OpenGL レンダラーとも自然に連携します。Vulkan への移行は 3 つのデスクトッププラットフォームでより多くのレンダリングと同期コードを維持する必要があり、ユーザーにとって明確な利点はありません。BongoCat の現在のワークロードでは、OpenGL によってレンダラーはより簡潔でデバッグ・保守が容易になり、必要なパフォーマンスを維持できます。 + +## 🙏 謝辞 + +© 2026 - **BongoCat** + +By vladelaina + +Made with ❤️ & ⌨️ From 92d39a5de5caeaad94811425caa4b217182b7aa4 Mon Sep 17 00:00:00 2001 From: StackNi <643824428@qq.com> Date: Tue, 1 Sep 2026 22:08:23 +0800 Subject: [PATCH 193/334] docs: add Korean README translation --- docs/README.ko-KR.md | 230 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) diff --git a/docs/README.ko-KR.md b/docs/README.ko-KR.md index e69de29b..8a46564d 100644 --- a/docs/README.ko-KR.md +++ b/docs/README.ko-KR.md @@ -0,0 +1,230 @@ + + +

💘 C/C++ × SDL3 × OpenGL,함께 섞어서 마음껏 두드리세요! Bong~ Bongo Cat!!!

+

+ 언어 선택 ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español +

+ +
+ +> [!TIP] +> 데모에 사용된 모델은 [宇痕冫](https://space.bilibili.com/348616056)님의 작품입니다. +> +> 🎁 **무료** 모델을 찾으시나요? 공식 웹사이트를 방문하세요: [bongocat.pet](https://bongocat.pet/models) + +

+ +## 📥 다운로드 + + + +- GitHub Releases + + [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest)에서 최신 버전을 다운로드하세요. + +## 🛠️ 소스 코드로 빌드하기 + +BongoCat은 CMake를 사용하며, C11 컴파일러, C++17 컴파일러, CMake 3.24 이상, 데스크톱 OpenGL 개발 파일이 필요합니다. 기본적으로 SDL3, yyjson, stb, miniaudio 및 Nuklear는 구성 단계에서 자동으로 다운로드되므로, 첫 구성 시 네트워크 연결이 필요합니다. + +프로젝트 루트 디렉토리(`CMakeLists.txt`가 있는 디렉토리)에서 다음 명령어를 실행하세요. + +### 📋 플랫폼 사전 요구 사항 + +- **Windows:** Visual Studio 2022("C++를 사용한 데스크톱 개발" 워크로드 설치) 및 CMake. MSVC 생성기를 사용하세요. MinGW는 진단 백엔드를 빌드할 수 있지만 Cubism SDK를 지원하지 않습니다. +- **macOS:** Xcode Command Line Tools, CMake 및 Ninja. 대상 아키텍처가 호스트 기본 아키텍처와 다른 경우 `CMAKE_OSX_ARCHITECTURES`를 통해 지정하세요. +- **Linux(Debian/Ubuntu):** GCC 또는 Clang, Ninja, OpenGL/X11 헤더 파일: + + ```bash + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build \ + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev + ``` + +### 🔧 구성 및 빌드 + +Linux 및 macOS에서는 Ninja와 같은 단일 구성 생성기를 사용하세요: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --parallel +``` + +Windows에서는 Visual Studio 2022 개발자 명령 프롬프트(또는 MSVC를 사용할 수 있는 기타 명령줄)에서 다음을 실행하세요: + +```powershell +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --config Release --parallel +``` + +실행 파일 위치: Linux `build/BongoCat`, macOS `build/BongoCat.app/Contents/MacOS/BongoCat`, Visual Studio 빌드 Windows 버전 `build/Release/BongoCat.exe`. + +### 🧪 테스트 + +CTest 대상은 기본적으로 활성화됩니다. 빌드 후 실행: + +```bash +ctest --test-dir build --output-on-failure +``` + +Visual Studio와 같은 다중 구성 생성기의 경우 빌드 구성을 명시적으로 지정하세요: + +```powershell +ctest --test-dir build -C Release --output-on-failure +``` + +### 🎭 Live2D / Cubism SDK (선택 사항) + +Cubism SDK를 찾을 수 없으면 CMake는 경고를 표시하고 진단 백엔드를 빌드합니다. 이 백엔드는 시작 및 플랫폼 진단에 사용되며 Live2D 모델 렌더링을 제공하지 않습니다. 전체 런타임을 빌드하려면 호환되는 Cubism SDK for Native를 설치하고 `vendor/CubismSdkForNative`에 배치하거나 경로를 명시적으로 전달하세요: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ + -DBONGO_CAT_REQUIRE_CUBISM=ON +``` + +SDK에는 Core 라이브러리, Framework 소스 코드, 그리고 `cmake/Cubism.cmake`에서 요구하는 레이아웃의 OpenGL GLEW 서드파티 디렉토리가 포함되어야 합니다. Windows Cubism 빌드에는 Visual Studio 2022가 필요합니다. `BONGO_CAT_REQUIRE_CUBISM=ON`은 SDK를 사용할 수 없을 때 진단 백엔드를 자동으로 선택하는 대신 구성을 실패하게 합니다. + +### ⚙️ CMake 옵션 + +| 옵션 | 기본값 | 설명 | +| --- | --- | --- | +| `BONGO_CAT_FETCH_DEPS` | `ON` | CMake `FetchContent`를 사용하여 고정 버전의 서드파티 종속성을 다운로드합니다. SDL3, yyjson, stb, miniaudio 및 Nuklear를 CMake에서 이미 사용할 수 있는 경우에만 `OFF`로 설정하세요. | +| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Cubism SDK for Native의 경로입니다. | +| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | SDK를 사용할 수 없을 때 구성을 실패하게 합니다. | +| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | 로컬 컴파일러 경고를 오류로 처리합니다. | + +오프라인 빌드 시 `BONGO_CAT_FETCH_DEPS=OFF`로 설정하고, SDL3(`SDL3-static` 포함) 및 yyjson의 CMake 패키지 구성을 제공하세요. stb, Nuklear 및 miniaudio를 자동으로 찾을 수 없는 경우 해당 include 디렉토리도 제공해야 합니다: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=OFF \ + -DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ + -DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ + -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio +``` + +## 📌 프로젝트 상태 + +![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") + +## 📜 라이선스 + +BongoCat 소스 코드 및 로컬 런타임은 [AGPL-3.0-only](../LICENSE) 라이선스를 따릅니다. + +기본 내장 모델(`standard`)은 여전히 MIT 라이선스를 따릅니다. `resources/assets/models/standard`, `keyboard` 및 `gamepad`에 번들로 제공되는 모델 리소스는 별도의 [MIT 라이선스 고지](../LICENSE-MIT)가 적용됩니다. 해당 MIT 라이선스는 모델 리소스 및 관련 아트워크에만 적용되며, BongoCat 소스 코드나 로컬 런타임의 라이선스를 변경하지 않습니다. + +## 🧭 기술 아키텍처 + +현재 네이티브 버전은 C/C++, SDL3 및 OpenGL을 기반으로 구축되었습니다. 아래 다이어그램은 런타임 데이터 흐름을 중점적으로 보여줍니다. 빌드 및 패키징 세부 사항은 CMake 파일을 참조하세요. + +### 🔄 런타임 소유권 및 프레임 스케줄링 + +각 프로세스는 하나의 `BongoCatApp`과 하나의 메인 스레드 이벤트/렌더링 루프를 소유합니다. 플랫폼 리스너는 입력 경계에서 중단됩니다: + +```text +플랫폼 리스너 (키보드/포인터) + | + v + C11 입력 상태 (원자적 에지 큐 + 병합된 포인터 위치) + | + v + 메인 스레드 애플리케이션 <----- SDL3 이벤트 + | + v + 모델 파라미터, 오버레이 및 UI 상태 + | + v + 모델 업데이트 -> OpenGL 합성 -> 플랫폼 프레젠테이션 +``` + +Windows 로우 레벨 훅, macOS Quartz 이벤트 탭, Linux XInput2 리스너는 메인 루프 외부에서 실행됩니다. 이들은 타임스탬프가 찍힌 키 및 마우스 버튼 에지를 유계 원자 큐에 게시하고, 독립적인 병합 슬롯을 통해 포인터 좌표를 게시합니다. 성공적인 게시 후에는 네이티브 SDL 웨이크 이벤트를 푸시하여 높은 빈도의 이동 이벤트가 정렬된 키 및 버튼 에지를 밀어내는 것을 방지합니다. Windows에서는 모델이 상대 이동을 요청할 때만 플랫폼 포인터 인터페이스를 통해 DirectInput을 사용합니다. SDL3 창, 환경 설정 및 게임패드 이벤트는 메인 스레드에서 처리되며, 게임패드 이벤트는 모델 파라미터나 단축키에 전달되기 전에 정규화됩니다. 어떤 플랫폼 리스너도 Live2D, 오버레이 또는 UI 코드를 직접 호출하지 않습니다. + +`bongo_cat_app_run`은 업데이트, 종료 및 보조 프로세스 파라미터를 담당하여 메인 프로세스의 단일 인스턴스 소유권을 보장하고, 애플리케이션 상태를 할당하며, 초기화를 수행하고, `bongo_cat_app_loop`로 진입한 후 정의된 순서대로 상태를 플러시하고 리소스를 소멸시킵니다. 초기화는 구성 및 저장 경로를 로드하고, 리소스를 찾고, SDL/OpenGL 펫 창을 생성하고, 플랫폼 백엔드를 초기화하고, Live2D/오버레이/오디오 서비스를 생성하고, 내장/설치/근처 모델 소스를 스캔하고, 사용 가능한 모델을 로드합니다. `BongoCatApp`은 설정, 세션 상태, 모델 및 동작 카탈로그, 플랫폼 핸들 및 런타임 서비스 핸들을 보유합니다. + +설치된 모델 패키지는 Mver을 표준 형식으로 사용합니다. 가져오기 프로세스는 선택한 파일이나 디렉토리를 구문 분석하고, 후보를 발견 및 검증하고, 패키지 ID 지문을 생성하고, Tauri 소스를 Mver로 변환하고, 이미지 패치를 적용하고, 정규화된 패키지를 `models_root`에 제출합니다. 그런 다음 런타임 어댑터를 생성하고 카탈로그를 새로 고칩니다. 근처 소스는 소스 디렉토리를 설치하지 않고 발견되며, 해당 어댑터와 검사 결과는 `models_root` 외부의 `cache_root` 아래에 캐시됩니다. + +각 메인 루프 반복은 SDL/네이티브 웨이크를 기다리거나, 가장 빠른 프레임, UI, 애니메이션 및 포인터 적중 마감 시간(최대 250ms)을 기다립니다. 루프는 대기 중인 SDL 이벤트를 처리하고, 원자 입력 큐를 비우고 해제 복구를 수행하고, 창 및 모델 새로 고침 상태를 업데이트한 다음 입력 파라미터를 적용합니다. Cubism이 활성화되면 모델 마감 시간은 `settings.model.max_fps`(기본값 60 FPS)를 따릅니다. 진단 빌드는 100ms 폴백 간격을 사용합니다. 모델 경과 시간은 최대 250ms까지 계산되며, 각 하위 단계 목표가 1/30초를 초과하지 않도록 최대 8개의 하위 단계로 분할됩니다. + +일반 펫 경로는 창이 보이고 최소화되지 않았으며 더티로 표시된 경우에만 렌더링됩니다. 각 프레임은 먼저 배경을 지우고, 모델을 그린 다음, 포인터, 키 및 효과 오버레이를 합성하고, 마지막으로 플랫폼 프리젠터를 호출합니다. 미리보기 작업은 즉시 렌더링을 요청할 수 있고, 스크린샷 렌더링은 프레젠테이션을 건너뛸 수 있습니다. macOS 및 Linux는 SDL OpenGL 창을 직접 교체합니다. Windows는 계층화된 프레젠테이션이 활성화되지 않은 경우 직접 교체하고, 그렇지 않으면 프레임 버퍼를 읽고 `UpdateLayeredWindow`를 호출합니다. 환경 설정 UI는 자체 SDL/OpenGL 창을 소유하고 별도로 렌더링 및 프레젠테이션을 수행합니다. + +C 런타임은 `include/bongo_cat/model.h`에 선언된 ABI를 호출합니다. Live2D 브리지 및 Cubism 구현은 `src/live2d`에 있으며, Cubism SDK가 활성화된 경우에만 C++17을 사용합니다. 나머지 로컬 런타임은 C11을 사용합니다. Cubism 타입은 불투명 C 핸들 뒤에 유지됩니다. SDK를 사용할 수 없을 때 `src/live2d/live2d_stub.c`는 진단 백엔드를 제공합니다. + +```mermaid +flowchart TB + Input(["키보드 / 마우스 / 게임패드"]) + BuiltIn(["내장 모델 리소스"]) + Sources(["외부 모델 소스
Mver, Tauri, .model3.json, 이미지 패치"]) + Desktop(["펫 창 및 환경 설정 창"]) + subgraph Runtime["BongoCat 네이티브 런타임"] + direction TB + Entry["src/main.c
bongo_cat_app_run"] + Startup["시작 및 초기화
구성, 저장소, 창, 플랫폼"] + Loop["SDL3 메인 루프
대기, 처리, 업데이트, 렌더링"] + Shutdown["종료
상태 플러시, 서비스 중지, 리소스 해제"] + InputQueue[("원자 입력 상태
에지 큐 및 병합된 포인터 위치")] + InputDispatch["입력 처리
단축키, 포인터 매핑, 모델 파라미터"] + State[("BongoCatApp 상태
설정, 세션, 카탈로그, 런타임 핸들")] + Import["모델 발견 및 가져오기
검증, Mver로 정규화, 설치/캐싱"] + Catalog[("모델 및 동작 카탈로그")] + Live2D["Live2D C ABI
Cubism SDK 또는 진단 스텁"] + Overlay["오버레이 및 오디오"] + Preferences["환경 설정 및 데스크톱 셸
Nuklear UI, 트레이, 창 작업"] + Compose["OpenGL 프레임 합성"] + Present["플랫폼 프레젠테이션"] + Entry --> Startup --> Loop + Loop --> Shutdown + Loop --> InputDispatch --> State + Loop <--> State + State --> Live2D + State --> Overlay + State <--> Preferences + Loop --> Preferences + Catalog --> State + State --> Compose + Live2D --> Compose + Overlay --> Compose + Compose --> Present + Loop --> Compose + end + subgraph Platform["플랫폼 백엔드"] + direction LR + Global["전역 키보드/포인터 캡처
Windows, macOS, Linux"] + SDL["SDL3 이벤트
창 및 게임패드 이벤트"] + end + Input --> Global --> InputQueue --> InputDispatch + Input --> SDL --> Loop + BuiltIn --> Catalog + Sources --> Import --> Catalog + Present --> Desktop + Preferences --> Desktop +``` + +## ❓ 자주 묻는 질문 + +### 🔒 BongoCat이 제 키보드나 마우스 입력을 기록하나요? + +아니요. BongoCat은 애니메이션 구동과 단축키를 위해 키보드 및 마우스 입력을 로컬에서 처리합니다. 키 입력, 마우스 동작 또는 기타 상호 작용 데이터를 기록하거나 업로드하지 않습니다. 구성도 로컬에만 저장되며, 애플리케이션에는 광고, 분석 도구 또는 사용자 추적 코드가 포함되어 있지 않습니다. 업데이트 확인을 수행할 때는 공개 버전 메타데이터만 요청하며, 입력, 구성 또는 사용 데이터를 전송하지 않습니다. + +### 🖼️ OpenGL을 사용하는 이유는 무엇인가요? Vulkan은 왜 사용하지 않나요? + +Vulkan이 나쁘기 때문이 아니라, BongoCat이 그 정도의 복잡성을 필요로 하지 않기 때문입니다. 애플리케이션은 주로 하나의 Live2D 모델, 소량의 UI 레이어 및 투명 데스크톱 창을 렌더링하며, OpenGL은 이를 쉽게 충족할 수 있고 SDL3 및 Cubism의 OpenGL 렌더러와 자연스럽게 통합됩니다. Vulkan으로 마이그레이션하려면 세 가지 데스크톱 플랫폼에서 더 많은 렌더링 및 동기화 코드를 유지 관리해야 하지만, 사용자에게 눈에 띄는 개선은 없을 것입니다. BongoCat의 현재 워크로드에서 OpenGL은 렌더러를 더 간결하고 디버깅 및 유지 관리하기 쉽게 유지하면서도 필요한 성능을 제공합니다. + +## 🙏 특별 감사 + +기여자 순위 + +
+저작권 © 2026 - **BongoCat**
+By vladelaina
+Made with ❤️ & ⌨️ +
From affcd1b6cdac4b3c6156fc05a51df09055ea2bb3 Mon Sep 17 00:00:00 2001 From: yuanziyi2022 Date: Wed, 2 Sep 2026 02:03:32 +0800 Subject: [PATCH 194/334] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BA=86=E4=B8=80?= =?UTF-8?q?=E4=BA=9BREADME?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/README.es-ES.md | 230 +++++++++++++++++++++++++++++++++++++++++++ docs/README.pt-BR.md | 230 +++++++++++++++++++++++++++++++++++++++++++ docs/README.ru-RU.md | 230 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 690 insertions(+) diff --git a/docs/README.es-ES.md b/docs/README.es-ES.md index e69de29b..bff6f02e 100644 --- a/docs/README.es-ES.md +++ b/docs/README.es-ES.md @@ -0,0 +1,230 @@ + + +

💘 C/C++ × SDL3 × OpenGL, mézclalo todo, ¡a tocar! ¡Bong~ Bongo Cat!!!

+

+ Elige el idioma ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español +

+ +
+ +> [!TIP] +> El modelo usado en la demo proviene de [宇痕冫](https://space.bilibili.com/348616056). +> +> 🎁 ¿Buscas modelos **gratuitos**? Visita nuestro sitio web oficial: [bongocat.pet](https://bongocat.pet/models) + +

+ +## 📥 Descarga + + + +- GitHub Releases + + Descarga la última versión desde [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest). + +## 🛠️ Compilar desde el código fuente + +BongoCat usa CMake y requiere un compilador C11, un compilador C++17, CMake 3.24 o superior y los archivos de desarrollo de OpenGL para escritorio. Por defecto, SDL3, yyjson, stb, miniaudio y Nuklear se descargan automáticamente durante la configuración, por lo que la primera configuración requiere conexión a internet. + +Ejecuta los siguientes comandos en la raíz del proyecto (el directorio que contiene `CMakeLists.txt`). + +### 📋 Requisitos por plataforma + +- **Windows:** Visual Studio 2022 (con la carga de trabajo «Desarrollo para escritorio con C++») y CMake. Usa el generador MSVC; MinGW puede compilar el backend de diagnóstico, pero no es compatible con el SDK de Cubism. +- **macOS:** Xcode Command Line Tools, CMake y Ninja. Si la arquitectura de destino difiere de la predeterminada del host, especifícala mediante `CMAKE_OSX_ARCHITECTURES`. +- **Linux (Debian/Ubuntu):** GCC o Clang, Ninja y los encabezados de OpenGL/X11: + + ```bash + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build \ + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev + ``` + +### 🔧 Configuración y compilación + +En Linux y macOS, usa un generador de configuración única como Ninja: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --parallel +``` + +En Windows, ejecuta los comandos desde el Símbolo del sistema para desarrolladores de Visual Studio 2022 (o cualquier shell donde MSVC esté disponible): + +```powershell +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --config Release --parallel +``` + +El ejecutable se encuentra en `build/BongoCat` en Linux, en `build/BongoCat.app/Contents/MacOS/BongoCat` en macOS y en `build/Release/BongoCat.exe` para las compilaciones de Visual Studio en Windows. + +### 🧪 Pruebas + +Los objetivos de CTest están habilitados por defecto. Ejecuta lo siguiente después de compilar: + +```bash +ctest --test-dir build --output-on-failure +``` + +Para un generador de múltiples configuraciones como Visual Studio, especifica explícitamente la configuración de compilación: + +```powershell +ctest --test-dir build -C Release --output-on-failure +``` + +### 🎭 Live2D / SDK de Cubism (opcional) + +Si no se encuentra el SDK de Cubism, CMake muestra una advertencia y compila el backend de diagnóstico. Este backend está pensado para el arranque y el diagnóstico de plataforma; no proporciona renderizado de modelos Live2D. Para compilar el runtime completo, instala una versión compatible del Cubism SDK for Native, colócalo en `vendor/CubismSdkForNative` o indica explícitamente su ruta: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ + -DBONGO_CAT_REQUIRE_CUBISM=ON +``` + +El SDK debe incluir la biblioteca Core, el código fuente de Framework y el directorio de terceros de OpenGL GLEW en la estructura esperada por `cmake/Cubism.cmake`. Las compilaciones de Cubism en Windows requieren Visual Studio 2022. Con `BONGO_CAT_REQUIRE_CUBISM=ON`, la configuración falla si el SDK no está disponible, en lugar de seleccionar silenciosamente el backend de diagnóstico. + +### ⚙️ Opciones de CMake + +| Opción | Valor por defecto | Descripción | +| --- | --- | --- | +| `BONGO_CAT_FETCH_DEPS` | `ON` | Descarga dependencias de terceros en versiones fijadas mediante `FetchContent` de CMake. Establécelo en `OFF` solo si SDL3, yyjson, stb, miniaudio y Nuklear ya están disponibles para CMake. | +| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Ruta del Cubism SDK for Native. | +| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | Hace fallar la configuración cuando no hay SDK disponible. | +| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | Trata las advertencias del compilador nativo como errores. | + +Para una compilación sin conexión, establece `BONGO_CAT_FETCH_DEPS=OFF` y proporciona las configuraciones de paquetes de CMake para SDL3 (incluido `SDL3-static`) y yyjson; si stb, Nuklear y miniaudio no se pueden detectar automáticamente, indica también sus directorios de inclusión: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=OFF \ + -DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ + -DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ + -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio +``` + +## 📌 Estado del proyecto + +![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") + +## 📜 Licencia + +El código fuente y el runtime nativo de BongoCat están bajo la licencia [AGPL-3.0-only](../LICENSE). + +El modo de modelo integrado por defecto (`standard`) permanece bajo la licencia MIT. Los recursos de modelo incluidos en `resources/assets/models/standard`, `keyboard` y `gamepad` están cubiertos por la [declaración de licencia MIT](../LICENSE-MIT) independiente. Esta licencia MIT se aplica únicamente a los recursos de los modelos y sus ilustraciones asociadas; no cambia la licencia del código fuente ni del runtime nativo de BongoCat. + +## 🧭 Arquitectura técnica + +La versión nativa actual se basa en C/C++, SDL3 y OpenGL. El siguiente diagrama destaca el flujo de datos en tiempo de ejecución; consulta los archivos de CMake para los detalles de compilación y empaquetado. + +### 🔄 Propiedad del runtime y planificación de fotogramas + +Cada proceso posee una instancia de `BongoCatApp` y un bucle de eventos/renderizado en el hilo principal. Los listeners de plataforma se detienen en el límite de entrada: + +```text +Listeners de plataforma (teclado/ puntero) + | + v + Estado de entrada C11 (cola atómica de flancos + posición de puntero fusionada) + | + v + aplicación en el hilo principal <----- eventos SDL3 + | + v + parámetros del modelo, superposiciones y estado de la interfaz + | + v + actualización del modelo -> composición OpenGL -> presentación de la plataforma +``` + +Los hooks de bajo nivel de Windows, el tap de eventos Quartz de macOS y los listeners XInput2 de Linux se ejecutan fuera del bucle principal. Publican pulsaciones de teclas y flancos de botones del ratón con marca de tiempo en una cola atómica acotada, y publican las coordenadas del puntero a través de una ranura de fusión independiente; tras una publicación correcta, se impulsa un evento de activación SDL nativo, lo que evita que los eventos de movimiento de alta frecuencia desplacen los flancos ordenados de teclas y botones. En Windows, DirectInput solo se usa a través de la interfaz de puntero de la plataforma cuando el modelo solicita movimiento relativo. Los eventos de ventana, preferencias y gamepad de SDL3 se procesan en el hilo principal; los eventos de gamepad se normalizan antes de pasarse a los parámetros del modelo o a los atajos. Ningún listener de plataforma llama directamente al código de Live2D, superposiciones o interfaz. + +`bongo_cat_app_run` gestiona los parámetros de actualización, cierre y procesos secundarios, hace cumplir la propiedad de una única instancia del proceso principal, asigna el estado de la aplicación, realiza la inicialización, entra en `bongo_cat_app_loop` y luego actualiza el estado y libera los recursos en el orden definido. La inicialización carga la configuración y las rutas de almacenamiento, localiza los recursos, crea la ventana de mascota SDL/OpenGL, inicializa los backends de plataforma, crea los servicios de Live2D/superposiciones/audio, escanea las fuentes de modelos integrados/instalados/cercanos y carga los modelos disponibles. `BongoCatApp` conserva la configuración, el estado de la sesión, los catálogos de modelos y comportamientos, los identificadores de plataforma y los identificadores de servicios en tiempo de ejecución. + +Los paquetes de modelos instalados usan Mver como formato canónico. El proceso de importación analiza el archivo o directorio seleccionado, descubre y valida los candidatos, genera una huella de identidad del paquete, convierte las fuentes Tauri a Mver, aplica parches de imagen y envía el paquete normalizado a `models_root`. Después se generan los adaptadores de tiempo de ejecución y se actualiza el catálogo. Las fuentes cercanas solo se descubren sin instalar su árbol de origen; sus adaptadores y resultados de comprobación se almacenan en caché bajo `cache_root`, fuera de `models_root`. + +Cada iteración del bucle principal espera activaciones SDL/nativas o el vencimiento más temprano de los plazos de fotograma, interfaz, animación y acierto de puntero (máximo 250 ms). El bucle distribuye los eventos SDL en cola, vacía la cola de entrada atómica y realiza la recuperación de liberaciones, actualiza el estado de la ventana y de actualización del modelo y luego aplica los parámetros de entrada. Con Cubism habilitado, el plazo del modelo sigue `settings.model.max_fps` (predeterminado 60 FPS); las compilaciones de diagnóstico usan un intervalo de reserva de 100 ms. El tiempo transcurrido del modelo se cuenta como máximo 250 ms y se divide en hasta ocho subpasos, cada uno con un objetivo de no más de 1/30 de segundo. + +La ruta normal de la mascota solo se renderiza cuando la ventana es visible, no está minimizada y está marcada como sucia. Cada fotograma primero limpia el fondo, dibuja el modelo y luego compone las superposiciones de puntero, teclas y efectos, y finalmente llama al presentador de la plataforma. Las operaciones de vista previa pueden solicitar renderizado inmediato; el renderizado de capturas de pantalla puede omitir la presentación. macOS y Linux intercambian directamente la ventana SDL OpenGL; Windows la intercambia directamente cuando la presentación en capas no está habilitada; de lo contrario, lee el búfer de fotogramas y llama a `UpdateLayeredWindow`. La interfaz de preferencias tiene su propia ventana SDL/OpenGL y se renderiza y presenta por separado. + +El runtime C llama a la ABI declarada en `include/bongo_cat/model.h`. El puente Live2D y la implementación de Cubism están en `src/live2d` y solo usan C++17 cuando el SDK de Cubism está habilitado; el resto del runtime nativo usa C11. Los tipos de Cubism se mantienen detrás de identificadores C opacos; cuando el SDK no está disponible, `src/live2d/live2d_stub.c` proporciona el backend de diagnóstico. + +```mermaid +flowchart TB + Input(["Teclado / Ratón / Gamepad"]) + BuiltIn(["Recursos de modelos integrados"]) + Sources(["Fuentes de modelos externos
Mver, Tauri, .model3.json, parches de imagen"]) + Desktop(["Ventana de mascota y ventana de preferencias"]) + subgraph Runtime["Runtime nativo de BongoCat"] + direction TB + Entry["src/main.c
bongo_cat_app_run"] + Startup["Arranque e inicialización
Configuración, almacenamiento, ventana, plataforma"] + Loop["Bucle principal SDL3
Esperar, distribuir, actualizar, renderizar"] + Shutdown["Apagado
Actualizar estado, detener servicios, liberar recursos"] + InputQueue[("Estado de entrada atómico
Cola de flancos y posición de puntero fusionada")] + InputDispatch["Distribución de entrada
Atajos, mapeo de puntero, parámetros del modelo"] + State[("Estado de BongoCatApp
Configuración, sesión, catálogo, identificadores de runtime")] + Import["Descubrimiento e importación de modelos
Validación, normalización a Mver, instalación/caché"] + Catalog[("Catálogo de modelos y comportamientos")] + Live2D["Live2D C ABI
SDK de Cubism o stub de diagnóstico"] + Overlay["Superposiciones y audio"] + Preferences["Preferencias y shell de escritorio
UI de Nuklear, bandeja, acciones de ventana"] + Compose["Composición de fotogramas OpenGL"] + Present["Presentación de la plataforma"] + Entry --> Startup --> Loop + Loop --> Shutdown + Loop --> InputDispatch --> State + Loop <--> State + State --> Live2D + State --> Overlay + State <--> Preferences + Loop --> Preferences + Catalog --> State + State --> Compose + Live2D --> Compose + Overlay --> Compose + Compose --> Present + Loop --> Compose + end + subgraph Platform["Backends de plataforma"] + direction LR + Global["Captura global de teclado/puntero
Windows, macOS, Linux"] + SDL["Eventos SDL3
Eventos de ventana y gamepad"] + end + Input --> Global --> InputQueue --> InputDispatch + Input --> SDL --> Loop + BuiltIn --> Catalog + Sources --> Import --> Catalog + Present --> Desktop + Preferences --> Desktop +``` + +## ❓ Preguntas frecuentes + +### 🔒 ¿BongoCat registra mis entradas de teclado o ratón? + +No. BongoCat procesa las entradas de teclado y ratón localmente para impulsar animaciones y atajos. No registra ni sube pulsaciones de teclas, acciones del ratón ni otros datos de interacción. La configuración también se guarda solo localmente; la aplicación no incluye anuncios, herramientas de análisis ni código de seguimiento de usuarios. Al comprobar actualizaciones solo se solicitan los metadatos públicos de versión; no se envían datos de entrada, configuración ni uso. + +### 🖼️ ¿Por qué OpenGL y no Vulkan? + +No porque Vulkan sea malo, sino porque BongoCat no necesita ese nivel de complejidad. La aplicación renderiza principalmente un modelo Live2D, algunas capas de interfaz y una ventana de escritorio transparente; OpenGL lo cubre con facilidad y se integra de forma natural con SDL3 y el renderizador OpenGL de Cubism. Migrar a Vulkan implicaría mantener más código de renderizado y sincronización en tres plataformas de escritorio, sin un beneficio notable para el usuario. Para la carga de trabajo actual de BongoCat, OpenGL hace que el renderizador sea más ligero, más fácil de depurar y mantener, y sigue ofreciendo el rendimiento necesario. + +## 🙏 Agradecimientos especiales + +Clasificación de contribuyentes + +
+Derechos de autor © 2026 - **BongoCat**
+By vladelaina
+Made with ❤️ & ⌨️ +
diff --git a/docs/README.pt-BR.md b/docs/README.pt-BR.md index e69de29b..d425d7b9 100644 --- a/docs/README.pt-BR.md +++ b/docs/README.pt-BR.md @@ -0,0 +1,230 @@ + + +

💘 C/C++ × SDL3 × OpenGL, misture tudo e bata à vontade! Bong~ Bongo Cat!!!

+

+ Escolha o idioma ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español +

+ +
+ +> [!TIP] +> O modelo usado na demonstração é de [宇痕冫](https://space.bilibili.com/348616056). +> +> 🎁 Procurando modelos **gratuitos**? Visite nosso site oficial: [bongocat.pet](https://bongocat.pet/models) + +

+ +## 📥 Download + + + +- GitHub Releases + + Baixe a versão mais recente nas [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest). + +## 🛠️ Compilar a partir do código-fonte + +O BongoCat usa CMake e requer um compilador C11, um compilador C++17, CMake 3.24 ou superior e os arquivos de desenvolvimento de OpenGL para desktop. Por padrão, SDL3, yyjson, stb, miniaudio e Nuklear são baixados automaticamente durante a configuração, portanto a primeira configuração requer conexão com a internet. + +Execute os comandos abaixo na raiz do projeto (o diretório que contém `CMakeLists.txt`). + +### 📋 Pré-requisitos por plataforma + +- **Windows:** Visual Studio 2022 (com a carga de trabalho ‘Desenvolvimento para desktop com C++’) e CMake. Use o gerador MSVC; o MinGW pode compilar o backend de diagnóstico, mas não suporta o SDK do Cubism. +- **macOS:** Xcode Command Line Tools, CMake e Ninja. Se a arquitetura de destino for diferente da padrão do host, especifique-a por meio de `CMAKE_OSX_ARCHITECTURES`. +- **Linux (Debian/Ubuntu):** GCC ou Clang, Ninja e os cabeçalhos de OpenGL/X11: + + ```bash + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build \ + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev + ``` + +### 🔧 Configuração e compilação + +No Linux e no macOS, use um gerador de configuração única como o Ninja: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --parallel +``` + +No Windows, execute os comandos no Prompt de Comando do Desenvolvedor do Visual Studio 2022 (ou em qualquer shell em que o MSVC esteja disponível): + +```powershell +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --config Release --parallel +``` + +O executável fica em `build/BongoCat` no Linux, em `build/BongoCat.app/Contents/MacOS/BongoCat` no macOS e em `build/Release/BongoCat.exe` para compilações do Visual Studio no Windows. + +### 🧪 Testes + +Os alvos do CTest estão habilitados por padrão. Execute o seguinte após compilar: + +```bash +ctest --test-dir build --output-on-failure +``` + +Para um gerador de múltiplas configurações, como o Visual Studio, especifique explicitamente a configuração de compilação: + +```powershell +ctest --test-dir build -C Release --output-on-failure +``` + +### 🎭 Live2D / SDK do Cubism (opcional) + +Se o SDK do Cubism não for encontrado, o CMake emitirá um aviso e compilará o backend de diagnóstico. Esse backend é destinado à inicialização e ao diagnóstico de plataforma; ele não fornece renderização de modelos Live2D. Para compilar o runtime completo, instale uma versão compatível do Cubism SDK for Native, coloque-a em `vendor/CubismSdkForNative` ou informe o caminho explicitamente: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ + -DBONGO_CAT_REQUIRE_CUBISM=ON +``` + +O SDK deve incluir a biblioteca Core, o código-fonte do Framework e o diretório de terceiros do OpenGL GLEW na estrutura esperada por `cmake/Cubism.cmake`. As compilações de Cubism no Windows exigem o Visual Studio 2022. Com `BONGO_CAT_REQUIRE_CUBISM=ON`, a configuração falha quando o SDK não está disponível, em vez de selecionar silenciosamente o backend de diagnóstico. + +### ⚙️ Opções do CMake + +| Opção | Valor padrão | Descrição | +| --- | --- | --- | +| `BONGO_CAT_FETCH_DEPS` | `ON` | Baixa dependências de terceiros em versões fixadas via `FetchContent` do CMake. Defina como `OFF` apenas se SDL3, yyjson, stb, miniaudio e Nuklear já estiverem disponíveis para o CMake. | +| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Caminho para o Cubism SDK for Native. | +| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | Faz a configuração falhar quando não há SDK disponível. | +| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | Trata avisos do compilador nativo como erros. | + +Para uma compilação offline, defina `BONGO_CAT_FETCH_DEPS=OFF` e forneça as configurações de pacote do CMake para SDL3 (incluindo `SDL3-static`) e yyjson; se stb, Nuklear e miniaudio não puderem ser detectados automaticamente, informe também seus diretórios de inclusão: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=OFF \ + -DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ + -DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ + -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio +``` + +## 📌 Status do projeto + +![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") + +## 📜 Licença + +O código-fonte e o runtime nativo do BongoCat estão licenciados sob [AGPL-3.0-only](../LICENSE). + +O modo de modelo integrado padrão (`standard`) permanece sob a licença MIT. Os recursos de modelo incluídos em `resources/assets/models/standard`, `keyboard` e `gamepad` são cobertos pela [declaração de licença MIT](../LICENSE-MIT) separada. Essa licença MIT se aplica apenas aos recursos dos modelos e às artes que os acompanham; ela não altera a licença do código-fonte nem do runtime nativo do BongoCat. + +## 🧭 Arquitetura técnica + +A versão nativa atual é construída com C/C++, SDL3 e OpenGL. O diagrama abaixo destaca o fluxo de dados em tempo de execução; consulte os arquivos do CMake para obter detalhes de compilação e empacotamento. + +### 🔄 Propriedade do runtime e agendamento de quadros + +Cada processo possui uma instância de `BongoCatApp` e um loop de eventos/renderização no thread principal. Os listeners de plataforma param na fronteira de entrada: + +```text +Listeners de plataforma (teclado/ponteiro) + | + v + Estado de entrada C11 (fila atômica de bordas + posição do ponteiro mesclada) + | + v + aplicação no thread principal <----- eventos SDL3 + | + v + parâmetros do modelo, sobreposições e estado da interface + | + v + atualização do modelo -> composição OpenGL -> apresentação da plataforma +``` + +Os hooks de baixo nível do Windows, o tap de eventos Quartz do macOS e os listeners XInput2 do Linux são executados fora do loop principal. Eles publicam bordas de teclas e botões do mouse com carimbo de data/hora em uma fila atômica limitada e publicam as coordenadas do ponteiro por meio de um slot de mesclagem independente; após uma publicação bem-sucedida, um evento de ativação SDL nativo é enviado, evitando que eventos de movimento de alta frequência desloquem as bordas ordenadas de teclas e botões. No Windows, o DirectInput é usado pela interface de ponteiro da plataforma apenas quando o modelo solicita movimento relativo. Os eventos de janela, preferências e gamepad do SDL3 são processados no thread principal; os eventos de gamepad são normalizados antes de serem passados aos parâmetros do modelo ou atalhos. Nenhum listener de plataforma chama diretamente o código de Live2D, sobreposições ou interface. + +`bongo_cat_app_run` trata dos parâmetros de atualização, encerramento e processos secundários, impõe a propriedade de instância única do processo principal, aloca o estado do aplicativo, executa a inicialização, entra em `bongo_cat_app_loop` e, em seguida, atualiza o estado e destrói os recursos na ordem definida. A inicialização carrega a configuração e os caminhos de armazenamento, localiza os recursos, cria a janela de mascote SDL/OpenGL, inicializa os backends de plataforma, cria os serviços de Live2D/sobreposições/áudio, verifica fontes de modelos integradas/instaladas/próximas e carrega os modelos disponíveis. `BongoCatApp` mantém as configurações, o estado da sessão, os catálogos de modelos e comportamentos, os identificadores de plataforma e os identificadores de serviços de runtime. + +Os pacotes de modelos instalados usam Mver como formato canônico. O processo de importação analisa o arquivo ou diretório selecionado, descobre e valida candidatos, gera uma impressão digital de identidade do pacote, converte fontes Tauri em Mver, aplica patches de imagem e envia o pacote normalizado para `models_root`. Em seguida, os adaptadores de runtime são gerados e o catálogo é atualizado. Fontes próximas são descobertas apenas sem instalar a árvore de origem; seus adaptadores e resultados de verificação são armazenados em cache sob `cache_root`, fora de `models_root`. + +Cada iteração do loop principal espera ativações SDL/nativas ou o vencimento mais cedo dos prazos de quadro, interface, animação e acerto do ponteiro (máximo de 250 ms). O loop distribui os eventos SDL enfileirados, esvazia a fila de entrada atômica e executa a recuperação de liberação, atualiza o estado da janela e da atualização do modelo e, em seguida, aplica os parâmetros de entrada. Com o Cubism habilitado, o prazo do modelo segue `settings.model.max_fps` (padrão de 60 FPS); builds de diagnóstico usam um intervalo de reserva de 100 ms. O tempo decorrido do modelo é contado no máximo como 250 ms e dividido em até oito subetapas, cada uma com o objetivo de não mais que 1/30 de segundo. + +O caminho normal da mascote é renderizado apenas quando a janela está visível, não minimizada e marcada como suja. Cada quadro primeiro limpa o fundo, desenha o modelo e então compõe as sobreposições de ponteiro, teclas e efeitos, e finalmente chama o apresentador da plataforma. As operações de visualização podem solicitar renderização imediata; a renderização de capturas de tela pode pular a apresentação. macOS e Linux trocam diretamente a janela SDL OpenGL; o Windows a troca diretamente quando a apresentação em camadas não está habilitada; caso contrário, lê o buffer de quadros e chama `UpdateLayeredWindow`. A interface de preferências tem sua própria janela SDL/OpenGL e é renderizada e apresentada separadamente. + +O runtime C chama a ABI declarada em `include/bongo_cat/model.h`. A ponte Live2D e a implementação do Cubism estão em `src/live2d` e usam C++17 apenas quando o SDK do Cubism está habilitado; o restante do runtime nativo usa C11. Os tipos do Cubism são mantidos atrás de identificadores C opacos; quando o SDK não está disponível, `src/live2d/live2d_stub.c` fornece o backend de diagnóstico. + +```mermaid +flowchart TB + Input(["Teclado / Mouse / Gamepad"]) + BuiltIn(["Recursos de modelos integrados"]) + Sources(["Fontes de modelos externos
Mver, Tauri, .model3.json, patches de imagem"]) + Desktop(["Janela de mascote e janela de preferências"]) + subgraph Runtime["Runtime nativo do BongoCat"] + direction TB + Entry["src/main.c
bongo_cat_app_run"] + Startup["Inicialização e inicialização
Configuração, armazenamento, janela, plataforma"] + Loop["Loop principal SDL3
Aguardar, distribuir, atualizar, renderizar"] + Shutdown["Encerramento
Atualizar estado, parar serviços, liberar recursos"] + InputQueue[("Estado de entrada atômico
Fila de bordas e posição do ponteiro mesclada")] + InputDispatch["Distribuição de entrada
Atalhos, mapeamento do ponteiro, parâmetros do modelo"] + State[("Estado do BongoCatApp
Configurações, sessão, catálogo, identificadores de runtime")] + Import["Descoberta e importação de modelos
Validação, normalização para Mver, instalação/cache"] + Catalog[("Catálogo de modelos e comportamentos")] + Live2D["Live2D C ABI
SDK do Cubism ou stub de diagnóstico"] + Overlay["Sobreposições e áudio"] + Preferences["Preferências e shell de desktop
UI Nuklear, bandeja, ações de janela"] + Compose["Composição de quadros OpenGL"] + Present["Apresentação da plataforma"] + Entry --> Startup --> Loop + Loop --> Shutdown + Loop --> InputDispatch --> State + Loop <--> State + State --> Live2D + State --> Overlay + State <--> Preferences + Loop --> Preferences + Catalog --> State + State --> Compose + Live2D --> Compose + Overlay --> Compose + Compose --> Present + Loop --> Compose + end + subgraph Platform["Backends de plataforma"] + direction LR + Global["Captura global de teclado/ponteiro
Windows, macOS, Linux"] + SDL["Eventos SDL3
Eventos de janela e gamepad"] + end + Input --> Global --> InputQueue --> InputDispatch + Input --> SDL --> Loop + BuiltIn --> Catalog + Sources --> Import --> Catalog + Present --> Desktop + Preferences --> Desktop +``` + +## ❓ Perguntas frequentes + +### 🔒 O BongoCat registra minhas entradas de teclado ou mouse? + +Não. O BongoCat processa entradas de teclado e mouse localmente para impulsionar animações e atalhos. Ele não registra nem envia teclas pressionadas, ações do mouse ou outros dados de interação. A configuração também é salva apenas localmente; o aplicativo não contém anúncios, ferramentas de análise nem código de rastreamento de usuários. Ao verificar atualizações, apenas metadados públicos de versão são solicitados; nenhum dado de entrada, configuração ou uso é enviado. + +### 🖼️ Por que OpenGL em vez de Vulkan? + +Não porque Vulkan seja ruim, mas porque o BongoCat não precisa desse nível de complexidade. O aplicativo renderiza principalmente um modelo Live2D, algumas camadas de interface e uma janela de desktop transparente; o OpenGL dá conta disso facilmente e se integra naturalmente com o SDL3 e o renderizador OpenGL do Cubism. Migrar para Vulkan exigiria manter mais código de renderização e sincronização em três plataformas de desktop, sem benefício perceptível para o usuário. Para a carga de trabalho atual do BongoCat, o OpenGL torna o renderizador mais enxuto, mais fácil de depurar e manter, continuando a oferecer o desempenho necessário. + +## 🙏 Agradecimentos especiais + +Ranking de contribuidores + +
+Copyright © 2026 - **BongoCat**
+By vladelaina
+Made with ❤️ & ⌨️ +
diff --git a/docs/README.ru-RU.md b/docs/README.ru-RU.md index e69de29b..e1d7f266 100644 --- a/docs/README.ru-RU.md +++ b/docs/README.ru-RU.md @@ -0,0 +1,230 @@ + + +

💘 C/C++ × SDL3 × OpenGL — смешай всё и барабань в своё удовольствие! Бонг~ Bongo Cat!!!

+

+ Выберите язык ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español +

+ +
+ +> [!TIP] +> Модель, использованная в демо, принадлежит [宇痕冫](https://space.bilibili.com/348616056). +> +> 🎁 Ищете **бесплатные** модели? Загляните на наш официальный сайт: [bongocat.pet](https://bongocat.pet/models) + +

+ +## 📥 Загрузка + + + +- GitHub Releases + + Скачайте последнюю версию из [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest). + +## 🛠️ Сборка из исходного кода + +BongoCat использует CMake и требует компилятор C11, компилятор C++17, CMake 3.24 или новее, а также файлы разработки OpenGL для настольных систем. По умолчанию SDL3, yyjson, stb, miniaudio и Nuklear загружаются автоматически на этапе конфигурации, поэтому для первой настройки требуется подключение к сети. + +Выполните следующие команды в корневом каталоге проекта (каталоге, содержащем `CMakeLists.txt`). + +### 📋 Предварительные требования по платформам + +- **Windows:** Visual Studio 2022 (с рабочей нагрузкой «Разработка классических приложений на C++») и CMake. Используйте генератор MSVC; MinGW может собрать диагностический бэкенд, но не поддерживает Cubism SDK. +- **macOS:** Xcode Command Line Tools, CMake и Ninja. Если целевая архитектура отличается от архитектуры хоста по умолчанию, укажите её через `CMAKE_OSX_ARCHITECTURES`. +- **Linux (Debian/Ubuntu):** GCC или Clang, Ninja, а также заголовочные файлы OpenGL/X11: + + ```bash + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build \ + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev + ``` + +### 🔧 Настройка и сборка + +В Linux и macOS используйте генератор с одной конфигурацией, например Ninja: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --parallel +``` + +В Windows выполняйте команды из командной строки разработчика Visual Studio 2022 (или любой другой оболочки, где доступен MSVC): + +```powershell +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --config Release --parallel +``` + +Исполняемый файл находится в `build/BongoCat` в Linux, в `build/BongoCat.app/Contents/MacOS/BongoCat` в macOS и в `build/Release/BongoCat.exe` для сборок Visual Studio в Windows. + +### 🧪 Тестирование + +Цели CTest включены по умолчанию. После сборки выполните: + +```bash +ctest --test-dir build --output-on-failure +``` + +Для генератора с несколькими конфигурациями, такого как Visual Studio, явно укажите конфигурацию сборки: + +```powershell +ctest --test-dir build -C Release --output-on-failure +``` + +### 🎭 Live2D / Cubism SDK (необязательно) + +Если Cubism SDK не найден, CMake выдаст предупреждение и соберёт диагностический бэкенд. Этот бэкенд предназначен для запуска и диагностики платформы; он не обеспечивает рендеринг моделей Live2D. Чтобы собрать полный рантайм, установите совместимую версию Cubism SDK for Native, поместите её в `vendor/CubismSdkForNative` или явно укажите путь: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ + -DBONGO_CAT_REQUIRE_CUBISM=ON +``` + +SDK должен включать библиотеку Core, исходный код Framework и каталог сторонних библиотек OpenGL GLEW в структуре, ожидаемой `cmake/Cubism.cmake`. Сборки Cubism в Windows требуют Visual Studio 2022. При `BONGO_CAT_REQUIRE_CUBISM=ON` конфигурация завершится ошибкой, если SDK недоступен, а не будет молча использовать диагностический бэкенд. + +### ⚙️ Параметры CMake + +| Параметр | По умолчанию | Описание | +| --- | --- | --- | +| `BONGO_CAT_FETCH_DEPS` | `ON` | Загружает сторонние зависимости фиксированных версий через `FetchContent` CMake. Установите `OFF`, только если SDL3, yyjson, stb, miniaudio и Nuklear уже доступны для CMake. | +| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Путь к Cubism SDK for Native. | +| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | Завершает конфигурацию ошибкой, если SDK недоступен. | +| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | Обрабатывает предупреждения нативного компилятора как ошибки. | + +Для автономной сборки установите `BONGO_CAT_FETCH_DEPS=OFF` и предоставьте конфигурации пакетов CMake для SDL3 (включая `SDL3-static`) и yyjson; если stb, Nuklear и miniaudio не могут быть найдены автоматически, укажите также их каталоги включения: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=OFF \ + -DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ + -DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ + -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio +``` + +## 📌 Статус проекта + +![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") + +## 📜 Лицензия + +Исходный код и нативный рантайм BongoCat распространяются под лицензией [AGPL-3.0-only](../LICENSE). + +Встроенный по умолчанию режим модели (`standard`) остаётся под лицензией MIT. Ресурсы моделей, входящие в `resources/assets/models/standard`, `keyboard` и `gamepad`, покрываются отдельным [объявлением о лицензии MIT](../LICENSE-MIT). Эта лицензия MIT распространяется только на ресурсы моделей и сопутствующие художественные материалы; она не меняет лицензию исходного кода или нативного рантайма BongoCat. + +## 🧭 Техническая архитектура + +Текущая нативная версия построена на C/C++, SDL3 и OpenGL. Диаграмма ниже акцентирует поток данных во время выполнения; подробности сборки и упаковки см. в файлах CMake. + +### 🔄 Владение рантаймом и планирование кадров + +Каждый процесс владеет экземпляром `BongoCatApp` и циклом событий/рендеринга в главном потоке. Платформенные слушатели останавливаются на границе ввода: + +```text +Платформенные слушатели (клавиатура/указатель) + | + v + Состояние ввода C11 (атомарная очередь фронтов + объединённая позиция указателя) + | + v + приложение в главном потоке <----- события SDL3 + | + v + параметры модели, оверлеи и состояние интерфейса + | + v + обновление модели -> композиция OpenGL -> представление на платформе +``` + +Низкоуровневые хуки Windows, обработчик событий Quartz в macOS и слушатели XInput2 в Linux работают вне главного цикла. Они публикуют нажатия клавиш и фронты кнопок мыши с отметками времени в ограниченную атомарную очередь и публикуют координаты указателя через отдельный слот объединения; после успешной публикации отправляется нативное событие пробуждения SDL, что не позволяет высокочастотным событиям движения вытеснять упорядоченные фронты клавиш и кнопок. В Windows DirectInput используется через интерфейс указателя платформы только тогда, когда модель запрашивает относительное движение. События окна, настроек и геймпада SDL3 обрабатываются в главном потоке; события геймпада нормализуются перед передачей параметрам модели или сочетаниям клавиш. Ни один платформенный слушатель не вызывает напрямую код Live2D, оверлеев или интерфейса. + +`bongo_cat_app_run` обрабатывает параметры обновления, завершения и вторичных процессов, обеспечивает владение единственным экземпляром главного процесса, выделяет состояние приложения, выполняет инициализацию, входит в `bongo_cat_app_loop`, а затем обновляет состояние и уничтожает ресурсы в определённом порядке. Инициализация загружает конфигурацию и пути хранения, находит ресурсы, создаёт окно питомца SDL/OpenGL, инициализирует платформенные бэкенды, создаёт сервисы Live2D/оверлеев/аудио, сканирует встроенные/установленные/близлежащие источники моделей и загружает доступные модели. `BongoCatApp` хранит настройки, состояние сеанса, каталоги моделей и поведения, дескрипторы платформы и дескрипторы сервисов рантайма. + +Установленные пакеты моделей используют Mver в качестве канонического формата. Процесс импорта анализирует выбранный файл или каталог, обнаруживает и проверяет кандидатов, формирует отпечаток идентичности пакета, преобразует источники Tauri в Mver, применяет патчи изображений и отправляет нормализованный пакет в `models_root`. Затем создаются адаптеры рантайма и обновляется каталог. Близлежащие источники обнаруживаются без установки их дерева исходников; их адаптеры и результаты проверок кэшируются в `cache_root` за пределами `models_root`. + +Каждая итерация главного цикла ожидает пробуждения SDL/нативных событий или самого раннего срока кадра, интерфейса, анимации и попадания указателя (максимум 250 мс). Цикл распределяет поставленные в очередь события SDL, очищает атомарную очередь ввода и выполняет восстановление освобождений, обновляет состояние окна и обновления модели, а затем применяет входные параметры. При включённом Cubism срок модели следует `settings.model.max_fps` (по умолчанию 60 FPS); диагностические сборки используют резервный интервал 100 мс. Прошедшее время модели учитывается максимум 250 мс и делится на до восьми подшагов, каждый из которых не превышает 1/30 секунды. + +Обычный путь питомца рендерится только тогда, когда окно видимо, не свёрнуто и помечено как изменённое. Каждый кадр сначала очищает фон, рисует модель, затем компонует оверлеи указателя, клавиш и эффектов и, наконец, вызывает презентер платформы. Операции предпросмотра могут запросить немедленный рендеринг; рендеринг скриншотов может пропустить представление. macOS и Linux напрямую обменивают окно SDL OpenGL; Windows обменивает его напрямую, если многослойное представление не включено, в противном случае считывает кадровый буфер и вызывает `UpdateLayeredWindow`. Интерфейс настроек имеет собственное окно SDL/OpenGL и рендерится и представляется отдельно. + +Среда выполнения C вызывает ABI, объявленный в `include/bongo_cat/model.h`. Мост Live2D и реализация Cubism находятся в `src/live2d` и используют C++17 только при включённом Cubism SDK; остальной нативный рантайм использует C11. Типы Cubism остаются за непрозрачными дескрипторами C; когда SDK недоступен, `src/live2d/live2d_stub.c` предоставляет диагностический бэкенд. + +```mermaid +flowchart TB + Input(["Клавиатура / Мышь / Геймпад"]) + BuiltIn(["Встроенные ресурсы моделей"]) + Sources(["Внешние источники моделей
Mver, Tauri, .model3.json, патчи изображений"]) + Desktop(["Окно питомца и окно настроек"]) + subgraph Runtime["Нативный рантайм BongoCat"] + direction TB + Entry["src/main.c
bongo_cat_app_run"] + Startup["Запуск и инициализация
Конфигурация, хранилище, окно, платформа"] + Loop["Главный цикл SDL3
Ожидание, распределение, обновление, рендеринг"] + Shutdown["Завершение
Обновить состояние, остановить сервисы, освободить ресурсы"] + InputQueue[("Атомарное состояние ввода
Очередь фронтов и объединённая позиция указателя")] + InputDispatch["Распределение ввода
Сочетания клавиш, сопоставление указателя, параметры модели"] + State[("Состояние BongoCatApp
Настройки, сеанс, каталог, дескрипторы рантайма")] + Import["Обнаружение и импорт моделей
Проверка, нормализация в Mver, установка/кэш"] + Catalog[("Каталог моделей и поведений")] + Live2D["Live2D C ABI
Cubism SDK или диагностический заглушка"] + Overlay["Оверлеи и аудио"] + Preferences["Настройки и оболочка рабочего стола
UI Nuklear, трей, операции с окном"] + Compose["Композиция кадров OpenGL"] + Present["Представление на платформе"] + Entry --> Startup --> Loop + Loop --> Shutdown + Loop --> InputDispatch --> State + Loop <--> State + State --> Live2D + State --> Overlay + State <--> Preferences + Loop --> Preferences + Catalog --> State + State --> Compose + Live2D --> Compose + Overlay --> Compose + Compose --> Present + Loop --> Compose + end + subgraph Platform["Платформенные бэкенды"] + direction LR + Global["Глобальный захват клавиатуры/указателя
Windows, macOS, Linux"] + SDL["События SDL3
События окна и геймпада"] + end + Input --> Global --> InputQueue --> InputDispatch + Input --> SDL --> Loop + BuiltIn --> Catalog + Sources --> Import --> Catalog + Present --> Desktop + Preferences --> Desktop +``` + +## ❓ Часто задаваемые вопросы + +### 🔒 Записывает ли BongoCat мой ввод с клавиатуры или мыши? + +Нет. BongoCat обрабатывает ввод с клавиатуры и мыши локально для управления анимациями и сочетаниями клавиш. Он не записывает и не загружает нажатия клавиш, действия мыши или другие данные взаимодействия. Конфигурация также сохраняется только локально; приложение не содержит рекламы, аналитических инструментов или кода отслеживания пользователей. При проверке обновлений запрашиваются только публичные метаданные версии; никакие данные ввода, конфигурации или использования не отправляются. + +### 🖼️ Почему OpenGL, а не Vulkan? + +Не потому что Vulkan плох, а потому что BongoCat не нуждается в такой сложности. Приложение в основном рендерит одну модель Live2D, несколько слоёв интерфейса и прозрачное окно рабочего стола; OpenGL легко справляется с этим и естественно сочетается с SDL3 и рендерером OpenGL от Cubism. Переход на Vulkan потребовал бы поддержки большего объёма кода рендеринга и синхронизации на трёх настольных платформах без заметного преимущества для пользователя. Для текущей нагрузки BongoCat OpenGL делает рендерер более компактным, лёгким в отладке и обслуживании, сохраняя при этом необходимую производительность. + +## 🙏 Особая благодарность + +Рейтинг контрибьюторов + +
+Авторские права © 2026 - **BongoCat**
+By vladelaina
+Made with ❤️ & ⌨️ +
From 28fc2881cb09d692611ececcd772c28ee53552e1 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 2 Sep 2026 09:24:11 +0800 Subject: [PATCH 195/334] feat: publish v1.4.0 for all desktop platforms --- .github/workflows/release.yml | 120 +++++++++++++++--- CMakeLists.txt | 2 +- build.bat | 7 +- cmake/PlatformRuntime.cmake | 1 + include/bongo_cat/config.h | 1 + resources/assets/locales/de-DE.json | 3 +- resources/assets/locales/en-US.json | 3 +- resources/assets/locales/es-ES.json | 3 +- resources/assets/locales/fr-FR.json | 3 +- resources/assets/locales/ja-JP.json | 3 +- resources/assets/locales/ko-KR.json | 3 +- resources/assets/locales/pt-BR.json | 3 +- resources/assets/locales/ru-RU.json | 3 +- resources/assets/locales/zh-CN.json | 3 +- resources/assets/locales/zh-Hant.json | 3 +- scripts/build-windows.ps1 | 102 ++++++++++++++- src/core/config_session_defaults.c | 2 + src/core/config_session_io.c | 5 + src/platform/windows/windows_startup.c | 52 +++++++- src/platform/windows/windows_startup.h | 13 ++ src/platform/windows/windows_update_handoff.c | 48 +++++++ src/runtime/lifecycle/config_store.c | 1 + src/runtime/update/update_channel.c | 45 +++++++ src/runtime/update/update_http.c | 15 ++- src/runtime/update/update_internal.h | 2 + src/runtime/update/update_service.c | 86 +++++++++---- src/runtime/update/update_service.h | 1 + src/ui/preferences/preferences_about_footer.c | 37 +++++- src/ui/preferences/preferences_render.c | 8 +- src/ui/theme/ui_catime.h | 2 +- src/ui/theme/ui_catime_tabs.c | 6 +- 31 files changed, 517 insertions(+), 69 deletions(-) create mode 100644 src/platform/windows/windows_update_handoff.c diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e16f1c85..eb1e1c21 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Release BongoCat for Windows +name: Release BongoCat on: push: @@ -16,16 +16,26 @@ jobs: build: name: build / ${{ matrix.platform }} if: github.repository == 'vladelaina/BongoCat' - runs-on: windows-2022 + runs-on: ${{ matrix.os }} strategy: fail-fast: false - max-parallel: 1 matrix: include: - - platform: windows-x64 + - os: windows-2022 + platform: windows-x64 cmake_arch: x64 - - platform: windows-x86 + - os: windows-2022 + platform: windows-x86 cmake_arch: Win32 + - os: ubuntu-latest + platform: linux-x64 + cmake_arch: '' + - os: macos-15-intel + platform: macos-x64 + cmake_arch: x86_64 + - os: macos-15 + platform: macos-arm64 + cmake_arch: arm64 env: CUBISM_SDK_ARCHIVE_URL: ${{ secrets.CUBISM_SDK_ARCHIVE_URL }} CUBISM_SDK_ARCHIVE_SHA256: ${{ secrets.CUBISM_SDK_ARCHIVE_SHA256 }} @@ -52,7 +62,16 @@ jobs: shell: pwsh run: ./.github/scripts/restore-cubism-sdk.ps1 - - name: Configure release + - name: Install Linux dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + cmake ninja-build libgl1-mesa-dev libx11-dev libxi-dev \ + libxfixes-dev + + - name: Configure Windows release + if: runner.os == 'Windows' shell: pwsh run: | cmake -S . -B build-release -G "Visual Studio 17 2022" ` @@ -60,19 +79,39 @@ jobs: -DBONGO_CAT_REQUIRE_CUBISM=ON ` -DBONGO_CAT_WARNINGS_AS_ERRORS=ON - - name: Build release + - name: Configure Unix release + if: runner.os != 'Windows' + run: | + cmake -S . -B build-release -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_OSX_ARCHITECTURES=${{ matrix.cmake_arch }} \ + -DBONGO_CAT_REQUIRE_CUBISM=ON \ + -DBONGO_CAT_WARNINGS_AS_ERRORS=ON + + - name: Build Windows release + if: runner.os == 'Windows' shell: pwsh run: >- & ./.github/scripts/build-windows.ps1 cmake --build build-release --config Release --parallel 2 - - name: Test release + - name: Build Unix release + if: runner.os != 'Windows' + run: bash .github/scripts/build-unix.sh cmake --build build-release --parallel 2 + + - name: Test Windows release + if: runner.os == 'Windows' shell: pwsh run: >- & ./.github/scripts/test-windows.ps1 ctest --test-dir build-release -C Release --output-on-failure + - name: Test Unix release + if: runner.os != 'Windows' + run: ctest --test-dir build-release --output-on-failure + - name: Install NSIS + if: runner.os == 'Windows' shell: pwsh run: | if (-not (Get-Command makensis.exe -ErrorAction SilentlyContinue)) { @@ -93,7 +132,8 @@ jobs: $nsisRoot | Out-File -FilePath $env:GITHUB_PATH ` -Append -Encoding utf8 - - name: Package release + - name: Package Windows release + if: runner.os == 'Windows' shell: pwsh run: | $name = (Get-Content build-release/bongocat-package-name.txt -Raw).Trim() @@ -128,6 +168,36 @@ jobs: Copy-Item -LiteralPath $portable, $portableChecksum, $setup, ` $setupChecksum -Destination release-output + - name: Package Unix release + if: runner.os != 'Windows' + shell: pwsh + run: | + $name = (Get-Content build-release/bongocat-package-name.txt -Raw).Trim() + $expected = "BongoCat-${{ steps.version.outputs.version }}-${{ matrix.platform }}" + if ($name -ne $expected) { + throw "Unexpected release package name: $name" + } + + cmake --build build-release --target package + if ($LASTEXITCODE -ne 0) { + throw 'Unix archive packaging failed' + } + $archives = @(Get-ChildItem build-release/dist -File | Where-Object { + $_.Name -match '\.(zip|tar\.gz)$' + }) + if ($archives.Count -ne 1) { + throw "Expected one Unix release archive, found $($archives.Count)" + } + $archive = $archives[0] + $checksum = "$($archive.FullName).sha256" + if (-not (Test-Path -LiteralPath $checksum -PathType Leaf)) { + throw "Release checksum is missing: $checksum" + } + + New-Item -ItemType Directory -Path release-output -Force | Out-Null + Copy-Item -LiteralPath $archive.FullName, $checksum ` + -Destination release-output + - name: Upload packaged release uses: actions/upload-artifact@v7 with: @@ -194,10 +264,10 @@ jobs: retention-days: 90 publish: - name: publish / windows + name: publish / all platforms needs: build if: github.repository == 'vladelaina/BongoCat' - runs-on: windows-2022 + runs-on: ubuntu-latest permissions: contents: write @@ -208,7 +278,7 @@ jobs: - name: Download packaged releases uses: actions/download-artifact@v8 with: - pattern: bongocat-release-windows-* + pattern: bongocat-release-* path: release-output merge-multiple: true @@ -221,12 +291,23 @@ jobs: if ($env:GITHUB_REF_NAME -ne "v$version") { throw "Tag $env:GITHUB_REF_NAME does not match project version $version" } - $files = foreach ($platform in 'windows-x64', 'windows-x86') { + $files = @() + foreach ($platform in 'windows-x64', 'windows-x86') { $base = "BongoCat-$version-$platform" - "release-output/$base-portable.exe" - "release-output/$base-portable.exe.sha256" - "release-output/$base-setup.exe" - "release-output/$base-setup.exe.sha256" + $files += "release-output/$base-portable.exe" + $files += "release-output/$base-portable.exe.sha256" + $files += "release-output/$base-setup.exe" + $files += "release-output/$base-setup.exe.sha256" + } + $unixArchives = @{ + 'linux-x64' = 'tar.gz' + 'macos-x64' = 'zip' + 'macos-arm64' = 'zip' + } + foreach ($platform in $unixArchives.Keys) { + $archive = "release-output/BongoCat-$version-$platform.$($unixArchives[$platform])" + $files += $archive + $files += "$archive.sha256" } foreach ($file in $files) { if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { @@ -270,6 +351,9 @@ jobs: - [Windows x64 portable](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/BongoCat-${{ steps.version.outputs.version }}-windows-x64-portable.exe) - [Windows x86 installer](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/BongoCat-${{ steps.version.outputs.version }}-windows-x86-setup.exe) - [Windows x86 portable](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/BongoCat-${{ steps.version.outputs.version }}-windows-x86-portable.exe) + - [macOS Apple Silicon](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/BongoCat-${{ steps.version.outputs.version }}-macos-arm64.zip) + - [macOS Intel](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/BongoCat-${{ steps.version.outputs.version }}-macos-x64.zip) + - [Linux x64](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/BongoCat-${{ steps.version.outputs.version }}-linux-x64.tar.gz) generate_release_notes: true fail_on_unmatched_files: true @@ -277,3 +361,5 @@ jobs: files: | release-output/*-portable.exe release-output/*-setup.exe + release-output/*.zip + release-output/*.tar.gz diff --git a/CMakeLists.txt b/CMakeLists.txt index b9367540..183382ee 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -set(BONGO_CAT_VERSION "1.3.0") +set(BONGO_CAT_VERSION "1.4.0") project(BongoCat VERSION ${BONGO_CAT_VERSION} DESCRIPTION "BongoCat" HOMEPAGE_URL "https://github.com/vladelaina/BongoCat" LANGUAGES C CXX) include(cmake/Version.cmake) include(FetchContent) diff --git a/build.bat b/build.bat index c364cd60..3e80970d 100644 --- a/build.bat +++ b/build.bat @@ -8,8 +8,13 @@ set "BUILD_JOBS=%BONGOCAT_BUILD_JOBS%" if "%BUILD_JOBS%"=="" set "BUILD_JOBS=2" set "CLEAN_ARG=" if /I "%BONGOCAT_CLEAN_BUILD%"=="1" set "CLEAN_ARG=-Clean" +if /I "%~2"=="-Clean" set "CLEAN_ARG=-Clean" +if /I "%~3"=="-Clean" set "CLEAN_ARG=-Clean" set "CUBISM_ARG=" if /I "%BONGOCAT_REQUIRE_CUBISM%"=="1" set "CUBISM_ARG=-RequireCubism" +set "PACKAGE_ARG=" +if /I "%~2"=="-Package" set "PACKAGE_ARG=-Package" +if /I "%~3"=="-Package" set "PACKAGE_ARG=-Package" where powershell.exe >nul 2>&1 if errorlevel 1 ( @@ -21,6 +26,6 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass ^ -File "%~dp0scripts\build-windows.ps1" ^ -Configuration "%BUILD_TYPE%" ^ -BuildDir "%~dp0build-cubism" ^ - -Jobs %BUILD_JOBS% %CUBISM_ARG% %CLEAN_ARG% + -Jobs %BUILD_JOBS% %CUBISM_ARG% %CLEAN_ARG% %PACKAGE_ARG% exit /b %ERRORLEVEL% diff --git a/cmake/PlatformRuntime.cmake b/cmake/PlatformRuntime.cmake index 1be6bde4..8d79cd61 100644 --- a/cmake/PlatformRuntime.cmake +++ b/cmake/PlatformRuntime.cmake @@ -6,6 +6,7 @@ if(WIN32) src/platform/windows/windows_directory.c src/platform/windows/windows_package.c src/platform/windows/windows_startup.c + src/platform/windows/windows_update_handoff.c src/platform/windows/windows_borderless.c src/platform/windows/windows_capture.c src/platform/windows/windows_pointer.c diff --git a/include/bongo_cat/config.h b/include/bongo_cat/config.h index 003efcd0..d5a46aa6 100644 --- a/include/bongo_cat/config.h +++ b/include/bongo_cat/config.h @@ -127,6 +127,7 @@ typedef struct BongoCatSessionState { char active_model_id[BONGO_CAT_ID_CAP]; int last_update_check_day; char last_update_check_version[BONGO_CAT_UPDATE_VERSION_CAP]; + char available_update_version[BONGO_CAT_UPDATE_VERSION_CAP]; char additional_model_ids[BONGO_CAT_ADDITIONAL_MODEL_CAP][BONGO_CAT_ID_CAP]; size_t additional_model_count; BongoCatActiveBehavior active_behaviors[BONGO_CAT_BEHAVIOR_BINDING_CAP]; diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index 05df8ed6..c031a174 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -211,7 +211,8 @@ "checkingUpdate": "Wird geprüft...", "updateAvailable": "Neue Version verfügbar:", "updateFailed": "Updates konnten nicht geprüft werden", - "downloadUpdate": "Version v%s herunterladen", + "updateHttpFailed": "GitHub meldete den HTTP-Status %s", + "downloadUpdate": "Auf v%s aktualisieren", "openStore": "Microsoft Store öffnen", "viewReleases": "Versionen anzeigen", "openUpdateFailed": "Die Update-Seite konnte nicht geöffnet werden", diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index e6e100a6..dd06eab1 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -211,7 +211,8 @@ "checkingUpdate": "Checking...", "updateAvailable": "New version available:", "updateFailed": "Unable to check for updates", - "downloadUpdate": "Download v%s", + "updateHttpFailed": "GitHub returned HTTP status %s", + "downloadUpdate": "Update to v%s", "openStore": "Open Microsoft Store", "viewReleases": "View releases", "openUpdateFailed": "Unable to open the update page", diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index 882f8aba..adb71875 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -211,7 +211,8 @@ "checkingUpdate": "Comprobando...", "updateAvailable": "Nueva versión disponible:", "updateFailed": "No se pudieron buscar actualizaciones", - "downloadUpdate": "Descargar v%s", + "updateHttpFailed": "GitHub devolvió el estado HTTP %s", + "downloadUpdate": "Actualizar a v%s", "openStore": "Abrir Microsoft Store", "viewReleases": "Ver versiones", "openUpdateFailed": "No se pudo abrir la página de actualización", diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index 70d10108..c45cb524 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -211,7 +211,8 @@ "checkingUpdate": "Vérification...", "updateAvailable": "Nouvelle version disponible :", "updateFailed": "Impossible de vérifier les mises à jour", - "downloadUpdate": "Télécharger v%s", + "updateHttpFailed": "GitHub a renvoyé le statut HTTP %s", + "downloadUpdate": "Mettre à jour vers v%s", "openStore": "Ouvrir Microsoft Store", "viewReleases": "Voir les versions", "openUpdateFailed": "Impossible d'ouvrir la page de mise à jour", diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index bde11cca..a21a1bea 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -211,7 +211,8 @@ "checkingUpdate": "確認中...", "updateAvailable": "新しいバージョンがあります:", "updateFailed": "更新を確認できませんでした", - "downloadUpdate": "v%s をダウンロード", + "updateHttpFailed": "GitHub が HTTP ステータス %s を返しました", + "downloadUpdate": "v%s に更新", "openStore": "Microsoft Store を開く", "viewReleases": "リリースを表示", "openUpdateFailed": "更新ページを開けませんでした", diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index 2d29b392..e031163f 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -211,7 +211,8 @@ "checkingUpdate": "확인 중...", "updateAvailable": "새 버전 사용 가능:", "updateFailed": "업데이트를 확인할 수 없습니다", - "downloadUpdate": "v%s 다운로드", + "updateHttpFailed": "GitHub에서 HTTP 상태 코드 %s를 반환했습니다", + "downloadUpdate": "v%s(으)로 업데이트", "openStore": "Microsoft Store 열기", "viewReleases": "릴리스 보기", "openUpdateFailed": "업데이트 페이지를 열 수 없습니다", diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index 18f79911..deab6d35 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -211,7 +211,8 @@ "checkingUpdate": "Verificando...", "updateAvailable": "Nova versão disponível:", "updateFailed": "Não foi possível verificar atualizações", - "downloadUpdate": "Baixar v%s", + "updateHttpFailed": "O GitHub retornou o status HTTP %s", + "downloadUpdate": "Atualizar para v%s", "openStore": "Abrir Microsoft Store", "viewReleases": "Ver versões", "openUpdateFailed": "Não foi possível abrir a página de atualização", diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index 2007f911..df0f16b4 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -211,7 +211,8 @@ "checkingUpdate": "Проверка...", "updateAvailable": "Доступна новая версия:", "updateFailed": "Не удалось проверить обновления", - "downloadUpdate": "Скачать v%s", + "updateHttpFailed": "GitHub вернул код HTTP %s", + "downloadUpdate": "Обновить до v%s", "openStore": "Открыть Microsoft Store", "viewReleases": "Посмотреть выпуски", "openUpdateFailed": "Не удалось открыть страницу обновления", diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index f63213fc..2f0a064e 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -211,7 +211,8 @@ "checkingUpdate": "正在检查...", "updateAvailable": "发现新版本:", "updateFailed": "无法检查更新", - "downloadUpdate": "下载 v%s", + "updateHttpFailed": "GitHub 返回 HTTP 状态码 %s", + "downloadUpdate": "更新至 v%s", "openStore": "打开 Microsoft Store", "viewReleases": "查看发布版本", "openUpdateFailed": "无法打开更新页面", diff --git a/resources/assets/locales/zh-Hant.json b/resources/assets/locales/zh-Hant.json index 8f7290ca..d23043da 100644 --- a/resources/assets/locales/zh-Hant.json +++ b/resources/assets/locales/zh-Hant.json @@ -211,7 +211,8 @@ "checkingUpdate": "正在檢查...", "updateAvailable": "發現新版本:", "updateFailed": "無法檢查更新", - "downloadUpdate": "下載 v%s", + "updateHttpFailed": "GitHub 回傳 HTTP 狀態碼 %s", + "downloadUpdate": "更新至 v%s", "openStore": "開啟 Microsoft Store", "viewReleases": "查看發佈版本", "openUpdateFailed": "無法開啟更新頁面", diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 index 9c1ec41e..93426abf 100644 --- a/scripts/build-windows.ps1 +++ b/scripts/build-windows.ps1 @@ -5,6 +5,7 @@ param( [ValidateRange(1, 64)] [int]$Jobs = 2, [switch]$RequireCubism, + [switch]$Package, [switch]$Clean ) @@ -54,6 +55,45 @@ if (-not (Get-Command cmake -ErrorAction SilentlyContinue)) { exit 1 } +if ($Package) { + $makensis = Get-Command makensis.exe -ErrorAction SilentlyContinue + if (-not $makensis) { + $nsisCandidates = @() + foreach ($programFiles in @(${env:ProgramFiles(x86)}, $env:ProgramFiles)) { + if ($programFiles) { + $nsisCandidates += Join-Path $programFiles 'NSIS\makensis.exe' + $nsisCandidates += Join-Path $programFiles 'NSIS\Bin\makensis.exe' + } + } + $uninstallKeys = @( + 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*') + foreach ($key in $uninstallKeys) { + Get-ItemProperty -Path $key -Name InstallLocation ` + -ErrorAction SilentlyContinue | ForEach-Object { + if ($_.InstallLocation) { + $nsisCandidates += Join-Path $_.InstallLocation 'makensis.exe' + $nsisCandidates += Join-Path $_.InstallLocation 'Bin\makensis.exe' + } + } + } + $nsisPath = $nsisCandidates | + Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | + Select-Object -First 1 + if ($nsisPath) { + $env:Path = "$(Split-Path $nsisPath -Parent);$env:Path" + $makensis = Get-Command makensis.exe -ErrorAction SilentlyContinue + } + } + if (-not $makensis) { + Write-Host 'Package build requires NSIS (makensis.exe), but it was not found.' + Write-Host 'Install NSIS from https://nsis.sourceforge.io/Download and run again.' + Write-Host 'The normal application build does not require NSIS.' + exit 1 + } +} + if ($Clean -and (Test-Path -LiteralPath $BuildDir)) { $rootPrefix = $root.TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar $insideRoot = $BuildDir.StartsWith($rootPrefix, @@ -62,8 +102,33 @@ if ($Clean -and (Test-Path -LiteralPath $BuildDir)) { Write-Host "Refusing to clean unexpected directory: $BuildDir" exit 1 } - Write-BuildProgress 2 'Cleaning previous build...' - Remove-Item -LiteralPath $BuildDir -Recurse -Force + Write-BuildProgress 2 'Cleaning previous build artifacts...' + $depsPath = Join-Path $BuildDir '_deps' + $preservedDeps = Join-Path (Split-Path $BuildDir -Parent) ( + '.bongocat-deps-' + [Guid]::NewGuid().ToString('N')) + $hasDeps = Test-Path -LiteralPath $depsPath + try { + if ($hasDeps) { + Move-Item -LiteralPath $depsPath -Destination $preservedDeps ` + -Force -ErrorAction Stop + } + Remove-Item -LiteralPath $BuildDir -Recurse -Force -ErrorAction Stop + New-Item -ItemType Directory -Force -Path $BuildDir | Out-Null + if ($hasDeps) { + Move-Item -LiteralPath $preservedDeps ` + -Destination (Join-Path $BuildDir '_deps') -Force ` + -ErrorAction Stop + } + } catch { + if ($hasDeps -and (Test-Path -LiteralPath $preservedDeps) -and + -not (Test-Path -LiteralPath $depsPath)) { + New-Item -ItemType Directory -Force -Path $BuildDir | Out-Null + Move-Item -LiteralPath $preservedDeps -Destination $depsPath ` + -Force -ErrorAction SilentlyContinue + } + Write-Host "Cleaning failed: $($_.Exception.Message)" + exit 1 + } } New-Item -ItemType Directory -Force -Path $BuildDir | Out-Null @@ -167,4 +232,37 @@ Write-BuildProgress 100 'Build complete.' -NewLine Write-Host ("Build time: {0:mm\:ss}" -f $elapsedTotal) Write-Host "Output: $output" Write-Host "Logs: $buildLog" + +if ($Package) { + Write-Host '' + Write-Host 'Building versioned portable and installer packages...' + $packageArgs = @('--build', $BuildDir, '--config', $Configuration, + '--target', 'package-portable', 'package-installer', '--parallel', $Jobs) + & cmake @packageArgs + $packageStatus = $LASTEXITCODE + if ($packageStatus -ne 0) { + Write-Host 'Package generation failed.' + Write-Host 'Ensure NSIS is installed and available in PATH for the installer.' + Write-Host "CPack configuration: $(Join-Path $BuildDir 'CPackConfig.cmake')" + exit $packageStatus + } + $packageNameFile = Join-Path $BuildDir 'bongocat-package-name.txt' + if (-not (Test-Path -LiteralPath $packageNameFile)) { + Write-Host "Package name file was not produced: $packageNameFile" + exit 1 + } + $packageName = (Get-Content -LiteralPath $packageNameFile -Raw).Trim() + $packageDist = Join-Path $BuildDir 'dist' + $portable = Join-Path $packageDist "$packageName-portable.exe" + $installer = Join-Path $packageDist "$packageName-setup.exe" + if (-not (Test-Path -LiteralPath $portable) -or + -not (Test-Path -LiteralPath $installer)) { + Write-Host 'Package generation completed without both expected files.' + Write-Host "Expected portable: $portable" + Write-Host "Expected installer: $installer" + exit 1 + } + Write-Host "Portable package: $portable" + Write-Host "Installer package: $installer" +} exit 0 diff --git a/src/core/config_session_defaults.c b/src/core/config_session_defaults.c index 4dd170fd..42cac653 100644 --- a/src/core/config_session_defaults.c +++ b/src/core/config_session_defaults.c @@ -120,6 +120,8 @@ void bongo_cat_session_validate(BongoCatSessionState *session) { session->last_update_check_day = 0; normalize_text(session->last_update_check_version, sizeof(session->last_update_check_version)); + normalize_text(session->available_update_version, + sizeof(session->available_update_version)); compact_additional_models(session); compact_active_behaviors(session); } diff --git a/src/core/config_session_io.c b/src/core/config_session_io.c index ea911d48..fcbd3e9c 100644 --- a/src/core/config_session_io.c +++ b/src/core/config_session_io.c @@ -151,6 +151,9 @@ BongoCatResult bongo_cat_session_load(const char *path, bongo_cat_config_read_text("Session", root, "lastUpdateCheckVersion", loaded.last_update_check_version, sizeof(loaded.last_update_check_version), error) && + bongo_cat_config_read_text("Session", root, + "availableUpdateVersion", loaded.available_update_version, + sizeof(loaded.available_update_version), error) && read_additional_models(additional_models, &loaded, error) && read_active_behaviors(active_behaviors, &loaded, error); yyjson_doc_free(document); @@ -243,6 +246,8 @@ BongoCatResult bongo_cat_session_save(const char *path, canonical.last_update_check_day) && yyjson_mut_obj_add_strcpy(doc, root, "lastUpdateCheckVersion", canonical.last_update_check_version) && + yyjson_mut_obj_add_strcpy(doc, root, "availableUpdateVersion", + canonical.available_update_version) && write_additional_models(doc, root, &canonical) && write_active_behaviors(doc, root, &canonical); if (!built) { diff --git a/src/platform/windows/windows_startup.c b/src/platform/windows/windows_startup.c index c5995205..0aa16651 100644 --- a/src/platform/windows/windows_startup.c +++ b/src/platform/windows/windows_startup.c @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -14,12 +15,15 @@ static HANDLE instance_mutex; static HANDLE instance_wake_event; static HANDLE instance_update_shutdown_event; static HANDLE instance_stopped_event; +static HANDLE instance_info_mapping; +static BongoCatWindowsInstanceInfo *instance_info; static wchar_t instance_title[96] = BONGO_CAT_PET_WINDOW_TITLE_W; static wchar_t instance_mutex_name[128] = L"Local\\BongoCat.SingleInstance"; static wchar_t instance_wake_name[128] = L"Local\\BongoCat.WakeInstance"; static wchar_t instance_update_shutdown_name[128] = L"Local\\BongoCat.UpdateShutdown"; static wchar_t instance_stopped_name[128] = L"Local\\BongoCat.InstanceStopped"; +static wchar_t instance_info_name[128] = L"Local\\BongoCat.InstanceInfo"; static bool identity_ready; static bool safe_identity(const char *value) { @@ -49,12 +53,27 @@ static void initialize_identity(void) { swprintf(instance_stopped_name, sizeof(instance_stopped_name) / sizeof(instance_stopped_name[0]), L"Local\\BongoCat.InstanceStopped.%hs", value); + swprintf(instance_info_name, + sizeof(instance_info_name) / sizeof(instance_info_name[0]), + L"Local\\BongoCat.InstanceInfo.%hs", value); } const wchar_t *bongo_cat_windows_instance_title(void) { initialize_identity(); return instance_title; } +const wchar_t *bongo_cat_windows_update_shutdown_name(void) { + initialize_identity(); return instance_update_shutdown_name; +} + +const wchar_t *bongo_cat_windows_instance_stopped_name(void) { + initialize_identity(); return instance_stopped_name; +} + +const wchar_t *bongo_cat_windows_instance_info_name(void) { + initialize_identity(); return instance_info_name; +} + static void create_instance_events(void) { if (!instance_wake_event) instance_wake_event = CreateEventW(NULL, FALSE, FALSE, instance_wake_name); @@ -64,6 +83,22 @@ static void create_instance_events(void) { if (!instance_stopped_event) instance_stopped_event = CreateEventW(NULL, TRUE, FALSE, instance_stopped_name); + if (!instance_info_mapping) { + instance_info_mapping = CreateFileMappingW(INVALID_HANDLE_VALUE, NULL, + PAGE_READWRITE, 0, sizeof(BongoCatWindowsInstanceInfo), + instance_info_name); + if (instance_info_mapping) + instance_info = MapViewOfFile(instance_info_mapping, + FILE_MAP_WRITE, 0, 0, sizeof(BongoCatWindowsInstanceInfo)); + if (instance_info) { + memset(instance_info, 0, sizeof(*instance_info)); + instance_info->magic = 0x42434D49u; + GetModuleFileNameW(NULL, instance_info->executable_path, + BONGO_CAT_WINDOWS_INSTANCE_PATH_CAP); + snprintf(instance_info->version, sizeof(instance_info->version), + "%s", BONGO_CAT_VERSION); + } + } } static void wake_existing_instance(void) { @@ -92,8 +127,19 @@ bool bongo_cat_platform_single_instance_begin(void) { if (GetLastError() != ERROR_ALREADY_EXISTS) { create_instance_events(); return true; } - wake_existing_instance(); CloseHandle(instance_mutex); instance_mutex = NULL; + if (bongo_cat_windows_update_handoff()) { + for (int attempt = 0; attempt < 30; ++attempt) { + instance_mutex = CreateMutexW(NULL, FALSE, instance_mutex_name); + if (instance_mutex && GetLastError() != ERROR_ALREADY_EXISTS) { + create_instance_events(); return true; + } + if (instance_mutex) CloseHandle(instance_mutex); + instance_mutex = NULL; + Sleep(100); + } + } + wake_existing_instance(); instance_mutex = CreateMutexW(NULL, FALSE, instance_mutex_name); if (instance_mutex && GetLastError() != ERROR_ALREADY_EXISTS) { create_instance_events(); return true; @@ -143,6 +189,10 @@ void bongo_cat_platform_single_instance_end(void) { instance_update_shutdown_event = NULL; if (instance_stopped_event) CloseHandle(instance_stopped_event); instance_stopped_event = NULL; + if (instance_info) UnmapViewOfFile(instance_info); + instance_info = NULL; + if (instance_info_mapping) CloseHandle(instance_info_mapping); + instance_info_mapping = NULL; if (instance_wake_event) CloseHandle(instance_wake_event); instance_wake_event = NULL; if (instance_mutex) CloseHandle(instance_mutex); diff --git a/src/platform/windows/windows_startup.h b/src/platform/windows/windows_startup.h index caaea18a..58079151 100644 --- a/src/platform/windows/windows_startup.h +++ b/src/platform/windows/windows_startup.h @@ -2,9 +2,22 @@ #define BONGO_CAT_WINDOWS_STARTUP_H #ifdef _WIN32 +#include "bongo_cat/common.h" #include +#define BONGO_CAT_WINDOWS_INSTANCE_PATH_CAP 2048 + +typedef struct BongoCatWindowsInstanceInfo { + uint32_t magic; + wchar_t executable_path[BONGO_CAT_WINDOWS_INSTANCE_PATH_CAP]; + char version[BONGO_CAT_UPDATE_VERSION_CAP]; +} BongoCatWindowsInstanceInfo; + const wchar_t *bongo_cat_windows_instance_title(void); +const wchar_t *bongo_cat_windows_update_shutdown_name(void); +const wchar_t *bongo_cat_windows_instance_stopped_name(void); +const wchar_t *bongo_cat_windows_instance_info_name(void); +bool bongo_cat_windows_update_handoff(void); #endif #endif diff --git a/src/platform/windows/windows_update_handoff.c b/src/platform/windows/windows_update_handoff.c new file mode 100644 index 00000000..20cc26b1 --- /dev/null +++ b/src/platform/windows/windows_update_handoff.c @@ -0,0 +1,48 @@ +#include "windows_startup.h" +#include "bongo_cat/update.h" + +#ifdef _WIN32 +#include + +static bool request_shutdown(HWND existing) { + HANDLE stopped = OpenEventW(SYNCHRONIZE, FALSE, + bongo_cat_windows_instance_stopped_name()); + HANDLE shutdown = OpenEventW(EVENT_MODIFY_STATE, FALSE, + bongo_cat_windows_update_shutdown_name()); + if (!stopped || !shutdown || !existing) { + if (stopped) CloseHandle(stopped); + if (shutdown) CloseHandle(shutdown); + return false; + } + bool signaled = SetEvent(shutdown) != 0; + if (signaled) PostMessageW(existing, WM_CLOSE, 0, 0); + DWORD result = signaled ? WaitForSingleObject(stopped, 15000) : WAIT_FAILED; + CloseHandle(shutdown); + CloseHandle(stopped); + return result == WAIT_OBJECT_0; +} + +bool bongo_cat_windows_update_handoff(void) { + HWND existing = FindWindowW(NULL, bongo_cat_windows_instance_title()); + HANDLE mapping = existing ? OpenFileMappingW(FILE_MAP_READ, FALSE, + bongo_cat_windows_instance_info_name()) : NULL; + BongoCatWindowsInstanceInfo info = {0}; + BongoCatWindowsInstanceInfo *shared = mapping ? MapViewOfFile(mapping, + FILE_MAP_READ, 0, 0, sizeof(info)) : NULL; + if (shared) info = *shared; + if (shared) UnmapViewOfFile(shared); + if (mapping) CloseHandle(mapping); + wchar_t current_path[BONGO_CAT_WINDOWS_INSTANCE_PATH_CAP] = {0}; + DWORD current_length = GetModuleFileNameW(NULL, current_path, + BONGO_CAT_WINDOWS_INSTANCE_PATH_CAP); + if (!existing || !current_length || current_length >= + BONGO_CAT_WINDOWS_INSTANCE_PATH_CAP || + (info.magic == 0x42434D49u && + (_wcsicmp(current_path, info.executable_path) == 0 || + !info.version[0] || bongo_cat_update_compare_versions( + BONGO_CAT_VERSION, info.version) <= 0))) + return false; + if (info.magic != 0x42434D49u || !info.version[0]) return false; + return request_shutdown(existing); +} +#endif diff --git a/src/runtime/lifecycle/config_store.c b/src/runtime/lifecycle/config_store.c index b9147661..0f9eb2f2 100644 --- a/src/runtime/lifecycle/config_store.c +++ b/src/runtime/lifecycle/config_store.c @@ -61,6 +61,7 @@ static uint64_t session_hash(const BongoCatSessionState *session) { HASH_FIELD(session->active_model_id); HASH_FIELD(session->last_update_check_day); HASH_FIELD(session->last_update_check_version); + HASH_FIELD(session->available_update_version); HASH_FIELD(session->additional_model_count); size_t model_count = session->additional_model_count; if (model_count > BONGO_CAT_ADDITIONAL_MODEL_CAP) diff --git a/src/runtime/update/update_channel.c b/src/runtime/update/update_channel.c index 77885a91..3a41dea2 100644 --- a/src/runtime/update/update_channel.c +++ b/src/runtime/update/update_channel.c @@ -1,4 +1,10 @@ #include "update_internal.h" +#include "bongo_cat/i18n.h" +#include "preferences_notice.h" + +#include +#include +#include #ifdef _WIN32 #include "windows_package.h" @@ -58,3 +64,42 @@ bool bongo_cat_update_platform_installed(void) { return false; } const char *bongo_cat_update_platform_asset(void) { return "unsupported"; } #endif + +static const char *tr(BongoCatUpdateService *service, const char *key, + const char *fallback) { + return bongo_cat_i18n_get(service->app->i18n, key, fallback); +} + +void bongo_cat_update_show_completion(BongoCatUpdateService *service) { + BongoCatUpdateSnapshot snapshot; + bongo_cat_update_snapshot(service, &snapshot); + char message[192]; + if (snapshot.status == BONGO_CAT_UPDATE_CURRENT) { + snprintf(message, sizeof(message), "%s v%s", tr(service, + "native.support.latest", "Already up to date"), BONGO_CAT_VERSION); + bongo_cat_preferences_notice_show(service->app, message, false); + } else if (snapshot.status == BONGO_CAT_UPDATE_AVAILABLE) { + snprintf(message, sizeof(message), "%s v%s", tr(service, + "native.support.updateAvailable", "New version available:"), + snapshot.release.version); + bongo_cat_preferences_notice_show(service->app, message, false); + } else if (snapshot.status == BONGO_CAT_UPDATE_ERROR) { + char detail[384], status_text[16]; + static const char prefix[] = "GitHub returned HTTP status "; + const char *value = snapshot.error + sizeof(prefix) - 1; + char *end = NULL; + unsigned long status = strncmp(snapshot.error, prefix, + sizeof(prefix) - 1) == 0 ? strtoul(value, &end, 10) : 0; + if (status && end && !*end) { + snprintf(status_text, sizeof(status_text), "%lu", status); + snprintf(detail, sizeof(detail), tr(service, + "native.support.updateHttpFailed", + "GitHub returned HTTP status %s"), status_text); + } else snprintf(detail, sizeof(detail), "%s", snapshot.error[0] ? + snapshot.error : tr(service, "native.support.updateFailed", + "Unable to check for updates")); + bongo_cat_preferences_notice_show(service->app, detail, true); + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Update check failed: %s", snapshot.error); + } +} diff --git a/src/runtime/update/update_http.c b/src/runtime/update/update_http.c index 321f8c67..698a8710 100644 --- a/src/runtime/update/update_http.c +++ b/src/runtime/update/update_http.c @@ -45,6 +45,17 @@ static bool response_status(HINTERNET request, DWORD *status) { WINHTTP_NO_HEADER_INDEX) != FALSE; } +static const char *winhttp_error_name(DWORD code) { + switch (code) { + case ERROR_WINHTTP_TIMEOUT: return "network timeout"; + case ERROR_WINHTTP_NAME_NOT_RESOLVED: return "DNS lookup failed"; + case ERROR_WINHTTP_CANNOT_CONNECT: return "network connection refused"; + case ERROR_WINHTTP_CONNECTION_ERROR: return "network connection error"; + case ERROR_WINHTTP_SECURE_FAILURE: return "TLS/SSL certificate error"; + default: return "network request failed"; + } +} + static char *read_response(BongoCatUpdateService *service, HINTERNET request) { size_t capacity = UPDATE_INITIAL_CAPACITY, length = 0; @@ -106,6 +117,7 @@ BongoCatUpdateFetchResult bongo_cat_update_http_fetch( bool sent = requested && WinHttpSendRequest(request, headers, (DWORD)-1L, WINHTTP_NO_REQUEST_DATA, 0, 0, 0) && WinHttpReceiveResponse(request, NULL); + DWORD request_error = sent ? ERROR_SUCCESS : GetLastError(); DWORD status = 0; bool valid_status = sent && response_status(request, &status) && status == 200; @@ -116,7 +128,8 @@ BongoCatUpdateFetchResult bongo_cat_update_http_fetch( release_handle(service, &service->http_session, session); if (cancelled(service)) return BONGO_CAT_UPDATE_FETCH_CANCELLED; if (!sent) { - snprintf(error, error_capacity, "Cannot connect to GitHub"); + snprintf(error, error_capacity, "Network error: %s (code %lu)", + winhttp_error_name(request_error), (unsigned long)request_error); return BONGO_CAT_UPDATE_FETCH_NETWORK; } if (!valid_status) { diff --git a/src/runtime/update/update_internal.h b/src/runtime/update/update_internal.h index 3792b209..259d81bb 100644 --- a/src/runtime/update/update_internal.h +++ b/src/runtime/update/update_internal.h @@ -24,6 +24,7 @@ struct BongoCatUpdateService { BongoCatUpdateRelease release; char error[256]; bool manual; + bool open_after_check; bool installed; bool shutting_down; bool http_cancelled; @@ -36,6 +37,7 @@ BongoCatUpdateFetchResult bongo_cat_update_http_fetch( BongoCatUpdateService *service, char **response, char *error, size_t error_capacity); void bongo_cat_update_http_cancel(BongoCatUpdateService *service); +void bongo_cat_update_show_completion(BongoCatUpdateService *service); bool bongo_cat_update_platform_supported(void); bool bongo_cat_update_platform_store(void); bool bongo_cat_update_platform_installed(void); diff --git a/src/runtime/update/update_service.c b/src/runtime/update/update_service.c index b8a2aa8b..f405f8ac 100644 --- a/src/runtime/update/update_service.c +++ b/src/runtime/update/update_service.c @@ -37,7 +37,8 @@ static void complete(BongoCatUpdateService *service, if (notify) { service->status = status; if (release) service->release = *release; - else memset(&service->release, 0, sizeof(service->release)); + else if (status != BONGO_CAT_UPDATE_ERROR) + memset(&service->release, 0, sizeof(service->release)); snprintf(service->error, sizeof(service->error), "%s", error ? error : ""); } @@ -98,6 +99,17 @@ BongoCatUpdateService *bongo_cat_update_create(BongoCatApp *app) { else { service->status = BONGO_CAT_UPDATE_IDLE; service->installed = bongo_cat_update_platform_installed(); + if (app->session.available_update_version[0] && + bongo_cat_update_compare_versions( + app->session.available_update_version, + BONGO_CAT_VERSION) > 0) { + snprintf(service->release.version, + sizeof(service->release.version), "%s", + app->session.available_update_version); + service->status = BONGO_CAT_UPDATE_AVAILABLE; + } else { + app->session.available_update_version[0] = '\0'; + } } return service; } @@ -130,6 +142,18 @@ bool bongo_cat_update_check(BongoCatUpdateService *service, bool manual) { return started; } +bool bongo_cat_update_refresh_and_open(BongoCatUpdateService *service) { + if (!service) return false; + SDL_LockMutex(service->mutex); + service->open_after_check = true; + SDL_UnlockMutex(service->mutex); + if (bongo_cat_update_check(service, true)) return true; + SDL_LockMutex(service->mutex); + service->open_after_check = false; + SDL_UnlockMutex(service->mutex); + return false; +} + void bongo_cat_update_start_automatic(BongoCatUpdateService *service) { if (!service || !service->app || service->app->smoke || service->app->secondary_pet) return; @@ -139,9 +163,6 @@ void bongo_cat_update_start_automatic(BongoCatUpdateService *service) { strcmp(session->last_update_check_version, BONGO_CAT_VERSION) == 0) return; if (!bongo_cat_update_check(service, false)) return; - session->last_update_check_day = today; - snprintf(session->last_update_check_version, - sizeof(session->last_update_check_version), "%s", BONGO_CAT_VERSION); } void bongo_cat_update_snapshot(BongoCatUpdateService *service, @@ -165,39 +186,48 @@ static const char *tr(BongoCatUpdateService *service, const char *key, return bongo_cat_i18n_get(service->app->i18n, key, fallback); } -static void show_completion(BongoCatUpdateService *service) { - BongoCatUpdateSnapshot snapshot; - bongo_cat_update_snapshot(service, &snapshot); - char message[192]; - if (snapshot.status == BONGO_CAT_UPDATE_CURRENT) { - snprintf(message, sizeof(message), "%s v%s", tr(service, - "native.support.latest", "Already up to date"), - BONGO_CAT_VERSION); - bongo_cat_preferences_notice_show(service->app, message, false); - } else if (snapshot.status == BONGO_CAT_UPDATE_AVAILABLE) { - snprintf(message, sizeof(message), "%s v%s", tr(service, - "native.support.updateAvailable", "New version available:"), - snapshot.release.version); - bongo_cat_preferences_notice_show(service->app, message, false); - } else if (snapshot.status == BONGO_CAT_UPDATE_ERROR) { - bongo_cat_preferences_notice_show(service->app, tr(service, - "native.support.updateFailed", "Unable to check for updates"), - true); - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "Update check failed: %s", snapshot.error); - } -} - bool bongo_cat_update_event(BongoCatUpdateService *service, const SDL_Event *event) { if (!service || !event || event->type != service->event_type || event->user.data1 != service) return false; SDL_LockMutex(service->mutex); bool manual = service->manual; + BongoCatUpdateStatus status = service->status; + bool open_after_check = service->open_after_check; + service->open_after_check = false; + BongoCatUpdateRelease release = service->release; SDL_UnlockMutex(service->mutex); reap_worker(service); + if ((status == BONGO_CAT_UPDATE_AVAILABLE || + status == BONGO_CAT_UPDATE_ERROR) && release.version[0]) + snprintf(service->app->session.available_update_version, + sizeof(service->app->session.available_update_version), "%s", + release.version); + else if (status == BONGO_CAT_UPDATE_CURRENT) + service->app->session.available_update_version[0] = '\0'; + if (!manual && (status == BONGO_CAT_UPDATE_CURRENT || + status == BONGO_CAT_UPDATE_AVAILABLE)) { + int today = local_day(); + if (today) { + BongoCatSessionState *session = &service->app->session; + session->last_update_check_day = today; + snprintf(session->last_update_check_version, + sizeof(session->last_update_check_version), "%s", + BONGO_CAT_VERSION); + } + } bongo_cat_preferences_invalidate(service->app->preferences); - if (manual) show_completion(service); + if (open_after_check && status == BONGO_CAT_UPDATE_AVAILABLE) { + if (!bongo_cat_update_open(service)) { + char detail[384]; + const char *reason = SDL_GetError(); + snprintf(detail, sizeof(detail), "%s: %s", tr(service, + "native.support.openUpdateFailed", + "Unable to open the update page"), + reason && reason[0] ? reason : "system rejected the URL"); + bongo_cat_preferences_notice_show(service->app, detail, true); + } + } else if (manual) bongo_cat_update_show_completion(service); return true; } diff --git a/src/runtime/update/update_service.h b/src/runtime/update/update_service.h index dc1d6a85..b1132b35 100644 --- a/src/runtime/update/update_service.h +++ b/src/runtime/update/update_service.h @@ -32,6 +32,7 @@ bool bongo_cat_update_event(BongoCatUpdateService *service, void bongo_cat_update_snapshot(BongoCatUpdateService *service, BongoCatUpdateSnapshot *snapshot); bool bongo_cat_update_open(BongoCatUpdateService *service); +bool bongo_cat_update_refresh_and_open(BongoCatUpdateService *service); void bongo_cat_update_destroy(BongoCatUpdateService *service); #endif diff --git a/src/ui/preferences/preferences_about_footer.c b/src/ui/preferences/preferences_about_footer.c index c9656a36..87dc540c 100644 --- a/src/ui/preferences/preferences_about_footer.c +++ b/src/ui/preferences/preferences_about_footer.c @@ -79,6 +79,13 @@ static void draw_update(BongoCatPreferences *value, if (p.effects) bongo_cat_ui_paint_shadow(context, bounds, 10, 0, 4, 14, 0, nk_rgba(p.accent.r, p.accent.g, p.accent.b, 89)); nk_fill_rect(canvas, bounds, 10, p.accent); + if (snapshot->status == BONGO_CAT_UPDATE_AVAILABLE || + (snapshot->status == BONGO_CAT_UPDATE_ERROR && + snapshot->release.version[0])) { + /* Mark the actionable update without obscuring its label. */ + nk_fill_circle(canvas, nk_rect(bounds.x + bounds.w - 9.0f, + bounds.y + 5.0f, 6.0f, 6.0f), p.danger); + } float content_x = bounds.x + (bounds.w - content_width) * .5f; bongo_cat_preferences_icon_draw(value, canvas, BONGO_CAT_UI_ICON_SYNC, nk_rect(content_x, bounds.y + 10, 16, 16), nk_rgb(255, 255, 255)); @@ -89,12 +96,24 @@ static void draw_update(BongoCatPreferences *value, if (hit(context, bounds)) { if (snapshot->status == BONGO_CAT_UPDATE_CHECKING) return; if (snapshot->status == BONGO_CAT_UPDATE_AVAILABLE || - snapshot->status == BONGO_CAT_UPDATE_STORE || + (snapshot->status == BONGO_CAT_UPDATE_ERROR && + snapshot->release.version[0])) { + if (!bongo_cat_update_refresh_and_open(value->app->update)) + bongo_cat_preferences_notice_show(value->app, tr(value, + "native.support.updateFailed", + "Unable to check for updates"), true); + } else if (snapshot->status == BONGO_CAT_UPDATE_STORE || snapshot->status == BONGO_CAT_UPDATE_UNSUPPORTED) { if (!bongo_cat_update_open(value->app->update)) - bongo_cat_preferences_notice_show(value->app, tr(value, - "native.support.openUpdateFailed", - "Unable to open the update page"), true); + { + char detail[384]; + const char *reason = SDL_GetError(); + snprintf(detail, sizeof(detail), "%s: %s", tr(value, + "native.support.openUpdateFailed", + "Unable to open the update page"), + reason && reason[0] ? reason : "system rejected the URL"); + bongo_cat_preferences_notice_show(value->app, detail, true); + } } else if (!bongo_cat_update_check(value->app->update, true)) { bongo_cat_preferences_notice_show(value->app, tr(value, "native.support.updateFailed", @@ -110,9 +129,17 @@ static const char *update_label(BongoCatPreferences *value, return tr(value, "native.support.checkingUpdate", "Checking..."); case BONGO_CAT_UPDATE_AVAILABLE: snprintf(buffer, capacity, tr(value, - "native.support.downloadUpdate", "Download v%s"), + "native.support.downloadUpdate", "Update to v%s"), snapshot->release.version); return buffer; + case BONGO_CAT_UPDATE_ERROR: + if (snapshot->release.version[0]) { + snprintf(buffer, capacity, tr(value, + "native.support.downloadUpdate", "Update to v%s"), + snapshot->release.version); + return buffer; + } + return tr(value, "native.support.checkUpdate", "Check for updates"); case BONGO_CAT_UPDATE_STORE: return tr(value, "native.support.openStore", "Open Microsoft Store"); case BONGO_CAT_UPDATE_UNSUPPORTED: diff --git a/src/ui/preferences/preferences_render.c b/src/ui/preferences/preferences_render.c index 7734c255..afd2ac8c 100644 --- a/src/ui/preferences/preferences_render.c +++ b/src/ui/preferences/preferences_render.c @@ -9,6 +9,7 @@ #include "ui_paint.h" #include "bongo_cat/memory_policy.h" #include "bongo_cat/tray.h" +#include "update_service.h" #include #include #include @@ -83,8 +84,13 @@ static bool draw_shell(BongoCatPreferences *value, struct nk_context *context, if (title_clicked && !SDL_OpenURL("https://bongocat.pet")) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Cannot open website: %s", SDL_GetError()); bongo_cat_ui_set_icons(draw_icon, value); + BongoCatUpdateSnapshot update_snapshot; + bongo_cat_update_snapshot(value->app->update, &update_snapshot); + bool about_badge = update_snapshot.status == BONGO_CAT_UPDATE_AVAILABLE || + (update_snapshot.status == BONGO_CAT_UPDATE_ERROR && + update_snapshot.release.version[0]); bongo_cat_ui_tabs(context, menus, menu_icons, 4, &value->page, - !modal, dark, interior_height, + !modal, dark, interior_height, about_badge, draw_icon, value); if (!value->page_seen) { value->page_seen = true; value->last_page = value->page; diff --git a/src/ui/theme/ui_catime.h b/src/ui/theme/ui_catime.h index f22b37ef..e76c6e92 100644 --- a/src/ui/theme/ui_catime.h +++ b/src/ui/theme/ui_catime.h @@ -50,7 +50,7 @@ bool bongo_cat_ui_close_button(struct nk_context *context, struct nk_color normal, struct nk_color hover, bool interactive); void bongo_cat_ui_tabs(struct nk_context *context, const char *const *labels, const int *icons, int count, int *active, bool interactive, bool dark, - float available_height, + float available_height, bool about_badge, BongoCatUIIconDraw draw_icon, void *icon_userdata); void bongo_cat_ui_set_icons(BongoCatUIIconDraw draw_icon, void *icon_userdata); diff --git a/src/ui/theme/ui_catime_tabs.c b/src/ui/theme/ui_catime_tabs.c index 46a0fafe..fab38ab3 100644 --- a/src/ui/theme/ui_catime_tabs.c +++ b/src/ui/theme/ui_catime_tabs.c @@ -71,7 +71,7 @@ static void tab_content(struct nk_command_buffer *canvas, void bongo_cat_ui_tabs(struct nk_context *context, const char *const *labels, const int *icons, int count, int *active, bool interactive, bool dark, - float available_height, + float available_height, bool about_badge, BongoCatUIIconDraw draw_icon, void *icon_userdata) { BongoCatUIPalette p = bongo_cat_ui_palette(dark); struct nk_command_buffer *canvas = nk_window_get_canvas(context); @@ -111,6 +111,10 @@ void bongo_cat_ui_tabs(struct nk_context *context, const char *const *labels, (hover ? p.accent : p.muted); tab_content(canvas, context, tile, labels[i], icons ? icons[i] : i, color, draw_icon, icon_userdata); + if (about_badge && i == count - 1) { + nk_fill_circle(canvas, nk_rect(tile.x + tile.w - 11.0f, + tile.y + 7.0f, 7.0f, 7.0f), p.danger); + } if (hover) bongo_cat_ui_cursor_hover_rect(context, hit, BONGO_CAT_UI_CURSOR_POINTER); if (hover && nk_input_is_mouse_click_in_rect(&context->input, From 6a503c0977dcbad1279b63abf7e5642ed1a11e7a Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 2 Sep 2026 09:31:30 +0800 Subject: [PATCH 196/334] chore: ignore local diagnostic state --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index ef09b35a..8bedfe12 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,8 @@ Desktop.ini # Local Windows audit tools /cmake/*.ps1 !/cmake/RunCubismViewerBlindTest.ps1 + +# Local diagnostic test state +/.diagnostic-session/ +/.diagnostic-storage/ +/.diagnostic-storage-morning/ From 9c70785a53cfc37d328081a022177be2748f9f35 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 2 Sep 2026 10:19:28 +0800 Subject: [PATCH 197/334] feat: support macOS and Linux update checks --- .github/workflows/ci.yml | 7 +- .github/workflows/release.yml | 2 +- cmake/PlatformRuntime.cmake | 6 +- cmake/RuntimeSources.cmake | 1 + docs/README.de-DE.md | 2 +- docs/README.es-ES.md | 2 +- docs/README.fr-FR.md | 2 +- docs/README.ja-JP.md | 2 +- docs/README.ko-KR.md | 2 +- docs/README.pt-BR.md | 2 +- docs/README.ru-RU.md | 2 +- docs/README.zh-CN.md | 2 +- docs/README.zh-Hant.md | 2 +- src/core/update_release.c | 25 ++-- src/runtime/update/update_channel.c | 31 ++++- src/runtime/update/update_http.c | 16 --- src/runtime/update/update_http_unix.c | 173 ++++++++++++++++++++++++++ tests/core/test_update.c | 13 ++ 18 files changed, 252 insertions(+), 40 deletions(-) create mode 100644 src/runtime/update/update_http_unix.c diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38b877e7..4d99cc5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,8 @@ jobs: sudo apt-get update sudo apt-get install -y --no-install-recommends \ cmake ninja-build cppcheck \ - libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev \ + libcurl4-openssl-dev - name: Reject legacy product-name references run: | @@ -122,7 +123,7 @@ jobs: sudo apt-get update sudo apt-get install -y --no-install-recommends \ cmake ninja-build libgl1-mesa-dev libx11-dev libxi-dev \ - libxfixes-dev + libxfixes-dev libcurl4-openssl-dev - name: Configure Windows release if: runner.os == 'Windows' @@ -213,7 +214,7 @@ jobs: sudo apt-get update sudo apt-get install -y --no-install-recommends \ cmake ninja-build libgl1-mesa-dev libx11-dev libxi-dev \ - libxfixes-dev + libxfixes-dev libcurl4-openssl-dev - name: Configure release run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb1e1c21..76213bfb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,7 +68,7 @@ jobs: sudo apt-get update sudo apt-get install -y --no-install-recommends \ cmake ninja-build libgl1-mesa-dev libx11-dev libxi-dev \ - libxfixes-dev + libxfixes-dev libcurl4-openssl-dev - name: Configure Windows release if: runner.os == 'Windows' diff --git a/cmake/PlatformRuntime.cmake b/cmake/PlatformRuntime.cmake index 8d79cd61..8c6462fe 100644 --- a/cmake/PlatformRuntime.cmake +++ b/cmake/PlatformRuntime.cmake @@ -27,6 +27,7 @@ if(WIN32) dinput8 dxguid dwmapi ole32 shell32 user32 uuid windowscodecs advapi32 winhttp) elseif(APPLE) + find_package(CURL REQUIRED) enable_language(OBJC) target_sources(bongo_cat_runtime PRIVATE src/platform/macos/macos.m @@ -36,8 +37,9 @@ elseif(APPLE) src/platform/macos/macos_menu.m src/platform/macos/macos_tray.m) target_link_libraries(bongo_cat_runtime PRIVATE "-framework Cocoa" - "-framework ApplicationServices") + "-framework ApplicationServices" CURL::libcurl) else() + find_package(CURL REQUIRED) find_package(X11 REQUIRED) if(NOT TARGET X11::Xi OR NOT TARGET X11::Xfixes) message(FATAL_ERROR @@ -48,6 +50,6 @@ else() src/platform/linux/linux_menu.c src/platform/linux/linux_x11.c) target_link_libraries(bongo_cat_runtime PRIVATE - X11::X11 X11::Xi X11::Xfixes m) + X11::X11 X11::Xi X11::Xfixes m CURL::libcurl) target_link_libraries(bongo_cat_core PRIVATE m) endif() diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index c4e22c28..bfa0ae52 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -49,6 +49,7 @@ set(BONGO_CAT_RUNTIME_LIFECYCLE_SOURCES set(BONGO_CAT_RUNTIME_UPDATE_SOURCES src/runtime/update/update_channel.c src/runtime/update/update_http.c + src/runtime/update/update_http_unix.c src/runtime/update/update_service.c) set(BONGO_CAT_RUNTIME_DIAGNOSTIC_SOURCES diff --git a/docs/README.de-DE.md b/docs/README.de-DE.md index d3eb9ef4..7fd7b650 100644 --- a/docs/README.de-DE.md +++ b/docs/README.de-DE.md @@ -42,7 +42,7 @@ Führe die folgenden Befehle im Projektstammverzeichnis aus (dem Verzeichnis, da ```bash sudo apt-get update sudo apt-get install -y build-essential cmake ninja-build \ - libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev libcurl4-openssl-dev ``` ### 🔧 Konfiguration und Build diff --git a/docs/README.es-ES.md b/docs/README.es-ES.md index bff6f02e..581619b1 100644 --- a/docs/README.es-ES.md +++ b/docs/README.es-ES.md @@ -42,7 +42,7 @@ Ejecuta los siguientes comandos en la raíz del proyecto (el directorio que cont ```bash sudo apt-get update sudo apt-get install -y build-essential cmake ninja-build \ - libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev libcurl4-openssl-dev ``` ### 🔧 Configuración y compilación diff --git a/docs/README.fr-FR.md b/docs/README.fr-FR.md index 92732f33..63e5496a 100644 --- a/docs/README.fr-FR.md +++ b/docs/README.fr-FR.md @@ -72,7 +72,7 @@ contenant `CMakeLists.txt`). ```bash sudo apt-get update sudo apt-get install -y build-essential cmake ninja-build \ - libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev libcurl4-openssl-dev ``` ### 🔧 Configuration et compilation diff --git a/docs/README.ja-JP.md b/docs/README.ja-JP.md index 67e274c5..d4511c5e 100644 --- a/docs/README.ja-JP.md +++ b/docs/README.ja-JP.md @@ -26,7 +26,7 @@ BongoCat は CMake を使用しており、C11 コンパイラ、C++17 コンパ ```bash sudo apt-get update sudo apt-get install -y build-essential cmake ninja-build \ -libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev libcurl4-openssl-dev ``` ### 🔧 設定とビルド diff --git a/docs/README.ko-KR.md b/docs/README.ko-KR.md index 8a46564d..67408276 100644 --- a/docs/README.ko-KR.md +++ b/docs/README.ko-KR.md @@ -42,7 +42,7 @@ BongoCat은 CMake를 사용하며, C11 컴파일러, C++17 컴파일러, CMake 3 ```bash sudo apt-get update sudo apt-get install -y build-essential cmake ninja-build \ - libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev libcurl4-openssl-dev ``` ### 🔧 구성 및 빌드 diff --git a/docs/README.pt-BR.md b/docs/README.pt-BR.md index d425d7b9..f323218e 100644 --- a/docs/README.pt-BR.md +++ b/docs/README.pt-BR.md @@ -42,7 +42,7 @@ Execute os comandos abaixo na raiz do projeto (o diretório que contém `CMakeLi ```bash sudo apt-get update sudo apt-get install -y build-essential cmake ninja-build \ - libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev libcurl4-openssl-dev ``` ### 🔧 Configuração e compilação diff --git a/docs/README.ru-RU.md b/docs/README.ru-RU.md index e1d7f266..e2b3a671 100644 --- a/docs/README.ru-RU.md +++ b/docs/README.ru-RU.md @@ -42,7 +42,7 @@ BongoCat использует CMake и требует компилятор C11, ```bash sudo apt-get update sudo apt-get install -y build-essential cmake ninja-build \ - libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev libcurl4-openssl-dev ``` ### 🔧 Настройка и сборка diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 90fc1307..51b6009f 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -42,7 +42,7 @@ BongoCat 使用 CMake,需要 C11 编译器、C++17 编译器、CMake 3.24 或 ```bash sudo apt-get update sudo apt-get install -y build-essential cmake ninja-build \ - libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev libcurl4-openssl-dev ``` ### 🔧 配置与构建 diff --git a/docs/README.zh-Hant.md b/docs/README.zh-Hant.md index f14e2989..503bed04 100644 --- a/docs/README.zh-Hant.md +++ b/docs/README.zh-Hant.md @@ -67,7 +67,7 @@ BongoCat 使用 CMake,需要 C11 編譯器、C++17 編譯器、CMake 3.24 或 ```bash sudo apt-get update sudo apt-get install -y build-essential cmake ninja-build \ - libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev libcurl4-openssl-dev ``` ### 🔧 配置與建置 diff --git a/src/core/update_release.c b/src/core/update_release.c index edb4957a..6e9c7590 100644 --- a/src/core/update_release.c +++ b/src/core/update_release.c @@ -51,11 +51,21 @@ static bool read_assets(yyjson_val *root, const char *platform, BongoCatUpdateRelease *release) { yyjson_val *assets = yyjson_obj_get(root, "assets"); if (!yyjson_is_arr(assets)) return false; - char installer[128], portable[128]; - int installer_length = snprintf(installer, sizeof(installer), - "BongoCat-%s-%s-setup.exe", release->version, platform); - int portable_length = snprintf(portable, sizeof(portable), - "BongoCat-%s-%s-portable.exe", release->version, platform); + char installer[128] = {0}, portable[128] = {0}; + int installer_length = 0, portable_length = 0; + if (strncmp(platform, "windows-", 8) == 0) { + installer_length = snprintf(installer, sizeof(installer), + "BongoCat-%s-%s-setup.exe", release->version, platform); + portable_length = snprintf(portable, sizeof(portable), + "BongoCat-%s-%s-portable.exe", release->version, platform); + } else if (strcmp(platform, "linux-x64") == 0) { + portable_length = snprintf(portable, sizeof(portable), + "BongoCat-%s-%s.tar.gz", release->version, platform); + } else if (strcmp(platform, "macos-x64") == 0 || + strcmp(platform, "macos-arm64") == 0) { + portable_length = snprintf(portable, sizeof(portable), + "BongoCat-%s-%s.zip", release->version, platform); + } else return false; if (installer_length < 0 || (size_t)installer_length >= sizeof(installer) || portable_length < 0 || (size_t)portable_length >= sizeof(portable)) return false; @@ -63,8 +73,9 @@ static bool read_assets(yyjson_val *root, const char *platform, yyjson_val *asset; yyjson_arr_foreach(assets, index, count, asset) { if (!yyjson_is_obj(asset)) continue; - if (!release->installer_url[0]) copy_asset_url(asset, installer, - release->installer_url, sizeof(release->installer_url)); + if (installer[0] && !release->installer_url[0]) + copy_asset_url(asset, installer, release->installer_url, + sizeof(release->installer_url)); if (!release->portable_url[0]) copy_asset_url(asset, portable, release->portable_url, sizeof(release->portable_url)); } diff --git a/src/runtime/update/update_channel.c b/src/runtime/update/update_channel.c index 3a41dea2..cd77f55d 100644 --- a/src/runtime/update/update_channel.c +++ b/src/runtime/update/update_channel.c @@ -58,10 +58,37 @@ const char *bongo_cat_update_platform_asset(void) { #else -bool bongo_cat_update_platform_supported(void) { return false; } +/* Unix packages are published as a single archive for each architecture. + * They do not have an installer registration to inspect, but they can still + * use the release API and open the matching archive when an update exists. */ +bool bongo_cat_update_platform_supported(void) { +#if defined(__APPLE__) +#if defined(__aarch64__) || defined(__arm64__) || defined(__x86_64__) + return true; +#else + return false; +#endif +#elif defined(__linux__) && (defined(__x86_64__) || defined(__amd64__)) + return true; +#else + return false; +#endif +} bool bongo_cat_update_platform_store(void) { return false; } bool bongo_cat_update_platform_installed(void) { return false; } -const char *bongo_cat_update_platform_asset(void) { return "unsupported"; } +const char *bongo_cat_update_platform_asset(void) { +#if defined(__APPLE__) +#if defined(__aarch64__) || defined(__arm64__) + return "macos-arm64"; +#else + return "macos-x64"; +#endif +#elif defined(__x86_64__) || defined(__amd64__) + return "linux-x64"; +#else + return "unsupported"; +#endif +} #endif diff --git a/src/runtime/update/update_http.c b/src/runtime/update/update_http.c index 698a8710..e92b080e 100644 --- a/src/runtime/update/update_http.c +++ b/src/runtime/update/update_http.c @@ -160,20 +160,4 @@ void bongo_cat_update_http_cancel(BongoCatUpdateService *service) { if (session) WinHttpCloseHandle(session); } -#else - -BongoCatUpdateFetchResult bongo_cat_update_http_fetch( - BongoCatUpdateService *service, char **response, char *error, - size_t error_capacity) { - (void)service; - *response = NULL; - snprintf(error, error_capacity, - "Automatic updates are not available on this platform yet"); - return BONGO_CAT_UPDATE_FETCH_UNSUPPORTED; -} - -void bongo_cat_update_http_cancel(BongoCatUpdateService *service) { - (void)service; -} - #endif diff --git a/src/runtime/update/update_http_unix.c b/src/runtime/update/update_http_unix.c new file mode 100644 index 00000000..4ddd33b5 --- /dev/null +++ b/src/runtime/update/update_http_unix.c @@ -0,0 +1,173 @@ +#include "update_internal.h" + +#ifndef _WIN32 + +#include +#include +#include +#include +#include + +#define UPDATE_RESPONSE_LIMIT (1024u * 1024u) + +typedef struct UpdateResponseBuffer { + char *data; + size_t length; + size_t capacity; + bool failed; +} UpdateResponseBuffer; + +static bool cancelled(BongoCatUpdateService *service) { + SDL_LockMutex(service->http_mutex); + bool value = service->http_cancelled; + SDL_UnlockMutex(service->http_mutex); + return value; +} + +static size_t write_response(const void *data, size_t size, size_t count, + void *userdata) { + UpdateResponseBuffer *buffer = userdata; + if (!buffer || size == 0 || count > SIZE_MAX / size) return 0; + size_t amount = size * count; + if (amount > UPDATE_RESPONSE_LIMIT - buffer->length) { + buffer->failed = true; + return 0; + } + size_t required = buffer->length + amount + 1; + if (required > buffer->capacity) { + size_t next = buffer->capacity; + while (next < required && next <= UPDATE_RESPONSE_LIMIT / 2) + next *= 2; + if (next < required) next = required; + char *resized = realloc(buffer->data, next); + if (!resized) { + buffer->failed = true; + return 0; + } + buffer->data = resized; + buffer->capacity = next; + } + memcpy(buffer->data + buffer->length, data, amount); + buffer->length += amount; + buffer->data[buffer->length] = '\0'; + return count; +} + +static int transfer_progress(void *userdata, curl_off_t download_total, + curl_off_t download_now, curl_off_t upload_total, curl_off_t upload_now) { + (void)download_total; + (void)download_now; + (void)upload_total; + (void)upload_now; + return cancelled(userdata) ? 1 : 0; +} + +static const char *curl_error_name(CURLcode code) { + switch (code) { + case CURLE_OPERATION_TIMEDOUT: return "network timeout"; + case CURLE_COULDNT_RESOLVE_HOST: return "DNS lookup failed"; + case CURLE_COULDNT_CONNECT: return "network connection refused"; + case CURLE_SSL_CONNECT_ERROR: + case CURLE_PEER_FAILED_VERIFICATION: return "TLS/SSL certificate error"; + case CURLE_ABORTED_BY_CALLBACK: return "request cancelled"; + default: return "network request failed"; + } +} + +BongoCatUpdateFetchResult bongo_cat_update_http_fetch( + BongoCatUpdateService *service, char **response, char *error, + size_t error_capacity) { + *response = NULL; + if (!service || !error || !error_capacity) + return BONGO_CAT_UPDATE_FETCH_MEMORY; + if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) { + snprintf(error, error_capacity, "Cannot initialize the network client"); + return BONGO_CAT_UPDATE_FETCH_MEMORY; + } + CURL *curl = curl_easy_init(); + if (!curl) { + snprintf(error, error_capacity, "Cannot initialize the network client"); + return BONGO_CAT_UPDATE_FETCH_MEMORY; + } + SDL_LockMutex(service->http_mutex); + bool accepted = !service->http_cancelled; + if (accepted) service->http_request = curl; + SDL_UnlockMutex(service->http_mutex); + if (!accepted) { + curl_easy_cleanup(curl); + return BONGO_CAT_UPDATE_FETCH_CANCELLED; + } + + UpdateResponseBuffer buffer = { + .data = malloc(1), .length = 0, .capacity = 1, .failed = false + }; + if (!buffer.data) { + curl_easy_cleanup(curl); + SDL_LockMutex(service->http_mutex); + service->http_request = NULL; + SDL_UnlockMutex(service->http_mutex); + snprintf(error, error_capacity, + "Cannot allocate the GitHub response buffer"); + return BONGO_CAT_UPDATE_FETCH_MEMORY; + } + buffer.data[0] = '\0'; + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Accept: application/vnd.github+json"); + headers = curl_slist_append(headers, "X-GitHub-Api-Version: 2022-11-28"); + curl_easy_setopt(curl, CURLOPT_URL, + "https://api.github.com/repos/vladelaina/BongoCat/releases/latest"); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "BongoCat Update Checker/1.0"); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, 5000L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, 15000L); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_response); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &buffer); + curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, transfer_progress); + curl_easy_setopt(curl, CURLOPT_XFERINFODATA, service); + curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); + CURLcode result = curl_easy_perform(curl); + long status = 0; + if (result == CURLE_OK) + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status); + curl_slist_free_all(headers); + SDL_LockMutex(service->http_mutex); + service->http_request = NULL; + bool was_cancelled = service->http_cancelled; + SDL_UnlockMutex(service->http_mutex); + curl_easy_cleanup(curl); + + if (was_cancelled || result == CURLE_ABORTED_BY_CALLBACK) { + free(buffer.data); + return BONGO_CAT_UPDATE_FETCH_CANCELLED; + } + if (buffer.failed) { + snprintf(error, error_capacity, "Cannot read the GitHub response"); + free(buffer.data); + return BONGO_CAT_UPDATE_FETCH_MEMORY; + } + if (result != CURLE_OK) { + snprintf(error, error_capacity, "Network error: %s (code %d)", + curl_error_name(result), (int)result); + free(buffer.data); + return BONGO_CAT_UPDATE_FETCH_NETWORK; + } + if (status != 200) { + snprintf(error, error_capacity, + status == 404 ? "No published BongoCat release is available" : + "GitHub returned HTTP status %ld", status); + free(buffer.data); + return BONGO_CAT_UPDATE_FETCH_RESPONSE; + } + *response = buffer.data; + return BONGO_CAT_UPDATE_FETCH_OK; +} + +void bongo_cat_update_http_cancel(BongoCatUpdateService *service) { + if (!service || !service->http_mutex) return; + SDL_LockMutex(service->http_mutex); + service->http_cancelled = true; + SDL_UnlockMutex(service->http_mutex); +} + +#endif diff --git a/tests/core/test_update.c b/tests/core/test_update.c index d00b5e51..67dacca0 100644 --- a/tests/core/test_update.c +++ b/tests/core/test_update.c @@ -20,6 +20,14 @@ static const char release_json[] = "\"browser_download_url\":\"https://github.com/vladelaina/BongoCat/" "releases/download/v1.2.3/BongoCat-1.2.3-windows-x64-portable.exe\"}]}"; +static const char unix_release_json[] = + "{\"tag_name\":\"v2.0.0\"," + "\"html_url\":\"https://github.com/vladelaina/BongoCat/releases/tag/v2.0.0\"," + "\"draft\":false,\"prerelease\":false,\"assets\":[" + "{\"name\":\"BongoCat-2.0.0-linux-x64.tar.gz\"," + "\"browser_download_url\":\"https://github.com/vladelaina/BongoCat/" + "releases/download/v2.0.0/BongoCat-2.0.0-linux-x64.tar.gz\"}]}"; + void test_update(void) { CHECK(bongo_cat_update_version_valid("1.2.3-rc.1+build.4")); CHECK(!bongo_cat_update_version_valid("1")); @@ -42,6 +50,11 @@ void test_update(void) { CHECK(strstr(release.portable_url, "windows-x64-portable.exe") != NULL); CHECK(strcmp(release.notes, "Fixed input and rendering.") == 0); + CHECK(bongo_cat_update_parse_release(unix_release_json, "linux-x64", + &release, &error)); + CHECK(release.installer_url[0] == '\0'); + CHECK(strstr(release.portable_url, "linux-x64.tar.gz") != NULL); + const char unsafe[] = "{\"tag_name\":\"v2.0.0\",\"draft\":false," "\"prerelease\":false,\"html_url\":\"https://example.com/v2\"," From ad0eef856cc1bb4c3bfe9d08ceaef389da6b415e Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 2 Sep 2026 17:09:50 +0800 Subject: [PATCH 198/334] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7f1e387f..a471267d 100644 --- a/README.md +++ b/README.md @@ -355,7 +355,7 @@ we need. - +[linux.do](https://linux.do/) ---
From 2b4117cea9ce5036b835191b38db24b8c54489ff Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 2 Sep 2026 17:22:18 +0800 Subject: [PATCH 199/334] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a471267d..e6011728 100644 --- a/README.md +++ b/README.md @@ -355,7 +355,7 @@ we need. -[linux.do](https://linux.do/) +[linux.do](https://linux.do/t/topic/2845597) ---
From 13c0713c8b65c7d7c32286975c4339d60f7d4ef7 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 3 Sep 2026 17:12:57 +0800 Subject: [PATCH 200/334] Fix Windows transparency after shell refresh --- src/platform/windows/windows.c | 24 +++++++++- src/platform/windows/windows_capture.c | 66 ++++++++++++++++++++++++-- src/platform/windows/windows_capture.h | 4 ++ src/runtime/shell/window.c | 4 ++ 4 files changed, 94 insertions(+), 4 deletions(-) diff --git a/src/platform/windows/windows.c b/src/platform/windows/windows.c index 813b0925..ac1a9426 100644 --- a/src/platform/windows/windows.c +++ b/src/platform/windows/windows.c @@ -16,8 +16,26 @@ static HWND native_window(BongoCatPlatform *platform) { SDL_PROP_WINDOW_WIN32_HWND_POINTER, NULL); } +static bool SDLCALL windows_message_hook(void *userdata, MSG *message) { + (void)userdata; + if (!message || !message->hwnd) return true; + /* The capture handler filters by its per-window properties. Returning + false only consumes the refresh/timer messages that it owns. */ + return !bongo_cat_windows_capture_handle_message(message->hwnd, + message->message, message->wParam); +} + void bongo_cat_platform_configure_preferences_window(SDL_Window *window) { - (void)window; + if (!window) return; + HWND handle = (HWND)SDL_GetPointerProperty(SDL_GetWindowProperties(window), + SDL_PROP_WINDOW_WIN32_HWND_POINTER, NULL); + bool transparent = (SDL_GetWindowFlags(window) & + SDL_WINDOW_TRANSPARENT) != 0; + bongo_cat_windows_capture_mark_transparent(handle, transparent); + if (transparent) { + bongo_cat_windows_capture_install_transparency_handler(handle); + bongo_cat_windows_capture_restore_transparency(handle); + } } BongoCatResult bongo_cat_platform_init(BongoCatPlatform *platform, SDL_Window *window, @@ -49,6 +67,9 @@ BongoCatResult bongo_cat_platform_init(BongoCatPlatform *platform, SDL_Window *w SetWindowTextW(hwnd, bongo_cat_windows_instance_title()); bongo_cat_windows_borderless_install(hwnd); bongo_cat_windows_capture_configure(hwnd); + bongo_cat_windows_capture_mark_transparent(hwnd, + (SDL_GetWindowFlags(window) & SDL_WINDOW_TRANSPARENT) != 0); + SDL_SetWindowsMessageHook(windows_message_hook, NULL); if (!SDL_SetWindowResizable(window, true)) { SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, "Borderless resize is unavailable: %s", SDL_GetError()); @@ -65,6 +86,7 @@ void bongo_cat_platform_shutdown(BongoCatPlatform *platform) { bongo_cat_windows_direct_input_destroy(platform); bongo_cat_windows_input_stop(platform); bongo_cat_windows_layered_destroy(platform); + SDL_SetWindowsMessageHook(NULL, NULL); } static HWND desktop_anchor(void) { diff --git a/src/platform/windows/windows_capture.c b/src/platform/windows/windows_capture.c index 466990e9..c82fceae 100644 --- a/src/platform/windows/windows_capture.c +++ b/src/platform/windows/windows_capture.c @@ -9,12 +9,45 @@ static UINT taskbar_created_message; static UINT capture_refresh_message; +static const wchar_t capture_property[] = L"BongoCat.CaptureWindow"; +static const wchar_t transparent_property[] = L"BongoCat.TransparentWindow"; +static const wchar_t transparency_proc_property[] = + L"BongoCat.TransparentWindowProc"; static bool removal_warning_emitted; static bool style_warning_emitted; static bool transparency_warning_emitted; static bool environment_logged; #define BONGO_CAT_CAPTURE_REFRESH_TIMER ((UINT_PTR)0xBC51) +static bool has_property(HWND window, const wchar_t *name) { + return window && name && GetPropW(window, name) != NULL; +} + +void bongo_cat_windows_capture_mark_transparent(HWND window, bool enabled) { + if (!window) return; + if (enabled) SetPropW(window, transparent_property, (HANDLE)1); + else RemovePropW(window, transparent_property); +} + +static LRESULT CALLBACK transparency_window_proc(HWND window, UINT message, + WPARAM wparam, LPARAM lparam) { + if (bongo_cat_windows_capture_handle_message(window, message, wparam)) + return 0; + WNDPROC original = (WNDPROC)GetPropW(window, transparency_proc_property); + return CallWindowProcW(original ? original : DefWindowProcW, window, + message, wparam, lparam); +} + +void bongo_cat_windows_capture_install_transparency_handler(HWND window) { + if (!window || GetPropW(window, transparency_proc_property)) return; + WNDPROC original = (WNDPROC)GetWindowLongPtrW(window, GWLP_WNDPROC); + if (!original || !SetPropW(window, transparency_proc_property, + (HANDLE)original)) return; + if (!SetWindowLongPtrW(window, GWLP_WNDPROC, + (LONG_PTR)transparency_window_proc)) + RemovePropW(window, transparency_proc_property); +} + static bool read_extended_style(HWND window, LONG_PTR *style) { SetLastError(ERROR_SUCCESS); LONG_PTR value = GetWindowLongPtrW(window, GWL_EXSTYLE); @@ -72,6 +105,16 @@ bool bongo_cat_windows_capture_restore_transparency(HWND window) { return SUCCEEDED(result); } +static void repair_transparency(HWND window) { + if (!has_property(window, transparent_property)) return; + if (bongo_cat_windows_capture_restore_transparency(window)) { + /* A composition reset does not necessarily produce a paint message. + Invalidate the surface so the alpha-aware back buffer is presented + again (and, on Windows, the layered proxy is rebuilt). */ + InvalidateRect(window, NULL, FALSE); + } +} + static void log_environment(HWND window) { if (environment_logged) return; environment_logged = true; @@ -140,6 +183,7 @@ static void schedule_refresh(HWND window) { bool bongo_cat_windows_capture_configure(HWND window) { if (!window || !IsWindow(window)) return false; register_messages(); + SetPropW(window, capture_property, (HANDLE)1); LONG_PTR style = 0; if (!read_extended_style(window, &style)) { if (!style_warning_emitted) { @@ -194,18 +238,34 @@ bool bongo_cat_windows_capture_configure(HWND window) { bool bongo_cat_windows_capture_handle_message( HWND window, UINT message, WPARAM wparam) { register_messages(); + if ((message == WM_DWMCOMPOSITIONCHANGED || message == WM_DISPLAYCHANGE) && + has_property(window, transparent_property)) { + repair_transparency(window); + /* Let SDL continue processing WM_DISPLAYCHANGE so its display list is + refreshed. WM_DWMCOMPOSITIONCHANGED has no SDL-side state to update. */ + return false; + } if (capture_refresh_message && message == capture_refresh_message) { + if (!has_property(window, capture_property)) return false; refresh_taskbar(window); return true; } if (message == WM_TIMER && - wparam == BONGO_CAT_CAPTURE_REFRESH_TIMER) { + wparam == BONGO_CAT_CAPTURE_REFRESH_TIMER && + has_property(window, capture_property)) { KillTimer(window, BONGO_CAT_CAPTURE_REFRESH_TIMER); refresh_taskbar(window); return true; } - if (taskbar_created_message && message == taskbar_created_message) - schedule_refresh(window); + if (taskbar_created_message && message == taskbar_created_message) { + bool capture_window = has_property(window, capture_property); + if (capture_window) { + refresh_taskbar(window); + schedule_refresh(window); + } + repair_transparency(window); + return capture_window; + } return false; } #endif diff --git a/src/platform/windows/windows_capture.h b/src/platform/windows/windows_capture.h index cf5654f5..51a8b04f 100644 --- a/src/platform/windows/windows_capture.h +++ b/src/platform/windows/windows_capture.h @@ -6,6 +6,10 @@ #include bool bongo_cat_windows_capture_configure(HWND window); +/* Mark windows whose OpenGL back buffer relies on DWM alpha composition. */ +void bongo_cat_windows_capture_mark_transparent(HWND window, bool enabled); +/* Install a small native shim for synchronous DWM/shell notifications. */ +void bongo_cat_windows_capture_install_transparency_handler(HWND window); bool bongo_cat_windows_capture_restore_transparency(HWND window); void bongo_cat_windows_capture_log(HWND window, const char *stage); bool bongo_cat_windows_capture_handle_message( diff --git a/src/runtime/shell/window.c b/src/runtime/shell/window.c index 7a2fbe6b..8428c7f6 100644 --- a/src/runtime/shell/window.c +++ b/src/runtime/shell/window.c @@ -156,6 +156,10 @@ bool bongo_cat_window_event(BongoCatApp *app, const SDL_Event *event) { if (event->type != SDL_EVENT_WINDOW_EXPOSED) app->window_minimized = false; bongo_cat_app_reset_pointer_tracking(app); + /* DWM can discard the transparent redirection surface after an + Explorer/display refresh. Repaint even when the model is idle so + the restored alpha surface is submitted immediately. */ + app->dirty = true; } if (event->type == SDL_EVENT_WINDOW_FOCUS_GAINED || event->type == SDL_EVENT_WINDOW_FOCUS_LOST) { From 8058d0f07260153127421f2386dc8ded814237c0 Mon Sep 17 00:00:00 2001 From: Dendroculus Date: Fri, 4 Sep 2026 12:03:12 +0700 Subject: [PATCH 201/334] docs: add Indonesian README translation --- .gitignore | 1 + README.md | 2 +- docs/README.de-DE.md | 2 +- docs/README.es-ES.md | 2 +- docs/README.fr-FR.md | 2 +- docs/README.id-ID.md | 375 +++++++++++++++++++++++++++++++++++++++++ docs/README.ja-JP.md | 2 +- docs/README.ko-KR.md | 2 +- docs/README.pt-BR.md | 2 +- docs/README.ru-RU.md | 2 +- docs/README.zh-CN.md | 2 +- docs/README.zh-Hant.md | 2 +- 12 files changed, 386 insertions(+), 10 deletions(-) create mode 100644 docs/README.id-ID.md diff --git a/.gitignore b/.gitignore index 8bedfe12..655a299d 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,7 @@ Desktop.ini !/docs/README.pt-BR.md !/docs/README.ru-RU.md !/docs/README.es-ES.md +!/docs/README.id-ID.md !/docs/wechat.png # Local Windows audit tools diff --git a/README.md b/README.md index e6011728..25a41d23 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ 💘C/C++ × SDL3 × OpenGL, stir it up, mash it together! Bong~ Bongo Cat!!!

-English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español +English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia

diff --git a/docs/README.de-DE.md b/docs/README.de-DE.md index 7fd7b650..61437162 100644 --- a/docs/README.de-DE.md +++ b/docs/README.de-DE.md @@ -7,7 +7,7 @@

💘 C/C++ × SDL3 × OpenGL, zusammenrühren, drauflos trommeln! Bong~ Bongo Cat!!!

- Sprache wählen ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español + Sprache wählen ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia

diff --git a/docs/README.es-ES.md b/docs/README.es-ES.md index 581619b1..39c21249 100644 --- a/docs/README.es-ES.md +++ b/docs/README.es-ES.md @@ -7,7 +7,7 @@

💘 C/C++ × SDL3 × OpenGL, mézclalo todo, ¡a tocar! ¡Bong~ Bongo Cat!!!

- Elige el idioma ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español + Elige el idioma ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia

diff --git a/docs/README.fr-FR.md b/docs/README.fr-FR.md index 63e5496a..35b5737e 100644 --- a/docs/README.fr-FR.md +++ b/docs/README.fr-FR.md @@ -13,7 +13,7 @@ 💘C/C++ × SDL3 × OpenGL, mélangez le tout, assemblez-le ! Bong~ Bongo Cat !!!

-English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español +English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia

diff --git a/docs/README.id-ID.md b/docs/README.id-ID.md new file mode 100644 index 00000000..257dc384 --- /dev/null +++ b/docs/README.id-ID.md @@ -0,0 +1,375 @@ + +

+ + Catime + + +

+ BongoCat +

+
+ + + +

+ 💘C/C++ × SDL3 × OpenGL, campur semuanya, satukan! Bong~ Bongo Cat!!! +

+

+English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia +

+

+ + + + + +

+ + + +
+ +
+ + +> [!TIP] +> Model yang ditampilkan dalam demo ini berasal dari [宇痕冫](https://space.bilibili.com/348616056). +> +> 🎁 Mencari model **gratis**? Kami bekerja sama dengan kreator model berbakat untuk menghadirkan beragam model gratis, sambil terus mengeksplorasi pengalaman desktop yang lebih seru! Kunjungi situs resmi kami: [bongocat.pet](https://bongocat.pet/models) + + +

+ + + +

+ +

+ +

+ +## 📥 Unduh + + + + + +- GitHub Releases + + Unduh rilis terbaru dari [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest). + +## 🛠️ Membangun dari Kode Sumber + +BongoCat menggunakan CMake dan memerlukan compiler C11, compiler C++17, CMake 3.24 +atau yang lebih baru, serta file pengembangan OpenGL desktop. SDL3, yyjson, stb, +miniaudio, dan Nuklear secara default diunduh saat proses konfigurasi, sehingga +konfigurasi pertama memerlukan akses jaringan. + +Jalankan perintah berikut dari root proyek (direktori yang berisi +`CMakeLists.txt`). + +### 📋 Prasyarat Platform + +- **Windows:** Visual Studio 2022 dengan workload Desktop C++ dan CMake. + Gunakan generator MSVC; MinGW dapat membangun backend diagnostik tetapi tidak + didukung untuk Cubism SDK. +- **macOS:** Xcode Command Line Tools, CMake, dan Ninja. Pilih arsitektur dengan + `CMAKE_OSX_ARCHITECTURES` jika berbeda dari default host. +- **Linux (Debian/Ubuntu):** GCC atau Clang, Ninja, serta header OpenGL/X11: + + ```bash + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build \ + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev + ``` + +### 🔧 Konfigurasi dan Build + +Di Linux dan macOS, gunakan generator single-configuration seperti Ninja: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --parallel +``` + +Di Windows, jalankan dari shell developer Visual Studio 2022 (atau shell lain +yang menyediakan MSVC): + +```powershell +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --config Release --parallel +``` + +File executable dihasilkan di `build/BongoCat` pada Linux, di +`build/BongoCat.app/Contents/MacOS/BongoCat` di macOS, dan ke +`build/Release/BongoCat.exe` untuk build Visual Studio. + +### 🧪 Pengujian + +Target CTest diaktifkan secara default. Jalankan setelah proses build: + +```bash +ctest --test-dir build --output-on-failure +``` + +Untuk generator multi-configuration seperti Visual Studio, pilih konfigurasi +build secara eksplisit: + +```powershell +ctest --test-dir build -C Release --output-on-failure +``` + +### 🎭 Live2D / Cubism SDK (Opsional) + +Jika Cubism SDK tidak tersedia, CMake akan menampilkan peringatan dan membangun +backend diagnostik. Backend ini ditujukan untuk diagnostik startup dan platform; +backend ini tidak menyediakan rendering model Live2D. Untuk membangun runtime +lengkap, pasang Cubism SDK for Native yang kompatibel lalu tempatkan di +`vendor/CubismSdkForNative` atau berikan lokasinya secara eksplisit: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ + -DBONGO_CAT_REQUIRE_CUBISM=ON +``` + +SDK harus berisi Core library, source Framework, dan tree third-party OpenGL GLEW +dengan tata letak yang diharapkan oleh `cmake/Cubism.cmake`. Build Cubism di +Windows memerlukan Visual Studio 2022. `BONGO_CAT_REQUIRE_CUBISM=ON` membuat +konfigurasi gagal alih-alih diam-diam memilih backend diagnostik. + +### ⚙️ Opsi CMake + +| Opsi | Default | Deskripsi | +| --- | --- | --- | +| `BONGO_CAT_FETCH_DEPS` | `ON` | Unduh dependency pihak ketiga yang versinya dipin menggunakan CMake `FetchContent`. Atur ke `OFF` hanya jika SDL3, yyjson, stb, miniaudio, dan Nuklear sudah tersedia untuk CMake. | +| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Path ke Cubism SDK for Native. | +| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | Gagalkan konfigurasi jika Cubism SDK yang dapat digunakan tidak tersedia. | +| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | Perlakukan warning compiler native sebagai error. | + +Untuk build offline dengan `BONGO_CAT_FETCH_DEPS=OFF`, sediakan konfigurasi +package CMake untuk SDL3 (termasuk `SDL3-static`) dan yyjson, serta direktori +include untuk stb, Nuklear, dan miniaudio jika tidak dapat ditemukan secara +otomatis: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=OFF \ + -DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ + -DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ + -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio +``` + +## 📌 Status Proyek + + +## 📜 Lisensi + +Kode sumber dan runtime native BongoCat dilisensikan di bawah +[AGPL-3.0-only](../LICENSE). + +Mode model bawaan default (`standard`) tetap berlisensi MIT. Aset model yang +disertakan dalam `resources/assets/models/standard`, `keyboard`, dan `gamepad` +dilindungi oleh [pemberitahuan lisensi MIT](../LICENSE-MIT) terpisah. +Lisensi MIT tersebut hanya berlaku untuk aset model dan artwork pendampingnya; +lisensi tersebut tidak mengubah lisensi kode sumber atau runtime native BongoCat. + + +## 🧭 Arsitektur Teknis + +> Versi native saat ini dibangun dengan C/C++, SDL3, dan OpenGL. Diagram di bawah berfokus pada aliran data runtime; detail build dan packaging berada di CMake. + +### 🔄 Kepemilikan Runtime dan Penjadwalan Frame + +Setiap proses memiliki satu `BongoCatApp` serta satu event loop dan render loop +di thread utama. Listener platform berhenti pada batas input: + +```text +Listener platform +(keyboard / pointer) + | + v + state input C11 + (antrean edge atomik + posisi pointer yang digabungkan) + | + v + aplikasi thread utama <----- event SDL3 + | + v + parameter model, overlay, dan state UI + | + v + update model -> komposisi OpenGL -> presentasi platform +``` + +Low-level hook Windows, event tap Quartz macOS, dan listener XInput2 Linux +berjalan di luar main loop. Komponen tersebut mempublikasikan edge tombol +keyboard dan mouse bertimestamp ke antrean atomik berbatas, serta mempublikasikan +koordinat pointer melalui slot terpisah yang digabungkan; setiap publikasi yang +berhasil memicu event wake native SDL. Hal ini mencegah gerakan berfrekuensi +tinggi mendesak keluar edge tombol keyboard dan mouse yang berurutan. Di +Windows, DirectInput hanya +digunakan melalui antarmuka pointer platform ketika model meminta gerakan +relatif. Event jendela SDL3, preferensi, dan gamepad ditangani di thread utama, +tempat event gamepad dinormalisasi sebelum mencapai parameter model atau +shortcut. Tidak ada listener platform yang memanggil kode Live2D, overlay, atau +UI secara langsung. + +`bongo_cat_app_run` menangani update-shutdown dan argumen proses sekunder, +memastikan kepemilikan single-instance untuk proses utama, mengalokasikan state +aplikasi, menjalankan inisialisasi, masuk ke `bongo_cat_app_loop`, lalu melakukan +flush state dan menghancurkan resource dalam urutan yang terdefinisi. +Inisialisasi memuat konfigurasi dan path penyimpanan, menemukan aset, membuat +jendela pet SDL/OpenGL, menginisialisasi backend platform, membuat layanan +Live2D, overlay, dan audio, memindai sumber model bawaan/terpasang/terdekat, lalu +memuat model yang dapat digunakan. `BongoCatApp` memiliki pengaturan, state +sesi, katalog model dan perilaku, handle platform, serta handle layanan runtime. + +Paket model yang terpasang menggunakan Mver sebagai format kanonis. Workflow +impor menentukan file atau direktori yang dipilih, menemukan dan memvalidasi +kandidat, membuat fingerprint identitas paket, mengonversi sumber Tauri ke Mver, +menerapkan patch gambar, lalu menyimpan paket yang sudah dinormalisasi di bawah +`models_root`. Setelah itu, workflow menghasilkan adapter runtime dan +memperbarui katalog. Sumber terdekat ditemukan tanpa memasang source tree-nya; +adapter dan hasil inspeksinya di-cache di luar `models_root` di bawah +`cache_root`. + +Setiap iterasi main loop menunggu sinyal wakeup SDL/native atau deadline frame, UI, +animasi, atau pointer-hit terdekat yang masih tertunda (dengan waktu tunggu +maksimum 250 ms). Iterasi tersebut mendispatch event SDL yang mengantre, +menguras antrean input atomik dan release recovery, memperbarui state jendela +dan model-refresh, serta menerapkan parameter yang berasal dari input. Saat +Cubism aktif, deadline model mengikuti `settings.model.max_fps` (default 60 FPS); +build diagnostik menggunakan interval fallback 100 ms. Waktu model yang berlalu +dibatasi hingga 250 ms dan dibagi menjadi maksimal delapan substep, dengan +target tidak lebih dari 1/30 detik per substep. + +Jalur render pet normal hanya melakukan render saat jendela terlihat, tidak diminimalkan, +dan ditandai dirty. Sebuah frame membersihkan latar belakang, menggambar model, dan +mengomposisikan overlay pointer, tombol, dan efek sebelum memanggil presenter +platform. Operasi preview dapat meminta render langsung, sedangkan render +capture dapat melewati presentasi. macOS dan Linux menukar jendela SDL OpenGL +secara langsung. Windows melakukan swap langsung saat layered presentation tidak +aktif; jika aktif, Windows membaca kembali frame untuk `UpdateLayeredWindow`. +UI preferensi memiliki jendela SDL/OpenGL terpisah yang dirender dan +dipresentasikan secara independen dari jendela pet. + +Runtime C memanggil ABI yang dideklarasikan di `include/bongo_cat/model.h`. +Bridge Live2D dan implementasi Cubism berada di `src/live2d` dan hanya +menggunakan C++17 saat Cubism SDK diaktifkan; runtime native lainnya menggunakan +C11. Tipe Cubism tetap berada di balik handle C yang opaque, sedangkan +`src/live2d/live2d_stub.c` menyediakan backend diagnostik ketika SDK tidak +tersedia. + + +```mermaid +flowchart TB + Input(["Keyboard / mouse / gamepad"]) + BuiltIn(["Aset model bawaan"]) + Sources(["Sumber model eksternal
Mver, Tauri, .model3.json, patch gambar"]) + Desktop(["Jendela pet dan jendela preferensi"]) + + subgraph Runtime["Runtime native BongoCat"] + direction TB + Entry["src/main.c
bongo_cat_app_run"] + Startup["Startup dan inisialisasi
konfigurasi, penyimpanan, jendela, platform"] + Loop["Main loop SDL3
tunggu, dispatch, update, render"] + Shutdown["Shutdown
flush state, hentikan layanan, lepaskan resource"] + InputQueue[("State input atomik
antrean edge dan posisi pointer yang digabungkan")] + InputDispatch["Dispatch input
shortcut, pemetaan pointer, parameter model"] + State[("State BongoCatApp
pengaturan, sesi, katalog, handle runtime")] + Import["Penemuan dan impor model
validasi, normalisasi ke Mver, instal/cache"] + Catalog[("Katalog model dan perilaku")] + Live2D["ABI C Live2D
Cubism SDK atau stub diagnostik"] + Overlay["Overlay dan audio"] + Preferences["Preferensi dan shell desktop
UI Nuklear, tray, aksi jendela"] + Compose["Komposisi frame OpenGL"] + Present["Presentasi platform"] + + Entry --> Startup --> Loop + Loop --> Shutdown + Loop --> InputDispatch --> State + Loop <--> State + State --> Live2D + State --> Overlay + State <--> Preferences + Loop --> Preferences + Catalog --> State + State --> Compose + Live2D --> Compose + Overlay --> Compose + Compose --> Present + Loop --> Compose + end + + subgraph Platform["Backend platform"] + direction LR + Global["Capture keyboard / pointer global
Windows, macOS, Linux"] + SDL["Event SDL3
event jendela dan gamepad"] + end + + Input --> Global --> InputQueue --> InputDispatch + Input --> SDL --> Loop + BuiltIn --> Catalog + Sources --> Import --> Catalog + Present --> Desktop + Preferences --> Desktop +``` + +## ❓ FAQ + +### 🔒 Apakah BongoCat merekam input keyboard atau mouse saya? + +Tidak. BongoCat memproses input keyboard dan mouse secara lokal untuk +menggerakkan animasi dan shortcut. BongoCat tidak merekam atau mengunggah +keystroke, aksi mouse, atau data interaksi lainnya. Konfigurasi juga disimpan +secara lokal, dan aplikasi tidak memiliki iklan, alat analitik, atau kode +pelacakan pengguna. Saat pemeriksaan pembaruan dilakukan, aplikasi hanya meminta +metadata rilis publik; aplikasi tidak mengirim data input, konfigurasi, atau +penggunaan. + +### 🖼️ Mengapa OpenGL, bukan Vulkan? + +Kami memilih OpenGL bukan karena Vulkan buruk, tetapi karena BongoCat tidak +memerlukan tingkat kompleksitas tersebut. Aplikasi terutama merender satu model +Live2D, beberapa lapisan UI, dan sebuah jendela desktop transparan. OpenGL sudah +menangani kebutuhan tersebut dengan nyaman dan bekerja secara alami dengan SDL3 +serta renderer OpenGL milik Cubism. Beralih ke Vulkan berarti harus memelihara +lebih banyak kode rendering dan sinkronisasi di tiga platform desktop, tanpa +peningkatan yang terasa bagi pengguna. Untuk beban kerja BongoCat saat ini, OpenGL +menjaga renderer tetap lebih kecil, lebih mudah di-debug, dan lebih mudah +dipelihara sambil tetap memberikan performa yang dibutuhkan. + + + + +## Status Proyek +![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") + + +## 🙏 Ucapan Terima Kasih +> [!TIP] +> Setiap langkah BongoCat digerakkan oleh semangat open source. Kami dengan tulus berterima kasih kepada seluruh kontributor komunitas atas kontribusi mereka tanpa pamrih (tercantum di bawah berdasarkan urutan tanggal kontribusi). Dukungan Anda membuat pengalaman pendamping desktop menjadi lebih bebas dan autentik.❤️‍🔥 + + + + + + + +[linux.do](https://linux.do/t/topic/2845597) +--- + +
+ +Copyright © 2026 - **BongoCat**\ +Oleh vladelaina\ +Dibuat dengan ❤️ & ⌨️ + +
diff --git a/docs/README.ja-JP.md b/docs/README.ja-JP.md index d4511c5e..07282d82 100644 --- a/docs/README.ja-JP.md +++ b/docs/README.ja-JP.md @@ -1,6 +1,6 @@ # 💘 C/C++ × SDL3 × OpenGL を混ぜて、思う存分叩け!Bong~ Bongo Cat!!! -言語を選択 ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español +言語を選択 ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • [Bahasa Indonesia](README.id-ID.md) > [!TIP] > デモで使用されているモデルは [宇痕冫](https://space.bilibili.com/348616056) から提供されています。 diff --git a/docs/README.ko-KR.md b/docs/README.ko-KR.md index 67408276..5eb81eba 100644 --- a/docs/README.ko-KR.md +++ b/docs/README.ko-KR.md @@ -7,7 +7,7 @@

💘 C/C++ × SDL3 × OpenGL,함께 섞어서 마음껏 두드리세요! Bong~ Bongo Cat!!!

- 언어 선택 ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español + 언어 선택 ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia

diff --git a/docs/README.pt-BR.md b/docs/README.pt-BR.md index f323218e..5b6160a3 100644 --- a/docs/README.pt-BR.md +++ b/docs/README.pt-BR.md @@ -7,7 +7,7 @@

💘 C/C++ × SDL3 × OpenGL, misture tudo e bata à vontade! Bong~ Bongo Cat!!!

- Escolha o idioma ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español + Escolha o idioma ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia

diff --git a/docs/README.ru-RU.md b/docs/README.ru-RU.md index e2b3a671..668a5f7d 100644 --- a/docs/README.ru-RU.md +++ b/docs/README.ru-RU.md @@ -7,7 +7,7 @@

💘 C/C++ × SDL3 × OpenGL — смешай всё и барабань в своё удовольствие! Бонг~ Bongo Cat!!!

- Выберите язык ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español + Выберите язык ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia

diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 51b6009f..8941c639 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -7,7 +7,7 @@

💘 C/C++ × SDL3 × OpenGL,搅拌在一起,尽情敲击!Bong~ Bongo Cat!!!

- 选择语言 ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español + 选择语言 ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia

diff --git a/docs/README.zh-Hant.md b/docs/README.zh-Hant.md index 503bed04..0e29e8d7 100644 --- a/docs/README.zh-Hant.md +++ b/docs/README.zh-Hant.md @@ -14,7 +14,7 @@ 💘C/C++ × SDL3 × OpenGL,攪拌均勻,搗碎融合!Bong~ Bongo Cat!!!

-English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español +English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia

From f682cf269d2c53ace3e57da253dbcc22ce659110 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B7=E7=9F=B3Boy?= Date: Fri, 4 Sep 2026 21:25:26 +0800 Subject: [PATCH 202/334] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=20AGENTS.md?= =?UTF-8?q?=20=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 195 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..252a6fc5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,195 @@ +# AGENTS.md + +Guidance for AI coding agents working in this repository. + +## Project Overview + +BongoCat is a cross-platform desktop pet (Windows / macOS / Linux) written in +C11 with a C++17 Live2D bridge. It renders a Live2D (or built-in) model in a +transparent desktop window using SDL3 and OpenGL, and animates it from +keyboard / mouse / gamepad input. Public headers live in `include/bongo_cat/`; +the runtime is built as static libraries (`bongo_cat_core`, `bongo_cat_runtime`) +linked into one executable (`src/main.c` -> `bongo_cat_app_run`). + +Third-party deps (SDL3, yyjson, stb, miniaudio, Nuklear) are fetched at +configure time via `BONGO_CAT_FETCH_DEPS=ON` (default), so the first configure +needs network access. The Live2D Cubism SDK is optional and never fetched: see +"Cubism SDK" below. + +## Build Commands + +Run from the project root (the directory containing `CMakeLists.txt`). + +Linux / macOS (single-config generator): + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --parallel +``` + +Windows (from a Visual Studio 2022 developer shell; MSVC is required for +Cubism, MinGW is not supported for it): + +```powershell +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --config Release --parallel +``` + +On Windows you can also use `build.bat` (wraps `scripts/build-windows.ps1`, +builds into `build-cubism/`, supports `-Clean` and `-Package` arguments and the +`BONGOCAT_BUILD_JOBS` / `BONGOCAT_REQUIRE_CUBISM` / `BONGOCAT_CLEAN_BUILD` +environment variables). + +Executable output paths: +- Linux: `build/BongoCat` +- macOS: `build/BongoCat.app/Contents/MacOS/BongoCat` +- Windows (Visual Studio): `build/Release/BongoCat.exe` + +Without a Cubism SDK the build succeeds but produces the diagnostic backend +(see `src/live2d/live2d_stub.c`); it starts and renders platform diagnostics +but does not render Live2D models. + +### Cubism SDK + +To build the full runtime, place a Cubism SDK for Native at +`vendor/CubismSdkForNative` or pass `-DBONGO_CAT_CUBISM_SDK=/path/to/...`. +`-DBONGO_CAT_REQUIRE_CUBISM=ON` makes configuration fail instead of silently +selecting the diagnostic backend. Windows Cubism builds require MSVC. + +### CMake Options + +| Option | Default | Description | +| --- | --- | --- | +| `BONGO_CAT_FETCH_DEPS` | `ON` | Download pinned third-party dependencies via FetchContent. | +| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Path to the Cubism SDK for Native. | +| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | Fail configuration when a usable Cubism SDK is unavailable. | +| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | Treat compiler warnings as errors (CI builds warning-clean). | + +## Tests + +CTest targets are enabled by default. Always build first, then run: + +```bash +ctest --test-dir build --output-on-failure +# Visual Studio multi-config: +ctest --test-dir build -C Release --output-on-failure +``` + +Test executables (defined in `cmake/Tests.cmake`): `core`, `i18n`, `ui`, +`app-state`, `model-import-unit`; plus `live2d-motion-state` when Cubism is +enabled and `windows-capture` on Windows. Tests live under `tests/`, grouped +by area (`core/`, `i18n/`, `ui/`, `model_import/`, `live2d/`, `platform/`). + +To run a single test: `ctest --test-dir build -R core --output-on-failure` +(`-R` matches test names). + +## Mandatory Validation Gates + +These are part of the `ALL` build target (see CMakeLists.txt) and also run in +CI (`ci.yml`). Any code change must keep them passing: + +1. **`check-lines`** (`cmake/CheckLines.cmake`) - no source file under `src/`, + `include/`, `tests/`, `cmake/`, or the root `CMakeLists.txt` may exceed + **280 lines**. When a file grows past the limit, split it instead of + squeezing logic together. +2. **`check-localization`** (`cmake/CheckLocalization.cmake`) - every i18n key + used in source (quoted keys such as `"native.*"`, `"pages.*"`, + `"components.*"`, `"composables.*"`) must exist in **every** locale JSON in + `resources/assets/locales/`. When adding a UI string, add the key to all + locale files in the same change. +3. **`check-platform-runtime-safety`** (`cmake/CheckPlatformRuntimeSafety.cmake`) + - production code (everything under `src/` except `src/tools/`) must not + call forbidden APIs (process injection, raw input injection, service + control, ptrace equivalents, etc.). Sensitive capabilities are restricted + to specific owner files listed in `SENSITIVE_RULES` (e.g. `SetWindowsHookEx` + only in `src/platform/windows/windows_input.c`, `CGEventTapCreate` only in + `src/platform/macos/macos_input.m`). Never move or duplicate these calls + into other files; add a new owner entry only with clear justification. +4. **`check-cubism-user-model-safety`** + (`cmake/CheckCubismUserModelSafety.cmake`) - guards how user models are + handled in Cubism paths. + +CI (`.github/workflows/ci.yml`) additionally runs Cppcheck, rejects legacy +product-name references, builds warning-clean on Linux, and builds/tests/ +packages on Windows, macOS, and Linux. + +## Architecture + +Entry point: `src/main.c` -> `bongo_cat_app_run` (lifecycle in +`src/runtime/lifecycle/`). One `BongoCatApp` per process, one main-thread +SDL3 event + render loop. + +``` +Platform listeners (keyboard / pointer) [out of main loop] + | atomic edge queue + coalesced pointer slot + SDL wake event + v +main thread: dispatch SDL events, drain input queue, update state + v +model parameters -> Live2D C ABI -> OpenGL composition -> platform presentation +``` + +Key rule: **platform listeners stop at the input boundary**. Native hooks +(Windows low-level hooks, macOS Quartz event tap, Linux XInput2) only publish +timestamped edges/pointer positions to the atomic input state +(`src/core/input_state.c`) and push a wake event. They never call Live2D, +overlay, or UI code directly. Keep this boundary when modifying input code. + +Directory map: + +| Path | Purpose | +| --- | --- | +| `include/bongo_cat/` | Public C API headers (the ABI between the C runtime and the C++ Live2D bridge). | +| `src/core/` | C11 foundation: config, i18n, input state, model catalog, paths, JSON, shortcuts, SHA-256, update checks. | +| `src/runtime/` | Application runtime: `lifecycle/` (startup, loop, shutdown), `input/` (gamepad, mouse, shortcuts), `model/` (catalogs, multi-pet, behavior), `model/import/` (Mver / Tauri / nearby model import), `shell/` (window, tray, menus), `update/`, `diagnostics/`. | +| `src/live2d/` | Live2D bridge: C ABI implementation on Cubism (C++17, only here) and `live2d_stub.c` diagnostic fallback. Cubism types stay behind opaque C handles. | +| `src/platform/` | Per-platform backends: `windows/`, `macos/`, `linux/`, plus `common/`. Presenters, input listeners, layered windows. | +| `src/media/` | Image/audio loading (stb, miniaudio). | +| `src/render/` | GL API helpers and overlay rendering. | +| `src/ui/` | Nuklear-based preferences window: `backend/`, `rendering/`, `theme/`, `preferences/`. Separate SDL/OpenGL window from the pet window. | +| `src/tools/` | Windows-only validation tools (desktop capture, blind tests, metrics); excluded from safety policy. | +| `tests/` | CTest executables grouped by area. | +| `resources/assets/` | Bundled models (`standard`, `keyboard`, `gamepad`), locales, icons. | +| `cmake/` | Build modules: dependencies, Cubism, sources lists, validation checks, packaging, tests. | + +Model packages: **Mver is the canonical format**. Tauri packages are converted +to Mver on import; nearby sources are inspected without installing (cached +under `cache_root`, not `models_root`). + +## Code Conventions + +- Language standards: C11 (no extensions) and C++17 (only for Cubism bridge + code in `src/live2d/`). MSVC additionally requires + `/experimental:c11atomics` for C files using C11 atomics (already wired up + in CMake for existing files; add it for new files with atomics, see + `CMakeLists.txt` and `cmake/Tests.cmake`). +- Formatting: `.editorconfig` - UTF-8, LF line endings, final newline, trimmed + trailing whitespace. +- Warnings: build with `-Wall -Wextra` (MSVC `/W4`). CI builds warning-clean; + prefer keeping new code warning-free. +- The 280-line-per-file limit is enforced; keep new files small and focused. +- New C/C++ source files must be added to the source lists in `cmake/` + (`RuntimeSources.cmake`, `Tests.cmake`, or `CMakeLists.txt` for core) - the + build does not glob. +- Translations: any new UI string needs a key in all + `resources/assets/locales/*.json` files or `check-localization` fails. +- Licensing: source is AGPL-3.0-only; bundled model assets in + `resources/assets/models/{standard,keyboard,gamepad}` are MIT-licensed. Do + not mix asset code into source files or vice versa. + +## Useful Targets + +- `cmake --build build --target check-lines` (and `check-localization`, + `check-platform-runtime-safety`, `check-cubism-user-model-safety`) - run a + single validation gate. +- `bongo_cat_validation_tools` (Windows only) - builds diagnostic viewer/metric + tools under `src/tools/`. + +## Additional Notes + +- `docs/` contains translated READMEs; when changing user-facing build/usage + instructions, update `README.md` and the translations where feasible. +- Windows shell scripting uses PowerShell (`scripts/`, `.github/scripts/`); + keep batch files thin wrappers like `build.bat`. From e7ed6e27c404a72e6c461fd76b6307a04f782faf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B7=E7=9F=B3Boy?= Date: Fri, 4 Sep 2026 21:31:08 +0800 Subject: [PATCH 203/334] docs: sync zh-CN README with main README updates --- docs/README.zh-CN.md | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 8941c639..dcf69b19 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -9,13 +9,26 @@

选择语言 ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia

+

+ + + + + +

> [!TIP] > 演示中使用的模型来自 [宇痕冫](https://space.bilibili.com/348616056)。 > -> 🎁 想找**免费**模型?欢迎访问官网:[bongocat.pet](https://bongocat.pet/models) +> 🎁 想找**免费**模型?我们与才华横溢的模型创作者合作,为您带来丰富多样的免费模型,同时持续探索更多有趣的桌面体验!欢迎访问我们的官方网站:[bongocat.pet](https://bongocat.pet/models) + +

+ + + +

@@ -220,8 +233,17 @@ flowchart TB 这不是因为 Vulkan 不好,而是 BongoCat 不需要那种程度的复杂性。应用主要渲染一个 Live2D 模型、少量 UI 图层和透明桌面窗口,OpenGL 已能轻松满足需求,并且能自然地与 SDL3 及 Cubism 的 OpenGL 渲染器配合。迁移到 Vulkan 将需要在三个桌面平台维护更多渲染和同步代码,却不会为用户带来明显提升。对于 BongoCat 当前的工作负载,OpenGL 让渲染器更精简、更易调试和维护,同时仍能提供所需性能。 ## 🙏 特别感谢 +> [!TIP] +> BongoCat 的每一步都得益于开源精神。我们衷心感谢所有社区贡献者的无私奉献(按贡献日期先后排序列于下方)。正是你们的支持,让桌面陪伴更加自由与真诚。❤️‍🔥 + + + + + + -贡献排行榜 +[linux.do](https://linux.do/t/topic/2845597) +---
版权所有 © 2026 - **BongoCat**
From 90387632619cd6bb4f6783d5df522a8fbf28370b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B7=E7=9F=B3Boy?= Date: Fri, 4 Sep 2026 21:31:30 +0800 Subject: [PATCH 204/334] docs: sync zh-Hant README with main README updates --- docs/README.zh-Hant.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/README.zh-Hant.md b/docs/README.zh-Hant.md index 0e29e8d7..7ba3dab9 100644 --- a/docs/README.zh-Hant.md +++ b/docs/README.zh-Hant.md @@ -35,9 +35,15 @@ > [!TIP] > 本示範所使用的模型來自 [宇痕冫](https://space.bilibili.com/348616056)。 > -> 🎁 尋找**免費**模型?請瀏覽我們的官方網站:[bongocat.pet](https://bongocat.pet/models) +> 🎁 尋找**免費**模型?我們與才華洋溢的模型創作者合作,為您帶來豐富多樣的免費模型,同時持續探索更多有趣的桌面體驗!請瀏覽我們的官方網站:[bongocat.pet](https://bongocat.pet/models) +

+ + + +

+

@@ -67,7 +73,7 @@ BongoCat 使用 CMake,需要 C11 編譯器、C++17 編譯器、CMake 3.24 或 ```bash sudo apt-get update sudo apt-get install -y build-essential cmake ninja-build \ - libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev libcurl4-openssl-dev + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev libcurl4-openssl-dev ``` ### 🔧 配置與建置 @@ -261,14 +267,16 @@ flowchart TB ## 🙏 特別感謝 - - - 貢獻者排行榜 - +> [!TIP] +> BongoCat 的每一步都得益於開源精神。我們衷心感謝所有社群貢獻者的無私奉獻(按貢獻日期先後排序列於下方)。正是你們的支持,讓桌面陪伴更加自由與真誠。❤️‍🔥 + + + +[linux.do](https://linux.do/t/topic/2845597) ---
From 6a16bea66f985404fb7f7f0310ca39b3124a07ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B7=E7=9F=B3Boy?= Date: Fri, 4 Sep 2026 21:31:51 +0800 Subject: [PATCH 205/334] docs: sync fr-FR README with main README updates --- docs/README.fr-FR.md | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/docs/README.fr-FR.md b/docs/README.fr-FR.md index 35b5737e..d44854aa 100644 --- a/docs/README.fr-FR.md +++ b/docs/README.fr-FR.md @@ -33,7 +33,14 @@ > [!TIP] > Le modèle présenté dans cette démonstration provient de [宇痕冫](https://space.bilibili.com/348616056). > -> 🎁Vous cherchez des modèles **gratuits** ? Rendez-vous sur notre site officiel : [bongocat.pet](https://bongocat.pet/models) +> 🎁 Vous cherchez des modèles **gratuits** ? Nous collaborons avec des créateurs de modèles talentueux pour vous proposer une grande variété de modèles gratuits, tout en explorant continuellement de nouvelles expériences de bureau amusantes ! Rendez-vous sur notre site officiel : [bongocat.pet](https://bongocat.pet/models) + + +

+ + + +

@@ -72,7 +79,7 @@ contenant `CMakeLists.txt`). ```bash sudo apt-get update sudo apt-get install -y build-essential cmake ninja-build \ - libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev libcurl4-openssl-dev + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev libcurl4-openssl-dev ``` ### 🔧 Configuration et compilation @@ -365,14 +372,16 @@ facile à maintenir, tout en fournissant les performances dont nous avons besoin ![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Image d'analyse Repobeats") ## 🙏 Remerciements - - - Classement des contributeurs - +> [!TIP] +> Chaque pas de BongoCat est porté par l'esprit de l'open source. Nous remercions sincèrement tous les contributeurs de la communauté pour leurs contributions désintéressées (listées ci-dessous par ordre chronologique de contribution). C'est votre soutien qui rend l'accompagnement sur le bureau plus libre et plus authentique.❤️‍🔥 + + + +[linux.do](https://linux.do/t/topic/2845597) ---

From 37734b504bbb33d5d7eb2eb3a3fca930d2c3e13d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B7=E7=9F=B3Boy?= Date: Fri, 4 Sep 2026 21:32:19 +0800 Subject: [PATCH 206/334] docs: sync de-DE README with main README updates --- docs/README.de-DE.md | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/docs/README.de-DE.md b/docs/README.de-DE.md index 61437162..fc7cfc60 100644 --- a/docs/README.de-DE.md +++ b/docs/README.de-DE.md @@ -9,13 +9,26 @@

Sprache wählen ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia

+

+ + + + + +

> [!TIP] > Das in der Demo verwendete Modell stammt von [宇痕冫](https://space.bilibili.com/348616056). > -> 🎁 Auf der Suche nach **kostenlosen** Modellen? Besuche unsere offizielle Website: [bongocat.pet](https://bongocat.pet/models) +> 🎁 Auf der Suche nach **kostenlosen** Modellen? Wir arbeiten mit talentierten Modellerstellern zusammen, um dir eine große Auswahl an kostenlosen Modellen zu bieten – und erkunden daneben ständig weitere unterhaltsame Desktop-Erlebnisse! Besuche unsere offizielle Website: [bongocat.pet](https://bongocat.pet/models) + +

+ + + +

@@ -220,8 +233,17 @@ Nein. BongoCat verarbeitet Tastatur- und Mauseingaben lokal, um Animationen und Nicht weil Vulkan schlecht ist, sondern weil BongoCat diese Komplexität nicht benötigt. Die Anwendung rendert hauptsächlich ein Live2D-Modell, einige UI-Ebenen und ein transparentes Desktop-Fenster – das bewältigt OpenGL mühelos, und es harmoniert auf natürliche Weise mit SDL3 und dem OpenGL-Renderer von Cubism. Eine Migration zu Vulkan würde mehr Render- und Synchronisationscode auf drei Desktop-Plattformen erfordern, ohne dem Nutzer einen spürbaren Vorteil zu bringen. Für die aktuellen Arbeitslasten von BongoCat macht OpenGL den Renderer schlanker, besser debugbar und wartbarer und liefert weiterhin die benötigte Leistung. ## 🙏 Besonderer Dank +> [!TIP] +> Jeder Schritt von BongoCat wird vom Open-Source-Gedanken getragen. Wir danken allen Community-Beitragenden aufrichtig für ihren selbstlosen Beitrag (unten nach Beitragsdatum sortiert aufgeführt). Eure Unterstützung macht die Begleitung auf dem Desktop freier und echten.❤️‍🔥 + + + + + + -Beiträger-Rangliste +[linux.do](https://linux.do/t/topic/2845597) +---
Copyright © 2026 - **BongoCat**
From 3aed9daa68848a8e7272824022a58d9bd4b323ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B7=E7=9F=B3Boy?= Date: Fri, 4 Sep 2026 21:32:43 +0800 Subject: [PATCH 207/334] docs: sync ja-JP README with main README updates --- docs/README.ja-JP.md | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/docs/README.ja-JP.md b/docs/README.ja-JP.md index 07282d82..e02ff085 100644 --- a/docs/README.ja-JP.md +++ b/docs/README.ja-JP.md @@ -2,10 +2,24 @@ 言語を選択 ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • [Bahasa Indonesia](README.id-ID.md) +

+ + + + + +

+ > [!TIP] > デモで使用されているモデルは [宇痕冫](https://space.bilibili.com/348616056) から提供されています。 > -> 🎁 **無料**のモデルをお探しですか?公式サイトをご覧ください:[bongocat.pet](https://bongocat.pet/models) +> 🎁 **無料**のモデルをお探しですか?私たちは才能あるモデルクリエイターと協力して、多彩な無料モデルをお届けするとともに、楽しいデスクトップ体験を継続的に探求しています!公式サイトをご覧ください:[bongocat.pet](https://bongocat.pet/models) + +

+ + + +

## 📥 ダウンロード @@ -26,7 +40,7 @@ BongoCat は CMake を使用しており、C11 コンパイラ、C++17 コンパ ```bash sudo apt-get update sudo apt-get install -y build-essential cmake ninja-build \ - libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev libcurl4-openssl-dev + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev libcurl4-openssl-dev ``` ### 🔧 設定とビルド @@ -230,9 +244,22 @@ Preferences --> Desktop これは Vulkan が良くないからではなく、BongoCat がそのレベルの複雑さを必要としないからです。このアプリは主に 1 つの Live2D モデル、少数の UI レイヤー、透明なデスクトップウィンドウをレンダリングします。OpenGL で十分にまかなえ、SDL3 および Cubism の OpenGL レンダラーとも自然に連携します。Vulkan への移行は 3 つのデスクトッププラットフォームでより多くのレンダリングと同期コードを維持する必要があり、ユーザーにとって明確な利点はありません。BongoCat の現在のワークロードでは、OpenGL によってレンダラーはより簡潔でデバッグ・保守が容易になり、必要なパフォーマンスを維持できます。 ## 🙏 謝辞 +> [!TIP] +> BongoCat の一歩一歩はオープンソースの精神に支えられています。コミュニティのコントリビューターの皆さんの無私の貢献に心より感謝します(貢献日の古い順に記載)。皆さんのご支援こそが、デスクトップでの伴走をより自由で温かいものにしています。❤️‍🔥 + -© 2026 - **BongoCat** + + + -By vladelaina +[linux.do](https://linux.do/t/topic/2845597) +--- + +
+ +Copyright © 2026 - **BongoCat**\ +By vladelaina\ Made with ❤️ & ⌨️ + +
From cc8d19a0431be26b79c6e0d421d7e6133524847a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B7=E7=9F=B3Boy?= Date: Fri, 4 Sep 2026 21:33:22 +0800 Subject: [PATCH 208/334] docs: sync ko-KR README with main README updates --- docs/README.ko-KR.md | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/docs/README.ko-KR.md b/docs/README.ko-KR.md index 5eb81eba..ffca38d3 100644 --- a/docs/README.ko-KR.md +++ b/docs/README.ko-KR.md @@ -9,13 +9,26 @@

언어 선택 ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia

+

+ + + + + +

> [!TIP] > 데모에 사용된 모델은 [宇痕冫](https://space.bilibili.com/348616056)님의 작품입니다. > -> 🎁 **무료** 모델을 찾으시나요? 공식 웹사이트를 방문하세요: [bongocat.pet](https://bongocat.pet/models) +> 🎁 **무료** 모델을 찾으시나요? 저희는 재능 있는 모델 크리에이터들과 협력하여 다양한 무료 모델을 제공하고, 더 재미있는 데스크톱 경험도 끊임없이 탐색하고 있습니다! 공식 웹사이트를 방문하세요: [bongocat.pet](https://bongocat.pet/models) + +

+ + + +

@@ -42,7 +55,7 @@ BongoCat은 CMake를 사용하며, C11 컴파일러, C++17 컴파일러, CMake 3 ```bash sudo apt-get update sudo apt-get install -y build-essential cmake ninja-build \ - libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev libcurl4-openssl-dev + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev libcurl4-openssl-dev ``` ### 🔧 구성 및 빌드 @@ -220,8 +233,17 @@ flowchart TB Vulkan이 나쁘기 때문이 아니라, BongoCat이 그 정도의 복잡성을 필요로 하지 않기 때문입니다. 애플리케이션은 주로 하나의 Live2D 모델, 소량의 UI 레이어 및 투명 데스크톱 창을 렌더링하며, OpenGL은 이를 쉽게 충족할 수 있고 SDL3 및 Cubism의 OpenGL 렌더러와 자연스럽게 통합됩니다. Vulkan으로 마이그레이션하려면 세 가지 데스크톱 플랫폼에서 더 많은 렌더링 및 동기화 코드를 유지 관리해야 하지만, 사용자에게 눈에 띄는 개선은 없을 것입니다. BongoCat의 현재 워크로드에서 OpenGL은 렌더러를 더 간결하고 디버깅 및 유지 관리하기 쉽게 유지하면서도 필요한 성능을 제공합니다. ## 🙏 특별 감사 +> [!TIP] +> BongoCat의 모든 걸음은 오픈 소스 정신으로 이루어집니다. 커뮤니티 기여자분들의 사심 없는 기여에 진심으로 감사드립니다(기여 날짜순 정렬). 여러분의 지원이 데스크톱 동반을 더욱 자유롭고 진심 어린 것으로 만들어 줍니다.❤️‍🔥 + + + + + + -기여자 순위 +[linux.do](https://linux.do/t/topic/2845597) +---
저작권 © 2026 - **BongoCat**
From f87ba1de277ec74c8928f158b5a5246ccc77bf6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B7=E7=9F=B3Boy?= Date: Fri, 4 Sep 2026 21:34:08 +0800 Subject: [PATCH 209/334] docs: sync pt-BR README with main README updates --- docs/README.pt-BR.md | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/docs/README.pt-BR.md b/docs/README.pt-BR.md index 5b6160a3..9d85425a 100644 --- a/docs/README.pt-BR.md +++ b/docs/README.pt-BR.md @@ -9,13 +9,26 @@

Escolha o idioma ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia

+

+ + + + + +

> [!TIP] > O modelo usado na demonstração é de [宇痕冫](https://space.bilibili.com/348616056). > -> 🎁 Procurando modelos **gratuitos**? Visite nosso site oficial: [bongocat.pet](https://bongocat.pet/models) +> 🎁 Procurando modelos **gratuitos**? Trabalhamos com criadores de modelos talentosos para trazer uma grande variedade de modelos gratuitos, enquanto exploramos continuamente experiências de desktop ainda mais divertidas! Visite nosso site oficial: [bongocat.pet](https://bongocat.pet/models) + +

+ + + +

@@ -220,8 +233,17 @@ Não. O BongoCat processa entradas de teclado e mouse localmente para impulsiona Não porque Vulkan seja ruim, mas porque o BongoCat não precisa desse nível de complexidade. O aplicativo renderiza principalmente um modelo Live2D, algumas camadas de interface e uma janela de desktop transparente; o OpenGL dá conta disso facilmente e se integra naturalmente com o SDL3 e o renderizador OpenGL do Cubism. Migrar para Vulkan exigiria manter mais código de renderização e sincronização em três plataformas de desktop, sem benefício perceptível para o usuário. Para a carga de trabalho atual do BongoCat, o OpenGL torna o renderizador mais enxuto, mais fácil de depurar e manter, continuando a oferecer o desempenho necessário. ## 🙏 Agradecimentos especiais +> [!TIP] +> Cada passo do BongoCat é movido pelo espírito do código aberto. Agradecemos sinceramente a todos os colaboradores da comunidade por suas contribuições altruístas (listadas abaixo em ordem cronológica de contribuição). É o apoio de vocês que torna a companhia no desktop mais livre e genuína.❤️‍🔥 + + + + + + -Ranking de contribuidores +[linux.do](https://linux.do/t/topic/2845597) +---
Copyright © 2026 - **BongoCat**
From 8971180fec788c2320f15fcc651ec1b593f3d871 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B7=E7=9F=B3Boy?= Date: Fri, 4 Sep 2026 21:34:52 +0800 Subject: [PATCH 210/334] docs: sync ru-RU README with main README updates --- docs/README.ru-RU.md | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/docs/README.ru-RU.md b/docs/README.ru-RU.md index 668a5f7d..6d0325a8 100644 --- a/docs/README.ru-RU.md +++ b/docs/README.ru-RU.md @@ -9,13 +9,26 @@

Выберите язык ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia

+

+ + + + + +

> [!TIP] > Модель, использованная в демо, принадлежит [宇痕冫](https://space.bilibili.com/348616056). > -> 🎁 Ищете **бесплатные** модели? Загляните на наш официальный сайт: [bongocat.pet](https://bongocat.pet/models) +> 🎁 Ищете **бесплатные** модели? Мы сотрудничаем с талантливыми создателями моделей, чтобы предложить вам широкий выбор бесплатных моделей, и постоянно исследуем новые интересные возможности для рабочего стола! Загляните на наш официальный сайт: [bongocat.pet](https://bongocat.pet/models) + +

+ + + +

@@ -220,8 +233,17 @@ flowchart TB Не потому что Vulkan плох, а потому что BongoCat не нуждается в такой сложности. Приложение в основном рендерит одну модель Live2D, несколько слоёв интерфейса и прозрачное окно рабочего стола; OpenGL легко справляется с этим и естественно сочетается с SDL3 и рендерером OpenGL от Cubism. Переход на Vulkan потребовал бы поддержки большего объёма кода рендеринга и синхронизации на трёх настольных платформах без заметного преимущества для пользователя. Для текущей нагрузки BongoCat OpenGL делает рендерер более компактным, лёгким в отладке и обслуживании, сохраняя при этом необходимую производительность. ## 🙏 Особая благодарность +> [!TIP] +> Каждый шаг BongoCat вдохновлён духом открытого исходного кода. Мы искренне благодарим всех участников сообщества за их бескорыстный вклад (перечислен ниже в хронологическом порядке). Именно ваша поддержка делает настольного спутника более свободным и искренним.❤️‍🔥 + + + + + + -Рейтинг контрибьюторов +[linux.do](https://linux.do/t/topic/2845597) +---
Авторские права © 2026 - **BongoCat**
From 34068bad879430ec6a8e80db8e21fefb0868cb10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B7=E7=9F=B3Boy?= Date: Fri, 4 Sep 2026 21:35:38 +0800 Subject: [PATCH 211/334] docs: sync es-ES README with main README updates --- docs/README.es-ES.md | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/docs/README.es-ES.md b/docs/README.es-ES.md index 39c21249..d85c9ba2 100644 --- a/docs/README.es-ES.md +++ b/docs/README.es-ES.md @@ -9,13 +9,26 @@

Elige el idioma ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia

+

+ + + + + +

> [!TIP] > El modelo usado en la demo proviene de [宇痕冫](https://space.bilibili.com/348616056). > -> 🎁 ¿Buscas modelos **gratuitos**? Visita nuestro sitio web oficial: [bongocat.pet](https://bongocat.pet/models) +> 🎁 ¿Buscas modelos **gratuitos**? Trabajamos con creadores de modelos talentosos para ofrecerte una gran variedad de modelos gratuitos, ¡mientras seguimos explorando experiencias de escritorio aún más divertidas! Visita nuestro sitio web oficial: [bongocat.pet](https://bongocat.pet/models) + +

+ + + +

@@ -220,8 +233,17 @@ No. BongoCat procesa las entradas de teclado y ratón localmente para impulsar a No porque Vulkan sea malo, sino porque BongoCat no necesita ese nivel de complejidad. La aplicación renderiza principalmente un modelo Live2D, algunas capas de interfaz y una ventana de escritorio transparente; OpenGL lo cubre con facilidad y se integra de forma natural con SDL3 y el renderizador OpenGL de Cubism. Migrar a Vulkan implicaría mantener más código de renderizado y sincronización en tres plataformas de escritorio, sin un beneficio notable para el usuario. Para la carga de trabajo actual de BongoCat, OpenGL hace que el renderizador sea más ligero, más fácil de depurar y mantener, y sigue ofreciendo el rendimiento necesario. ## 🙏 Agradecimientos especiales +> [!TIP] +> Cada paso de BongoCat está impulsado por el espíritu del código abierto. Agradecemos sinceramente a todos los colaboradores de la comunidad por sus contribuciones desinteresadas (listadas a continuación en orden cronológico). Es vuestro apoyo lo que hace que la compañía en el escritorio sea más libre y genuina.❤️‍🔥 + + + + + + -Clasificación de contribuyentes +[linux.do](https://linux.do/t/topic/2845597) +---
Derechos de autor © 2026 - **BongoCat**
From f690842b3c1fcf319e3275e8217b2c2d792bd2c9 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 4 Sep 2026 22:14:18 +0800 Subject: [PATCH 212/334] =?UTF-8?q?=F0=9F=8C=9F=20chore:=20refresh=20devel?= =?UTF-8?q?opment=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 195 ------------------------------------------------------ 1 file changed, 195 deletions(-) delete mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 252a6fc5..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,195 +0,0 @@ -# AGENTS.md - -Guidance for AI coding agents working in this repository. - -## Project Overview - -BongoCat is a cross-platform desktop pet (Windows / macOS / Linux) written in -C11 with a C++17 Live2D bridge. It renders a Live2D (or built-in) model in a -transparent desktop window using SDL3 and OpenGL, and animates it from -keyboard / mouse / gamepad input. Public headers live in `include/bongo_cat/`; -the runtime is built as static libraries (`bongo_cat_core`, `bongo_cat_runtime`) -linked into one executable (`src/main.c` -> `bongo_cat_app_run`). - -Third-party deps (SDL3, yyjson, stb, miniaudio, Nuklear) are fetched at -configure time via `BONGO_CAT_FETCH_DEPS=ON` (default), so the first configure -needs network access. The Live2D Cubism SDK is optional and never fetched: see -"Cubism SDK" below. - -## Build Commands - -Run from the project root (the directory containing `CMakeLists.txt`). - -Linux / macOS (single-config generator): - -```bash -cmake -S . -B build -G Ninja \ - -DCMAKE_BUILD_TYPE=Release \ - -DBONGO_CAT_FETCH_DEPS=ON -cmake --build build --parallel -``` - -Windows (from a Visual Studio 2022 developer shell; MSVC is required for -Cubism, MinGW is not supported for it): - -```powershell -cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` - -DBONGO_CAT_FETCH_DEPS=ON -cmake --build build --config Release --parallel -``` - -On Windows you can also use `build.bat` (wraps `scripts/build-windows.ps1`, -builds into `build-cubism/`, supports `-Clean` and `-Package` arguments and the -`BONGOCAT_BUILD_JOBS` / `BONGOCAT_REQUIRE_CUBISM` / `BONGOCAT_CLEAN_BUILD` -environment variables). - -Executable output paths: -- Linux: `build/BongoCat` -- macOS: `build/BongoCat.app/Contents/MacOS/BongoCat` -- Windows (Visual Studio): `build/Release/BongoCat.exe` - -Without a Cubism SDK the build succeeds but produces the diagnostic backend -(see `src/live2d/live2d_stub.c`); it starts and renders platform diagnostics -but does not render Live2D models. - -### Cubism SDK - -To build the full runtime, place a Cubism SDK for Native at -`vendor/CubismSdkForNative` or pass `-DBONGO_CAT_CUBISM_SDK=/path/to/...`. -`-DBONGO_CAT_REQUIRE_CUBISM=ON` makes configuration fail instead of silently -selecting the diagnostic backend. Windows Cubism builds require MSVC. - -### CMake Options - -| Option | Default | Description | -| --- | --- | --- | -| `BONGO_CAT_FETCH_DEPS` | `ON` | Download pinned third-party dependencies via FetchContent. | -| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Path to the Cubism SDK for Native. | -| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | Fail configuration when a usable Cubism SDK is unavailable. | -| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | Treat compiler warnings as errors (CI builds warning-clean). | - -## Tests - -CTest targets are enabled by default. Always build first, then run: - -```bash -ctest --test-dir build --output-on-failure -# Visual Studio multi-config: -ctest --test-dir build -C Release --output-on-failure -``` - -Test executables (defined in `cmake/Tests.cmake`): `core`, `i18n`, `ui`, -`app-state`, `model-import-unit`; plus `live2d-motion-state` when Cubism is -enabled and `windows-capture` on Windows. Tests live under `tests/`, grouped -by area (`core/`, `i18n/`, `ui/`, `model_import/`, `live2d/`, `platform/`). - -To run a single test: `ctest --test-dir build -R core --output-on-failure` -(`-R` matches test names). - -## Mandatory Validation Gates - -These are part of the `ALL` build target (see CMakeLists.txt) and also run in -CI (`ci.yml`). Any code change must keep them passing: - -1. **`check-lines`** (`cmake/CheckLines.cmake`) - no source file under `src/`, - `include/`, `tests/`, `cmake/`, or the root `CMakeLists.txt` may exceed - **280 lines**. When a file grows past the limit, split it instead of - squeezing logic together. -2. **`check-localization`** (`cmake/CheckLocalization.cmake`) - every i18n key - used in source (quoted keys such as `"native.*"`, `"pages.*"`, - `"components.*"`, `"composables.*"`) must exist in **every** locale JSON in - `resources/assets/locales/`. When adding a UI string, add the key to all - locale files in the same change. -3. **`check-platform-runtime-safety`** (`cmake/CheckPlatformRuntimeSafety.cmake`) - - production code (everything under `src/` except `src/tools/`) must not - call forbidden APIs (process injection, raw input injection, service - control, ptrace equivalents, etc.). Sensitive capabilities are restricted - to specific owner files listed in `SENSITIVE_RULES` (e.g. `SetWindowsHookEx` - only in `src/platform/windows/windows_input.c`, `CGEventTapCreate` only in - `src/platform/macos/macos_input.m`). Never move or duplicate these calls - into other files; add a new owner entry only with clear justification. -4. **`check-cubism-user-model-safety`** - (`cmake/CheckCubismUserModelSafety.cmake`) - guards how user models are - handled in Cubism paths. - -CI (`.github/workflows/ci.yml`) additionally runs Cppcheck, rejects legacy -product-name references, builds warning-clean on Linux, and builds/tests/ -packages on Windows, macOS, and Linux. - -## Architecture - -Entry point: `src/main.c` -> `bongo_cat_app_run` (lifecycle in -`src/runtime/lifecycle/`). One `BongoCatApp` per process, one main-thread -SDL3 event + render loop. - -``` -Platform listeners (keyboard / pointer) [out of main loop] - | atomic edge queue + coalesced pointer slot + SDL wake event - v -main thread: dispatch SDL events, drain input queue, update state - v -model parameters -> Live2D C ABI -> OpenGL composition -> platform presentation -``` - -Key rule: **platform listeners stop at the input boundary**. Native hooks -(Windows low-level hooks, macOS Quartz event tap, Linux XInput2) only publish -timestamped edges/pointer positions to the atomic input state -(`src/core/input_state.c`) and push a wake event. They never call Live2D, -overlay, or UI code directly. Keep this boundary when modifying input code. - -Directory map: - -| Path | Purpose | -| --- | --- | -| `include/bongo_cat/` | Public C API headers (the ABI between the C runtime and the C++ Live2D bridge). | -| `src/core/` | C11 foundation: config, i18n, input state, model catalog, paths, JSON, shortcuts, SHA-256, update checks. | -| `src/runtime/` | Application runtime: `lifecycle/` (startup, loop, shutdown), `input/` (gamepad, mouse, shortcuts), `model/` (catalogs, multi-pet, behavior), `model/import/` (Mver / Tauri / nearby model import), `shell/` (window, tray, menus), `update/`, `diagnostics/`. | -| `src/live2d/` | Live2D bridge: C ABI implementation on Cubism (C++17, only here) and `live2d_stub.c` diagnostic fallback. Cubism types stay behind opaque C handles. | -| `src/platform/` | Per-platform backends: `windows/`, `macos/`, `linux/`, plus `common/`. Presenters, input listeners, layered windows. | -| `src/media/` | Image/audio loading (stb, miniaudio). | -| `src/render/` | GL API helpers and overlay rendering. | -| `src/ui/` | Nuklear-based preferences window: `backend/`, `rendering/`, `theme/`, `preferences/`. Separate SDL/OpenGL window from the pet window. | -| `src/tools/` | Windows-only validation tools (desktop capture, blind tests, metrics); excluded from safety policy. | -| `tests/` | CTest executables grouped by area. | -| `resources/assets/` | Bundled models (`standard`, `keyboard`, `gamepad`), locales, icons. | -| `cmake/` | Build modules: dependencies, Cubism, sources lists, validation checks, packaging, tests. | - -Model packages: **Mver is the canonical format**. Tauri packages are converted -to Mver on import; nearby sources are inspected without installing (cached -under `cache_root`, not `models_root`). - -## Code Conventions - -- Language standards: C11 (no extensions) and C++17 (only for Cubism bridge - code in `src/live2d/`). MSVC additionally requires - `/experimental:c11atomics` for C files using C11 atomics (already wired up - in CMake for existing files; add it for new files with atomics, see - `CMakeLists.txt` and `cmake/Tests.cmake`). -- Formatting: `.editorconfig` - UTF-8, LF line endings, final newline, trimmed - trailing whitespace. -- Warnings: build with `-Wall -Wextra` (MSVC `/W4`). CI builds warning-clean; - prefer keeping new code warning-free. -- The 280-line-per-file limit is enforced; keep new files small and focused. -- New C/C++ source files must be added to the source lists in `cmake/` - (`RuntimeSources.cmake`, `Tests.cmake`, or `CMakeLists.txt` for core) - the - build does not glob. -- Translations: any new UI string needs a key in all - `resources/assets/locales/*.json` files or `check-localization` fails. -- Licensing: source is AGPL-3.0-only; bundled model assets in - `resources/assets/models/{standard,keyboard,gamepad}` are MIT-licensed. Do - not mix asset code into source files or vice versa. - -## Useful Targets - -- `cmake --build build --target check-lines` (and `check-localization`, - `check-platform-runtime-safety`, `check-cubism-user-model-safety`) - run a - single validation gate. -- `bongo_cat_validation_tools` (Windows only) - builds diagnostic viewer/metric - tools under `src/tools/`. - -## Additional Notes - -- `docs/` contains translated READMEs; when changing user-facing build/usage - instructions, update `README.md` and the translations where feasible. -- Windows shell scripting uses PowerShell (`scripts/`, `.github/scripts/`); - keep batch files thin wrappers like `build.bat`. From 1ffab85699e67ed4e12fa9f91a8fa8cc94b8e416 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 5 Sep 2026 02:04:14 +0800 Subject: [PATCH 213/334] Fix mouse tracking when games lock cursor --- README.md | 3 +- include/bongo_cat/app.h | 3 + include/bongo_cat/platform.h | 4 + scripts/build-windows.ps1 | 43 ++++++-- src/platform/linux/linux.c | 7 ++ src/platform/macos/macos.m | 7 ++ src/platform/windows/windows.c | 35 ++++++- src/platform/windows/windows_direct_input.c | 87 +++++++++++++--- src/platform/windows/windows_direct_input.h | 2 + src/platform/windows/windows_input.c | 105 ++++++++++++++++++-- src/runtime/input/mouse.c | 102 +++++++++++++++++-- src/runtime/input/mouse_mapping.c | 36 ++++++- src/runtime/runtime.h | 4 +- 13 files changed, 386 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index e6011728..d18fd299 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,8 @@ to the bounded atomic queue and publish pointer coordinates through a separate coalescing slot; a successful publish pushes a native SDL wake event. This keeps high-frequency motion from displacing ordered key and button edges. On Windows, DirectInput is used only through the platform pointer interface when a -model requests relative movement. SDL3 window, preferences, and gamepad events +model requests relative movement or a foreground application locks the cursor. +SDL3 window, preferences, and gamepad events are handled on the main thread, where gamepad events are normalized before they reach model parameters or shortcuts. No platform listener calls Live2D, overlay, or UI code directly. diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index ec55375c..cbb093a8 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -125,6 +125,8 @@ typedef struct BongoCatApp { uint64_t pointer_hit_deadline_ns; uint64_t display_recovery_due_ns; uint64_t mouse_last_ns; + uint64_t mouse_diagnostic_due_ns; + uint64_t mouse_hook_samples; uint64_t frame_audit_bmp_ns; uint64_t random_expression_due_ns; float random_expression_interval_seconds; @@ -147,6 +149,7 @@ typedef struct BongoCatApp { bool side_mouse_down; bool model_pointer_anchor_ready; float model_pointer_anchor_x, model_pointer_anchor_y; + bool pointer_relative_active; bool window_minimized; double pointer_x, pointer_y; bool resize_gesture; diff --git a/include/bongo_cat/platform.h b/include/bongo_cat/platform.h index 4a656d60..2cd8bbf1 100644 --- a/include/bongo_cat/platform.h +++ b/include/bongo_cat/platform.h @@ -14,6 +14,7 @@ typedef struct BongoCatPlatform { void *native; void *presenter; void *relative_pointer; + uint64_t relative_pointer_retry_ms; uint32_t wake_event_type; float window_opacity; } BongoCatPlatform; @@ -96,9 +97,12 @@ bool bongo_cat_platform_frame_alpha(const BongoCatPlatform *platform, void bongo_cat_platform_set_visible(BongoCatPlatform *platform, bool visible); bool bongo_cat_platform_pointer_local(BongoCatPlatform *platform, double screen_x, double screen_y, float *local_x, float *local_y); +/* Reports a foreground application's fixed/locked system cursor state. */ +bool bongo_cat_platform_pointer_locked(BongoCatPlatform *platform); bool bongo_cat_platform_relative_pointer(BongoCatPlatform *platform, double *x, double *y); void bongo_cat_platform_relative_pointer_reset(BongoCatPlatform *platform); +void bongo_cat_platform_relative_pointer_release(BongoCatPlatform *platform); void bongo_cat_platform_set_always_on_top(BongoCatPlatform *platform, bool enabled); void bongo_cat_platform_raise_window(SDL_Window *window); /* Configure platform-native chrome for the preferences window when available. */ diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 index 93426abf..a8ac5cfa 100644 --- a/scripts/build-windows.ps1 +++ b/scripts/build-windows.ps1 @@ -50,6 +50,21 @@ function Show-FailureLog { } } +function Test-NsisCompiler { + param([string]$Path) + if (-not $Path -or + -not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $false } + try { + $versionOutput = @(& $Path /VERSION 2>&1) + $versionStatus = $LASTEXITCODE + } catch { + return $false + } + if ($versionStatus -ne 0) { return $false } + $versionText = ($versionOutput | ForEach-Object { $_.ToString() }) -join "`n" + return $versionText.Trim() -match '^v?\d+(?:\.\d+)+$' +} + if (-not (Get-Command cmake -ErrorAction SilentlyContinue)) { Write-Host 'Error: CMake was not found in PATH.' exit 1 @@ -57,7 +72,9 @@ if (-not (Get-Command cmake -ErrorAction SilentlyContinue)) { if ($Package) { $makensis = Get-Command makensis.exe -ErrorAction SilentlyContinue - if (-not $makensis) { + $makensisPath = if ($makensis -and + (Test-NsisCompiler $makensis.Source)) { $makensis.Source } else { $null } + if (-not $makensisPath) { $nsisCandidates = @() foreach ($programFiles in @(${env:ProgramFiles(x86)}, $env:ProgramFiles)) { if ($programFiles) { @@ -73,25 +90,35 @@ if ($Package) { Get-ItemProperty -Path $key -Name InstallLocation ` -ErrorAction SilentlyContinue | ForEach-Object { if ($_.InstallLocation) { - $nsisCandidates += Join-Path $_.InstallLocation 'makensis.exe' - $nsisCandidates += Join-Path $_.InstallLocation 'Bin\makensis.exe' + # Some uninstall registry entries store the path with + # surrounding quotes (for example, `"C:\Program Files\NSIS"`). + # Strip those quotes before passing it to Join-Path; + # otherwise PowerShell interprets the drive as `"C`. + $installLocation = ([string]$_.InstallLocation).Trim().Trim('"') + if ($installLocation) { + $nsisCandidates += Join-Path $installLocation 'makensis.exe' + $nsisCandidates += Join-Path $installLocation 'Bin\makensis.exe' + } } } } $nsisPath = $nsisCandidates | - Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | + Select-Object -Unique | + Where-Object { Test-NsisCompiler $_ } | Select-Object -First 1 if ($nsisPath) { $env:Path = "$(Split-Path $nsisPath -Parent);$env:Path" - $makensis = Get-Command makensis.exe -ErrorAction SilentlyContinue + $makensisPath = $nsisPath } } - if (-not $makensis) { - Write-Host 'Package build requires NSIS (makensis.exe), but it was not found.' - Write-Host 'Install NSIS from https://nsis.sourceforge.io/Download and run again.' + if (-not $makensisPath) { + Write-Host 'Package build requires a working NSIS compiler (makensis.exe).' + Write-Host 'NSIS was not found or failed its /VERSION check.' + Write-Host 'Install or repair NSIS from https://nsis.sourceforge.io/Download and run again.' Write-Host 'The normal application build does not require NSIS.' exit 1 } + Write-Host "NSIS compiler: $makensisPath" } if ($Clean -and (Test-Path -LiteralPath $BuildDir)) { diff --git a/src/platform/linux/linux.c b/src/platform/linux/linux.c index 5045a918..f3a44732 100644 --- a/src/platform/linux/linux.c +++ b/src/platform/linux/linux.c @@ -128,6 +128,10 @@ bool bongo_cat_platform_pointer_local(BongoCatPlatform *platform, double screen_ *local_x = (float)(screen_x - x); *local_y = (float)(screen_y - y); return *local_x >= 0 && *local_x < width && *local_y >= 0 && *local_y < height; } +bool bongo_cat_platform_pointer_locked(BongoCatPlatform *platform) { + (void)platform; + return false; +} bool bongo_cat_platform_relative_pointer(BongoCatPlatform *platform, double *x, double *y) { (void)platform; (void)x; (void)y; @@ -136,6 +140,9 @@ bool bongo_cat_platform_relative_pointer(BongoCatPlatform *platform, void bongo_cat_platform_relative_pointer_reset(BongoCatPlatform *platform) { (void)platform; } +void bongo_cat_platform_relative_pointer_release(BongoCatPlatform *platform) { + (void)platform; +} void bongo_cat_platform_set_always_on_top(BongoCatPlatform *platform, bool enabled) { SDL_SetWindowAlwaysOnTop(platform->window, enabled); bongo_cat_linux_x11_configure_capture_window(platform); diff --git a/src/platform/macos/macos.m b/src/platform/macos/macos.m index 50d051ff..8e5186fa 100644 --- a/src/platform/macos/macos.m +++ b/src/platform/macos/macos.m @@ -127,6 +127,10 @@ bool bongo_cat_platform_pointer_local(BongoCatPlatform *platform, double screen_ *local_x = (float)(screen_x - x); *local_y = (float)(screen_y - y); return *local_x >= 0 && *local_x < width && *local_y >= 0 && *local_y < height; } +bool bongo_cat_platform_pointer_locked(BongoCatPlatform *platform) { + (void)platform; + return false; +} bool bongo_cat_platform_relative_pointer(BongoCatPlatform *platform, double *x, double *y) { (void)platform; (void)x; (void)y; @@ -135,6 +139,9 @@ bool bongo_cat_platform_relative_pointer(BongoCatPlatform *platform, void bongo_cat_platform_relative_pointer_reset(BongoCatPlatform *platform) { (void)platform; } +void bongo_cat_platform_relative_pointer_release(BongoCatPlatform *platform) { + (void)platform; +} void bongo_cat_platform_set_always_on_top(BongoCatPlatform *platform, bool enabled) { [native_window(platform) setLevel:enabled ? NSFloatingWindowLevel : NSNormalWindowLevel]; } diff --git a/src/platform/windows/windows.c b/src/platform/windows/windows.c index ac1a9426..375187f4 100644 --- a/src/platform/windows/windows.c +++ b/src/platform/windows/windows.c @@ -61,9 +61,6 @@ BongoCatResult bongo_cat_platform_init(BongoCatPlatform *platform, SDL_Window *w "Global input hooks are unavailable; the window will continue without global input"); } HWND hwnd = native_window(platform); - if (!bongo_cat_windows_direct_input_create(platform, hwnd)) - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "Mver-compatible DirectInput mouse tracking is unavailable"); SetWindowTextW(hwnd, bongo_cat_windows_instance_title()); bongo_cat_windows_borderless_install(hwnd); bongo_cat_windows_capture_configure(hwnd); @@ -138,7 +135,39 @@ void bongo_cat_platform_begin_drag(BongoCatPlatform *platform, bongo_cat_windows_begin_drag(hwnd, modal_tick, userdata); } bool bongo_cat_platform_dynamic_hit_supported(void) { return true; } +bool bongo_cat_platform_pointer_locked(BongoCatPlatform *platform) { + if (!platform) return false; + HWND foreground = GetForegroundWindow(); + DWORD foreground_pid = 0; + if (!foreground || !GetWindowThreadProcessId(foreground, &foreground_pid) || + foreground_pid == GetCurrentProcessId()) return false; + RECT clip = {0}; + if (!GetClipCursor(&clip)) return false; + LONG width = clip.right - clip.left; + LONG height = clip.bottom - clip.top; + return width >= 0 && width <= 2 && height >= 0 && height <= 2; +} +bool bongo_cat_platform_relative_pointer(BongoCatPlatform *platform, + double *x, double *y) { + if (!platform || !x || !y) return false; + uint64_t now_ms = GetTickCount64(); + if (!platform->relative_pointer) { + if (platform->relative_pointer_retry_ms > now_ms) return false; + if (!bongo_cat_windows_direct_input_create(platform, + native_window(platform))) { + platform->relative_pointer_retry_ms = now_ms + 5000; + return false; + } + platform->relative_pointer_retry_ms = 0; + } + return bongo_cat_windows_direct_input_read(platform, x, y); +} void bongo_cat_platform_relative_pointer_reset(BongoCatPlatform *platform) { bongo_cat_windows_direct_input_reset(platform); } +void bongo_cat_platform_relative_pointer_release(BongoCatPlatform *platform) { + if (!platform) return; + bongo_cat_windows_direct_input_destroy(platform); + platform->relative_pointer_retry_ms = 0; +} #endif diff --git a/src/platform/windows/windows_direct_input.c b/src/platform/windows/windows_direct_input.c index 58c349b6..1edbeda6 100644 --- a/src/platform/windows/windows_direct_input.c +++ b/src/platform/windows/windows_direct_input.c @@ -6,6 +6,7 @@ #define DIRECTINPUT_VERSION 0x0800 #endif #include +#include #include #include #include @@ -23,6 +24,8 @@ typedef struct BongoCatDirectInput { unsigned long long failures; long long total_x; long long total_y; + ULONGLONG last_log_ms; + bool diagnostic_ready; } BongoCatDirectInput; void bongo_cat_windows_direct_input_destroy(BongoCatPlatform *platform) { @@ -33,6 +36,9 @@ void bongo_cat_windows_direct_input_destroy(BongoCatPlatform *platform) { state->mouse->lpVtbl->Release(state->mouse); } if (state->input) state->input->lpVtbl->Release(state->input); + if (state->reads) SDL_Log("[input] Relative pointer stopped: reads=%llu " + "reacquires=%llu failures=%llu total=%lld,%lld", state->reads, + state->reacquires, state->failures, state->total_x, state->total_y); if (state->audit) { fprintf(state->audit, "summary reads=%llu reacquires=%llu failures=%llu total_x=%lld total_y=%lld\n", @@ -45,38 +51,67 @@ void bongo_cat_windows_direct_input_destroy(BongoCatPlatform *platform) { } void bongo_cat_windows_direct_input_reset(BongoCatPlatform *platform) { - double x = 0.0, y = 0.0; - bongo_cat_platform_relative_pointer(platform, &x, &y); BongoCatDirectInput *state = platform ? platform->relative_pointer : NULL; - if (state) state->rebase_pending = true; + if (!state) return; + double x = 0.0, y = 0.0; + bongo_cat_windows_direct_input_read(platform, &x, &y); + state->rebase_pending = true; } bool bongo_cat_windows_direct_input_create(BongoCatPlatform *platform, void *window) { if (!platform || !window) return false; BongoCatDirectInput *state = calloc(1, sizeof(*state)); - if (!state || FAILED(DirectInput8Create(GetModuleHandleW(NULL), + if (!state) { + if (!platform->relative_pointer_retry_ms) + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "[input] Relative pointer initialization failed: stage=allocate"); + return false; + } + HRESULT result = DirectInput8Create(GetModuleHandleW(NULL), DIRECTINPUT_VERSION, &IID_IDirectInput8A, (void **)&state->input, - NULL))) { + NULL); + if (FAILED(result)) { + if (!platform->relative_pointer_retry_ms) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "[input] Relative pointer initialization failed: " + "stage=DirectInput8Create result=0x%08lx", + (unsigned long)result); free(state); return false; } platform->relative_pointer = state; - HRESULT result = state->input->lpVtbl->CreateDevice(state->input, + const char *stage = "CreateDevice"; + result = state->input->lpVtbl->CreateDevice(state->input, &GUID_SysMouse, &state->mouse, NULL); - if (SUCCEEDED(result)) result = state->mouse->lpVtbl->SetCooperativeLevel( - state->mouse, (HWND)window, DISCL_NONEXCLUSIVE | DISCL_BACKGROUND); - if (SUCCEEDED(result)) result = state->mouse->lpVtbl->SetDataFormat( - state->mouse, &c_dfDIMouse); + if (SUCCEEDED(result)) { + stage = "SetCooperativeLevel"; + result = state->mouse->lpVtbl->SetCooperativeLevel(state->mouse, + (HWND)window, DISCL_NONEXCLUSIVE | DISCL_BACKGROUND); + } + if (SUCCEEDED(result)) { + stage = "SetDataFormat"; + result = state->mouse->lpVtbl->SetDataFormat(state->mouse, + &c_dfDIMouse); + } DIPROPDWORD property = {{sizeof(property), sizeof(property.diph), 0, DIPH_DEVICE}, 16}; - if (SUCCEEDED(result)) result = state->mouse->lpVtbl->SetProperty( - state->mouse, DIPROP_BUFFERSIZE, &property.diph); - if (SUCCEEDED(result)) result = state->mouse->lpVtbl->Acquire(state->mouse); + if (SUCCEEDED(result)) { + stage = "SetProperty"; + result = state->mouse->lpVtbl->SetProperty(state->mouse, + DIPROP_BUFFERSIZE, &property.diph); + } + if (SUCCEEDED(result)) { + stage = "Acquire"; + result = state->mouse->lpVtbl->Acquire(state->mouse); + } if (SUCCEEDED(result)) { state->absolute_ready = GetPhysicalCursorPos(&state->absolute) != FALSE; state->rebase_pending = true; state->last_read_ms = GetTickCount64(); + SDL_Log("[input] Relative pointer initialized: mode=DirectInput " + "cooperative=background-nonexclusive physical_cursor=%d", + state->absolute_ready); const char *audit_path = getenv("BONGO_CAT_DIRECT_INPUT_AUDIT_FILE"); if (audit_path && audit_path[0]) { state->audit = bongo_cat_file_open(audit_path, "wb"); @@ -87,11 +122,15 @@ bool bongo_cat_windows_direct_input_create(BongoCatPlatform *platform, } return true; } + if (!platform->relative_pointer_retry_ms) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "[input] Relative pointer initialization failed: " + "stage=%s result=0x%08lx", stage, (unsigned long)result); bongo_cat_windows_direct_input_destroy(platform); return false; } -bool bongo_cat_platform_relative_pointer(BongoCatPlatform *platform, +bool bongo_cat_windows_direct_input_read(BongoCatPlatform *platform, double *x, double *y) { BongoCatDirectInput *state = platform ? platform->relative_pointer : NULL; if (!state || !state->mouse || !x || !y) return false; @@ -121,8 +160,9 @@ bool bongo_cat_platform_relative_pointer(BongoCatPlatform *platform, if (reacquired) state->reacquires++; bool sample_ready = SUCCEEDED(result); bool available = sample_ready || fallback_ready; - bool rebase = state->rebase_pending || stale || reacquired || !sample_ready; - bool use_fallback = !stale && fallback_ready && (!sample_ready || + bool rebase = state->rebase_pending || stale || + (!sample_ready && !fallback_ready); + bool use_fallback = !stale && fallback_ready && (!sample_ready || reacquired || ((mouse.lX == 0 && mouse.lY == 0) && (fallback_x != 0 || fallback_y != 0))); if (rebase) { @@ -142,6 +182,21 @@ bool bongo_cat_platform_relative_pointer(BongoCatPlatform *platform, state->total_y += (long long)*y; state->rebase_pending = !available; if (!sample_ready) state->failures++; + bool log_due = !state->diagnostic_ready || + now_ms - state->last_log_ms >= 30000; + if (log_due) { + SDL_Log("[input] Relative pointer sample: result=0x%08lx " + "sample=%d reacquired=%d stale=%d physical=%d fallback=%d " + "rebase=%d direct=%ld,%ld physical_delta=%ld,%ld " + "output=%.0f,%.0f reads=%llu failures=%llu reacquires=%llu", + (unsigned long)result, sample_ready, reacquired, stale, + fallback_ready, use_fallback, rebase, + (long)mouse.lX, (long)mouse.lY, + (long)fallback_x, (long)fallback_y, *x, *y, + state->reads, state->failures, state->reacquires); + state->last_log_ms = now_ms; + } + state->diagnostic_ready = true; if (state->audit) { fprintf(state->audit, "read=%llu result=0x%08lx reacquired=%d stale=%d rebase=%d fallback=%d dx=%.0f dy=%.0f total_x=%lld total_y=%lld\n", diff --git a/src/platform/windows/windows_direct_input.h b/src/platform/windows/windows_direct_input.h index 6457a8c6..2bd9eaa7 100644 --- a/src/platform/windows/windows_direct_input.h +++ b/src/platform/windows/windows_direct_input.h @@ -5,6 +5,8 @@ bool bongo_cat_windows_direct_input_create(BongoCatPlatform *platform, void *window); +bool bongo_cat_windows_direct_input_read(BongoCatPlatform *platform, + double *x, double *y); void bongo_cat_windows_direct_input_destroy(BongoCatPlatform *platform); void bongo_cat_windows_direct_input_reset(BongoCatPlatform *platform); diff --git a/src/platform/windows/windows_input.c b/src/platform/windows/windows_input.c index 846ebf08..fa8a0bcc 100644 --- a/src/platform/windows/windows_input.c +++ b/src/platform/windows/windows_input.c @@ -17,6 +17,15 @@ typedef struct WindowsInputState { BongoCatWindowsKeyboard keyboard_state; UINT test_drop_key_up; DWORD test_start_delay_ms; + DWORD keyboard_hook_error, mouse_hook_error; + unsigned long long keyboard_events, mouse_moves, mouse_buttons; + unsigned long long reported_keyboard_events, reported_mouse_moves; + unsigned long long reported_mouse_buttons; + ULONGLONG last_diagnostic_ms; + bool diagnostic_ready; + POINT last_mouse; + DWORD last_mouse_flags; + bool last_mouse_known; } WindowsInputState; static WindowsInputState *global_state; @@ -54,9 +63,12 @@ static void emit_key(bool down, const char *name, void *userdata) { static LRESULT CALLBACK keyboard_hook(int code, WPARAM message, LPARAM data) { WindowsInputState *state = global_state; - if (code == HC_ACTION && state) bongo_cat_windows_keyboard_event( - &state->keyboard_state, (const KBDLLHOOKSTRUCT *)data, message, - &state->test_drop_key_up, emit_key, NULL); + if (code == HC_ACTION && state) { + state->keyboard_events++; + bongo_cat_windows_keyboard_event(&state->keyboard_state, + (const KBDLLHOOKSTRUCT *)data, message, + &state->test_drop_key_up, emit_key, NULL); + } return CallNextHookEx(NULL, code, message, data); } @@ -75,7 +87,10 @@ static LRESULT CALLBACK mouse_hook(int code, WPARAM message, LPARAM data) { WindowsInputState *state = global_state; if (code == HC_ACTION && state) { const MSLLHOOKSTRUCT *mouse = (const MSLLHOOKSTRUCT *)data; + state->last_mouse = mouse->pt; state->last_mouse_flags = mouse->flags; + state->last_mouse_known = true; if (message == WM_MOUSEMOVE) { + state->mouse_moves++; AcquireSRWLockShared(&state->platform_lock); BongoCatPlatform *platform = state->platform; if (platform && bongo_cat_input_mouse(platform->input, @@ -85,8 +100,11 @@ static LRESULT CALLBACK mouse_hook(int code, WPARAM message, LPARAM data) { const char *name = mouse_button(message, mouse->mouseData); bool down = message == WM_LBUTTONDOWN || message == WM_RBUTTONDOWN || message == WM_MBUTTONDOWN || message == WM_XBUTTONDOWN; - if (name) push_event(down ? BONGO_CAT_INPUT_MOUSE_DOWN : - BONGO_CAT_INPUT_MOUSE_UP, name, down ? 1.0f : 0.0f); + if (name) { + state->mouse_buttons++; + push_event(down ? BONGO_CAT_INPUT_MOUSE_DOWN : + BONGO_CAT_INPUT_MOUSE_UP, name, down ? 1.0f : 0.0f); + } } } return CallNextHookEx(NULL, code, message, data); @@ -100,18 +118,83 @@ static void dispatch_messages(void) { } } +static void log_input_summary(WindowsInputState *state, ULONGLONG now_ms) { + RECT clip = {0}; + bool clip_known = GetClipCursor(&clip) != FALSE; + RECT desktop = { + GetSystemMetrics(SM_XVIRTUALSCREEN), + GetSystemMetrics(SM_YVIRTUALSCREEN), 0, 0 + }; + desktop.right = desktop.left + GetSystemMetrics(SM_CXVIRTUALSCREEN); + desktop.bottom = desktop.top + GetSystemMetrics(SM_CYVIRTUALSCREEN); + bool confined = clip_known && (clip.left > desktop.left || + clip.top > desktop.top || clip.right < desktop.right || + clip.bottom < desktop.bottom); + HWND foreground = GetForegroundWindow(); + DWORD foreground_pid = 0; + if (foreground) GetWindowThreadProcessId(foreground, &foreground_pid); + POINT physical = {0}; + bool physical_known = GetPhysicalCursorPos(&physical) != FALSE; + CURSORINFO cursor = {.cbSize = sizeof(cursor)}; + bool cursor_known = GetCursorInfo(&cursor) != FALSE; + bool cursor_visible = !cursor_known || + (cursor.flags & CURSOR_SHOWING) != 0; + unsigned long long key_delta = state->keyboard_events - + state->reported_keyboard_events; + unsigned long long move_delta = state->mouse_moves - + state->reported_mouse_moves; + unsigned long long button_delta = state->mouse_buttons - + state->reported_mouse_buttons; + bool active = confined || !cursor_visible || key_delta || + move_delta || button_delta; + ULONGLONG interval_ms = active ? 30000 : 60000; + if (state->diagnostic_ready && + now_ms - state->last_diagnostic_ms < interval_ms) return; + SDL_Log("[input] Windows hooks: keyboard=%d mouse=%d " + "key_events=%llu(+%llu) mouse_moves=%llu(+%llu) " + "mouse_buttons=%llu(+%llu) last_known=%d last=%ld,%ld flags=0x%lx " + "physical_known=%d physical=%ld,%ld cursor_known=%d cursor_visible=%d " + "cursor_confined=%d clip_known=%d clip=%ld,%ld,%ld,%ld " + "desktop=%ld,%ld,%ld,%ld foreground_pid=%lu own_foreground=%d", + state->keyboard != NULL, state->mouse != NULL, + state->keyboard_events, key_delta, state->mouse_moves, move_delta, + state->mouse_buttons, button_delta, + state->last_mouse_known, (long)state->last_mouse.x, + (long)state->last_mouse.y, (unsigned long)state->last_mouse_flags, + physical_known, + (long)physical.x, (long)physical.y, + cursor_known, cursor_visible, + confined, clip_known, + (long)clip.left, (long)clip.top, (long)clip.right, (long)clip.bottom, + (long)desktop.left, (long)desktop.top, + (long)desktop.right, (long)desktop.bottom, + (unsigned long)foreground_pid, + foreground_pid == GetCurrentProcessId()); + state->reported_keyboard_events = state->keyboard_events; + state->reported_mouse_moves = state->mouse_moves; + state->reported_mouse_buttons = state->mouse_buttons; + state->last_diagnostic_ms = now_ms; + state->diagnostic_ready = true; +} + static DWORD WINAPI input_thread(void *context) { WindowsInputState *state = context; if (state->test_start_delay_ms && WaitForSingleObject(state->stop, state->test_start_delay_ms) == WAIT_OBJECT_0) return 0; if (WaitForSingleObject(state->stop, 0) == WAIT_OBJECT_0) return 0; global_state = state; + SetLastError(ERROR_SUCCESS); state->keyboard = SetWindowsHookExW(WH_KEYBOARD_LL, keyboard_hook, NULL, 0); + if (!state->keyboard) state->keyboard_hook_error = GetLastError(); + SetLastError(ERROR_SUCCESS); state->mouse = SetWindowsHookExW(WH_MOUSE_LL, mouse_hook, NULL, 0); - if (!state->keyboard || !state->mouse) SDL_LogWarn( - SDL_LOG_CATEGORY_APPLICATION, - "Global input hooks are unavailable; global input may be incomplete"); + if (!state->mouse) state->mouse_hook_error = GetLastError(); + SDL_Log("[input] Windows hook installation: keyboard=%d error=%lu " + "mouse=%d error=%lu", state->keyboard != NULL, + (unsigned long)state->keyboard_hook_error, state->mouse != NULL, + (unsigned long)state->mouse_hook_error); ULONGLONG last_reconcile_ms = GetTickCount64(); + ULONGLONG next_diagnostic_probe_ms = last_reconcile_ms; for (;;) { DWORD wait = MsgWaitForMultipleObjects(1, &state->stop, FALSE, 25, QS_ALLINPUT); @@ -123,12 +206,18 @@ static DWORD WINAPI input_thread(void *context) { break; } ULONGLONG now_ms = GetTickCount64(); + if (now_ms >= next_diagnostic_probe_ms) { + log_input_summary(state, now_ms); + next_diagnostic_probe_ms = now_ms + 1000; + } if (now_ms - last_reconcile_ms >= 25) { bongo_cat_windows_keyboard_reconcile(&state->keyboard_state, now_ms, emit_key, NULL); last_reconcile_ms = now_ms; } } + state->diagnostic_ready = false; + log_input_summary(state, GetTickCount64()); if (state->keyboard) UnhookWindowsHookEx(state->keyboard); if (state->mouse) UnhookWindowsHookEx(state->mouse); global_state = NULL; diff --git a/src/runtime/input/mouse.c b/src/runtime/input/mouse.c index 8858dd29..f17ccd24 100644 --- a/src/runtime/input/mouse.c +++ b/src/runtime/input/mouse.c @@ -56,10 +56,65 @@ static bool reconcile_button(BongoCatApp *app, bool *current, bool pressed, return true; } +static void log_mouse_diagnostics(BongoCatApp *app, uint64_t now, + bool received, double target_x, double target_y, + float global_x, float global_y, SDL_MouseButtonFlags buttons, + bool cursor_locked, bool relative_requested) { + bool hook_active = app->mouse_last_ns != 0; + bool mismatch = hook_active && + (target_x != global_x || target_y != global_y); + if (app->mouse_diagnostic_due_ns && + now < app->mouse_diagnostic_due_ns) return; + app->mouse_diagnostic_due_ns = now + + (mismatch || relative_requested ? 30000000000ull : 60000000000ull); + uint64_t hook_age_ms = app->mouse_last_ns && now >= app->mouse_last_ns + ? (now - app->mouse_last_ns) / 1000000ull : 0; + SDL_Log("[input] Pointer pipeline: source=%s sample_now=%d " + "samples=%llu age_ms=%llu selected=%.1f,%.1f sdl=%.1f,%.1f " + "mismatch=%d sdl_buttons=0x%x model_buttons=%d,%d,%d " + "pointer_known=%d cursor_locked=%d relative_requested=%d relative_device=%d " + "dropped=%llu model=%s mode=%d ignore=%d centered=%d", + hook_active ? "native-hook" : "sdl-fallback", received, + (unsigned long long)app->mouse_hook_samples, + (unsigned long long)hook_age_ms, target_x, target_y, + global_x, global_y, mismatch, (unsigned)buttons, + app->left_mouse_down, app->right_mouse_down, app->side_mouse_down, + app->pointer_known, cursor_locked, relative_requested, + app->platform.relative_pointer != NULL, + (unsigned long long)atomic_load_explicit(&app->input.dropped, + memory_order_relaxed), + app->loaded_model[0] ? app->loaded_model : "none", + (int)app->loaded_mode, app->settings.model.ignore_mouse, + app->settings.model.mouse_centered); +} + +static void log_pointer_mode_change(const BongoCatApp *app, + bool cursor_locked, bool profile_relative, bool relative_requested, + bool map_requested, bool map_ok, double target_x, double target_y, + double model_x, double model_y, bool model_moved) { + const char *reason = relative_requested + ? (cursor_locked ? "cursor-locked" : "model-profile") + : (cursor_locked ? "mouse-disabled" : "cursor-free"); + SDL_Log("[input] Pointer mode changed: mode=%s reason=%s lock=%d " + "profile=%d map_requested=%d map_ok=%d relative_device=%d " + "target=%.1f,%.1f virtual=%.1f,%.1f " + "moved=%d model=%s", + relative_requested ? "relative" : "absolute", reason, + cursor_locked, profile_relative, map_requested, map_ok, + app->platform.relative_pointer != NULL, target_x, target_y, + model_x, model_y, model_moved, + app->loaded_model[0] ? app->loaded_model : "none"); +} + void bongo_cat_app_apply_mouse(BongoCatApp *app) { if (!app) return; double target_x, target_y; bool received = bongo_cat_input_take_mouse(&app->input, &target_x, &target_y); + uint64_t now = SDL_GetTicksNS(); + if (received) { + app->mouse_last_ns = now ? now : 1; + app->mouse_hook_samples++; + } float global_x = 0.0f, global_y = 0.0f; SDL_MouseButtonFlags buttons = SDL_GetGlobalMouseState(&global_x, &global_y); bool button_changed = reconcile_button(app, &app->left_mouse_down, @@ -72,9 +127,12 @@ void bongo_cat_app_apply_mouse(BongoCatApp *app) { button_changed = true; app->dirty = true; } - if (!received || target_x != global_x || target_y != global_y) { + if (!received && (!app->mouse_last_ns || !app->pointer_known)) { target_x = global_x; target_y = global_y; + } else if (!received) { + target_x = app->pointer_x; + target_y = app->pointer_y; } bool moved = !app->pointer_known || app->pointer_x != target_x || app->pointer_y != target_y; @@ -88,15 +146,41 @@ void bongo_cat_app_apply_mouse(BongoCatApp *app) { bongo_cat_window_capture_pointer_hit(app); } bongo_cat_window_sync_click_through(app); - uint64_t now = SDL_GetTicksNS(); - app->mouse_last_ns = now; double model_x = target_x, model_y = target_y; bool model_moved = moved; - if (app->model_render_options.mver_projection && - !app->settings.model.mouse_centered && - !bongo_cat_app_map_mver_pointer(app, target_x, target_y, - &model_x, &model_y, &model_moved)) return; - if (app->settings.model.ignore_mouse || (!model_moved && !button_changed)) return; + bool profile_relative = app->model_render_options.mver_projection && + app->model_render_options.mouse_force_move; + bool cursor_locked = bongo_cat_platform_pointer_locked(&app->platform); + bool relative_requested = !app->settings.model.ignore_mouse && + (profile_relative || cursor_locked); + bool pointer_mode_changed = false; + if (relative_requested != app->pointer_relative_active) { + pointer_mode_changed = true; + app->mver_pointer = (BongoCatMverPointerState){0}; + if (relative_requested) + bongo_cat_platform_relative_pointer_reset(&app->platform); + else bongo_cat_platform_relative_pointer_release(&app->platform); + app->pointer_relative_active = relative_requested; + } + bool map_pointer = !app->settings.model.ignore_mouse && (cursor_locked || + (app->model_render_options.mver_projection && + (!app->settings.model.mouse_centered || profile_relative))); + bool map_ok = !map_pointer; + if (map_pointer) { + map_ok = bongo_cat_app_map_pointer(app, relative_requested, target_x, + target_y, &model_x, &model_y, &model_moved); + if (!map_ok) { + model_x = target_x; model_y = target_y; model_moved = moved; + } + } + if (pointer_mode_changed) + log_pointer_mode_change(app, cursor_locked, profile_relative, + relative_requested, map_pointer, map_ok, target_x, target_y, + model_x, model_y, model_moved); + log_mouse_diagnostics(app, now, received, target_x, target_y, + global_x, global_y, buttons, cursor_locked, relative_requested); + if (app->settings.model.ignore_mouse || + (!model_moved && !button_changed && !pointer_mode_changed)) return; bongo_cat_app_apply_mouse_coordinates(app, model_x, model_y, - target_x, target_y); + cursor_locked ? model_x : target_x, cursor_locked ? model_y : target_y); } diff --git a/src/runtime/input/mouse_mapping.c b/src/runtime/input/mouse_mapping.c index bc3ec3ab..789f59c2 100644 --- a/src/runtime/input/mouse_mapping.c +++ b/src/runtime/input/mouse_mapping.c @@ -54,19 +54,41 @@ static bool mver_pointer_bounds(const BongoCatApp *app, SDL_Rect *bounds) { return bounds->w > 0 && bounds->h > 0; } -bool bongo_cat_app_map_mver_pointer(BongoCatApp *app, double absolute_x, - double absolute_y, double *x, double *y, bool *changed) { +static bool tracked_pointer_bounds(BongoCatApp *app, double absolute_x, + double absolute_y, SDL_Rect *bounds) { + if (!app || !bounds) return false; + if (app->model_render_options.mver_projection) + return mver_pointer_bounds(app, bounds); + SDL_Point point = {(int)absolute_x, (int)absolute_y}; + SDL_DisplayID window_display = app->window + ? SDL_GetDisplayForWindow(app->window) : 0; + SDL_DisplayID display = app->settings.model.mouse_centered + ? window_display : SDL_GetDisplayForPoint(&point); + if (!display) display = window_display; + return display && SDL_GetDisplayBounds(display, bounds) && + bounds->w > 0 && bounds->h > 0; +} + +bool bongo_cat_app_map_pointer(BongoCatApp *app, bool relative_requested, + double absolute_x, double absolute_y, double *x, double *y, bool *changed) { SDL_Rect bounds; - if (!mver_pointer_bounds(app, &bounds)) return false; + if (!tracked_pointer_bounds(app, absolute_x, absolute_y, &bounds)) return false; BongoCatMverPointerBounds pointer_bounds = { bounds.x, bounds.y, bounds.w, bounds.h }; double relative_x = 0.0, relative_y = 0.0; - bool use_relative = app->model_render_options.mouse_force_move && + bool use_relative = relative_requested && bongo_cat_platform_relative_pointer(&app->platform, &relative_x, &relative_y); bool initialized = app->mver_pointer.initialized; double previous_x = app->mver_pointer.x, previous_y = app->mver_pointer.y; + /* A temporary device read failure must not snap a locked game pointer + back to the fixed screen coordinate. Keep the virtual position until + relative samples resume; the first sample still seeds from reality. */ + if (relative_requested && initialized && !use_relative) { + absolute_x = previous_x; + absolute_y = previous_y; + } if (!bongo_cat_mver_pointer_update(&app->mver_pointer, absolute_x, absolute_y, relative_x, relative_y, use_relative, &pointer_bounds, x, y)) return false; @@ -177,10 +199,14 @@ void bongo_cat_app_apply_mouse_coordinates(BongoCatApp *app, double hand_x, void bongo_cat_app_reset_pointer_tracking(BongoCatApp *app) { if (!app) return; - bongo_cat_platform_relative_pointer_reset(&app->platform); + if (app->pointer_relative_active) + bongo_cat_platform_relative_pointer_reset(&app->platform); + else bongo_cat_platform_relative_pointer_release(&app->platform); app->mver_pointer = (BongoCatMverPointerState){0}; app->model_pointer_anchor_ready = false; app->pointer_known = false; + app->mouse_last_ns = 0; + app->pointer_relative_active = false; app->dirty = true; } diff --git a/src/runtime/runtime.h b/src/runtime/runtime.h index 67ec432b..cb58b5e3 100644 --- a/src/runtime/runtime.h +++ b/src/runtime/runtime.h @@ -86,8 +86,8 @@ void bongo_cat_gamepads_set_enabled(BongoCatApp *app, bool enabled); void bongo_cat_app_reset_gamepad(BongoCatApp *app); void bongo_cat_app_apply_mouse(BongoCatApp *app); void bongo_cat_app_reset_pointer_tracking(BongoCatApp *app); -bool bongo_cat_app_map_mver_pointer(BongoCatApp *app, double absolute_x, - double absolute_y, double *x, double *y, bool *changed); +bool bongo_cat_app_map_pointer(BongoCatApp *app, bool relative_requested, + double absolute_x, double absolute_y, double *x, double *y, bool *changed); void bongo_cat_app_apply_mouse_coordinates(BongoCatApp *app, double hand_x, double hand_y, double gaze_x, double gaze_y); void bongo_cat_app_drain_input(BongoCatApp *app, bool allow_shortcuts); From 3775502dd588329c0553ff07eb8487e5545b9ccc Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 5 Sep 2026 02:27:37 +0800 Subject: [PATCH 214/334] Keep startup usable when nearby model scan fails --- src/runtime/model/model_catalog_runtime.c | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/runtime/model/model_catalog_runtime.c b/src/runtime/model/model_catalog_runtime.c index d980b2ca..6c830fac 100644 --- a/src/runtime/model/model_catalog_runtime.c +++ b/src/runtime/model/model_catalog_runtime.c @@ -111,8 +111,19 @@ BongoCatResult bongo_cat_model_catalog_scan(BongoCatApp *app, bool cleanup, if (!app) return BONGO_CAT_ERROR_ARGUMENT; BongoCatResult result = scan_owned_models(app, cleanup); if (result != BONGO_CAT_OK) return result; - if (nearby_root && !SDL_getenv("BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN")) - return scan_nearby_root(app, nearby_root); + if (nearby_root && !SDL_getenv("BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN")) { + /* Nearby folders are an optional convenience (the default root is + often the folder from which the portable executable was launched). + A malformed third-party model must not make the built-in catalog + disappear: the scan can add valid entries before it reports the + malformed one. Keep those entries and let startup continue. */ + BongoCatResult nearby = scan_nearby_root(app, nearby_root); + if (nearby != BONGO_CAT_OK) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Nearby model scan was incomplete; continuing with %llu " + "usable model(s)", + (unsigned long long)app->models.count); + } return BONGO_CAT_OK; } From b9e740f8cd21fa5d111fba10da89632553cd65ce Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 5 Sep 2026 10:20:05 +0800 Subject: [PATCH 215/334] Fix buffered relative mouse tracking in games --- src/platform/windows/windows_direct_input.c | 105 ++++++++++++++++---- 1 file changed, 84 insertions(+), 21 deletions(-) diff --git a/src/platform/windows/windows_direct_input.c b/src/platform/windows/windows_direct_input.c index 1edbeda6..07b24a3e 100644 --- a/src/platform/windows/windows_direct_input.c +++ b/src/platform/windows/windows_direct_input.c @@ -24,10 +24,43 @@ typedef struct BongoCatDirectInput { unsigned long long failures; long long total_x; long long total_y; + unsigned long long buffered_events; + unsigned long long buffer_overflows; ULONGLONG last_log_ms; bool diagnostic_ready; } BongoCatDirectInput; +static HRESULT read_relative_data(BongoCatDirectInput *state, + DIDEVICEOBJECTDATA *events, DWORD *event_count, long long *x, + long long *y, bool *overflow) { + *x = 0; *y = 0; *overflow = false; + HRESULT result = state->mouse->lpVtbl->GetDeviceData(state->mouse, + sizeof(events[0]), events, event_count, 0); + if (result != DIERR_NOTBUFFERED) { + if (SUCCEEDED(result)) { + *overflow = result == DI_BUFFEROVERFLOW; + for (DWORD index = 0; index < *event_count; ++index) { + if (events[index].dwOfs == DIMOFS_X) + *x += (LONG)events[index].dwData; + else if (events[index].dwOfs == DIMOFS_Y) + *y += (LONG)events[index].dwData; + } + } + return result; + } + /* A few virtual mouse drivers accept DIPROP_BUFFERSIZE but still expose + only the immediate state. Keep those drivers usable. */ + *event_count = 0; + DIMOUSESTATE mouse = {0}; + result = state->mouse->lpVtbl->GetDeviceState(state->mouse, + sizeof(mouse), &mouse); + if (SUCCEEDED(result)) { + *x = mouse.lX; + *y = mouse.lY; + } + return result; +} + void bongo_cat_windows_direct_input_destroy(BongoCatPlatform *platform) { BongoCatDirectInput *state = platform ? platform->relative_pointer : NULL; if (!state) return; @@ -37,12 +70,16 @@ void bongo_cat_windows_direct_input_destroy(BongoCatPlatform *platform) { } if (state->input) state->input->lpVtbl->Release(state->input); if (state->reads) SDL_Log("[input] Relative pointer stopped: reads=%llu " - "reacquires=%llu failures=%llu total=%lld,%lld", state->reads, - state->reacquires, state->failures, state->total_x, state->total_y); + "reacquires=%llu failures=%llu buffered_events=%llu overflows=%llu " + "total=%lld,%lld", state->reads, state->reacquires, + state->failures, state->buffered_events, state->buffer_overflows, + state->total_x, state->total_y); if (state->audit) { fprintf(state->audit, - "summary reads=%llu reacquires=%llu failures=%llu total_x=%lld total_y=%lld\n", + "summary reads=%llu reacquires=%llu failures=%llu " + "buffered_events=%llu buffer_overflows=%llu total_x=%lld total_y=%lld\n", state->reads, state->reacquires, state->failures, + state->buffered_events, state->buffer_overflows, state->total_x, state->total_y); fclose(state->audit); } @@ -94,8 +131,10 @@ bool bongo_cat_windows_direct_input_create(BongoCatPlatform *platform, result = state->mouse->lpVtbl->SetDataFormat(state->mouse, &c_dfDIMouse); } + /* Keep enough history for a few frames while a game is busy. The + consumer still drains the queue in one bounded read below. */ DIPROPDWORD property = {{sizeof(property), sizeof(property.diph), - 0, DIPH_DEVICE}, 16}; + 0, DIPH_DEVICE}, 128}; if (SUCCEEDED(result)) { stage = "SetProperty"; result = state->mouse->lpVtbl->SetProperty(state->mouse, @@ -137,7 +176,14 @@ bool bongo_cat_windows_direct_input_read(BongoCatPlatform *platform, ULONGLONG now_ms = GetTickCount64(); bool stale = state->last_read_ms && now_ms - state->last_read_ms > 250; state->last_read_ms = now_ms; - DIMOUSESTATE mouse = {0}; + /* Use DirectInput's buffered event API. GetDeviceState can report a + zero delta when another application is consuming raw/relative mouse + input between our frames; buffered events preserve those deltas until + this process reads them. */ + DIDEVICEOBJECTDATA events[128] = {0}; + DWORD event_count = (DWORD)(sizeof(events) / sizeof(events[0])); + long long direct_x = 0, direct_y = 0; + bool buffer_overflow = false; POINT absolute = {0}; bool absolute_ready = GetPhysicalCursorPos(&absolute) != FALSE; bool fallback_ready = absolute_ready && state->absolute_ready; @@ -147,30 +193,42 @@ bool bongo_cat_windows_direct_input_read(BongoCatPlatform *platform, state->absolute = absolute; state->absolute_ready = true; } else state->absolute_ready = false; - HRESULT result = state->mouse->lpVtbl->GetDeviceState( - state->mouse, sizeof(mouse), &mouse); + HRESULT result = read_relative_data(state, events, &event_count, + &direct_x, &direct_y, &buffer_overflow); bool reacquired = false; if (FAILED(result)) { result = state->mouse->lpVtbl->Acquire(state->mouse); reacquired = SUCCEEDED(result); - if (reacquired) result = state->mouse->lpVtbl->GetDeviceState( - state->mouse, sizeof(mouse), &mouse); + if (reacquired) { + event_count = (DWORD)(sizeof(events) / sizeof(events[0])); + result = read_relative_data(state, events, &event_count, + &direct_x, &direct_y, &buffer_overflow); + } } state->reads++; if (reacquired) state->reacquires++; bool sample_ready = SUCCEEDED(result); + if (sample_ready) { + if (buffer_overflow) state->buffer_overflows++; + state->buffered_events += event_count; + } bool available = sample_ready || fallback_ready; - bool rebase = state->rebase_pending || stale || - (!sample_ready && !fallback_ready); - bool use_fallback = !stale && fallback_ready && (!sample_ready || reacquired || - ((mouse.lX == 0 && mouse.lY == 0) && - (fallback_x != 0 || fallback_y != 0))); + /* A delayed frame is not evidence that the input is invalid. In + particular, a game can keep moving/recentering the cursor while this + window is not scheduled for a frame. Only an explicit reset or a + complete loss of both sources should rebase the virtual pointer. */ + bool rebase = state->rebase_pending || (!sample_ready && !fallback_ready); + bool direct_zero = direct_x == 0 && direct_y == 0; + bool physical_delta = fallback_x != 0 || fallback_y != 0; + bool use_fallback = fallback_ready && (!sample_ready || + (physical_delta && (direct_zero || buffer_overflow)) || + (direct_zero && reacquired)); if (rebase) { *x = 0.0; *y = 0.0; } else if (sample_ready && !use_fallback) { - *x = mouse.lX; - *y = mouse.lY; + *x = direct_x; + *y = direct_y; } else if (fallback_ready) { *x = fallback_x; *y = fallback_y; @@ -187,21 +245,26 @@ bool bongo_cat_windows_direct_input_read(BongoCatPlatform *platform, if (log_due) { SDL_Log("[input] Relative pointer sample: result=0x%08lx " "sample=%d reacquired=%d stale=%d physical=%d fallback=%d " - "rebase=%d direct=%ld,%ld physical_delta=%ld,%ld " - "output=%.0f,%.0f reads=%llu failures=%llu reacquires=%llu", + "rebase=%d direct=%lld,%lld physical_delta=%ld,%ld " + "events=%lu overflow=%d output=%.0f,%.0f reads=%llu " + "failures=%llu reacquires=%llu", (unsigned long)result, sample_ready, reacquired, stale, fallback_ready, use_fallback, rebase, - (long)mouse.lX, (long)mouse.lY, + direct_x, direct_y, (long)fallback_x, (long)fallback_y, *x, *y, + (unsigned long)event_count, buffer_overflow, state->reads, state->failures, state->reacquires); state->last_log_ms = now_ms; } state->diagnostic_ready = true; if (state->audit) { fprintf(state->audit, - "read=%llu result=0x%08lx reacquired=%d stale=%d rebase=%d fallback=%d dx=%.0f dy=%.0f total_x=%lld total_y=%lld\n", + "read=%llu result=0x%08lx reacquired=%d stale=%d rebase=%d " + "fallback=%d events=%lu overflow=%d dx=%.0f dy=%.0f " + "total_x=%lld total_y=%lld\n", state->reads, (unsigned long)result, reacquired, stale, rebase, - use_fallback, *x, *y, + use_fallback, (unsigned long)event_count, buffer_overflow, + *x, *y, state->total_x, state->total_y); fflush(state->audit); } From fc2bf3721cffa96eab135e0fd1c6bbce961f9ead Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 5 Sep 2026 10:39:35 +0800 Subject: [PATCH 216/334] Bump version to 1.4.1 --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 183382ee..13b264bd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -set(BONGO_CAT_VERSION "1.4.0") +set(BONGO_CAT_VERSION "1.4.1") project(BongoCat VERSION ${BONGO_CAT_VERSION} DESCRIPTION "BongoCat" HOMEPAGE_URL "https://github.com/vladelaina/BongoCat" LANGUAGES C CXX) include(cmake/Version.cmake) include(FetchContent) From b8cbcd3b8db22a12c28e528b274fa6ece444166d Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 5 Sep 2026 11:48:15 +0800 Subject: [PATCH 217/334] Clarify DirectInput coordinate conversions --- src/platform/windows/windows_direct_input.c | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/platform/windows/windows_direct_input.c b/src/platform/windows/windows_direct_input.c index 07b24a3e..8794640c 100644 --- a/src/platform/windows/windows_direct_input.c +++ b/src/platform/windows/windows_direct_input.c @@ -40,9 +40,9 @@ static HRESULT read_relative_data(BongoCatDirectInput *state, if (SUCCEEDED(result)) { *overflow = result == DI_BUFFEROVERFLOW; for (DWORD index = 0; index < *event_count; ++index) { - if (events[index].dwOfs == DIMOFS_X) + if (events[index].dwOfs == (DWORD)DIMOFS_X) *x += (LONG)events[index].dwData; - else if (events[index].dwOfs == DIMOFS_Y) + else if (events[index].dwOfs == (DWORD)DIMOFS_Y) *y += (LONG)events[index].dwData; } } @@ -227,11 +227,11 @@ bool bongo_cat_windows_direct_input_read(BongoCatPlatform *platform, *x = 0.0; *y = 0.0; } else if (sample_ready && !use_fallback) { - *x = direct_x; - *y = direct_y; + *x = (double)direct_x; + *y = (double)direct_y; } else if (fallback_ready) { - *x = fallback_x; - *y = fallback_y; + *x = (double)fallback_x; + *y = (double)fallback_y; } else { *x = 0.0; *y = 0.0; From 971ec4af8d97cfa49302c2c20b44b376af83c276 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 5 Sep 2026 12:12:32 +0800 Subject: [PATCH 218/334] Update README.ja-JP.md --- docs/README.ja-JP.md | 264 ------------------------------------------- 1 file changed, 264 deletions(-) diff --git a/docs/README.ja-JP.md b/docs/README.ja-JP.md index e02ff085..d3f5a12f 100644 --- a/docs/README.ja-JP.md +++ b/docs/README.ja-JP.md @@ -1,265 +1 @@ -# 💘 C/C++ × SDL3 × OpenGL を混ぜて、思う存分叩け!Bong~ Bongo Cat!!! -言語を選択 ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • [Bahasa Indonesia](README.id-ID.md) - -

- - - - - -

- -> [!TIP] -> デモで使用されているモデルは [宇痕冫](https://space.bilibili.com/348616056) から提供されています。 -> -> 🎁 **無料**のモデルをお探しですか?私たちは才能あるモデルクリエイターと協力して、多彩な無料モデルをお届けするとともに、楽しいデスクトップ体験を継続的に探求しています!公式サイトをご覧ください:[bongocat.pet](https://bongocat.pet/models) - -

- - - -

- -## 📥 ダウンロード - -[GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest) から最新バージョンをダウンロードしてください。 - -## 🛠️ ソースからのビルド - -BongoCat は CMake を使用しており、C11 コンパイラ、C++17 コンパイラ、CMake 3.24 以降、およびデスクトップ用 OpenGL 開発ファイルが必要です。デフォルトでは SDL3、yyjson、stb、miniaudio、Nuklear が構成フェーズで自動的にダウンロードされるため、初回の構成にはインターネット接続が必要です。 - -プロジェクトのルートディレクトリ(`CMakeLists.txt` を含むディレクトリ)で以下のコマンドを実行してください。 - -### 📋 プラットフォーム別の前提条件 - -- **Windows:** Visual Studio 2022(「C++ によるデスクトップ開発」ワークロードをインストール)と CMake。MSVC ジェネレータを使用してください。MinGW では診断バックエンドをビルドできますが、Cubism SDK には対応していません。 -- **macOS:** Xcode Command Line Tools、CMake、Ninja。対象アーキテクチャがホストのデフォルトと異なる場合は、`CMAKE_OSX_ARCHITECTURES` で指定してください。 -- **Linux(Debian/Ubuntu):** GCC または Clang、Ninja、および OpenGL/X11 ヘッダー: - -```bash -sudo apt-get update -sudo apt-get install -y build-essential cmake ninja-build \ - libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev libcurl4-openssl-dev -``` - -### 🔧 設定とビルド - -Linux と macOS では、Ninja のような単一構成ジェネレータを使用してください: - -```bash -cmake -S . -B build -G Ninja \ --DCMAKE_BUILD_TYPE=Release \ --DBONGO_CAT_FETCH_DEPS=ON -cmake --build build --parallel -``` - -Windows では、Visual Studio 2022 の開発者コマンドプロンプト(または MSVC が利用可能な他のコマンドライン)から実行してください: - -```powershell -cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` --DBONGO_CAT_FETCH_DEPS=ON -cmake --build build --config Release --parallel -``` - -実行可能ファイルの場所:Linux では `build/BongoCat`、macOS では `build/BongoCat.app/Contents/MacOS/BongoCat`、Visual Studio ビルドの Windows では `build/Release/BongoCat.exe`。 - -### 🧪 テスト - -CTest ターゲットはデフォルトで有効です。ビルド後に実行: - -```bash -ctest --test-dir build --output-on-failure -``` - -Visual Studio のような複数構成ジェネレータの場合は、ビルド構成を明示的に指定してください: - -```powershell -ctest --test-dir build -C Release --output-on-failure -``` - -### 🎭 Live2D / Cubism SDK(任意) - -Cubism SDK が見つからない場合、CMake は警告を表示して診断バックエンドをビルドします。このバックエンドは起動およびプラットフォーム診断用であり、Live2D モデルのレンダリングは提供しません。完全なランタイムをビルドするには、互換性のある Cubism SDK for Native をインストールし、`vendor/CubismSdkForNative` に配置するか、パスを明示的に渡してください: - -```bash -cmake -S . -B build -G Ninja \ --DCMAKE_BUILD_TYPE=Release \ --DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ --DBONGO_CAT_REQUIRE_CUBISM=ON -``` - -SDK には Core ライブラリ、Framework ソース、および `cmake/Cubism.cmake` で要求されるレイアウトの OpenGL GLEW サードパーティディレクトリを含める必要があります。Windows の Cubism ビルドには Visual Studio 2022 が必要です。`BONGO_CAT_REQUIRE_CUBISM=ON` は、SDK が利用できない場合に診断バックエンドを静かに選択するのではなく、構成を失敗させます。 - -### ⚙️ CMake オプション - -| オプション | デフォルト値 | 説明 | -| --- | --- | --- | -| `BONGO_CAT_FETCH_DEPS` | `ON` | CMake `FetchContent` を使用して、固定バージョンのサードパーティ依存関係をダウンロードします。SDL3、yyjson、stb、miniaudio、Nuklear が CMake で利用可能な場合にのみ `OFF` に設定してください。 | -| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Cubism SDK for Native のパス。 | -| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | SDK が利用できない場合に構成を失敗させます。 | -| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | ローカルコンパイラの警告をエラーとして扱います。 | - -オフラインビルドでは `BONGO_CAT_FETCH_DEPS=OFF` を設定し、SDL3(`SDL3-static` を含む)と yyjson の CMake パッケージ設定を提供してください。stb、Nuklear、miniaudio を自動的に発見できない場合は、それらのインクルードディレクトリも提供してください: - -```bash -cmake -S . -B build -G Ninja \ --DCMAKE_BUILD_TYPE=Release \ --DBONGO_CAT_FETCH_DEPS=OFF \ --DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ --DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ --DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio -``` - -## 📌 プロジェクトの状態 - -![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") - -## 📜 ライセンス - -BongoCat のソースコードとローカルランタイムは [AGPL-3.0-only](../LICENSE) ライセンスです。 - -デフォルトの内蔵モデルモード(`standard`)は引き続き MIT ライセンスです。`resources/assets/models/standard`、`keyboard`、`gamepad` にバンドルされているモデルリソースは、別途の [MIT ライセンス宣言](../LICENSE-MIT) の対象です。この MIT ライセンスはモデルリソースおよびその付随するアートワークのみに適用され、BongoCat のソースコードやローカルランタイムのライセンスを変更するものではありません。 - -## 🧭 技術的なアーキテクチャ - -現在のネイティブバージョンは C/C++、SDL3、OpenGL で構築されています。以下の図はランタイムのデータフローに重点を置いています。ビルドとパッケージングの詳細は CMake ファイルを参照してください。 - -### 🔄 ランタイムの所有権とフレームスケジューリング - -各プロセスは 1 つの `BongoCatApp` と 1 つのメインスレッドのイベント/レンダリングループを所有します。プラットフォームリスナーは入力の境界で停止します: - -```text -プラットフォームリスナー(キーボード/ポインタ) -| -v -C11 入力状態(アトミックなエッジキュー + マージ済みポインタ位置) -| -v -メインスレッドアプリ <----- SDL3 イベント -| -v -モデルパラメータ、オーバーレイ、UI 状態 -| -v -モデル更新 -> OpenGL 合成 -> プラットフォーム表示 -``` - -Windows の低レベルフック、macOS の Quartz イベントタップ、Linux の XInput2 リスナーはメインループの外で動作します。これらはタイムスタンプ付きのキーとマウスボタンのエッジを有界アトミックキューに公開し、別個のマージスロットを介してポインタ座標を公開します。公開に成功するとネイティブの SDL ウェイクアップイベントをプッシュし、高頻度の移動イベントが順序付けられたキーおよびボタンのエッジを押し出すのを防ぎます。Windows では、モデルが相対移動を要求した場合にのみ、プラットフォームポインタインターフェースを介して DirectInput が使用されます。SDL3 のウィンドウ、設定、ゲームパッドイベントはメインスレッドで処理され、ゲームパッドイベントはモデルパラメータまたはショートカットに渡す前に正規化されます。どのプラットフォームリスナーも Live2D、オーバーレイ、UI コードを直接呼び出すことはありません。 - -`bongo_cat_app_run` は更新、シャットダウン、サブプロセスパラメータを処理し、メインプロセスのシングルインスタンス所有権を確保し、アプリ状態を割り当て、初期化を実行し、`bongo_cat_app_loop` に入り、その後定義された順序で状態をフラッシュしてリソースを破棄します。初期化は設定とストレージパスを読み込み、リソースを探し、SDL/OpenGL のペットウィンドウを作成し、プラットフォームバックエンドを初期化し、Live2D/オーバーレイ/オーディオサービスを作成し、内蔵/インストール済み/近傍のモデルソースをスキャンして、利用可能なモデルを読み込みます。`BongoCatApp` は設定、セッション状態、モデルと動作のカタログ、プラットフォームハンドル、ランタイムサービスのハンドルを保持します。 - -インストール済みモデルパッケージは Mver を正規形式として使用します。インポートプロセスは選択したファイルまたはディレクトリを解析し、候補を発見して検証し、パッケージの同一性フィンガープリントを生成し、Tauri ソースを Mver に変換し、画像パッチを適用し、正規化されたパッケージを `models_root` にコミットします。その後、ランタイムアダプタを生成してカタログを更新します。近傍ソースはソースディレクトリをインストールせずにのみ発見されます。そのアダプタと検証結果は `models_root` の外にある `cache_root` にキャッシュされます。 - -各メインループの反復は、SDL/ネイティブのウェイクアップ、または最も早いフレーム、UI、アニメーション、ポインタヒットの期限(最大 250 ミリ秒)を待ちます。ループはキューに入れられた SDL イベントをディスパッチし、アトミックな入力キューを空にして解放/復元を実行し、ウィンドウとモデルの更新状態を更新してから、入力パラメータを適用します。Cubism が有効な場合、モデルの期限は `settings.model.max_fps`(デフォルト 60 FPS)に従います。診断ビルドでは 100 ミリ秒のフォールバック間隔を使用します。モデルの経過時間は最大 250 ミリ秒として扱われ、各サブステップの目標が 1/30 秒以下になるように、最大 8 つのサブステップに分割されます。 - -通常のペットパスは、ウィンドウが表示され、最小化されておらず、ダーティとマークされている場合にのみレンダリングされます。各フレームは最初に背景をクリアし、モデルを描画し、それからポインタ、キー、エフェクトのオーバーレイを合成し、最後にプラットフォームレンダラーを呼び出します。プレビュー操作は即時レンダリングを要求できます。スクリーンショットレンダリングは表示をスキップできます。macOS と Linux は SDL OpenGL ウィンドウを直接スワップします。Windows はレイヤードプレゼンテーションが有効でない場合は直接スワップし、有効な場合はフレームバッファを読み取って `UpdateLayeredWindow` を呼び出します。設定 UI は独立した SDL/OpenGL ウィンドウを持ち、個別にレンダリングおよび表示されます。 - -C ランタイムは `include/bongo_cat/model.h` で宣言された ABI を呼び出します。Live2D ブリッジと Cubism 実装は `src/live2d` にあり、Cubism SDK が有効な場合のみ C++17 を使用します。残りのネイティブランタイムは C11 を使用します。Cubism 型は不透明な C ハンドルの背後に保持されます。SDK が利用できない場合、`src/live2d/live2d_stub.c` が診断バックエンドを提供します。 - -```mermaid -flowchart TB -Input(["キーボード / マウス / ゲームパッド"]) -BuiltIn(["内蔵モデルリソース"]) -Sources(["外部モデルソース - -Mver、Tauri、.model3.json、画像パッチ"]) -Desktop(["ペットウィンドウと設定ウィンドウ"]) -subgraph Runtime["BongoCat ネイティブランタイム"] -direction TB -Entry["src/main.c - -bongo_cat_app_run"] -Startup["起動と初期化 - -設定、ストレージ、ウィンドウ、プラットフォーム"] -Loop["SDL3 メインループ - -待機、配信、更新、レンダリング"] -Shutdown["シャットダウン - -状態のフラッシュ、サービス停止、リソース解放"] -InputQueue[("アトミック入力状態 - -エッジキューとマージ済みポインタ位置")] -InputDispatch["入力ディスパッチ - -ショートカット、ポインタマッピング、モデルパラメータ"] -State[("BongoCatApp 状態 - -設定、セッション、カタログ、ランタイムハンドル")] -Import["モデルの発見とインポート - -検証、Mver への正規化、インストール/キャッシュ"] -Catalog[("モデルと動作のカタログ")] -Live2D["Live2D C ABI - -Cubism SDK または診断スタブ"] -Overlay["オーバーレイとオーディオ"] -Preferences["設定とデスクトップシェル - -Nuklear UI、トレイ、ウィンドウ操作"] -Compose["OpenGL フレーム合成"] -Present["プラットフォーム表示"] -Entry --> Startup --> Loop -Loop --> Shutdown -Loop --> InputDispatch --> State -Loop <--> State -State --> Live2D -State --> Overlay -State <--> Preferences -Loop --> Preferences -Catalog --> State -State --> Compose -Live2D --> Compose -Overlay --> Compose -Compose --> Present -Loop --> Compose -end -subgraph Platform["プラットフォームバックエンド"] -direction LR -Global["グローバルなキーボード/ポインタキャプチャ - -Windows、macOS、Linux"] -SDL["SDL3 イベント - -ウィンドウとゲームパッドのイベント"] -end -Input --> Global --> InputQueue --> InputDispatch -Input --> SDL --> Loop -BuiltIn --> Catalog -Sources --> Import --> Catalog -Present --> Desktop -Preferences --> Desktop -``` - -## ❓ よくある質問 - -### 🔒 BongoCat は私のキーボードやマウスの入力を記録しますか? - -いいえ。BongoCat はキーボードとマウスの入力をローカルで処理し、アニメーションとショートカットを駆動します。キーストローク、マウス操作、その他のインタラクションデータを記録またはアップロードすることはありません。設定もローカルにのみ保存され、アプリには広告、分析ツール、ユーザートラッキングコードは含まれていません。更新チェックを実行するときは公開されたバージョンメタデータのみを要求し、入力、設定、使用データを送信することはありません。 - -### 🖼️ なぜ Vulkan ではなく OpenGL を使うのですか? - -これは Vulkan が良くないからではなく、BongoCat がそのレベルの複雑さを必要としないからです。このアプリは主に 1 つの Live2D モデル、少数の UI レイヤー、透明なデスクトップウィンドウをレンダリングします。OpenGL で十分にまかなえ、SDL3 および Cubism の OpenGL レンダラーとも自然に連携します。Vulkan への移行は 3 つのデスクトッププラットフォームでより多くのレンダリングと同期コードを維持する必要があり、ユーザーにとって明確な利点はありません。BongoCat の現在のワークロードでは、OpenGL によってレンダラーはより簡潔でデバッグ・保守が容易になり、必要なパフォーマンスを維持できます。 - -## 🙏 謝辞 -> [!TIP] -> BongoCat の一歩一歩はオープンソースの精神に支えられています。コミュニティのコントリビューターの皆さんの無私の貢献に心より感謝します(貢献日の古い順に記載)。皆さんのご支援こそが、デスクトップでの伴走をより自由で温かいものにしています。❤️‍🔥 - - - - - - - -[linux.do](https://linux.do/t/topic/2845597) ---- - -
- -Copyright © 2026 - **BongoCat**\ -By vladelaina\ -Made with ❤️ & ⌨️ - -
From 33da1f4b11272f24e6625cf9f262be58fed83437 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 5 Sep 2026 13:27:59 +0800 Subject: [PATCH 219/334] Fix Windows transparent window rendering --- cmake/PlatformRuntime.cmake | 1 + include/bongo_cat/app.h | 3 + src/platform/windows/windows.c | 11 +- src/platform/windows/windows_capture.c | 75 ++--------- src/platform/windows/windows_capture.h | 6 + src/platform/windows/windows_layered.c | 4 +- src/platform/windows/windows_layered_state.c | 1 + src/platform/windows/windows_pointer.c | 13 +- src/platform/windows/windows_transparency.c | 129 +++++++++++++++++++ src/runtime/lifecycle/app.c | 5 + src/runtime/lifecycle/app_loop.c | 18 ++- src/runtime/shell/window.c | 11 +- src/runtime/shell/window_hit.c | 9 +- src/ui/preferences/preferences_scale.c | 4 + src/ui/rendering/ui_paint.c | 8 +- src/ui/rendering/ui_paint_border.c | 7 +- tests/platform/test_windows_capture.c | 20 +++ 17 files changed, 245 insertions(+), 80 deletions(-) create mode 100644 src/platform/windows/windows_transparency.c diff --git a/cmake/PlatformRuntime.cmake b/cmake/PlatformRuntime.cmake index 8c6462fe..e2a25163 100644 --- a/cmake/PlatformRuntime.cmake +++ b/cmake/PlatformRuntime.cmake @@ -9,6 +9,7 @@ if(WIN32) src/platform/windows/windows_update_handoff.c src/platform/windows/windows_borderless.c src/platform/windows/windows_capture.c + src/platform/windows/windows_transparency.c src/platform/windows/windows_pointer.c src/platform/windows/windows_dialog.c src/platform/windows/windows_direct_input.c diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index cbb093a8..22bc18af 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -88,6 +88,9 @@ typedef struct BongoCatApp { bool secondary_control_known; bool secondary_control_visible; bool secondary_control_pass_through; + /* Keep the initial transparent window hidden until its first complete + model frame is ready. This prevents a platform-dependent black flash. */ + bool startup_visibility_pending; int secondary_origin_x, secondary_origin_y; uint64_t secondary_control_check_ns; uint64_t secondary_control_failure_ns; diff --git a/src/platform/windows/windows.c b/src/platform/windows/windows.c index 375187f4..ce829d35 100644 --- a/src/platform/windows/windows.c +++ b/src/platform/windows/windows.c @@ -34,7 +34,7 @@ void bongo_cat_platform_configure_preferences_window(SDL_Window *window) { bongo_cat_windows_capture_mark_transparent(handle, transparent); if (transparent) { bongo_cat_windows_capture_install_transparency_handler(handle); - bongo_cat_windows_capture_restore_transparency(handle); + bongo_cat_windows_capture_repair_transparency(handle); } } @@ -63,9 +63,13 @@ BongoCatResult bongo_cat_platform_init(BongoCatPlatform *platform, SDL_Window *w HWND hwnd = native_window(platform); SetWindowTextW(hwnd, bongo_cat_windows_instance_title()); bongo_cat_windows_borderless_install(hwnd); + bool transparent = (SDL_GetWindowFlags(window) & + SDL_WINDOW_TRANSPARENT) != 0; + /* Mark transparency before any OBS style transaction. Removing + WS_EX_TOOLWINDOW may recreate the DWM surface, so the configure path + must be able to repair it before the window is first shown. */ + bongo_cat_windows_capture_mark_transparent(hwnd, transparent); bongo_cat_windows_capture_configure(hwnd); - bongo_cat_windows_capture_mark_transparent(hwnd, - (SDL_GetWindowFlags(window) & SDL_WINDOW_TRANSPARENT) != 0); SDL_SetWindowsMessageHook(windows_message_hook, NULL); if (!SDL_SetWindowResizable(window, true)) { SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, @@ -73,6 +77,7 @@ BongoCatResult bongo_cat_platform_init(BongoCatPlatform *platform, SDL_Window *w } SetWindowPos(hwnd, NULL, 0, 0, 0, 0, SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE); + bongo_cat_windows_capture_repair_transparency(hwnd); bongo_cat_windows_capture_log(hwnd, "initialized"); return BONGO_CAT_OK; } diff --git a/src/platform/windows/windows_capture.c b/src/platform/windows/windows_capture.c index c82fceae..ade24ae0 100644 --- a/src/platform/windows/windows_capture.c +++ b/src/platform/windows/windows_capture.c @@ -10,12 +10,8 @@ static UINT taskbar_created_message; static UINT capture_refresh_message; static const wchar_t capture_property[] = L"BongoCat.CaptureWindow"; -static const wchar_t transparent_property[] = L"BongoCat.TransparentWindow"; -static const wchar_t transparency_proc_property[] = - L"BongoCat.TransparentWindowProc"; static bool removal_warning_emitted; static bool style_warning_emitted; -static bool transparency_warning_emitted; static bool environment_logged; #define BONGO_CAT_CAPTURE_REFRESH_TIMER ((UINT_PTR)0xBC51) @@ -23,29 +19,8 @@ static bool has_property(HWND window, const wchar_t *name) { return window && name && GetPropW(window, name) != NULL; } -void bongo_cat_windows_capture_mark_transparent(HWND window, bool enabled) { - if (!window) return; - if (enabled) SetPropW(window, transparent_property, (HANDLE)1); - else RemovePropW(window, transparent_property); -} - -static LRESULT CALLBACK transparency_window_proc(HWND window, UINT message, - WPARAM wparam, LPARAM lparam) { - if (bongo_cat_windows_capture_handle_message(window, message, wparam)) - return 0; - WNDPROC original = (WNDPROC)GetPropW(window, transparency_proc_property); - return CallWindowProcW(original ? original : DefWindowProcW, window, - message, wparam, lparam); -} - -void bongo_cat_windows_capture_install_transparency_handler(HWND window) { - if (!window || GetPropW(window, transparency_proc_property)) return; - WNDPROC original = (WNDPROC)GetWindowLongPtrW(window, GWLP_WNDPROC); - if (!original || !SetPropW(window, transparency_proc_property, - (HANDLE)original)) return; - if (!SetWindowLongPtrW(window, GWLP_WNDPROC, - (LONG_PTR)transparency_window_proc)) - RemovePropW(window, transparency_proc_property); +bool bongo_cat_windows_capture_is_configured(HWND window) { + return has_property(window, capture_property); } static bool read_extended_style(HWND window, LONG_PTR *style) { @@ -86,35 +61,6 @@ static HRESULT remove_taskbar_tab(HWND window) { return result; } -bool bongo_cat_windows_capture_restore_transparency(HWND window) { - if (!window) return false; - HRGN region = CreateRectRgn(-1, -1, 0, 0); - if (!region) return false; - DWM_BLURBEHIND blur = {0}; - blur.dwFlags = DWM_BB_ENABLE | DWM_BB_BLURREGION; - blur.fEnable = TRUE; - blur.hRgnBlur = region; - HRESULT result = DwmEnableBlurBehindWindow(window, &blur); - DeleteObject(region); - if (FAILED(result) && !transparency_warning_emitted) { - transparency_warning_emitted = true; - SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, - "Cannot restore Windows transparent composition (0x%08lx)", - (unsigned long)result); - } - return SUCCEEDED(result); -} - -static void repair_transparency(HWND window) { - if (!has_property(window, transparent_property)) return; - if (bongo_cat_windows_capture_restore_transparency(window)) { - /* A composition reset does not necessarily produce a paint message. - Invalidate the surface so the alpha-aware back buffer is presented - again (and, on Windows, the layered proxy is rebuilt). */ - InvalidateRect(window, NULL, FALSE); - } -} - static void log_environment(HWND window) { if (environment_logged) return; environment_logged = true; @@ -191,6 +137,7 @@ bool bongo_cat_windows_capture_configure(HWND window) { SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, "Cannot read the window style required for OBS discovery"); } + bongo_cat_windows_capture_repair_transparency(window); return false; } /* OBS does not require APPWINDOW. Avoid a hide/show style transition for @@ -203,7 +150,6 @@ bool bongo_cat_windows_capture_configure(HWND window) { if (write_extended_style(window, next, &style_error)) { SetWindowPos(window, NULL, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED); - bongo_cat_windows_capture_restore_transparency(window); } else if (!style_warning_emitted) { style_warning_emitted = true; SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, @@ -217,6 +163,10 @@ bool bongo_cat_windows_capture_configure(HWND window) { UpdateWindow(window); } } + /* Style/frame changes can recreate the DWM redirection surface. Repair + after the complete hide/show transaction, and on the idempotent path + as well so repeated configure calls remain safe. */ + bongo_cat_windows_capture_repair_transparency(window); LONG_PTR applied = 0; bool ready = read_extended_style(window, &applied) && !(applied & (WS_EX_TOOLWINDOW | WS_EX_NOREDIRECTIONBITMAP)); @@ -238,13 +188,8 @@ bool bongo_cat_windows_capture_configure(HWND window) { bool bongo_cat_windows_capture_handle_message( HWND window, UINT message, WPARAM wparam) { register_messages(); - if ((message == WM_DWMCOMPOSITIONCHANGED || message == WM_DISPLAYCHANGE) && - has_property(window, transparent_property)) { - repair_transparency(window); - /* Let SDL continue processing WM_DISPLAYCHANGE so its display list is - refreshed. WM_DWMCOMPOSITIONCHANGED has no SDL-side state to update. */ - return false; - } + if (bongo_cat_windows_capture_handle_transparency_message( + window, message, wparam)) return true; if (capture_refresh_message && message == capture_refresh_message) { if (!has_property(window, capture_property)) return false; refresh_taskbar(window); @@ -263,7 +208,7 @@ bool bongo_cat_windows_capture_handle_message( refresh_taskbar(window); schedule_refresh(window); } - repair_transparency(window); + bongo_cat_windows_capture_repair_transparency(window); return capture_window; } return false; diff --git a/src/platform/windows/windows_capture.h b/src/platform/windows/windows_capture.h index 51a8b04f..de7890eb 100644 --- a/src/platform/windows/windows_capture.h +++ b/src/platform/windows/windows_capture.h @@ -8,9 +8,15 @@ bool bongo_cat_windows_capture_configure(HWND window); /* Mark windows whose OpenGL back buffer relies on DWM alpha composition. */ void bongo_cat_windows_capture_mark_transparent(HWND window, bool enabled); +/* Returns whether OBS/capture discovery has been configured for this HWND. */ +bool bongo_cat_windows_capture_is_configured(HWND window); /* Install a small native shim for synchronous DWM/shell notifications. */ void bongo_cat_windows_capture_install_transparency_handler(HWND window); bool bongo_cat_windows_capture_restore_transparency(HWND window); +/* Re-apply DWM alpha composition after a shell, style, or display change. */ +void bongo_cat_windows_capture_repair_transparency(HWND window); +bool bongo_cat_windows_capture_handle_transparency_message( + HWND window, UINT message, WPARAM wparam); void bongo_cat_windows_capture_log(HWND window, const char *stage); bool bongo_cat_windows_capture_handle_message( HWND window, UINT message, WPARAM wparam); diff --git a/src/platform/windows/windows_layered.c b/src/platform/windows/windows_layered.c index 9b20ee97..7841ca47 100644 --- a/src/platform/windows/windows_layered.c +++ b/src/platform/windows/windows_layered.c @@ -228,7 +228,9 @@ bool bongo_cat_platform_present(BongoCatPlatform *platform, int width, int heigh HWND source = native_window(platform); if (!source || !value->visible || !IsWindowVisible(source) || IsIconic(source)) { if (value->proxy) ShowWindow(value->proxy, SW_HIDE); - return source != NULL; + /* A hidden source has no frame to expose through the proxy. Let the + startup path show it before retrying the first presentation. */ + return false; } RECT bounds; if (!GetWindowRect(source, &bounds)) return false; diff --git a/src/platform/windows/windows_layered_state.c b/src/platform/windows/windows_layered_state.c index 11923a59..e9fe4b74 100644 --- a/src/platform/windows/windows_layered_state.c +++ b/src/platform/windows/windows_layered_state.c @@ -47,6 +47,7 @@ void bongo_cat_platform_set_visible(BongoCatPlatform *platform, if (visible) { HWND source = native_window(platform); bongo_cat_windows_capture_configure(source); + bongo_cat_windows_capture_repair_transparency(source); bongo_cat_windows_capture_log(source, "visible"); } } diff --git a/src/platform/windows/windows_pointer.c b/src/platform/windows/windows_pointer.c index dba8b7cb..e367c68d 100644 --- a/src/platform/windows/windows_pointer.c +++ b/src/platform/windows/windows_pointer.c @@ -45,7 +45,14 @@ void bongo_cat_platform_raise_window(SDL_Window *window) { BringWindowToTop(handle); SetForegroundWindow(handle); if (proxy) BringWindowToTop(proxy); - bongo_cat_windows_capture_configure(handle); + /* Preferences windows are transparent too, but are not OBS capture + sources. Running the capture style transaction on them can recreate the + DWM surface and reintroduce an opaque edge. Only configure windows that + were explicitly registered as capture sources. */ + if (bongo_cat_windows_capture_is_configured(handle)) + bongo_cat_windows_capture_configure(handle); + else + bongo_cat_windows_capture_repair_transparency(handle); } bool bongo_cat_platform_set_geometry(BongoCatPlatform *platform, @@ -71,6 +78,8 @@ bool bongo_cat_platform_set_geometry(BongoCatPlatform *platform, size_changed ? height : current_height, SWP_NOZORDER | SWP_NOACTIVATE) != 0; if (!changed) return false; - return SDL_SyncWindow(platform->window); + bool synced = SDL_SyncWindow(platform->window); + if (synced) bongo_cat_windows_capture_repair_transparency(window); + return synced; } #endif diff --git a/src/platform/windows/windows_transparency.c b/src/platform/windows/windows_transparency.c new file mode 100644 index 00000000..9e1b475f --- /dev/null +++ b/src/platform/windows/windows_transparency.c @@ -0,0 +1,129 @@ +#include "windows_capture.h" + +#ifdef _WIN32 +#include +#include + +static UINT transparency_repair_message; +static const wchar_t transparent_property[] = L"BongoCat.TransparentWindow"; +static const wchar_t transparency_proc_property[] = + L"BongoCat.TransparentWindowProc"; +static const wchar_t transparency_repair_property[] = + L"BongoCat.TransparentWindowRepairPending"; +static bool transparency_warning_emitted; + +static bool is_transparent(HWND window) { + return window && GetPropW(window, transparent_property) != NULL; +} + +void bongo_cat_windows_capture_mark_transparent(HWND window, bool enabled) { + if (!window) return; + if (enabled) SetPropW(window, transparent_property, (HANDLE)1); + else { + RemovePropW(window, transparent_property); + RemovePropW(window, transparency_repair_property); + } +} + +static void register_message(void) { + if (!transparency_repair_message) + transparency_repair_message = RegisterWindowMessageW( + L"BongoCat.TransparentWindow.Repair"); +} + +static LRESULT CALLBACK transparency_window_proc(HWND window, UINT message, + WPARAM wparam, LPARAM lparam) { + if (bongo_cat_windows_capture_handle_message(window, message, wparam)) + return 0; + WNDPROC original = (WNDPROC)GetPropW(window, transparency_proc_property); + return CallWindowProcW(original ? original : DefWindowProcW, window, + message, wparam, lparam); +} + +void bongo_cat_windows_capture_install_transparency_handler(HWND window) { + if (!window || GetPropW(window, transparency_proc_property)) return; + WNDPROC original = (WNDPROC)GetWindowLongPtrW(window, GWLP_WNDPROC); + if (!original || !SetPropW(window, transparency_proc_property, + (HANDLE)original)) return; + if (!SetWindowLongPtrW(window, GWLP_WNDPROC, + (LONG_PTR)transparency_window_proc)) + RemovePropW(window, transparency_proc_property); +} + +bool bongo_cat_windows_capture_restore_transparency(HWND window) { + if (!window) return false; + HRGN region = CreateRectRgn(-1, -1, 0, 0); + if (!region) return false; + DWM_BLURBEHIND blur = {0}; + blur.dwFlags = DWM_BB_ENABLE | DWM_BB_BLURREGION; + blur.fEnable = TRUE; + blur.hRgnBlur = region; + HRESULT result = DwmEnableBlurBehindWindow(window, &blur); + DeleteObject(region); + if (SUCCEEDED(result)) { + /* DWM may restore a border or rounded corner after a shell/DPI + transition. Keep transparent popup windows borderless as well. */ + const int no_rounding = 1; /* DWMWCP_DONOTROUND */ + const DWORD no_border = 0xfffffffeu; /* DWMWA_COLOR_NONE */ + DwmSetWindowAttribute(window, 33, &no_rounding, sizeof(no_rounding)); + DwmSetWindowAttribute(window, 34, &no_border, sizeof(no_border)); + } + if (FAILED(result) && !transparency_warning_emitted) { + transparency_warning_emitted = true; + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Cannot restore Windows transparent composition (0x%08lx)", + (unsigned long)result); + } + return SUCCEEDED(result); +} + +void bongo_cat_windows_capture_repair_transparency(HWND window) { + if (!is_transparent(window)) return; + if (bongo_cat_windows_capture_restore_transparency(window)) { + /* A composition reset need not produce a paint message. */ + InvalidateRect(window, NULL, FALSE); + } +} + +static void schedule_repair(HWND window) { + if (!is_transparent(window)) return; + register_message(); + if (!transparency_repair_message || + GetPropW(window, transparency_repair_property)) return; + if (SetPropW(window, transparency_repair_property, (HANDLE)1) && + !PostMessageW(window, transparency_repair_message, 0, 0)) + RemovePropW(window, transparency_repair_property); +} + +bool bongo_cat_windows_capture_handle_transparency_message( + HWND window, UINT message, WPARAM wparam) { + (void)wparam; + register_message(); + if (is_transparent(window)) { + /* SDL's default erase handler fills the client area with black. */ + if (message == WM_ERASEBKGND) return true; + switch (message) { + case WM_DWMCOMPOSITIONCHANGED: + case WM_DWMNCRENDERINGCHANGED: + case WM_DWMCOLORIZATIONCOLORCHANGED: + case WM_DISPLAYCHANGE: + case WM_DPICHANGED: + case WM_THEMECHANGED: + case WM_NCACTIVATE: + case WM_SHOWWINDOW: + case WM_STYLECHANGED: + case WM_WINDOWPOSCHANGED: + schedule_repair(window); + break; + default: + break; + } + } + if (transparency_repair_message && message == transparency_repair_message) { + RemovePropW(window, transparency_repair_property); + bongo_cat_windows_capture_repair_transparency(window); + return true; + } + return false; +} +#endif diff --git a/src/runtime/lifecycle/app.c b/src/runtime/lifecycle/app.c index fa4e1029..c7b1f567 100644 --- a/src/runtime/lifecycle/app.c +++ b/src/runtime/lifecycle/app.c @@ -72,6 +72,11 @@ bool bongo_cat_app_initialize(BongoCatApp *app, int argc, char **argv, "%s", app->smoke_model); if (!app->secondary_pet && !app->autostart_launch) app->session.window.visible = true; + /* Keep a normally visible session hidden until the renderer has produced + its first complete frame. The native window is still configured and + sized below, but no platform gets a chance to expose an empty back + buffer during model/driver startup. */ + app->startup_visibility_pending = app->session.window.visible; bongo_cat_startup_stage(app, "configuration-ready"); if (bongo_cat_app_locate_assets(app, error) != BONGO_CAT_OK) return false; bongo_cat_startup_stage(app, "assets-ready"); diff --git a/src/runtime/lifecycle/app_loop.c b/src/runtime/lifecycle/app_loop.c index ddb4898d..53f82089 100644 --- a/src/runtime/lifecycle/app_loop.c +++ b/src/runtime/lifecycle/app_loop.c @@ -82,15 +82,28 @@ static bool render(BongoCatApp *app, bool present) { app->dirty = true; return cover_handled; } + bool reveal_startup = app->startup_visibility_pending && + app->session.window.visible; bongo_cat_frame_audit(app, width, height); bongo_cat_window_capture_pointer_hit(app); - if (!bongo_cat_platform_present(&app->platform, width, height)) { + /* Keep the native window hidden while diagnostics/readback finish. The + reveal is intentionally adjacent to the swap so an uninitialised front + buffer cannot be displayed as a black startup frame. */ + bool pre_presented = reveal_startup && + bongo_cat_platform_present(&app->platform, width, height); + if (reveal_startup) + bongo_cat_platform_set_visible(&app->platform, true); + bool presented = pre_presented || + bongo_cat_platform_present(&app->platform, width, height); + if (!presented) { + if (reveal_startup) bongo_cat_platform_set_visible(&app->platform, false); app->dirty = true; app->render_retry_ns = now + 1000000000ull; SDL_LogError(SDL_LOG_CATEGORY_VIDEO, "Main frame presentation failed: %s", SDL_GetError()); return false; } + if (reveal_startup) app->startup_visibility_pending = false; bongo_cat_frame_presented_audit(app); bongo_cat_startup_ready(app); bongo_cat_memory_policy_frame_presented(); @@ -140,7 +153,8 @@ bool bongo_cat_app_capture_pending_model_cover(BongoCatApp *app) { static void take_instance_wake(BongoCatApp *app) { if (!bongo_cat_platform_single_instance_take_wake()) return; bongo_cat_window_set_visible(app, true); - bongo_cat_platform_raise_window(app->window); + if (!app->startup_visibility_pending) + bongo_cat_platform_raise_window(app->window); SDL_Log("Existing instance requested window reveal"); } diff --git a/src/runtime/shell/window.c b/src/runtime/shell/window.c index 8428c7f6..381bf373 100644 --- a/src/runtime/shell/window.c +++ b/src/runtime/shell/window.c @@ -47,6 +47,11 @@ static bool try_window(BongoCatApp *app, bool transparent, int samples, BongoCatResult bongo_cat_window_create(BongoCatApp *app, BongoCatError *error) { SDL_SetHint(SDL_HINT_MOUSE_FOCUS_CLICKTHROUGH, "1"); +#ifdef _WIN32 + /* Transparent OpenGL windows must never receive SDL's default black + WM_ERASEBKGND fill before the first frame is submitted. */ + SDL_SetHint(SDL_HINT_WINDOWS_ERASE_BACKGROUND_MODE, "0"); +#endif if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS)) { bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, "SDL initialization failed: %s", SDL_GetError()); @@ -98,7 +103,11 @@ void bongo_cat_window_apply(BongoCatApp *app) { if (preferences->keep_in_screen) bongo_cat_window_clamp_to_display(app); else bongo_cat_window_recover_to_display(app); SDL_SyncWindow(app->window); - bongo_cat_platform_set_visible(&app->platform, state->visible); + /* A visible session is revealed by the first successful frame. Keeping + the native window hidden while loading avoids exposing an uninitialised + (and on some drivers black) back buffer. */ + bongo_cat_platform_set_visible(&app->platform, + state->visible && !app->startup_visibility_pending); bongo_cat_window_sync_click_through(app); bongo_cat_platform_set_always_on_top(&app->platform, preferences->always_on_top); diff --git a/src/runtime/shell/window_hit.c b/src/runtime/shell/window_hit.c index 5c11bbd1..c1f9af81 100644 --- a/src/runtime/shell/window_hit.c +++ b/src/runtime/shell/window_hit.c @@ -55,6 +55,7 @@ void bongo_cat_window_set_visible(BongoCatApp *app, bool visible) { if (!app || !app->window) return; app->session.window.visible = visible; if (!visible) { + app->startup_visibility_pending = false; bongo_cat_platform_set_visible(&app->platform, false); return; } @@ -67,13 +68,17 @@ void bongo_cat_window_set_visible(BongoCatApp *app, bool visible) { app->session.window.opacity_percent / 100.0f); if (app->settings.window.keep_in_screen) bongo_cat_window_clamp_to_display(app); else bongo_cat_window_recover_to_display(app); - bongo_cat_platform_set_visible(&app->platform, true); + /* Keep the native surface hidden until the first complete frame has been + submitted. The render loop will reveal it next to that presentation. */ + bongo_cat_platform_set_visible(&app->platform, + !app->startup_visibility_pending); bongo_cat_window_mark_hit_dirty(app); app->dirty = true; } void bongo_cat_window_raise_when_due(BongoCatApp *app, uint64_t now) { - if (!app || !app->startup_raise_due_ns || now < app->startup_raise_due_ns) return; + if (!app || !app->startup_raise_due_ns || + app->startup_visibility_pending || now < app->startup_raise_due_ns) return; app->startup_raise_due_ns = 0; bongo_cat_window_set_visible(app, true); bongo_cat_platform_raise_window(app->window); diff --git a/src/ui/preferences/preferences_scale.c b/src/ui/preferences/preferences_scale.c index 6b6b280e..68ef5279 100644 --- a/src/ui/preferences/preferences_scale.c +++ b/src/ui/preferences/preferences_scale.c @@ -124,6 +124,7 @@ bool bongo_cat_preferences_open_window(BongoCatPreferences *value) { SDL_SetWindowPosition(value->window, SDL_WINDOWPOS_CENTERED_DISPLAY(display), SDL_WINDOWPOS_CENTERED_DISPLAY(display)); SDL_SyncWindow(value->window); + bongo_cat_platform_configure_preferences_window(value->window); display = window_display(value->window); float raster_scale = 1.0f; bongo_cat_ui_query_window_scale(value->window, &layout_scale, &raster_scale); @@ -134,6 +135,7 @@ bool bongo_cat_preferences_open_window(BongoCatPreferences *value) { SDL_SetWindowPosition(value->window, SDL_WINDOWPOS_CENTERED_DISPLAY(display), SDL_WINDOWPOS_CENTERED_DISPLAY(display)); SDL_SyncWindow(value->window); + bongo_cat_platform_configure_preferences_window(value->window); bool context_ready = bongo_cat_preferences_gl_create(value); if (!context_ready && value->transparent_window) { SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, @@ -146,6 +148,7 @@ bool bongo_cat_preferences_open_window(BongoCatPreferences *value) { SDL_WINDOWPOS_CENTERED_DISPLAY(display), SDL_WINDOWPOS_CENTERED_DISPLAY(display)); SDL_SyncWindow(value->window); + bongo_cat_platform_configure_preferences_window(value->window); context_ready = bongo_cat_preferences_gl_create(value); } if (!context_ready) return false; @@ -218,6 +221,7 @@ bool bongo_cat_preferences_scale_event(BongoCatPreferences *value, SDL_SetWindowSize(value->window, width, height); fit_position(value->window, display, width, height); SDL_SyncWindow(value->window); + bongo_cat_platform_configure_preferences_window(value->window); value->live_resize_rendering = was_rendering; SDL_GL_MakeCurrent(value->app->window, value->app->gl_context); value->render_dirty = true; diff --git a/src/ui/rendering/ui_paint.c b/src/ui/rendering/ui_paint.c index e5364662..9d2da964 100644 --- a/src/ui/rendering/ui_paint.c +++ b/src/ui/rendering/ui_paint.c @@ -33,8 +33,12 @@ static uint32_t pack(struct nk_color color) { } static void pixel(unsigned char *target, struct nk_color color, float alpha) { - target[0] = color.r; target[1] = color.g; target[2] = color.b; - target[3] = (unsigned char)(color.a * clamp01(alpha) + .5f); + unsigned char coverage = (unsigned char)(color.a * clamp01(alpha) + .5f); + /* GL_LINEAR interpolates RGB and alpha independently. Keeping a colored + RGB value in a fully transparent texel can therefore create a visible + fringe on DWM-composited windows when a neighbouring sample is mixed. */ + target[0] = coverage ? color.r : 0; target[1] = coverage ? color.g : 0; target[2] = coverage ? color.b : 0; + target[3] = coverage; } static float rounded_distance(float x, float y, float width, float height, diff --git a/src/ui/rendering/ui_paint_border.c b/src/ui/rendering/ui_paint_border.c index fe2d34b2..23616241 100644 --- a/src/ui/rendering/ui_paint_border.c +++ b/src/ui/rendering/ui_paint_border.c @@ -72,7 +72,10 @@ void bongo_cat_ui_raster_dashed_rounded(unsigned char *pixels, clamp01(NK_MIN(phase + .5f, dash - phase + .5f)) : 0.0f; float alpha = clamp01(thickness * .5f + .75f - distance) * dash_alpha; unsigned char *target = pixels + ((size_t)y * width + x) * 4; - target[0] = color.r; target[1] = color.g; target[2] = color.b; - target[3] = (unsigned char)(color.a * alpha + .5f); + unsigned char coverage = (unsigned char)(color.a * alpha + .5f); + target[0] = coverage ? color.r : 0; + target[1] = coverage ? color.g : 0; + target[2] = coverage ? color.b : 0; + target[3] = coverage; } } diff --git a/tests/platform/test_windows_capture.c b/tests/platform/test_windows_capture.c index 2550827c..00baca42 100644 --- a/tests/platform/test_windows_capture.c +++ b/tests/platform/test_windows_capture.c @@ -195,10 +195,30 @@ static void test_capture_styles(void) { } } +static void test_transparency_lifecycle(void) { + HWND window = CreateWindowExW(0, L"STATIC", L"BongoCat transparency test", + WS_POPUP, 0, 0, 32, 32, NULL, NULL, GetModuleHandleW(NULL), NULL); + CHECK(window != NULL); + if (!window) return; + CHECK(!bongo_cat_windows_capture_is_configured(window)); + bongo_cat_windows_capture_mark_transparent(window, true); + CHECK(bongo_cat_windows_capture_handle_transparency_message( + window, WM_ERASEBKGND, 0)); + bongo_cat_windows_capture_install_transparency_handler(window); + CHECK(SendMessageW(window, WM_ERASEBKGND, 0, 0) == 0); + CHECK(bongo_cat_windows_capture_configure(window)); + CHECK(bongo_cat_windows_capture_is_configured(window)); + bongo_cat_windows_capture_mark_transparent(window, false); + CHECK(!bongo_cat_windows_capture_handle_transparency_message( + window, WM_ERASEBKGND, 0)); + DestroyWindow(window); +} + int main(void) { test_package_storage_root(); test_click_through_does_not_refresh_frame(); test_capture_styles(); + test_transparency_lifecycle(); test_tray_restart_notification(); test_popup_completion(); test_input_startup(); From db966dc484d0c52098398fdb236b56ed9452596b Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 5 Sep 2026 13:56:35 +0800 Subject: [PATCH 220/334] Improve game input fallback and diagnostics --- include/bongo_cat/app.h | 10 ++ src/core/app_state.c | 35 +++++- src/platform/windows/windows.c | 6 +- src/platform/windows/windows_direct_input.c | 54 ++++++++-- src/platform/windows/windows_input.c | 114 +++++++++++++++++--- src/platform/windows/windows_input.h | 3 + src/runtime/input/mouse.c | 41 ++++--- src/runtime/input/mouse_mapping.c | 2 + src/runtime/input/shortcuts.c | 13 ++- src/runtime/lifecycle/app_input.c | 47 ++++++++ 10 files changed, 278 insertions(+), 47 deletions(-) diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index 22bc18af..62a18497 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -130,6 +130,14 @@ typedef struct BongoCatApp { uint64_t mouse_last_ns; uint64_t mouse_diagnostic_due_ns; uint64_t mouse_hook_samples; + uint64_t input_diagnostic_due_ns; + uint64_t input_events_consumed; + uint64_t input_key_down_events, input_key_up_events; + uint64_t input_mouse_down_events, input_mouse_up_events; + uint64_t input_key_supported, input_key_unsupported; + uint64_t input_mouse_applied, input_mouse_updates; + uint64_t input_shortcuts_triggered; + char input_last_name[BONGO_CAT_ID_CAP]; uint64_t frame_audit_bmp_ns; uint64_t random_expression_due_ns; float random_expression_interval_seconds; @@ -150,9 +158,11 @@ typedef struct BongoCatApp { bool left_mouse_down; bool right_mouse_down; bool side_mouse_down; + bool mouse_button_event_pending; bool model_pointer_anchor_ready; float model_pointer_anchor_x, model_pointer_anchor_y; bool pointer_relative_active; + bool pointer_cursor_locked; bool window_minimized; double pointer_x, pointer_y; bool resize_gesture; diff --git a/src/core/app_state.c b/src/core/app_state.c index 05265f35..9a5437bb 100644 --- a/src/core/app_state.c +++ b/src/core/app_state.c @@ -66,7 +66,12 @@ static void update_hands(BongoCatApp *app) { } static void apply_key(BongoCatApp *app, const char *name, bool pressed) { - if (bongo_cat_overlay_key(app->overlay, name, pressed) < 0) return; + int hand = bongo_cat_overlay_key(app->overlay, name, pressed); + if (hand < 0) { + app->input_key_unsupported++; + return; + } + app->input_key_supported++; update_hands(app); app->dirty = true; } @@ -139,15 +144,35 @@ void bongo_cat_app_apply_input(BongoCatApp *app, const BongoCatInputEvent *event case BONGO_CAT_INPUT_KEY_UP: apply_key(app, event->name, false); break; case BONGO_CAT_INPUT_MOUSE_DOWN: case BONGO_CAT_INPUT_MOUSE_UP: { - if (strcmp(event->name, "Left") != 0 && strcmp(event->name, "Right") != 0) + bool left = strcmp(event->name, "Left") == 0; + bool right = strcmp(event->name, "Right") == 0; + bool side = strcmp(event->name, "Back") == 0 || + strcmp(event->name, "Forward") == 0; + if (!left && !right && !side) break; bool down = event->kind == BONGO_CAT_INPUT_MOUSE_DOWN; - if (strcmp(event->name, "Left") == 0) app->left_mouse_down = down; - else app->right_mouse_down = down; + bool changed; + if (left) { + changed = app->left_mouse_down != down; + app->left_mouse_down = down; + } else if (right) { + changed = app->right_mouse_down != down; + app->right_mouse_down = down; + } else { + changed = app->side_mouse_down != down; + app->side_mouse_down = down; + } + if (changed) app->mouse_button_event_pending = true; if (!down) app->pointer_hit_dirty = true; - const char *id = strcmp(event->name, "Left") == 0 + if (side) { + app->input_mouse_applied++; + app->dirty = true; + break; + } + const char *id = left ? "ParamMouseLeftDown" : "ParamMouseRightDown"; bongo_cat_live2d_set_parameter(app->live2d, id, down ? 1.0f : 0.0f); + app->input_mouse_applied++; app->dirty = true; break; } diff --git a/src/platform/windows/windows.c b/src/platform/windows/windows.c index ce829d35..f55cf08e 100644 --- a/src/platform/windows/windows.c +++ b/src/platform/windows/windows.c @@ -157,17 +157,19 @@ bool bongo_cat_platform_relative_pointer(BongoCatPlatform *platform, if (!platform || !x || !y) return false; uint64_t now_ms = GetTickCount64(); if (!platform->relative_pointer) { - if (platform->relative_pointer_retry_ms > now_ms) return false; + if (platform->relative_pointer_retry_ms > now_ms) + return bongo_cat_windows_input_take_relative(platform, x, y); if (!bongo_cat_windows_direct_input_create(platform, native_window(platform))) { platform->relative_pointer_retry_ms = now_ms + 5000; - return false; + return bongo_cat_windows_input_take_relative(platform, x, y); } platform->relative_pointer_retry_ms = 0; } return bongo_cat_windows_direct_input_read(platform, x, y); } void bongo_cat_platform_relative_pointer_reset(BongoCatPlatform *platform) { + bongo_cat_windows_input_reset_relative(platform); bongo_cat_windows_direct_input_reset(platform); } void bongo_cat_platform_relative_pointer_release(BongoCatPlatform *platform) { diff --git a/src/platform/windows/windows_direct_input.c b/src/platform/windows/windows_direct_input.c index 8794640c..b99aa1af 100644 --- a/src/platform/windows/windows_direct_input.c +++ b/src/platform/windows/windows_direct_input.c @@ -1,4 +1,5 @@ #include "windows_direct_input.h" +#include "windows_input.h" #include "bongo_cat/file.h" #ifdef _WIN32 @@ -26,6 +27,9 @@ typedef struct BongoCatDirectInput { long long total_y; unsigned long long buffered_events; unsigned long long buffer_overflows; + unsigned long long hook_fallback_samples; + long long hook_total_x; + long long hook_total_y; ULONGLONG last_log_ms; bool diagnostic_ready; } BongoCatDirectInput; @@ -71,15 +75,21 @@ void bongo_cat_windows_direct_input_destroy(BongoCatPlatform *platform) { if (state->input) state->input->lpVtbl->Release(state->input); if (state->reads) SDL_Log("[input] Relative pointer stopped: reads=%llu " "reacquires=%llu failures=%llu buffered_events=%llu overflows=%llu " - "total=%lld,%lld", state->reads, state->reacquires, - state->failures, state->buffered_events, state->buffer_overflows, + "hook_fallback_samples=%llu hook_total=%lld,%lld total=%lld,%lld", + state->reads, state->reacquires, state->failures, + state->buffered_events, state->buffer_overflows, + state->hook_fallback_samples, state->hook_total_x, state->hook_total_y, state->total_x, state->total_y); if (state->audit) { fprintf(state->audit, "summary reads=%llu reacquires=%llu failures=%llu " - "buffered_events=%llu buffer_overflows=%llu total_x=%lld total_y=%lld\n", + "buffered_events=%llu buffer_overflows=%llu " + "hook_fallback_samples=%llu hook_total_x=%lld hook_total_y=%lld " + "total_x=%lld total_y=%lld\n", state->reads, state->reacquires, state->failures, state->buffered_events, state->buffer_overflows, + state->hook_fallback_samples, state->hook_total_x, + state->hook_total_y, state->total_x, state->total_y); fclose(state->audit); } @@ -90,9 +100,11 @@ void bongo_cat_windows_direct_input_destroy(BongoCatPlatform *platform) { void bongo_cat_windows_direct_input_reset(BongoCatPlatform *platform) { BongoCatDirectInput *state = platform ? platform->relative_pointer : NULL; if (!state) return; + bongo_cat_windows_input_reset_relative(platform); double x = 0.0, y = 0.0; bongo_cat_windows_direct_input_read(platform, &x, &y); state->rebase_pending = true; + bongo_cat_windows_input_reset_relative(platform); } bool bongo_cat_windows_direct_input_create(BongoCatPlatform *platform, @@ -148,6 +160,7 @@ bool bongo_cat_windows_direct_input_create(BongoCatPlatform *platform, state->absolute_ready = GetPhysicalCursorPos(&state->absolute) != FALSE; state->rebase_pending = true; state->last_read_ms = GetTickCount64(); + bongo_cat_windows_input_reset_relative(platform); SDL_Log("[input] Relative pointer initialized: mode=DirectInput " "cooperative=background-nonexclusive physical_cursor=%d", state->absolute_ready); @@ -184,6 +197,9 @@ bool bongo_cat_windows_direct_input_read(BongoCatPlatform *platform, DWORD event_count = (DWORD)(sizeof(events) / sizeof(events[0])); long long direct_x = 0, direct_y = 0; bool buffer_overflow = false; + double hook_x = 0.0, hook_y = 0.0; + bool hook_delta = bongo_cat_windows_input_take_relative(platform, + &hook_x, &hook_y); POINT absolute = {0}; bool absolute_ready = GetPhysicalCursorPos(&absolute) != FALSE; bool fallback_ready = absolute_ready && state->absolute_ready; @@ -212,23 +228,30 @@ bool bongo_cat_windows_direct_input_read(BongoCatPlatform *platform, if (buffer_overflow) state->buffer_overflows++; state->buffered_events += event_count; } - bool available = sample_ready || fallback_ready; + bool available = sample_ready || fallback_ready || hook_delta; /* A delayed frame is not evidence that the input is invalid. In particular, a game can keep moving/recentering the cursor while this window is not scheduled for a frame. Only an explicit reset or a complete loss of both sources should rebase the virtual pointer. */ - bool rebase = state->rebase_pending || (!sample_ready && !fallback_ready); + bool rebase = state->rebase_pending || + (!sample_ready && !fallback_ready && !hook_delta); bool direct_zero = direct_x == 0 && direct_y == 0; bool physical_delta = fallback_x != 0 || fallback_y != 0; - bool use_fallback = fallback_ready && (!sample_ready || + bool use_hook = hook_delta && (!sample_ready || direct_zero || + buffer_overflow); + bool use_physical = fallback_ready && (!sample_ready || (physical_delta && (direct_zero || buffer_overflow)) || (direct_zero && reacquired)); + bool use_fallback = use_hook || (!hook_delta && use_physical); if (rebase) { *x = 0.0; *y = 0.0; } else if (sample_ready && !use_fallback) { *x = (double)direct_x; *y = (double)direct_y; + } else if (use_hook) { + *x = hook_x; + *y = hook_y; } else if (fallback_ready) { *x = (double)fallback_x; *y = (double)fallback_y; @@ -238,6 +261,11 @@ bool bongo_cat_windows_direct_input_read(BongoCatPlatform *platform, } state->total_x += (long long)*x; state->total_y += (long long)*y; + if (use_hook) { + state->hook_fallback_samples++; + state->hook_total_x += (long long)*x; + state->hook_total_y += (long long)*y; + } state->rebase_pending = !available; if (!sample_ready) state->failures++; bool log_due = !state->diagnostic_ready || @@ -246,13 +274,15 @@ bool bongo_cat_windows_direct_input_read(BongoCatPlatform *platform, SDL_Log("[input] Relative pointer sample: result=0x%08lx " "sample=%d reacquired=%d stale=%d physical=%d fallback=%d " "rebase=%d direct=%lld,%lld physical_delta=%ld,%ld " - "events=%lu overflow=%d output=%.0f,%.0f reads=%llu " + "hook_delta=%.0f,%.0f hook=%d events=%lu overflow=%d " + "output=%.0f,%.0f reads=%llu " "failures=%llu reacquires=%llu", (unsigned long)result, sample_ready, reacquired, stale, fallback_ready, use_fallback, rebase, direct_x, direct_y, - (long)fallback_x, (long)fallback_y, *x, *y, - (unsigned long)event_count, buffer_overflow, + (long)fallback_x, (long)fallback_y, + hook_x, hook_y, use_hook, + (unsigned long)event_count, buffer_overflow, *x, *y, state->reads, state->failures, state->reacquires); state->last_log_ms = now_ms; } @@ -260,10 +290,12 @@ bool bongo_cat_windows_direct_input_read(BongoCatPlatform *platform, if (state->audit) { fprintf(state->audit, "read=%llu result=0x%08lx reacquired=%d stale=%d rebase=%d " - "fallback=%d events=%lu overflow=%d dx=%.0f dy=%.0f " + "fallback=%d hook=%.0f,%.0f use_hook=%d events=%lu overflow=%d " + "dx=%.0f dy=%.0f " "total_x=%lld total_y=%lld\n", state->reads, (unsigned long)result, reacquired, stale, rebase, - use_fallback, (unsigned long)event_count, buffer_overflow, + use_fallback, hook_x, hook_y, use_hook, + (unsigned long)event_count, buffer_overflow, *x, *y, state->total_x, state->total_y); fflush(state->audit); diff --git a/src/platform/windows/windows_input.c b/src/platform/windows/windows_input.c index fa8a0bcc..15b9e775 100644 --- a/src/platform/windows/windows_input.c +++ b/src/platform/windows/windows_input.c @@ -10,6 +10,7 @@ typedef struct WindowsInputState { BongoCatPlatform *platform; SRWLOCK platform_lock; + SRWLOCK relative_lock; HANDLE thread; HANDLE stop; HHOOK keyboard; @@ -19,6 +20,9 @@ typedef struct WindowsInputState { DWORD test_start_delay_ms; DWORD keyboard_hook_error, mouse_hook_error; unsigned long long keyboard_events, mouse_moves, mouse_buttons; + unsigned long long keyboard_emitted, keyboard_ignored; + unsigned long long keyboard_queue_failures; + unsigned long long mouse_button_queue_failures; unsigned long long reported_keyboard_events, reported_mouse_moves; unsigned long long reported_mouse_buttons; ULONGLONG last_diagnostic_ms; @@ -26,6 +30,13 @@ typedef struct WindowsInputState { POINT last_mouse; DWORD last_mouse_flags; bool last_mouse_known; + DWORD last_key_code, last_key_message, last_key_flags; + char last_key_name[16]; + bool last_key_known; + POINT relative_mouse; + bool relative_mouse_known; + long long relative_x, relative_y; + unsigned long long relative_samples, relative_resets; } WindowsInputState; static WindowsInputState *global_state; @@ -37,37 +48,53 @@ static void wake_main_thread(WindowsInputState *state) { SDL_PushEvent(&wake); } -static void push_event(BongoCatInputKind kind, const char *name, float value) { +static bool push_event(BongoCatInputKind kind, const char *name, float value) { WindowsInputState *state = global_state; - if (!state || !name) return; + if (!state || !name) return false; AcquireSRWLockShared(&state->platform_lock); BongoCatPlatform *platform = state->platform; if (!platform) { ReleaseSRWLockShared(&state->platform_lock); - return; + return false; } BongoCatInputEvent event = {0}; event.kind = kind; event.timestamp_ms = GetTickCount64(); event.value = value; snprintf(event.name, sizeof(event.name), "%s", name); - if (bongo_cat_input_push(platform->input, &event)) wake_main_thread(state); + bool pushed = bongo_cat_input_push(platform->input, &event); + if (pushed) wake_main_thread(state); ReleaseSRWLockShared(&state->platform_lock); + return pushed; } static void emit_key(bool down, const char *name, void *userdata) { (void)userdata; - push_event(down ? BONGO_CAT_INPUT_KEY_DOWN : BONGO_CAT_INPUT_KEY_UP, - name, down ? 1.0f : 0.0f); + WindowsInputState *state = global_state; + bool pushed = push_event(down ? BONGO_CAT_INPUT_KEY_DOWN : + BONGO_CAT_INPUT_KEY_UP, name, down ? 1.0f : 0.0f); + if (!state) return; + if (pushed) state->keyboard_emitted++; + else state->keyboard_queue_failures++; } static LRESULT CALLBACK keyboard_hook(int code, WPARAM message, LPARAM data) { WindowsInputState *state = global_state; - if (code == HC_ACTION && state) { + if (code == HC_ACTION && state && data) { + const KBDLLHOOKSTRUCT *key = (const KBDLLHOOKSTRUCT *)data; state->keyboard_events++; - bongo_cat_windows_keyboard_event(&state->keyboard_state, - (const KBDLLHOOKSTRUCT *)data, message, + char key_name[16]; + const char *mapped_name = bongo_cat_windows_key_name(key, key_name); + state->last_key_code = key->vkCode; + state->last_key_message = (DWORD)message; + state->last_key_flags = key->flags; + state->last_key_known = mapped_name != NULL; + snprintf(state->last_key_name, sizeof(state->last_key_name), "%s", + mapped_name ? mapped_name : "none"); + bool emitted = bongo_cat_windows_keyboard_event(&state->keyboard_state, + key, message, &state->test_drop_key_up, emit_key, NULL); + if (!emitted) state->keyboard_ignored++; } return CallNextHookEx(NULL, code, message, data); } @@ -85,10 +112,24 @@ static const char *mouse_button(WPARAM message, DWORD mouse_data) { static LRESULT CALLBACK mouse_hook(int code, WPARAM message, LPARAM data) { WindowsInputState *state = global_state; - if (code == HC_ACTION && state) { + if (code == HC_ACTION && state && data) { const MSLLHOOKSTRUCT *mouse = (const MSLLHOOKSTRUCT *)data; - state->last_mouse = mouse->pt; state->last_mouse_flags = mouse->flags; + AcquireSRWLockExclusive(&state->relative_lock); + if (message == WM_MOUSEMOVE) { + if (state->relative_mouse_known) { + state->relative_x += (long long)mouse->pt.x - + (long long)state->relative_mouse.x; + state->relative_y += (long long)mouse->pt.y - + (long long)state->relative_mouse.y; + state->relative_samples++; + } + state->relative_mouse = mouse->pt; + state->relative_mouse_known = true; + } + state->last_mouse = mouse->pt; + state->last_mouse_flags = mouse->flags; state->last_mouse_known = true; + ReleaseSRWLockExclusive(&state->relative_lock); if (message == WM_MOUSEMOVE) { state->mouse_moves++; AcquireSRWLockShared(&state->platform_lock); @@ -102,8 +143,9 @@ static LRESULT CALLBACK mouse_hook(int code, WPARAM message, LPARAM data) { message == WM_MBUTTONDOWN || message == WM_XBUTTONDOWN; if (name) { state->mouse_buttons++; - push_event(down ? BONGO_CAT_INPUT_MOUSE_DOWN : - BONGO_CAT_INPUT_MOUSE_UP, name, down ? 1.0f : 0.0f); + if (!push_event(down ? BONGO_CAT_INPUT_MOUSE_DOWN : + BONGO_CAT_INPUT_MOUSE_UP, name, down ? 1.0f : 0.0f)) + state->mouse_button_queue_failures++; } } } @@ -150,9 +192,20 @@ static void log_input_summary(WindowsInputState *state, ULONGLONG now_ms) { ULONGLONG interval_ms = active ? 30000 : 60000; if (state->diagnostic_ready && now_ms - state->last_diagnostic_ms < interval_ms) return; + long long relative_x, relative_y; + unsigned long long relative_samples; + AcquireSRWLockShared(&state->relative_lock); + relative_x = state->relative_x; + relative_y = state->relative_y; + relative_samples = state->relative_samples; + ReleaseSRWLockShared(&state->relative_lock); SDL_Log("[input] Windows hooks: keyboard=%d mouse=%d " "key_events=%llu(+%llu) mouse_moves=%llu(+%llu) " "mouse_buttons=%llu(+%llu) last_known=%d last=%ld,%ld flags=0x%lx " + "last_key_known=%d last_key=%s code=%lu message=%lu flags=0x%lx " + "key_emitted=%llu key_ignored=%llu key_queue_failures=%llu " + "mouse_button_queue_failures=%llu relative_pending=%lld,%lld " + "relative_samples=%llu " "physical_known=%d physical=%ld,%ld cursor_known=%d cursor_visible=%d " "cursor_confined=%d clip_known=%d clip=%ld,%ld,%ld,%ld " "desktop=%ld,%ld,%ld,%ld foreground_pid=%lu own_foreground=%d", @@ -161,6 +214,13 @@ static void log_input_summary(WindowsInputState *state, ULONGLONG now_ms) { state->mouse_buttons, button_delta, state->last_mouse_known, (long)state->last_mouse.x, (long)state->last_mouse.y, (unsigned long)state->last_mouse_flags, + state->last_key_known, state->last_key_name[0] ? state->last_key_name : "none", + (unsigned long)state->last_key_code, + (unsigned long)state->last_key_message, + (unsigned long)state->last_key_flags, + state->keyboard_emitted, state->keyboard_ignored, + state->keyboard_queue_failures, state->mouse_button_queue_failures, + relative_x, relative_y, relative_samples, physical_known, (long)physical.x, (long)physical.y, cursor_known, cursor_visible, @@ -224,11 +284,39 @@ static DWORD WINAPI input_thread(void *context) { return 0; } +bool bongo_cat_windows_input_take_relative(BongoCatPlatform *platform, + double *x, double *y) { + WindowsInputState *state = platform ? platform->native : NULL; + if (!state || !x || !y) return false; + AcquireSRWLockExclusive(&state->relative_lock); + long long relative_x = state->relative_x; + long long relative_y = state->relative_y; + state->relative_x = 0; + state->relative_y = 0; + ReleaseSRWLockExclusive(&state->relative_lock); + *x = (double)relative_x; + *y = (double)relative_y; + return relative_x != 0 || relative_y != 0; +} + +void bongo_cat_windows_input_reset_relative(BongoCatPlatform *platform) { + WindowsInputState *state = platform ? platform->native : NULL; + if (!state) return; + AcquireSRWLockExclusive(&state->relative_lock); + state->relative_x = 0; + state->relative_y = 0; + state->relative_mouse_known = state->last_mouse_known; + if (state->last_mouse_known) state->relative_mouse = state->last_mouse; + state->relative_resets++; + ReleaseSRWLockExclusive(&state->relative_lock); +} + bool bongo_cat_windows_input_start(BongoCatPlatform *platform) { if (!platform || SDL_getenv("BONGO_CAT_TEST_HOOK_FAILURE")) return false; WindowsInputState *state = calloc(1, sizeof(*state)); if (!state) return false; InitializeSRWLock(&state->platform_lock); + InitializeSRWLock(&state->relative_lock); state->platform = platform; const char *drop_key_up = SDL_getenv("BONGO_CAT_TEST_DROP_KEY_UP"); if (drop_key_up) state->test_drop_key_up = diff --git a/src/platform/windows/windows_input.h b/src/platform/windows/windows_input.h index 8dfed282..93f4d955 100644 --- a/src/platform/windows/windows_input.h +++ b/src/platform/windows/windows_input.h @@ -6,6 +6,9 @@ #ifdef _WIN32 bool bongo_cat_windows_input_start(BongoCatPlatform *platform); void bongo_cat_windows_input_stop(BongoCatPlatform *platform); +bool bongo_cat_windows_input_take_relative(BongoCatPlatform *platform, + double *x, double *y); +void bongo_cat_windows_input_reset_relative(BongoCatPlatform *platform); #endif #endif diff --git a/src/runtime/input/mouse.c b/src/runtime/input/mouse.c index f17ccd24..bf6c4b22 100644 --- a/src/runtime/input/mouse.c +++ b/src/runtime/input/mouse.c @@ -117,15 +117,21 @@ void bongo_cat_app_apply_mouse(BongoCatApp *app) { } float global_x = 0.0f, global_y = 0.0f; SDL_MouseButtonFlags buttons = SDL_GetGlobalMouseState(&global_x, &global_y); - bool button_changed = reconcile_button(app, &app->left_mouse_down, - (buttons & SDL_BUTTON_LMASK) != 0, "ParamMouseLeftDown"); - button_changed = reconcile_button(app, &app->right_mouse_down, - (buttons & SDL_BUTTON_RMASK) != 0, "ParamMouseRightDown") || button_changed; - bool side_down = (buttons & (SDL_BUTTON_X1MASK | SDL_BUTTON_X2MASK)) != 0; - if (app->side_mouse_down != side_down) { - app->side_mouse_down = side_down; - button_changed = true; - app->dirty = true; + bool button_event_pending = app->mouse_button_event_pending; + bool cursor_locked = bongo_cat_platform_pointer_locked(&app->platform); + bool button_changed = false; + if (!button_event_pending && !cursor_locked) { + button_changed = reconcile_button(app, &app->left_mouse_down, + (buttons & SDL_BUTTON_LMASK) != 0, "ParamMouseLeftDown"); + button_changed = reconcile_button(app, &app->right_mouse_down, + (buttons & SDL_BUTTON_RMASK) != 0, "ParamMouseRightDown") || + button_changed; + bool side_down = (buttons & (SDL_BUTTON_X1MASK | SDL_BUTTON_X2MASK)) != 0; + if (app->side_mouse_down != side_down) { + app->side_mouse_down = side_down; + button_changed = true; + app->dirty = true; + } } if (!received && (!app->mouse_last_ns || !app->pointer_known)) { target_x = global_x; @@ -150,18 +156,21 @@ void bongo_cat_app_apply_mouse(BongoCatApp *app) { bool model_moved = moved; bool profile_relative = app->model_render_options.mver_projection && app->model_render_options.mouse_force_move; - bool cursor_locked = bongo_cat_platform_pointer_locked(&app->platform); bool relative_requested = !app->settings.model.ignore_mouse && (profile_relative || cursor_locked); + bool cursor_lock_changed = cursor_locked != app->pointer_cursor_locked; bool pointer_mode_changed = false; - if (relative_requested != app->pointer_relative_active) { + if (relative_requested != app->pointer_relative_active || + (relative_requested && cursor_lock_changed)) { pointer_mode_changed = true; - app->mver_pointer = (BongoCatMverPointerState){0}; + if (relative_requested != app->pointer_relative_active) + app->mver_pointer = (BongoCatMverPointerState){0}; if (relative_requested) bongo_cat_platform_relative_pointer_reset(&app->platform); else bongo_cat_platform_relative_pointer_release(&app->platform); app->pointer_relative_active = relative_requested; } + app->pointer_cursor_locked = cursor_locked; bool map_pointer = !app->settings.model.ignore_mouse && (cursor_locked || (app->model_render_options.mver_projection && (!app->settings.model.mouse_centered || profile_relative))); @@ -180,7 +189,13 @@ void bongo_cat_app_apply_mouse(BongoCatApp *app) { log_mouse_diagnostics(app, now, received, target_x, target_y, global_x, global_y, buttons, cursor_locked, relative_requested); if (app->settings.model.ignore_mouse || - (!model_moved && !button_changed && !pointer_mode_changed)) return; + (!model_moved && !button_changed && !pointer_mode_changed && + !button_event_pending)) { + app->mouse_button_event_pending = false; + return; + } + app->input_mouse_updates++; bongo_cat_app_apply_mouse_coordinates(app, model_x, model_y, cursor_locked ? model_x : target_x, cursor_locked ? model_y : target_y); + app->mouse_button_event_pending = false; } diff --git a/src/runtime/input/mouse_mapping.c b/src/runtime/input/mouse_mapping.c index 789f59c2..61690232 100644 --- a/src/runtime/input/mouse_mapping.c +++ b/src/runtime/input/mouse_mapping.c @@ -207,6 +207,8 @@ void bongo_cat_app_reset_pointer_tracking(BongoCatApp *app) { app->pointer_known = false; app->mouse_last_ns = 0; app->pointer_relative_active = false; + app->pointer_cursor_locked = false; + app->mouse_button_event_pending = false; app->dirty = true; } diff --git a/src/runtime/input/shortcuts.c b/src/runtime/input/shortcuts.c index 08615d66..c7b0becc 100644 --- a/src/runtime/input/shortcuts.c +++ b/src/runtime/input/shortcuts.c @@ -102,32 +102,37 @@ static bool behavior_shortcut(BongoCatApp *app, const BongoCatInputEvent *event) void bongo_cat_app_shortcuts(BongoCatApp *app, const BongoCatInputEvent *event) { if (!app) return; if (event->kind == BONGO_CAT_INPUT_GAMEPAD_BUTTON) { - behavior_shortcut(app, event); + if (behavior_shortcut(app, event)) app->input_shortcuts_triggered++; return; } bool primary = bongo_cat_shortcut_update(&app->shortcut_state, event); if (!primary) { - behavior_shortcut(app, event); + if (behavior_shortcut(app, event)) app->input_shortcuts_triggered++; return; } BongoCatShortcutPreferences *shortcuts = &app->settings.shortcuts; + bool handled = false; if (bongo_cat_shortcut_matches(&app->shortcut_state, event, shortcuts->toggle_pet_visibility)) { toggle_pet_visibility(app); + handled = true; } else if (bongo_cat_shortcut_matches(&app->shortcut_state, event, shortcuts->visible_preferences)) { bongo_cat_preferences_visible(app->preferences) ? bongo_cat_preferences_close(app->preferences) : bongo_cat_preferences_show(app->preferences); + handled = true; } else if (bongo_cat_shortcut_matches(&app->shortcut_state, event, shortcuts->mirror)) { app->settings.model.mirror = !app->settings.model.mirror; app->model_pointer_anchor_ready = false; app->dirty = true; bongo_cat_preferences_invalidate(app->preferences); + handled = true; } else if (bongo_cat_shortcut_matches(&app->shortcut_state, event, shortcuts->pass_through)) { app->settings.window.pass_through = !app->settings.window.pass_through; bongo_cat_window_mark_hit_dirty(app); bongo_cat_window_sync_click_through(app); bongo_cat_preferences_invalidate(app->preferences); + handled = true; } else if (bongo_cat_shortcut_matches(&app->shortcut_state, event, shortcuts->always_on_top)) { app->settings.window.always_on_top = !app->settings.window.always_on_top; @@ -135,7 +140,9 @@ void bongo_cat_app_shortcuts(BongoCatApp *app, const BongoCatInputEvent *event) bongo_cat_window_mark_hit_dirty(app); bongo_cat_window_sync_click_through(app); bongo_cat_preferences_invalidate(app->preferences); - } else behavior_shortcut(app, event); + handled = true; + } else handled = behavior_shortcut(app, event); + if (handled) app->input_shortcuts_triggered++; } static void test_key(BongoCatApp *app, BongoCatInputKind kind, const char *name) { diff --git a/src/runtime/lifecycle/app_input.c b/src/runtime/lifecycle/app_input.c index 1a4d020f..d0bc4474 100644 --- a/src/runtime/lifecycle/app_input.c +++ b/src/runtime/lifecycle/app_input.c @@ -6,10 +6,55 @@ #include #include +static void log_input_consumer(BongoCatApp *app) { + if (!app) return; + uint64_t now = SDL_GetTicksNS(); + if (app->input_diagnostic_due_ns && now < app->input_diagnostic_due_ns) + return; + app->input_diagnostic_due_ns = (now ? now : 1) + 30000000000ull; + SDL_Log("[input] Main consumer: events=%llu key_down=%llu key_up=%llu " + "mouse_down=%llu mouse_up=%llu key_supported=%llu " + "key_unsupported=%llu mouse_applied=%llu mouse_updates=%llu " + "shortcuts=%llu " + "last=%s dropped=%llu active=%zu buttons=%d,%d,%d " + "pointer_known=%d pointer=%.1f,%.1f", + (unsigned long long)app->input_events_consumed, + (unsigned long long)app->input_key_down_events, + (unsigned long long)app->input_key_up_events, + (unsigned long long)app->input_mouse_down_events, + (unsigned long long)app->input_mouse_up_events, + (unsigned long long)app->input_key_supported, + (unsigned long long)app->input_key_unsupported, + (unsigned long long)app->input_mouse_applied, + (unsigned long long)app->input_mouse_updates, + (unsigned long long)app->input_shortcuts_triggered, + app->input_last_name[0] ? app->input_last_name : "none", + (unsigned long long)atomic_load_explicit(&app->input.dropped, + memory_order_relaxed), app->active_input_count, + app->left_mouse_down, app->right_mouse_down, app->side_mouse_down, + app->pointer_known, app->pointer_x, app->pointer_y); +} + +static void record_input_event(BongoCatApp *app, + const BongoCatInputEvent *event) { + if (!app || !event) return; + app->input_events_consumed++; + switch (event->kind) { + case BONGO_CAT_INPUT_KEY_DOWN: app->input_key_down_events++; break; + case BONGO_CAT_INPUT_KEY_UP: app->input_key_up_events++; break; + case BONGO_CAT_INPUT_MOUSE_DOWN: app->input_mouse_down_events++; break; + case BONGO_CAT_INPUT_MOUSE_UP: app->input_mouse_up_events++; break; + default: break; + } + snprintf(app->input_last_name, sizeof(app->input_last_name), "%s", + event->name[0] ? event->name : "none"); +} + void bongo_cat_app_drain_input(BongoCatApp *app, bool allow_shortcuts) { if (app && app->secondary_pet) allow_shortcuts = false; BongoCatInputEvent event; while (bongo_cat_input_pop(&app->input, &event)) { + record_input_event(app, &event); if (app->smoke_ignore_global_input) continue; if (app->smoke_input_audit) { char path[BONGO_CAT_PATH_CAP]; @@ -31,10 +76,12 @@ void bongo_cat_app_drain_input(BongoCatApp *app, bool allow_shortcuts) { } uint64_t now = SDL_GetTicks(); while (bongo_cat_input_take_scheduled_release(&app->input, now, &event)) { + record_input_event(app, &event); if (allow_shortcuts && !bongo_cat_preferences_shortcuts_blocked(app->preferences)) bongo_cat_app_shortcuts(app, &event); bongo_cat_app_apply_input(app, &event); } if (!app->smoke_ignore_global_input) bongo_cat_app_apply_mouse(app); + log_input_consumer(app); } From 6e8a8fed93e2e9a7a071d9f6adf912e5ecc7540a Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 5 Sep 2026 15:37:19 +0800 Subject: [PATCH 221/334] Fix pointer origin when games lock cursor --- src/runtime/input/mouse.c | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/runtime/input/mouse.c b/src/runtime/input/mouse.c index bf6c4b22..d52ceeb2 100644 --- a/src/runtime/input/mouse.c +++ b/src/runtime/input/mouse.c @@ -119,6 +119,7 @@ void bongo_cat_app_apply_mouse(BongoCatApp *app) { SDL_MouseButtonFlags buttons = SDL_GetGlobalMouseState(&global_x, &global_y); bool button_event_pending = app->mouse_button_event_pending; bool cursor_locked = bongo_cat_platform_pointer_locked(&app->platform); + bool cursor_lock_changed = cursor_locked != app->pointer_cursor_locked; bool button_changed = false; if (!button_event_pending && !cursor_locked) { button_changed = reconcile_button(app, &app->left_mouse_down, @@ -140,6 +141,13 @@ void bongo_cat_app_apply_mouse(BongoCatApp *app) { target_x = app->pointer_x; target_y = app->pointer_y; } + /* Most games recenter the cursor as they enter relative-look mode. Use + that observed center as the new virtual origin instead of carrying a + pre-game edge position into the first relative sample. */ + if (cursor_locked && cursor_lock_changed) { + target_x = global_x; + target_y = global_y; + } bool moved = !app->pointer_known || app->pointer_x != target_x || app->pointer_y != target_y; if (moved) { @@ -156,9 +164,13 @@ void bongo_cat_app_apply_mouse(BongoCatApp *app) { bool model_moved = moved; bool profile_relative = app->model_render_options.mver_projection && app->model_render_options.mouse_force_move; + /* A model may request forced mouse movement, but while the cursor is + visible and free its hook coordinates are the authoritative screen + position. Enter relative mode only after another foreground window + actually confines the cursor; otherwise a missing raw sample would + leave the virtual point stuck at an old position. */ bool relative_requested = !app->settings.model.ignore_mouse && - (profile_relative || cursor_locked); - bool cursor_lock_changed = cursor_locked != app->pointer_cursor_locked; + cursor_locked; bool pointer_mode_changed = false; if (relative_requested != app->pointer_relative_active || (relative_requested && cursor_lock_changed)) { From 85609657275eae2540cbca594cd9e5eee20186d2 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 5 Sep 2026 15:59:44 +0800 Subject: [PATCH 222/334] Fix MSIX startup model discovery --- src/runtime/lifecycle/startup_arguments.c | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/runtime/lifecycle/startup_arguments.c b/src/runtime/lifecycle/startup_arguments.c index 0a2294c8..ddfd6f47 100644 --- a/src/runtime/lifecycle/startup_arguments.c +++ b/src/runtime/lifecycle/startup_arguments.c @@ -7,6 +7,10 @@ #include #include +#ifdef _WIN32 +#include "windows_package.h" +#endif + static bool store_argument(char *target, size_t capacity, const char *value, const char *name, BongoCatError *error) { int length = snprintf(target, capacity, "%s", value ? value : ""); @@ -18,8 +22,18 @@ static bool store_argument(char *target, size_t capacity, const char *value, static bool prepare_nearby_root(BongoCatApp *app, BongoCatError *error) { #ifdef _WIN32 - char *current = SDL_GetCurrentDirectory(); - const char *root = current && current[0] ? current : SDL_GetBasePath(); + /* A packaged full-trust app is commonly launched with System32 as its + current directory. That is not a user model directory, and scanning + it can walk protected Windows management folders (for example + appmgmt\MACHINE), causing startup to fail or consume the scan budget. + Nearby model discovery remains available through an explicit + --nearby-root argument and for unpackaged/portable launches. */ + char *current = NULL; + const char *root = NULL; + if (!bongo_cat_windows_is_packaged()) { + current = SDL_GetCurrentDirectory(); + root = current && current[0] ? current : SDL_GetBasePath(); + } #else const char *root = SDL_GetBasePath(); #endif From e7ff1344c798c15039c911b9eda28462cf059450 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 5 Sep 2026 16:02:41 +0800 Subject: [PATCH 223/334] Refactor mouse input pipeline modules --- cmake/RuntimeSources.cmake | 2 + src/runtime/input/mouse.c | 182 +------------------------- src/runtime/input/mouse_diagnostics.c | 69 ++++++++++ src/runtime/input/mouse_diagnostics.h | 18 +++ src/runtime/input/mouse_pipeline.c | 124 ++++++++++++++++++ 5 files changed, 215 insertions(+), 180 deletions(-) create mode 100644 src/runtime/input/mouse_diagnostics.c create mode 100644 src/runtime/input/mouse_diagnostics.h create mode 100644 src/runtime/input/mouse_pipeline.c diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index bfa0ae52..b7d18b01 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -65,7 +65,9 @@ set(BONGO_CAT_RUNTIME_DIAGNOSTIC_SOURCES set(BONGO_CAT_RUNTIME_INPUT_SOURCES src/runtime/input/gamepad.c src/runtime/input/mouse.c + src/runtime/input/mouse_diagnostics.c src/runtime/input/mouse_mapping.c + src/runtime/input/mouse_pipeline.c src/runtime/input/shortcuts.c) set(BONGO_CAT_RUNTIME_MODEL_SOURCES diff --git a/src/runtime/input/mouse.c b/src/runtime/input/mouse.c index d52ceeb2..2ce2da58 100644 --- a/src/runtime/input/mouse.c +++ b/src/runtime/input/mouse.c @@ -1,20 +1,9 @@ #include "runtime.h" -#include "bongo_cat/file.h" -#include "bongo_cat/path.h" #include -#include - -static void audit_mouse(BongoCatApp *app, double x, double y) { - if (!app->smoke_input_audit) return; - char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), app->state_root, "input-audit.txt")) return; - FILE *file = bongo_cat_file_open(path, "ab"); - if (!file) return; - fprintf(file, "mouse x=%.2f y=%.2f\n", x, y); - fclose(file); -} +/* Window-facing pointer state: hover hiding and hit-test scheduling. The + sampling/mapping pipeline lives in mouse_pipeline.c and mouse_mapping.c. */ void bongo_cat_app_track_hover(BongoCatApp *app, double x, double y) { int window_x, window_y, width, height; SDL_GetWindowPosition(app->window, &window_x, &window_y); @@ -44,170 +33,3 @@ void bongo_cat_app_update_hover(BongoCatApp *app, uint64_t now) { app->hover_hidden = true; bongo_cat_window_sync_click_through(app); } - -static bool reconcile_button(BongoCatApp *app, bool *current, bool pressed, - const char *parameter) { - if (*current == pressed) return false; - *current = pressed; - bongo_cat_live2d_set_parameter(app->live2d, parameter, - pressed ? 1.0f : 0.0f); - if (!pressed) bongo_cat_window_mark_hit_dirty(app); - app->dirty = true; - return true; -} - -static void log_mouse_diagnostics(BongoCatApp *app, uint64_t now, - bool received, double target_x, double target_y, - float global_x, float global_y, SDL_MouseButtonFlags buttons, - bool cursor_locked, bool relative_requested) { - bool hook_active = app->mouse_last_ns != 0; - bool mismatch = hook_active && - (target_x != global_x || target_y != global_y); - if (app->mouse_diagnostic_due_ns && - now < app->mouse_diagnostic_due_ns) return; - app->mouse_diagnostic_due_ns = now + - (mismatch || relative_requested ? 30000000000ull : 60000000000ull); - uint64_t hook_age_ms = app->mouse_last_ns && now >= app->mouse_last_ns - ? (now - app->mouse_last_ns) / 1000000ull : 0; - SDL_Log("[input] Pointer pipeline: source=%s sample_now=%d " - "samples=%llu age_ms=%llu selected=%.1f,%.1f sdl=%.1f,%.1f " - "mismatch=%d sdl_buttons=0x%x model_buttons=%d,%d,%d " - "pointer_known=%d cursor_locked=%d relative_requested=%d relative_device=%d " - "dropped=%llu model=%s mode=%d ignore=%d centered=%d", - hook_active ? "native-hook" : "sdl-fallback", received, - (unsigned long long)app->mouse_hook_samples, - (unsigned long long)hook_age_ms, target_x, target_y, - global_x, global_y, mismatch, (unsigned)buttons, - app->left_mouse_down, app->right_mouse_down, app->side_mouse_down, - app->pointer_known, cursor_locked, relative_requested, - app->platform.relative_pointer != NULL, - (unsigned long long)atomic_load_explicit(&app->input.dropped, - memory_order_relaxed), - app->loaded_model[0] ? app->loaded_model : "none", - (int)app->loaded_mode, app->settings.model.ignore_mouse, - app->settings.model.mouse_centered); -} - -static void log_pointer_mode_change(const BongoCatApp *app, - bool cursor_locked, bool profile_relative, bool relative_requested, - bool map_requested, bool map_ok, double target_x, double target_y, - double model_x, double model_y, bool model_moved) { - const char *reason = relative_requested - ? (cursor_locked ? "cursor-locked" : "model-profile") - : (cursor_locked ? "mouse-disabled" : "cursor-free"); - SDL_Log("[input] Pointer mode changed: mode=%s reason=%s lock=%d " - "profile=%d map_requested=%d map_ok=%d relative_device=%d " - "target=%.1f,%.1f virtual=%.1f,%.1f " - "moved=%d model=%s", - relative_requested ? "relative" : "absolute", reason, - cursor_locked, profile_relative, map_requested, map_ok, - app->platform.relative_pointer != NULL, target_x, target_y, - model_x, model_y, model_moved, - app->loaded_model[0] ? app->loaded_model : "none"); -} - -void bongo_cat_app_apply_mouse(BongoCatApp *app) { - if (!app) return; - double target_x, target_y; - bool received = bongo_cat_input_take_mouse(&app->input, &target_x, &target_y); - uint64_t now = SDL_GetTicksNS(); - if (received) { - app->mouse_last_ns = now ? now : 1; - app->mouse_hook_samples++; - } - float global_x = 0.0f, global_y = 0.0f; - SDL_MouseButtonFlags buttons = SDL_GetGlobalMouseState(&global_x, &global_y); - bool button_event_pending = app->mouse_button_event_pending; - bool cursor_locked = bongo_cat_platform_pointer_locked(&app->platform); - bool cursor_lock_changed = cursor_locked != app->pointer_cursor_locked; - bool button_changed = false; - if (!button_event_pending && !cursor_locked) { - button_changed = reconcile_button(app, &app->left_mouse_down, - (buttons & SDL_BUTTON_LMASK) != 0, "ParamMouseLeftDown"); - button_changed = reconcile_button(app, &app->right_mouse_down, - (buttons & SDL_BUTTON_RMASK) != 0, "ParamMouseRightDown") || - button_changed; - bool side_down = (buttons & (SDL_BUTTON_X1MASK | SDL_BUTTON_X2MASK)) != 0; - if (app->side_mouse_down != side_down) { - app->side_mouse_down = side_down; - button_changed = true; - app->dirty = true; - } - } - if (!received && (!app->mouse_last_ns || !app->pointer_known)) { - target_x = global_x; - target_y = global_y; - } else if (!received) { - target_x = app->pointer_x; - target_y = app->pointer_y; - } - /* Most games recenter the cursor as they enter relative-look mode. Use - that observed center as the new virtual origin instead of carrying a - pre-game edge position into the first relative sample. */ - if (cursor_locked && cursor_lock_changed) { - target_x = global_x; - target_y = global_y; - } - bool moved = !app->pointer_known || app->pointer_x != target_x || - app->pointer_y != target_y; - if (moved) { - audit_mouse(app, target_x, target_y); - bongo_cat_app_track_hover(app, target_x, target_y); - // Refresh the hit pixel before the next button press. Waiting for the - // scheduled frame can leave a stale transparent state for fast clicks. - if (app->click_through_applied && !app->left_mouse_down && - !app->right_mouse_down) - bongo_cat_window_capture_pointer_hit(app); - } - bongo_cat_window_sync_click_through(app); - double model_x = target_x, model_y = target_y; - bool model_moved = moved; - bool profile_relative = app->model_render_options.mver_projection && - app->model_render_options.mouse_force_move; - /* A model may request forced mouse movement, but while the cursor is - visible and free its hook coordinates are the authoritative screen - position. Enter relative mode only after another foreground window - actually confines the cursor; otherwise a missing raw sample would - leave the virtual point stuck at an old position. */ - bool relative_requested = !app->settings.model.ignore_mouse && - cursor_locked; - bool pointer_mode_changed = false; - if (relative_requested != app->pointer_relative_active || - (relative_requested && cursor_lock_changed)) { - pointer_mode_changed = true; - if (relative_requested != app->pointer_relative_active) - app->mver_pointer = (BongoCatMverPointerState){0}; - if (relative_requested) - bongo_cat_platform_relative_pointer_reset(&app->platform); - else bongo_cat_platform_relative_pointer_release(&app->platform); - app->pointer_relative_active = relative_requested; - } - app->pointer_cursor_locked = cursor_locked; - bool map_pointer = !app->settings.model.ignore_mouse && (cursor_locked || - (app->model_render_options.mver_projection && - (!app->settings.model.mouse_centered || profile_relative))); - bool map_ok = !map_pointer; - if (map_pointer) { - map_ok = bongo_cat_app_map_pointer(app, relative_requested, target_x, - target_y, &model_x, &model_y, &model_moved); - if (!map_ok) { - model_x = target_x; model_y = target_y; model_moved = moved; - } - } - if (pointer_mode_changed) - log_pointer_mode_change(app, cursor_locked, profile_relative, - relative_requested, map_pointer, map_ok, target_x, target_y, - model_x, model_y, model_moved); - log_mouse_diagnostics(app, now, received, target_x, target_y, - global_x, global_y, buttons, cursor_locked, relative_requested); - if (app->settings.model.ignore_mouse || - (!model_moved && !button_changed && !pointer_mode_changed && - !button_event_pending)) { - app->mouse_button_event_pending = false; - return; - } - app->input_mouse_updates++; - bongo_cat_app_apply_mouse_coordinates(app, model_x, model_y, - cursor_locked ? model_x : target_x, cursor_locked ? model_y : target_y); - app->mouse_button_event_pending = false; -} diff --git a/src/runtime/input/mouse_diagnostics.c b/src/runtime/input/mouse_diagnostics.c new file mode 100644 index 00000000..be1a461a --- /dev/null +++ b/src/runtime/input/mouse_diagnostics.c @@ -0,0 +1,69 @@ +#include "mouse_diagnostics.h" + +#include "bongo_cat/file.h" +#include "bongo_cat/path.h" + +#include + +/* Diagnostics are deliberately isolated from input decisions so logging and + smoke audits cannot obscure the runtime pointer state machine. */ +void bongo_cat_mouse_audit(BongoCatApp *app, double x, double y) { + if (!app->smoke_input_audit) return; + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), app->state_root, + "input-audit.txt")) return; + FILE *file = bongo_cat_file_open(path, "ab"); + if (!file) return; + fprintf(file, "mouse x=%.2f y=%.2f\n", x, y); + fclose(file); +} + +void bongo_cat_mouse_log_diagnostics(BongoCatApp *app, uint64_t now, + bool received, double target_x, double target_y, + float global_x, float global_y, SDL_MouseButtonFlags buttons, + bool cursor_locked, bool relative_requested) { + bool hook_active = app->mouse_last_ns != 0; + bool mismatch = hook_active && + (target_x != global_x || target_y != global_y); + if (app->mouse_diagnostic_due_ns && + now < app->mouse_diagnostic_due_ns) return; + app->mouse_diagnostic_due_ns = now + + (mismatch || relative_requested ? 30000000000ull : 60000000000ull); + uint64_t hook_age_ms = app->mouse_last_ns && now >= app->mouse_last_ns + ? (now - app->mouse_last_ns) / 1000000ull : 0; + SDL_Log("[input] Pointer pipeline: source=%s sample_now=%d " + "samples=%llu age_ms=%llu selected=%.1f,%.1f sdl=%.1f,%.1f " + "mismatch=%d sdl_buttons=0x%x model_buttons=%d,%d,%d " + "pointer_known=%d cursor_locked=%d relative_requested=%d relative_device=%d " + "dropped=%llu model=%s mode=%d ignore=%d centered=%d", + hook_active ? "native-hook" : "sdl-fallback", received, + (unsigned long long)app->mouse_hook_samples, + (unsigned long long)hook_age_ms, target_x, target_y, + global_x, global_y, mismatch, (unsigned)buttons, + app->left_mouse_down, app->right_mouse_down, app->side_mouse_down, + app->pointer_known, cursor_locked, relative_requested, + app->platform.relative_pointer != NULL, + (unsigned long long)atomic_load_explicit(&app->input.dropped, + memory_order_relaxed), + app->loaded_model[0] ? app->loaded_model : "none", + (int)app->loaded_mode, app->settings.model.ignore_mouse, + app->settings.model.mouse_centered); +} + +void bongo_cat_mouse_log_mode_change(const BongoCatApp *app, + bool cursor_locked, bool profile_relative, bool relative_requested, + bool map_requested, bool map_ok, double target_x, double target_y, + double model_x, double model_y, bool model_moved) { + const char *reason = relative_requested + ? (cursor_locked ? "cursor-locked" : "model-profile") + : (cursor_locked ? "mouse-disabled" : "cursor-free"); + SDL_Log("[input] Pointer mode changed: mode=%s reason=%s lock=%d " + "profile=%d map_requested=%d map_ok=%d relative_device=%d " + "target=%.1f,%.1f virtual=%.1f,%.1f " + "moved=%d model=%s", + relative_requested ? "relative" : "absolute", reason, + cursor_locked, profile_relative, map_requested, map_ok, + app->platform.relative_pointer != NULL, target_x, target_y, + model_x, model_y, model_moved, + app->loaded_model[0] ? app->loaded_model : "none"); +} diff --git a/src/runtime/input/mouse_diagnostics.h b/src/runtime/input/mouse_diagnostics.h new file mode 100644 index 00000000..54f9e7bc --- /dev/null +++ b/src/runtime/input/mouse_diagnostics.h @@ -0,0 +1,18 @@ +#ifndef BONGO_CAT_MOUSE_DIAGNOSTICS_H +#define BONGO_CAT_MOUSE_DIAGNOSTICS_H + +#include "bongo_cat/app.h" + +#include + +void bongo_cat_mouse_audit(BongoCatApp *app, double x, double y); +void bongo_cat_mouse_log_diagnostics(BongoCatApp *app, uint64_t now, + bool received, double target_x, double target_y, + float global_x, float global_y, SDL_MouseButtonFlags buttons, + bool cursor_locked, bool relative_requested); +void bongo_cat_mouse_log_mode_change(const BongoCatApp *app, + bool cursor_locked, bool profile_relative, bool relative_requested, + bool map_requested, bool map_ok, double target_x, double target_y, + double model_x, double model_y, bool model_moved); + +#endif diff --git a/src/runtime/input/mouse_pipeline.c b/src/runtime/input/mouse_pipeline.c new file mode 100644 index 00000000..2ca97a08 --- /dev/null +++ b/src/runtime/input/mouse_pipeline.c @@ -0,0 +1,124 @@ +#include "runtime.h" +#include "mouse_diagnostics.h" + +#include + +/* One frame of application mouse input. This module chooses the authoritative + pointer source, reconciles buttons, switches absolute/relative mode, and + hands normalized coordinates to the mapping module. */ +static bool reconcile_button(BongoCatApp *app, bool *current, bool pressed, + const char *parameter) { + if (*current == pressed) return false; + *current = pressed; + bongo_cat_live2d_set_parameter(app->live2d, parameter, + pressed ? 1.0f : 0.0f); + if (!pressed) bongo_cat_window_mark_hit_dirty(app); + app->dirty = true; + return true; +} + +void bongo_cat_app_apply_mouse(BongoCatApp *app) { + if (!app) return; + double target_x, target_y; + bool received = bongo_cat_input_take_mouse(&app->input, &target_x, &target_y); + uint64_t now = SDL_GetTicksNS(); + if (received) { + app->mouse_last_ns = now ? now : 1; + app->mouse_hook_samples++; + } + float global_x = 0.0f, global_y = 0.0f; + SDL_MouseButtonFlags buttons = SDL_GetGlobalMouseState(&global_x, &global_y); + bool button_event_pending = app->mouse_button_event_pending; + bool cursor_locked = bongo_cat_platform_pointer_locked(&app->platform); + bool cursor_lock_changed = cursor_locked != app->pointer_cursor_locked; + bool button_changed = false; + if (!button_event_pending && !cursor_locked) { + button_changed = reconcile_button(app, &app->left_mouse_down, + (buttons & SDL_BUTTON_LMASK) != 0, "ParamMouseLeftDown"); + button_changed = reconcile_button(app, &app->right_mouse_down, + (buttons & SDL_BUTTON_RMASK) != 0, "ParamMouseRightDown") || + button_changed; + bool side_down = (buttons & (SDL_BUTTON_X1MASK | SDL_BUTTON_X2MASK)) != 0; + if (app->side_mouse_down != side_down) { + app->side_mouse_down = side_down; + button_changed = true; + app->dirty = true; + } + } + if (!received && (!app->mouse_last_ns || !app->pointer_known)) { + target_x = global_x; + target_y = global_y; + } else if (!received) { + target_x = app->pointer_x; + target_y = app->pointer_y; + } + /* Most games recenter the cursor as they enter relative-look mode. Use + that observed center as the new virtual origin instead of carrying a + pre-game edge position into the first relative sample. */ + if (cursor_locked && cursor_lock_changed) { + target_x = global_x; + target_y = global_y; + } + bool moved = !app->pointer_known || app->pointer_x != target_x || + app->pointer_y != target_y; + if (moved) { + bongo_cat_mouse_audit(app, target_x, target_y); + bongo_cat_app_track_hover(app, target_x, target_y); + /* Refresh the hit pixel before the next button press. Waiting for the + scheduled frame can leave a stale transparent state for fast clicks. */ + if (app->click_through_applied && !app->left_mouse_down && + !app->right_mouse_down) + bongo_cat_window_capture_pointer_hit(app); + } + bongo_cat_window_sync_click_through(app); + double model_x = target_x, model_y = target_y; + bool model_moved = moved; + bool profile_relative = app->model_render_options.mver_projection && + app->model_render_options.mouse_force_move; + /* A model may request forced mouse movement, but while the cursor is + visible and free its hook coordinates are the authoritative screen + position. Enter relative mode only after another foreground window + actually confines the cursor; otherwise a missing raw sample would + leave the virtual point stuck at an old position. */ + bool relative_requested = !app->settings.model.ignore_mouse && + cursor_locked; + bool pointer_mode_changed = false; + if (relative_requested != app->pointer_relative_active || + (relative_requested && cursor_lock_changed)) { + pointer_mode_changed = true; + if (relative_requested != app->pointer_relative_active) + app->mver_pointer = (BongoCatMverPointerState){0}; + if (relative_requested) + bongo_cat_platform_relative_pointer_reset(&app->platform); + else bongo_cat_platform_relative_pointer_release(&app->platform); + app->pointer_relative_active = relative_requested; + } + app->pointer_cursor_locked = cursor_locked; + bool map_pointer = !app->settings.model.ignore_mouse && (cursor_locked || + (app->model_render_options.mver_projection && + (!app->settings.model.mouse_centered || profile_relative))); + bool map_ok = !map_pointer; + if (map_pointer) { + map_ok = bongo_cat_app_map_pointer(app, relative_requested, target_x, + target_y, &model_x, &model_y, &model_moved); + if (!map_ok) { + model_x = target_x; model_y = target_y; model_moved = moved; + } + } + if (pointer_mode_changed) + bongo_cat_mouse_log_mode_change(app, cursor_locked, profile_relative, + relative_requested, map_pointer, map_ok, target_x, target_y, + model_x, model_y, model_moved); + bongo_cat_mouse_log_diagnostics(app, now, received, target_x, target_y, + global_x, global_y, buttons, cursor_locked, relative_requested); + if (app->settings.model.ignore_mouse || + (!model_moved && !button_changed && !pointer_mode_changed && + !button_event_pending)) { + app->mouse_button_event_pending = false; + return; + } + app->input_mouse_updates++; + bongo_cat_app_apply_mouse_coordinates(app, model_x, model_y, + cursor_locked ? model_x : target_x, cursor_locked ? model_y : target_y); + app->mouse_button_event_pending = false; +} From 0ec50d719b42d8595fd5dc9bb1353724a8b2e3da Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 6 Sep 2026 01:30:06 +0800 Subject: [PATCH 224/334] Follow system theme for native menus --- include/bongo_cat/tray.h | 1 + src/platform/windows/windows_tray.c | 2 ++ src/runtime/shell/tray.c | 7 +++++++ src/runtime/shell/window_menu_actions.c | 4 +--- src/ui/rendering/ui_native_theme.c | 13 +++++++++++++ src/ui/rendering/ui_native_theme.h | 1 + 6 files changed, 25 insertions(+), 3 deletions(-) diff --git a/include/bongo_cat/tray.h b/include/bongo_cat/tray.h index b445bb48..14ef758a 100644 --- a/include/bongo_cat/tray.h +++ b/include/bongo_cat/tray.h @@ -9,6 +9,7 @@ typedef struct BongoCatTray BongoCatTray; BongoCatTray *bongo_cat_tray_create(BongoCatApp *app, BongoCatError *error); void bongo_cat_tray_destroy(BongoCatTray *tray); void bongo_cat_tray_sync(BongoCatTray *tray); +void bongo_cat_tray_prepare_menu(BongoCatTray *tray, void *native_handle); bool bongo_cat_tray_self_test(BongoCatTray *tray); #endif diff --git a/src/platform/windows/windows_tray.c b/src/platform/windows/windows_tray.c index 0a02c57d..9117a25f 100644 --- a/src/platform/windows/windows_tray.c +++ b/src/platform/windows/windows_tray.c @@ -1,6 +1,7 @@ #include "bongo_cat/platform.h" #include "windows_popup.h" #include "windows_tray.h" +#include "bongo_cat/tray.h" #ifdef _WIN32 #include @@ -30,6 +31,7 @@ static LRESULT CALLBACK tray_window_proc(HWND window, UINT message, return 0; } if (message == WM_USER + 1 && LOWORD(lparam) == WM_CONTEXTMENU && binding) { + bongo_cat_tray_prepare_menu(binding->userdata, window); if (binding->modal_tick) { binding->modal_tick(binding->userdata); SetTimer(window, BONGO_CAT_TRAY_MODAL_TIMER, 16, NULL); diff --git a/src/runtime/shell/tray.c b/src/runtime/shell/tray.c index 33e4f997..84095522 100644 --- a/src/runtime/shell/tray.c +++ b/src/runtime/shell/tray.c @@ -6,6 +6,7 @@ #include "bongo_cat/preferences.h" #include "modal_frame.h" #include "runtime.h" +#include "ui_native_theme.h" #include #include @@ -89,6 +90,12 @@ static void on_tray_restore(void *userdata) { tray->restore_after_ns = 0; } +void bongo_cat_tray_prepare_menu(BongoCatTray *tray, void *native_handle) { + if (!tray || !tray->app) return; + bool dark = SDL_GetSystemTheme() == SDL_SYSTEM_THEME_DARK; + bongo_cat_ui_native_menu_prepare_native(native_handle, dark); +} + static SDL_TrayEntry *add(SDL_TrayMenu *menu, const char *label, SDL_TrayEntryFlags flags, SDL_TrayCallback callback, BongoCatTray *tray) { SDL_TrayEntry *entry = SDL_InsertTrayEntryAt(menu, -1, label, flags); diff --git a/src/runtime/shell/window_menu_actions.c b/src/runtime/shell/window_menu_actions.c index 7763d210..c614f401 100644 --- a/src/runtime/shell/window_menu_actions.c +++ b/src/runtime/shell/window_menu_actions.c @@ -28,9 +28,7 @@ static bool select_model(BongoCatApp *app, const char *id) { void bongo_cat_window_show_context_menu(BongoCatApp *app) { if (!app) return; - bool dark_theme = app->settings.app.theme == BONGO_CAT_THEME_DARK || - (app->settings.app.theme == BONGO_CAT_THEME_AUTO && - SDL_GetSystemTheme() == SDL_SYSTEM_THEME_DARK); + bool dark_theme = SDL_GetSystemTheme() == SDL_SYSTEM_THEME_DARK; BongoCatWindowMenuPreview preview; bongo_cat_window_menu_preview_init(&preview, app); const char *model_names[BONGO_CAT_MODEL_CAP]; diff --git a/src/ui/rendering/ui_native_theme.c b/src/ui/rendering/ui_native_theme.c index 8fe15349..b669db35 100644 --- a/src/ui/rendering/ui_native_theme.c +++ b/src/ui/rendering/ui_native_theme.c @@ -68,6 +68,12 @@ static void initialize(void) { void bongo_cat_ui_native_menu_prepare(SDL_Window *window, bool dark) { initialize(); HWND handle = native_window(window); + bongo_cat_ui_native_menu_prepare_native(handle, dark); +} + +void bongo_cat_ui_native_menu_prepare_native(void *native_handle, bool dark) { + initialize(); + HWND handle = (HWND)native_handle; bool contrast = high_contrast(); int mode = contrast ? 0 : (dark ? 2 : 3); if (set_preferred_mode && mode != applied_mode) { @@ -76,6 +82,9 @@ void bongo_cat_ui_native_menu_prepare(SDL_Window *window, bool dark) { if (flush_menu_themes) flush_menu_themes(); } if (handle && allow_dark_window) allow_dark_window(handle, !contrast); + if (handle && set_window_theme) + set_window_theme(handle, dark && !contrast ? L"DarkMode_Explorer" : NULL, + NULL); } void bongo_cat_ui_native_theme_apply(SDL_Window *window, bool dark) { @@ -107,4 +116,8 @@ void bongo_cat_ui_native_theme_apply(SDL_Window *window, bool dark) { void bongo_cat_ui_native_menu_prepare(SDL_Window *window, bool dark) { (void)window; (void)dark; } + +void bongo_cat_ui_native_menu_prepare_native(void *handle, bool dark) { + (void)handle; (void)dark; +} #endif diff --git a/src/ui/rendering/ui_native_theme.h b/src/ui/rendering/ui_native_theme.h index ea68fbe2..8b577a2b 100644 --- a/src/ui/rendering/ui_native_theme.h +++ b/src/ui/rendering/ui_native_theme.h @@ -7,5 +7,6 @@ typedef struct SDL_Window SDL_Window; void bongo_cat_ui_native_theme_apply(SDL_Window *window, bool dark); void bongo_cat_ui_native_menu_prepare(SDL_Window *window, bool dark); +void bongo_cat_ui_native_menu_prepare_native(void *handle, bool dark); #endif From a200b647542344cbccb87cd586e80d6e506a3557 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 6 Sep 2026 14:01:35 +0800 Subject: [PATCH 225/334] feat: add configurable desktop pet window corners --- include/bongo_cat/config.h | 3 + resources/assets/locales/de-DE.json | 1 + resources/assets/locales/en-US.json | 1 + resources/assets/locales/es-ES.json | 1 + resources/assets/locales/fr-FR.json | 1 + resources/assets/locales/ja-JP.json | 1 + resources/assets/locales/ko-KR.json | 1 + resources/assets/locales/pt-BR.json | 1 + resources/assets/locales/ru-RU.json | 1 + resources/assets/locales/zh-CN.json | 1 + resources/assets/locales/zh-Hant.json | 1 + src/core/config_defaults.c | 4 + src/core/config_io.c | 3 + src/core/config_settings_save.c | 4 + src/runtime/lifecycle/app_loop.c | 1 + src/runtime/runtime.h | 2 + src/runtime/shell/window.c | 2 + src/runtime/shell/window_background.c | 103 +++++++++++++++++++++ src/runtime/shell/window_hit.c | 11 +++ src/ui/preferences/preferences_icons.h | 1 + src/ui/preferences/preferences_pages.c | 8 ++ src/ui/preferences/preferences_row_icons.c | 7 +- 22 files changed, 158 insertions(+), 1 deletion(-) diff --git a/include/bongo_cat/config.h b/include/bongo_cat/config.h index d5a46aa6..c04a29ca 100644 --- a/include/bongo_cat/config.h +++ b/include/bongo_cat/config.h @@ -7,6 +7,7 @@ #define BONGO_CAT_DEFAULT_WINDOW_SCALE_PERCENT 100.0f #define BONGO_CAT_DEFAULT_WINDOW_OPACITY_PERCENT 100.0f #define BONGO_CAT_DEFAULT_RANDOM_EXPRESSION_SECONDS 5.0f +#define BONGO_CAT_DEFAULT_WINDOW_CORNER_PERCENT 6.0f typedef enum BongoCatTheme { BONGO_CAT_THEME_AUTO, BONGO_CAT_THEME_LIGHT, BONGO_CAT_THEME_DARK } BongoCatTheme; typedef enum BongoCatLanguage { @@ -51,9 +52,11 @@ typedef struct BongoCatWindowPreferences { bool keep_in_screen; bool obs_background; bool random_expression; + bool rounded_corners; BongoCatObsBackgroundColor obs_background_color; float hide_delay_seconds; float random_expression_interval_seconds; + float corner_radius_percent; } BongoCatWindowPreferences; typedef struct BongoCatWindowState { diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index c031a174..f5287e65 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -21,6 +21,7 @@ "alwaysOnTop": "Immer im Vordergrund", "keepInScreen": "Auf dem Bildschirm halten", "windowSize": "Fenstergröße", + "windowCorners": "Fensterrundung (%)", "opacity": "Deckkraft", "randomExpression": "Zufällige Ausdrücke", "obsBackground": "Einfarbiger Hintergrund", diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index dd06eab1..edf13173 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -21,6 +21,7 @@ "alwaysOnTop": "Always on Top", "keepInScreen": "Keep on Screen", "windowSize": "Window Size", + "windowCorners": "Window Corners (%)", "opacity": "Opacity", "randomExpression": "Random Expressions", "obsBackground": "Solid Background", diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index adb71875..f72f0747 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -21,6 +21,7 @@ "alwaysOnTop": "Siempre visible", "keepInScreen": "Mantener en pantalla", "windowSize": "Tamaño de la ventana", + "windowCorners": "Esquinas redondeadas (%)", "opacity": "Opacidad", "randomExpression": "Expresiones aleatorias", "obsBackground": "Fondo sólido", diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index c45cb524..64602ea2 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -21,6 +21,7 @@ "alwaysOnTop": "Toujours au premier plan", "keepInScreen": "Maintenir à l'écran", "windowSize": "Taille de la fenêtre", + "windowCorners": "Coins arrondis (%)", "opacity": "Opacité", "randomExpression": "Expressions aléatoires", "obsBackground": "Arrière-plan uni", diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index a21a1bea..48680347 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -21,6 +21,7 @@ "alwaysOnTop": "常に手前に表示", "keepInScreen": "画面内に保持", "windowSize": "ウィンドウサイズ", + "windowCorners": "ウィンドウの角丸 (%)", "opacity": "不透明度", "randomExpression": "ランダム表情", "obsBackground": "単色背景", diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index e031163f..20ab1de7 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -21,6 +21,7 @@ "alwaysOnTop": "항상 위", "keepInScreen": "화면 안에 유지", "windowSize": "창 크기", + "windowCorners": "창 모서리 둥글기 (%)", "opacity": "불투명도", "randomExpression": "무작위 표정", "obsBackground": "단색 배경", diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index deab6d35..fa3125bf 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -21,6 +21,7 @@ "alwaysOnTop": "Sempre no Topo", "keepInScreen": "Manter na Tela", "windowSize": "Tamanho da Janela", + "windowCorners": "Cantos arredondados (%)", "opacity": "Opacidade", "randomExpression": "Expressões Aleatórias", "obsBackground": "Fundo sólido", diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index df0f16b4..f05a7dd8 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -21,6 +21,7 @@ "alwaysOnTop": "Поверх всех окон", "keepInScreen": "Удерживать на экране", "windowSize": "Размер окна", + "windowCorners": "Скругление углов (%)", "opacity": "Непрозрачность", "randomExpression": "Случайные выражения", "obsBackground": "Однотонный фон", diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index 2f0a064e..ae4c50a8 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -21,6 +21,7 @@ "alwaysOnTop": "窗口置顶", "keepInScreen": "保持在屏幕内", "windowSize": "窗口尺寸", + "windowCorners": "窗口圆角 (%)", "opacity": "不透明度", "randomExpression": "随机表情", "obsBackground": "纯色背景", diff --git a/resources/assets/locales/zh-Hant.json b/resources/assets/locales/zh-Hant.json index d23043da..51233d85 100644 --- a/resources/assets/locales/zh-Hant.json +++ b/resources/assets/locales/zh-Hant.json @@ -21,6 +21,7 @@ "alwaysOnTop": "視窗置頂", "keepInScreen": "保持在螢幕內", "windowSize": "視窗尺寸", + "windowCorners": "視窗圓角 (%)", "opacity": "不透明度", "randomExpression": "隨機表情", "obsBackground": "純色背景", diff --git a/src/core/config_defaults.c b/src/core/config_defaults.c index b7491aae..91b62f3d 100644 --- a/src/core/config_defaults.c +++ b/src/core/config_defaults.c @@ -194,6 +194,7 @@ void bongo_cat_settings_defaults(BongoCatSettings *config) { config->window.always_on_top = true; config->window.keep_in_screen = false; config->window.obs_background_color = BONGO_CAT_OBS_BACKGROUND_GREEN; + config->window.corner_radius_percent = BONGO_CAT_DEFAULT_WINDOW_CORNER_PERCENT; config->window.random_expression_interval_seconds = BONGO_CAT_DEFAULT_RANDOM_EXPRESSION_SECONDS; config->app.tray_visible = true; @@ -208,6 +209,9 @@ void bongo_cat_settings_validate(BongoCatSettings *config) { if (config->model.max_fps > 240) config->model.max_fps = 240; config->window.hide_delay_seconds = clampf_or( config->window.hide_delay_seconds, 0.0f, 60.0f, 0.0f); + config->window.corner_radius_percent = clampf_or( + config->window.corner_radius_percent, 1.0f, 50.0f, + BONGO_CAT_DEFAULT_WINDOW_CORNER_PERCENT); config->window.random_expression_interval_seconds = clampf_or( config->window.random_expression_interval_seconds, 1.0f, 3600.0f, BONGO_CAT_DEFAULT_RANDOM_EXPRESSION_SECONDS); diff --git a/src/core/config_io.c b/src/core/config_io.c index b8c502f9..5c3d68ce 100644 --- a/src/core/config_io.c +++ b/src/core/config_io.c @@ -69,6 +69,9 @@ static bool read_window(yyjson_val *object, BongoCatWindowPreferences *value, error) || !read_bool(object, "randomExpression", &value->random_expression, error) || + !read_bool(object, "roundedCorners", &value->rounded_corners, error) || + !read_float(object, "cornerRadiusPercent", &value->corner_radius_percent, + error) || !read_float(object, "hideDelaySeconds", &value->hide_delay_seconds, error) || !read_float(object, "randomExpressionIntervalSeconds", diff --git a/src/core/config_settings_save.c b/src/core/config_settings_save.c index 08147c0b..765576b4 100644 --- a/src/core/config_settings_save.c +++ b/src/core/config_settings_save.c @@ -34,6 +34,10 @@ static bool write_window(yyjson_mut_doc *doc, yyjson_mut_val *object, value->obs_background) && yyjson_mut_obj_add_bool(doc, object, "randomExpression", value->random_expression) && + yyjson_mut_obj_add_bool(doc, object, "roundedCorners", + value->rounded_corners) && + yyjson_mut_obj_add_real(doc, object, "cornerRadiusPercent", + value->corner_radius_percent) && yyjson_mut_obj_add_strcpy(doc, object, "captureBackgroundColor", bongo_cat_obs_background_color_name( value->obs_background_color)) && diff --git a/src/runtime/lifecycle/app_loop.c b/src/runtime/lifecycle/app_loop.c index 53f82089..088c07c8 100644 --- a/src/runtime/lifecycle/app_loop.c +++ b/src/runtime/lifecycle/app_loop.c @@ -84,6 +84,7 @@ static bool render(BongoCatApp *app, bool present) { } bool reveal_startup = app->startup_visibility_pending && app->session.window.visible; + bongo_cat_window_mask_corners(app, width, height); bongo_cat_frame_audit(app, width, height); bongo_cat_window_capture_pointer_hit(app); /* Keep the native window hidden while diagnostics/readback finish. The diff --git a/src/runtime/runtime.h b/src/runtime/runtime.h index cb58b5e3..42c1a4c7 100644 --- a/src/runtime/runtime.h +++ b/src/runtime/runtime.h @@ -110,6 +110,8 @@ void bongo_cat_frame_presentation_prepare(BongoCatApp *app, const unsigned char *pixels, int width, int height, bool visible); void bongo_cat_frame_presented_audit(BongoCatApp *app); void bongo_cat_window_clear_background(BongoCatApp *app); +void bongo_cat_window_mask_corners(BongoCatApp *app, int width, int height); +void bongo_cat_window_destroy_corner_mask(void); void bongo_cat_app_render_now(BongoCatApp *app); bool bongo_cat_app_capture_pending_model_cover(BongoCatApp *app); void bongo_cat_runtime_flow_update(BongoCatApp *app, uint64_t now); diff --git a/src/runtime/shell/window.c b/src/runtime/shell/window.c index 381bf373..f682e97e 100644 --- a/src/runtime/shell/window.c +++ b/src/runtime/shell/window.c @@ -213,6 +213,8 @@ bool bongo_cat_window_event(BongoCatApp *app, const SDL_Event *event) { void bongo_cat_window_destroy(BongoCatApp *app) { bongo_cat_window_drag_end(app); + if (app->gl_context && SDL_GL_MakeCurrent(app->window, app->gl_context)) + bongo_cat_window_destroy_corner_mask(); if (app->gl_context) SDL_GL_DestroyContext(app->gl_context); if (app->window) SDL_DestroyWindow(app->window); app->gl_context = NULL; diff --git a/src/runtime/shell/window_background.c b/src/runtime/shell/window_background.c index 88f3bed3..6c8c3740 100644 --- a/src/runtime/shell/window_background.c +++ b/src/runtime/shell/window_background.c @@ -1,7 +1,110 @@ #include "runtime.h" +#include "bongo_cat/gl_api.h" #include +static BongoCatGL corner_gl; +static GLuint corner_program, corner_vao; +static GLint corner_width, corner_height, corner_radius; +static PFNGLBLENDEQUATIONSEPARATEPROC corner_equation_separate; +static bool corner_failed; + +void bongo_cat_window_destroy_corner_mask(void) { + if (corner_vao) corner_gl.delete_vertex_arrays(1, &corner_vao); + if (corner_program) corner_gl.delete_program(corner_program); + corner_vao = corner_program = 0; + corner_failed = false; +} + +static bool prepare_corner_mask(void) { + if (corner_program) return true; + if (corner_failed) return false; + const char *vertex = + "#version 330 core\n" + "void main() {\n" + " vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);\n" + " gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);\n" + "}\n"; + const char *fragment = + "#version 330 core\n" + "uniform int width, height, radiusMilli;\n" + "out vec4 color;\n" + "void main() {\n" + " vec2 halfSize = vec2(width, height) * 0.5;\n" + " float radius = float(radiusMilli) * 0.001;\n" + " vec2 q = abs(gl_FragCoord.xy - halfSize) - (halfSize - radius);\n" + " float d = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius;\n" + " float coverage = clamp(0.5 - d, 0.0, 1.0);\n" + " color = vec4(coverage);\n" + "}\n"; + BongoCatError error = {0}; + corner_equation_separate = (PFNGLBLENDEQUATIONSEPARATEPROC) + SDL_GL_GetProcAddress("glBlendEquationSeparate"); + if (corner_equation_separate && bongo_cat_gl_load(&corner_gl, &error)) + corner_program = bongo_cat_gl_program(&corner_gl, vertex, fragment, &error); + if (corner_program) { + corner_gl.gen_vertex_arrays(1, &corner_vao); + if (corner_vao) { + corner_width = corner_gl.uniform_location(corner_program, "width"); + corner_height = corner_gl.uniform_location(corner_program, "height"); + corner_radius = corner_gl.uniform_location(corner_program, "radiusMilli"); + return true; + } + bongo_cat_window_destroy_corner_mask(); + } + corner_failed = true; + SDL_LogError(SDL_LOG_CATEGORY_VIDEO, "Cannot initialize window corners: %s", + error.message); + return false; +} + +void bongo_cat_window_mask_corners(BongoCatApp *app, int width, int height) { + if (!app->settings.window.rounded_corners || width <= 0 || height <= 0 || + !prepare_corner_mask()) return; + GLint program, vao, equation_rgb, equation_alpha, src_rgb, dst_rgb, + src_alpha, dst_alpha; + GLboolean color_mask[4]; + const GLenum capabilities[] = {GL_BLEND, GL_DEPTH_TEST, GL_STENCIL_TEST, + GL_SCISSOR_TEST, GL_CULL_FACE}; + GLboolean enabled[SDL_arraysize(capabilities)]; + glGetIntegerv(GL_CURRENT_PROGRAM, &program); + glGetIntegerv(GL_VERTEX_ARRAY_BINDING, &vao); + glGetIntegerv(GL_BLEND_EQUATION_RGB, &equation_rgb); + glGetIntegerv(GL_BLEND_EQUATION_ALPHA, &equation_alpha); + glGetIntegerv(GL_BLEND_SRC_RGB, &src_rgb); + glGetIntegerv(GL_BLEND_DST_RGB, &dst_rgb); + glGetIntegerv(GL_BLEND_SRC_ALPHA, &src_alpha); + glGetIntegerv(GL_BLEND_DST_ALPHA, &dst_alpha); + glGetBooleanv(GL_COLOR_WRITEMASK, color_mask); + for (size_t i = 0; i < SDL_arraysize(capabilities); ++i) { + enabled[i] = glIsEnabled(capabilities[i]); + if (i == 0) glEnable(capabilities[i]); + else glDisable(capabilities[i]); + } + glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + corner_gl.blend_equation(GL_FUNC_ADD); + /* Multiply premultiplied RGB and alpha together, including the solid + capture background, before either native presentation path reads it. */ + glBlendFunc(GL_ZERO, GL_SRC_ALPHA); + corner_gl.use_program(corner_program); + corner_gl.bind_vertex_array(corner_vao); + corner_gl.uniform_1i(corner_width, width); + corner_gl.uniform_1i(corner_height, height); + float percent = SDL_clamp(app->settings.window.corner_radius_percent, 1.0f, 50.0f); + corner_gl.uniform_1i(corner_radius, + (GLint)(SDL_min(width, height) * percent * 10.0f + 0.5f)); + glDrawArrays(GL_TRIANGLES, 0, 3); + corner_gl.bind_vertex_array((GLuint)vao); + corner_gl.use_program((GLuint)program); + corner_equation_separate(equation_rgb, equation_alpha); + corner_gl.blend_func_separate(src_rgb, dst_rgb, src_alpha, dst_alpha); + glColorMask(color_mask[0], color_mask[1], color_mask[2], color_mask[3]); + for (size_t i = 0; i < SDL_arraysize(capabilities); ++i) { + if (enabled[i]) glEnable(capabilities[i]); + else glDisable(capabilities[i]); + } +} + void bongo_cat_window_clear_background(BongoCatApp *app) { BongoCatWindowPreferences *window = &app->settings.window; uint32_t rgb = bongo_cat_obs_background_color_rgb( diff --git a/src/runtime/shell/window_hit.c b/src/runtime/shell/window_hit.c index c1f9af81..33a20d07 100644 --- a/src/runtime/shell/window_hit.c +++ b/src/runtime/shell/window_hit.c @@ -8,6 +8,17 @@ bool bongo_cat_window_visible_at_pointer(BongoCatApp *app, float x, float y) { if (!SDL_GetWindowSize(app->window, &width, &height) || !SDL_GetWindowSizeInPixels(app->window, &pixel_width, &pixel_height) || width <= 0 || height <= 0 || pixel_width <= 0 || pixel_height <= 0) return false; + if (app->settings.window.rounded_corners) { + float radius = SDL_min(pixel_width, pixel_height) * + SDL_clamp(app->settings.window.corner_radius_percent, 1.0f, 50.0f) / 100.0f; + float px = x * pixel_width / width; + float py = y * pixel_height / height; + float dx = SDL_max(SDL_fabsf(px - pixel_width * 0.5f) - + (pixel_width * 0.5f - radius), 0.0f); + float dy = SDL_max(SDL_fabsf(py - pixel_height * 0.5f) - + (pixel_height * 0.5f - radius), 0.0f); + if (dx * dx + dy * dy > radius * radius) return false; + } int pixel_x = SDL_clamp((int)(x * pixel_width / width), 0, pixel_width - 1); int pixel_y = pixel_height - 1 - SDL_clamp((int)(y * pixel_height / height), 0, pixel_height - 1); diff --git a/src/ui/preferences/preferences_icons.h b/src/ui/preferences/preferences_icons.h index b50efddd..f7dda850 100644 --- a/src/ui/preferences/preferences_icons.h +++ b/src/ui/preferences/preferences_icons.h @@ -16,6 +16,7 @@ typedef enum BongoCatPrefIcon { BONGO_CAT_PREF_ICON_KEEP_IN_SCREEN, BONGO_CAT_PREF_ICON_SOLID_BACKGROUND, BONGO_CAT_PREF_ICON_WINDOW_SIZE, + BONGO_CAT_PREF_ICON_WINDOW_CORNERS, BONGO_CAT_PREF_ICON_OPACITY, BONGO_CAT_PREF_ICON_RANDOM_EXPRESSION, BONGO_CAT_PREF_ICON_MIRROR, diff --git a/src/ui/preferences/preferences_pages.c b/src/ui/preferences/preferences_pages.c index f69c5662..af373745 100644 --- a/src/ui/preferences/preferences_pages.c +++ b/src/ui/preferences/preferences_pages.c @@ -68,6 +68,14 @@ static void page_display(BongoCatApp *app, struct nk_context *context) { bongo_cat_window_cancel_wheel_animation(app); bongo_cat_window_set_scale(app, requested_scale); } + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_WINDOW_CORNERS); + if (bongo_cat_pref_toggle_float(context, "window-corners", tr(app, + "pages.preference.cat.labels.windowCorners", "Window Corners (%)"), + &window->rounded_corners, 1.0f, &window->corner_radius_percent, + 50.0f, 1.0f, BONGO_CAT_DEFAULT_WINDOW_CORNER_PERCENT)) { + app->dirty = true; + bongo_cat_window_mark_hit_dirty(app); + } float old_opacity = window_state->opacity_percent; bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_OPACITY); bongo_cat_pref_slider(context, "opacity", tr(app, diff --git a/src/ui/preferences/preferences_row_icons.c b/src/ui/preferences/preferences_row_icons.c index a191c575..e07c0830 100644 --- a/src/ui/preferences/preferences_row_icons.c +++ b/src/ui/preferences/preferences_row_icons.c @@ -58,6 +58,11 @@ static void window_size(struct nk_command_buffer *c, struct nk_rect b, line(c, b, 10, 3, 15, 3, color); line(c, b, 15, 3, 15, 8, color); } +static void window_corners(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + nk_stroke_rect(c, nk_rect(b.x + 1, b.y + 1, 16, 16), 5, 1.5f, color); +} + static void opacity(struct nk_command_buffer *c, struct nk_rect b, struct nk_color color) { line(c, b, 9, 1, 4, 9, color); line(c, b, 9, 1, 14, 9, color); @@ -159,7 +164,7 @@ bool bongo_cat_pref_row_icon_draw(struct nk_command_buffer *canvas, typedef void (*Draw)(struct nk_command_buffer *, struct nk_rect, struct nk_color); static const Draw draws[] = {multiple_models, pass_through, always_on_top, - keep_in_screen, solid_background, window_size, opacity, + keep_in_screen, solid_background, window_size, window_corners, opacity, random_expression, mirror, mouse_mirror, mouse_centered, ignore_mouse, max_fps, autostart, language, theme, shortcut_visibility, shortcut_preferences}; From 2aecd18cddddd718f29b282e41af3360ee0ecc5c Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 6 Sep 2026 14:47:38 +0800 Subject: [PATCH 226/334] fix: allow desktop pet dragging beyond the top screen edge --- src/runtime/shell/window_drag.c | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/runtime/shell/window_drag.c b/src/runtime/shell/window_drag.c index 9e9f8b7c..7e90ad05 100644 --- a/src/runtime/shell/window_drag.c +++ b/src/runtime/shell/window_drag.c @@ -5,6 +5,17 @@ static int rounded_delta(float value) { return value < 0.0f ? (int)(value - 0.5f) : (int)(value + 0.5f); } +static bool use_pointer_drag(const BongoCatApp *app) { +#ifdef _WIN32 + /* Native caption dragging pulls partially offscreen windows back down + at the top edge, even when keep-in-screen is disabled. */ + (void)app; + return true; +#else + return app->settings.window.keep_in_screen; +#endif +} + static void move_with_pointer(BongoCatApp *app, float x, float y) { int next_x = app->drag_window_x + rounded_delta(x - app->drag_start_x); int next_y = app->drag_window_y + rounded_delta(y - app->drag_start_y); @@ -33,7 +44,7 @@ void bongo_cat_window_drag_motion(BongoCatApp *app, bongo_cat_window_drag_end(app); return; } if (app->window_drag_active) { - if (app->settings.window.keep_in_screen) + if (use_pointer_drag(app)) move_with_pointer(app, pointer_x, pointer_y); return; } @@ -42,10 +53,11 @@ void bongo_cat_window_drag_motion(BongoCatApp *app, if (x * x + y * y < 9.0f) return; app->drag_candidate = false; app->window_drag_active = true; - if (app->settings.window.keep_in_screen) { - bongo_cat_window_drag_bounds_refresh(app); + if (use_pointer_drag(app)) { + if (app->settings.window.keep_in_screen) + bongo_cat_window_drag_bounds_refresh(app); if (!SDL_CaptureMouse(true)) SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, - "Mouse capture is unavailable during constrained window drag: %s", + "Mouse capture is unavailable during window drag: %s", SDL_GetError()); move_with_pointer(app, pointer_x, pointer_y); } else { From f7212ab32c24ceef8698f63cf2546da0a1c6d28b Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 6 Sep 2026 17:35:16 +0800 Subject: [PATCH 227/334] feat(preferences): allow direct numeric input in steppers --- cmake/RuntimeSources.cmake | 1 + src/ui/preferences/preferences_controls.c | 21 ++-- src/ui/preferences/preferences_controls.h | 4 + src/ui/preferences/preferences_number_edit.c | 125 +++++++++++++++++++ src/ui/preferences/preferences_number_edit.h | 11 ++ 5 files changed, 154 insertions(+), 8 deletions(-) create mode 100644 src/ui/preferences/preferences_number_edit.c create mode 100644 src/ui/preferences/preferences_number_edit.h diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index b7d18b01..7875c6b9 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -203,6 +203,7 @@ set(BONGO_CAT_UI_PREFERENCES_SOURCES src/ui/preferences/preferences_behavior_row.c src/ui/preferences/preferences_combo.c src/ui/preferences/preferences_controls.c + src/ui/preferences/preferences_number_edit.c src/ui/preferences/preferences_dialog.c src/ui/preferences/preferences_form_label.c src/ui/preferences/preferences_fonts.c diff --git a/src/ui/preferences/preferences_controls.c b/src/ui/preferences/preferences_controls.c index 08b59ff0..2b31a4df 100644 --- a/src/ui/preferences/preferences_controls.c +++ b/src/ui/preferences/preferences_controls.c @@ -1,4 +1,5 @@ #include "preferences_controls.h" +#include "preferences_number_edit.h" #include "ui_animation.h" #include "ui_backend.h" #include "ui_catime.h" @@ -100,6 +101,7 @@ bool bongo_cat_pref_controls_animating(struct nk_context *context) { } void bongo_cat_pref_controls_reset(struct nk_context *context) { + bongo_cat_pref_number_edit_reset(context); if (repeat_state.context == context) memset(&repeat_state, 0, sizeof(repeat_state)); slider_drag_stop(context); @@ -108,6 +110,7 @@ void bongo_cat_pref_controls_reset(struct nk_context *context) { static double stepper(struct nk_context *context, const char *id, double minimum, double value, double maximum, double step, double default_value, bool integer, bool *changed) { + (void)default_value; struct nk_rect bounds; *changed = false; if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return value; @@ -160,20 +163,21 @@ static double stepper(struct nk_context *context, const char *id, else if (step < .1) snprintf(number, sizeof(number), "%.2f", value); else if (step < 1.0) snprintf(number, sizeof(number), "%.1f", value); else snprintf(number, sizeof(number), "%.0f", value); - centered(canvas, number_box, number, bongo_cat_ui_body_font(context), p.text); - if (hover) bongo_cat_ui_cursor_hover_rect(context, interaction, + if (minus_hover || plus_hover) bongo_cat_ui_cursor_hover_rect(context, interaction, BONGO_CAT_UI_CURSOR_POINTER); + double before = value; + bool editing = bongo_cat_pref_number_edit(context, id, number_box, number, + integer, minimum, maximum, &value); + if (!editing) centered(canvas, number_box, number, + bongo_cat_ui_body_font(context), p.text); int direction = repeat_direction(context, id, minus, plus_hit); float wheel = context->input.mouse.scroll_delta.y; - if (nk_input_is_mouse_hovering_rect(&context->input, number_box) && wheel != 0) { + if (!editing && nk_input_is_mouse_hovering_rect(&context->input, number_box) && wheel != 0) { direction = wheel > 0 ? 1 : -1; context->input.mouse.scroll_delta.y = 0; } - bool reset = nk_input_is_mouse_click_in_rect(&context->input, - NK_BUTTON_DOUBLE, number_box) != 0; - double next = reset ? NK_CLAMP(minimum, default_value, maximum) : - NK_CLAMP(minimum, value + direction * step, maximum); - if (next != value) { value = next; *changed = true; } + value = NK_CLAMP(minimum, value + direction * step, maximum); + *changed = value != before; return value; } @@ -183,6 +187,7 @@ bool bongo_cat_pref_control_float(struct nk_context *context, const char *id, bool changed; double result = stepper(context, id, minimum, *value, maximum, step, default_value, false, &changed); + changed = changed && *value != (float)result; if (changed) *value = (float)result; return changed; } diff --git a/src/ui/preferences/preferences_controls.h b/src/ui/preferences/preferences_controls.h index b44aa679..243da4dc 100644 --- a/src/ui/preferences/preferences_controls.h +++ b/src/ui/preferences/preferences_controls.h @@ -5,6 +5,10 @@ #include "bongo_cat/config.h" #include "nuklear_config.h" +/* Stable, unique ids retain numeric drafts; valid edits update value immediately. + * Integer fields accept digits and an optional minus; floats also accept a dot. + * Values are clamped to the supplied range. Clicking away discards incomplete text. + */ bool bongo_cat_pref_control_float(struct nk_context *context, const char *id, float minimum, float *value, float maximum, float step, float default_value); diff --git a/src/ui/preferences/preferences_number_edit.c b/src/ui/preferences/preferences_number_edit.c new file mode 100644 index 00000000..6eac3fe1 --- /dev/null +++ b/src/ui/preferences/preferences_number_edit.c @@ -0,0 +1,125 @@ +#include "preferences_number_edit.h" +#include "ui_backend.h" +#include "ui_catime.h" + +#include +#include +#include +#include + +static struct { + struct nk_context *context; + char id[64]; + char text[64]; + int length; + unsigned int frame; + bool integer; + bool negative; +} number_edit; + +void bongo_cat_pref_number_edit_reset(struct nk_context *context) { + if (number_edit.context == context) + memset(&number_edit, 0, sizeof(number_edit)); +} + +static nk_bool number_filter(const struct nk_text_edit *edit, nk_rune rune) { + (void)edit; + return (rune >= '0' && rune <= '9') || + (rune == '-' && number_edit.negative) || + (rune == '.' && !number_edit.integer); +} + +static bool valid_text(const char *text, bool integer, bool negative) { + if (*text == '-' && negative) ++text; + bool dot = false; + for (; *text; ++text) { + if (*text >= '0' && *text <= '9') continue; + if (*text == '.' && !integer && !dot) { dot = true; continue; } + return false; + } + return true; +} + +bool bongo_cat_pref_number_edit(struct nk_context *context, const char *id, + struct nk_rect bounds, const char *formatted, bool integer, + double minimum, double maximum, double *value) { + bool hover = nk_input_is_mouse_hovering_rect(&context->input, bounds); + bool pressed = nk_input_is_mouse_pressed(&context->input, NK_BUTTON_LEFT); + bool active = number_edit.context == context && + strcmp(number_edit.id, id) == 0; + if (active && ((pressed && !hover) || + (unsigned int)(context->seq - number_edit.frame) > 1)) { + bongo_cat_pref_number_edit_reset(context); + nk_edit_unfocus(context); + active = false; + } + bool begin = !active && pressed && hover; + if (begin) { + number_edit.context = context; + snprintf(number_edit.id, sizeof(number_edit.id), "%s", id); + snprintf(number_edit.text, sizeof(number_edit.text), "%s", formatted); + number_edit.length = (int)strlen(number_edit.text); + number_edit.integer = integer; + number_edit.negative = minimum < 0; + active = true; + BongoCatUIBackend *backend = bongo_cat_ui_backend_for_context(context); + if (backend) SDL_StartTextInput(backend->window); + } + if (hover) bongo_cat_ui_cursor_hover_rect(context, bounds, + BONGO_CAT_UI_CURSOR_TEXT); + if (!active) return false; + number_edit.frame = context->seq; + + char previous[sizeof(number_edit.text)]; + memcpy(previous, number_edit.text, sizeof(previous)); + int previous_length = number_edit.length; + BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); + struct nk_style_edit saved_style = context->style.edit; + const struct nk_user_font *saved_font = context->style.font; + context->style.font = bongo_cat_ui_body_font(context); + context->style.edit.normal = nk_style_item_color(p.field); + context->style.edit.hover = nk_style_item_color(p.field); + context->style.edit.active = nk_style_item_color(p.field); + context->style.edit.text_normal = p.text; + context->style.edit.text_hover = p.text; + context->style.edit.text_active = p.text; + context->style.edit.cursor_normal = p.text; + context->style.edit.cursor_hover = p.text; + context->style.edit.border = 0; + context->style.edit.rounding = 0; + context->style.edit.padding = nk_vec2(2, 0); + + /* Overlay the editor in an already allocated widget without advancing its row. */ + struct nk_panel saved_panel = *context->current->layout; + nk_layout_space_begin(context, NK_STATIC, bounds.h, 1); + struct nk_vec2 local = nk_layout_space_to_local(context, + nk_vec2(bounds.x, bounds.y)); + nk_layout_space_push(context, nk_rect(local.x, local.y, bounds.w, bounds.h)); + if (begin) nk_edit_unfocus(context); + nk_flags flags = nk_edit_string(context, + (nk_flags)NK_EDIT_FIELD | (nk_flags)NK_EDIT_AUTO_SELECT | + (nk_flags)NK_EDIT_SIG_ENTER, + number_edit.text, &number_edit.length, sizeof(number_edit.text) - 1, + number_filter); + nk_layout_space_end(context); + *context->current->layout = saved_panel; + context->style.edit = saved_style; + context->style.font = saved_font; + + number_edit.text[number_edit.length] = '\0'; + /* Clipboard insertion also needs whole-value validation. */ + if (!valid_text(number_edit.text, integer, minimum < 0)) { + memcpy(number_edit.text, previous, sizeof(previous)); + number_edit.length = previous_length; + } + char *end; + double parsed = strtod(number_edit.text, &end); + if (strcmp(previous, number_edit.text) != 0 && + end != number_edit.text && !*end && isfinite(parsed)) + *value = NK_CLAMP(minimum, parsed, maximum); + if (flags & (NK_EDIT_COMMITED | NK_EDIT_DEACTIVATED)) { + nk_edit_unfocus(context); + bongo_cat_pref_number_edit_reset(context); + } + return true; +} diff --git a/src/ui/preferences/preferences_number_edit.h b/src/ui/preferences/preferences_number_edit.h new file mode 100644 index 00000000..40a771d3 --- /dev/null +++ b/src/ui/preferences/preferences_number_edit.h @@ -0,0 +1,11 @@ +#ifndef BONGO_CAT_PREFERENCES_NUMBER_EDIT_H +#define BONGO_CAT_PREFERENCES_NUMBER_EDIT_H + +#include "preferences_controls.h" + +bool bongo_cat_pref_number_edit(struct nk_context *context, const char *id, + struct nk_rect bounds, const char *formatted, bool integer, + double minimum, double maximum, double *value); +void bongo_cat_pref_number_edit_reset(struct nk_context *context); + +#endif From d92dcaa617eda58a293a5924bb515163e0482b28 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 6 Sep 2026 22:01:58 +0800 Subject: [PATCH 228/334] fix(ui): restore double-click reset for numeric steppers --- src/ui/preferences/preferences_controls.c | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/ui/preferences/preferences_controls.c b/src/ui/preferences/preferences_controls.c index 2b31a4df..aa80ecc5 100644 --- a/src/ui/preferences/preferences_controls.c +++ b/src/ui/preferences/preferences_controls.c @@ -110,7 +110,6 @@ void bongo_cat_pref_controls_reset(struct nk_context *context) { static double stepper(struct nk_context *context, const char *id, double minimum, double value, double maximum, double step, double default_value, bool integer, bool *changed) { - (void)default_value; struct nk_rect bounds; *changed = false; if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return value; @@ -158,16 +157,23 @@ static double stepper(struct nk_context *context, const char *id, nk_stroke_line(canvas, plus.x + 13, cy, plus.x + 27, cy, 2, plus_color); nk_stroke_line(canvas, plus.x + 20, cy - 7, plus.x + 20, cy + 7, 2, plus_color); + if (minus_hover || plus_hover) bongo_cat_ui_cursor_hover_rect(context, interaction, + BONGO_CAT_UI_CURSOR_POINTER); + double before = value; + bool reset_default = nk_input_is_mouse_click_in_rect(&context->input, + NK_BUTTON_DOUBLE, number_box) != 0; + if (reset_default) { + value = NK_CLAMP(minimum, default_value, maximum); + bongo_cat_pref_number_edit_reset(context); + nk_edit_unfocus(context); + } char number[32]; if (integer) snprintf(number, sizeof(number), "%.0f", value); else if (step < .1) snprintf(number, sizeof(number), "%.2f", value); else if (step < 1.0) snprintf(number, sizeof(number), "%.1f", value); else snprintf(number, sizeof(number), "%.0f", value); - if (minus_hover || plus_hover) bongo_cat_ui_cursor_hover_rect(context, interaction, - BONGO_CAT_UI_CURSOR_POINTER); - double before = value; - bool editing = bongo_cat_pref_number_edit(context, id, number_box, number, - integer, minimum, maximum, &value); + bool editing = !reset_default && bongo_cat_pref_number_edit(context, id, + number_box, number, integer, minimum, maximum, &value); if (!editing) centered(canvas, number_box, number, bongo_cat_ui_body_font(context), p.text); int direction = repeat_direction(context, id, minus, plus_hit); @@ -176,7 +182,8 @@ static double stepper(struct nk_context *context, const char *id, direction = wheel > 0 ? 1 : -1; context->input.mouse.scroll_delta.y = 0; } - value = NK_CLAMP(minimum, value + direction * step, maximum); + if (!reset_default) + value = NK_CLAMP(minimum, value + direction * step, maximum); *changed = value != before; return value; } From 4c4fb8802839078286148ce65d15c45bfe776659 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 6 Sep 2026 23:11:57 +0800 Subject: [PATCH 229/334] feat: hide desktop pet on hover when click-through and topmost --- resources/assets/locales/de-DE.json | 2 +- resources/assets/locales/en-US.json | 2 +- resources/assets/locales/es-ES.json | 2 +- resources/assets/locales/fr-FR.json | 2 +- resources/assets/locales/ja-JP.json | 2 +- resources/assets/locales/ko-KR.json | 2 +- resources/assets/locales/pt-BR.json | 2 +- resources/assets/locales/ru-RU.json | 2 +- resources/assets/locales/zh-CN.json | 2 +- resources/assets/locales/zh-Hant.json | 2 +- src/runtime/input/mouse.c | 39 +++++++++++++------------- src/runtime/lifecycle/frame_clock.c | 13 ++++++++- src/runtime/shell/window_geometry.c | 23 ++++++++++++++- src/ui/preferences/preferences_pages.c | 7 +++++ 14 files changed, 71 insertions(+), 31 deletions(-) diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index f5287e65..b38e68b3 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -35,7 +35,7 @@ "obsBackground": "Fensteraufnahme schwarz?", "obsBackgroundHelp": "OBS: Diese Option aktivieren, die Windows-7-Kompatibilitätsmethode verwenden und den Hintergrund mit einem Farbschlüssel-Filter entfernen.", "windowSize": "Bewegen Sie die Maus an den Fensterrand oder halten Sie Umschalt gedrückt und ziehen Sie mit der rechten Maustaste.", - "hideOnHover": "Wenn aktiviert, wird das Fenster beim Darüberfahren mit der Maus automatisch ausgeblendet. Die Verzögerung 0 blendet es sofort aus." + "hideOnHover": "Wenn Klickdurchlässigkeit und Immer im Vordergrund aktiviert sind, wird das Haustier beim Darüberfahren sofort ausgeblendet und beim Verlassen wieder angezeigt." } }, "general": { diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index edf13173..188642b6 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -35,7 +35,7 @@ "obsBackground": "Window capture is black?", "obsBackgroundHelp": "OBS: enable this option, use the Windows 7 compatibility method, and remove the background with a color key filter.", "windowSize": "Move mouse to window edge, or hold Shift and right-drag to resize.", - "hideOnHover": "When enabled, the window hides automatically on mouse hover. You can set a delay; 0 means hide immediately." + "hideOnHover": "With Pass Through and Always on Top enabled, hide the pet immediately on hover and show it again when the pointer leaves." } }, "general": { diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index f72f0747..de1fb5be 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -35,7 +35,7 @@ "obsBackground": "¿La captura de ventana aparece en negro?", "obsBackgroundHelp": "OBS: activa esta opción, usa el método compatible con Windows 7 y elimina el fondo con un filtro de clave de color.", "windowSize": "Mueva el ratón al borde de la ventana o mantenga Mayús y arrastre con el botón derecho para cambiar el tamaño.", - "hideOnHover": "Al activarlo, la ventana se oculta automáticamente al pasar el ratón. Puede definir un retardo; 0 significa ocultar inmediatamente." + "hideOnHover": "Con el paso de clics y Siempre visible activados, la mascota se oculta inmediatamente al pasar el ratón y reaparece al retirarlo." } }, "general": { diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index 64602ea2..3a9b5753 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -35,7 +35,7 @@ "obsBackground": "La capture de fenêtre est noire ?", "obsBackgroundHelp": "OBS : activez cette option, utilisez la méthode compatible Windows 7 et supprimez le fond avec un filtre d'incrustation couleur.", "windowSize": "Placez la souris sur le bord de la fenêtre, ou maintenez Maj et faites glisser avec le bouton droit pour redimensionner.", - "hideOnHover": "Lorsque cette option est activée, la fenêtre se masque au survol de la souris. Vous pouvez définir un délai ; 0 signifie immédiatement." + "hideOnHover": "Lorsque le passage des clics et Toujours au premier plan sont activés, le compagnon se masque immédiatement au survol et réapparaît lorsque le pointeur s’éloigne." } }, "general": { diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index 48680347..8af22a1c 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -35,7 +35,7 @@ "obsBackground": "ウィンドウキャプチャが黒い?", "obsBackgroundHelp": "OBS:「Windows 7互換」方式でキャプチャする場合はこの項目を有効にし、カラーキーフィルターで背景を除去してください。", "windowSize": "マウスをウィンドウ端へ移動するか、Shiftキーを押しながら右ドラッグしてサイズを変更します。", - "hideOnHover": "有効にすると、マウスを重ねたときにウィンドウが自動的に隠れます。遅延を設定でき、0ではすぐに隠れます。" + "hideOnHover": "クリック透過と最前面表示が有効な場合、ペットにマウスを重ねるとすぐに非表示になり、離すと再表示されます。" } }, "general": { diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index 20ab1de7..11f188a3 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -35,7 +35,7 @@ "obsBackground": "창 캡처가 검은 화면인가요?", "obsBackgroundHelp": "OBS: 이 옵션을 켜고 Windows 7 호환 방식으로 캡처한 뒤 색상 키 필터로 배경을 제거하세요.", "windowSize": "마우스를 창 가장자리로 이동하거나 Shift를 누른 채 오른쪽 버튼으로 끌어 크기를 조절합니다.", - "hideOnHover": "활성화하면 마우스를 올렸을 때 창이 자동으로 숨겨집니다. 지연 시간을 설정할 수 있으며 0은 즉시 숨김입니다." + "hideOnHover": "클릭 통과와 항상 위에 표시가 모두 켜져 있으면 펫에 마우스를 올릴 때 즉시 숨기고, 마우스를 치우면 다시 표시합니다." } }, "general": { diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index fa3125bf..ebd03350 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -35,7 +35,7 @@ "obsBackground": "A captura da janela está preta?", "obsBackgroundHelp": "OBS: ative esta opção, use o método compatível com o Windows 7 e remova o fundo com um filtro de chave de cor.", "windowSize": "Mova o mouse para a borda da janela ou segure Shift e arraste com o botão direito para redimensionar.", - "hideOnHover": "Quando ativado, a janela se oculta automaticamente ao passar o mouse. Você pode definir um atraso; 0 significa ocultar imediatamente." + "hideOnHover": "Com a passagem de cliques e Sempre no Topo ativados, o mascote se oculta imediatamente ao passar o mouse e reaparece ao afastá-lo." } }, "general": { diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index f05a7dd8..50e66b87 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -35,7 +35,7 @@ "obsBackground": "Захват окна показывает чёрный экран?", "obsBackgroundHelp": "OBS: включите этот параметр, используйте метод совместимости с Windows 7 и удалите фон фильтром цветового ключа.", "windowSize": "Подведите мышь к краю окна или удерживайте Shift и перетаскивайте правой кнопкой для изменения размера.", - "hideOnHover": "Если включено, окно автоматически скрывается при наведении мыши. Можно задать задержку; 0 означает скрытие сразу." + "hideOnHover": "Если включены пропуск кликов и отображение поверх всех окон, питомец мгновенно скрывается при наведении и появляется, когда указатель уходит." } }, "general": { diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index ae4c50a8..bfab4d55 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -35,7 +35,7 @@ "obsBackground": "窗口捕获黑屏?", "obsBackgroundHelp": "OBS:开启当前选项,使用 Windows 7 兼容捕获,并用色键滤镜移除背景。\n哔哩哔哩直播姬:无需开启当前选项,选择游戏进程捕获,并开启允许窗口透明。", "windowSize": "将鼠标移至窗口边缘,或按住 Shift 并右键拖动,也可以调整窗口大小。", - "hideOnHover": "启用后,鼠标悬停窗口时自动隐藏。可设置延迟时间,0 表示立即隐藏。" + "hideOnHover": "鼠标穿透和置顶均开启时,鼠标移到桌宠上立即隐藏,移开后恢复显示。" } }, "general": { diff --git a/resources/assets/locales/zh-Hant.json b/resources/assets/locales/zh-Hant.json index 51233d85..488fcdff 100644 --- a/resources/assets/locales/zh-Hant.json +++ b/resources/assets/locales/zh-Hant.json @@ -35,7 +35,7 @@ "obsBackground": "視窗擷取黑屏?", "obsBackgroundHelp": "OBS:開啟目前選項,使用 Windows 7 相容擷取,並用色鍵濾鏡移除背景。\n嗶哩嗶哩直播姬:不需要開啟目前選項,選擇遊戲程序擷取,並開啟允許視窗透明。", "windowSize": "將滑鼠游標移至視窗邊緣,或按住 Shift 並右鍵拖曳,也可以調整視窗大小。", - "hideOnHover": "啟用後,滑鼠游標懸停視窗時自動隱藏。可設定延遲時間,0 表示立即隱藏。" + "hideOnHover": "滑鼠穿透和置頂皆啟用時,滑鼠移到桌寵上立即隱藏,移開後恢復顯示。" } }, "general": { diff --git a/src/runtime/input/mouse.c b/src/runtime/input/mouse.c index 2ce2da58..bf6216e0 100644 --- a/src/runtime/input/mouse.c +++ b/src/runtime/input/mouse.c @@ -5,31 +5,32 @@ /* Window-facing pointer state: hover hiding and hit-test scheduling. The sampling/mapping pipeline lives in mouse_pipeline.c and mouse_mapping.c. */ void bongo_cat_app_track_hover(BongoCatApp *app, double x, double y) { - int window_x, window_y, width, height; - SDL_GetWindowPosition(app->window, &window_x, &window_y); - SDL_GetWindowSize(app->window, &width, &height); - bool inside = x >= window_x && x <= window_x + width && - y >= window_y && y <= window_y + height; app->pointer_known = true; app->pointer_x = x; app->pointer_y = y; bongo_cat_window_schedule_pointer_hit(app); - if (inside == app->hover_inside) return; - app->hover_inside = inside; - app->hover_deadline_ns = inside ? SDL_GetTicksNS() + - (uint64_t)(app->settings.window.hide_delay_seconds * 1000000000.0) : 0; - if (!inside && app->hover_hidden) { - bongo_cat_platform_set_opacity(&app->platform, - app->session.window.opacity_percent / 100.0f); - app->hover_hidden = false; - bongo_cat_window_sync_click_through(app); - } + bongo_cat_app_update_hover(app, SDL_GetTicksNS()); } void bongo_cat_app_update_hover(BongoCatApp *app, uint64_t now) { - if (!app->settings.window.hide_on_hover || !app->hover_inside || app->hover_hidden || - !app->hover_deadline_ns || now < app->hover_deadline_ns) return; - bongo_cat_platform_set_opacity(&app->platform, 0.0f); - app->hover_hidden = true; + (void)now; + if (!app || !app->window) return; + bool enabled = app->settings.window.hide_on_hover && + app->settings.window.pass_through && app->settings.window.always_on_top && + app->session.window.visible && !app->window_minimized && + !app->startup_visibility_pending; + float local_x, local_y; + /* Window opacity does not change the frame alpha, so hidden pets can + still detect the pointer leaving their visible model pixels. */ + bool inside = enabled && app->pointer_known && + bongo_cat_platform_pointer_local(&app->platform, + app->pointer_x, app->pointer_y, &local_x, &local_y) && + bongo_cat_window_visible_at_pointer(app, local_x, local_y); + app->hover_inside = inside; + app->hover_deadline_ns = 0; + if (app->hover_hidden == inside) return; + bongo_cat_platform_set_opacity(&app->platform, + inside ? 0.0f : app->session.window.opacity_percent / 100.0f); + app->hover_hidden = inside; bongo_cat_window_sync_click_through(app); } diff --git a/src/runtime/lifecycle/frame_clock.c b/src/runtime/lifecycle/frame_clock.c index 17ffa4bc..13b6c10a 100644 --- a/src/runtime/lifecycle/frame_clock.c +++ b/src/runtime/lifecycle/frame_clock.c @@ -35,7 +35,9 @@ int bongo_cat_window_wait_timeout(const BongoCatApp *app, uint64_t now) { wait_ms = 4; if (app->wheel_animation_active && wait_ms > 8) wait_ms = 8; if (app->session.window.visible && !app->window_minimized && - app->click_through_applied && wait_ms > 16) wait_ms = 16; + (app->click_through_applied || (app->settings.window.pass_through && + app->settings.window.always_on_top && app->settings.window.hide_on_hover)) && + wait_ms > 16) wait_ms = 16; bool pending_hit = app->session.window.visible && !app->window_minimized && app->pointer_hit_dirty && app->pointer_hit_deadline_ns && !app->settings.window.pass_through && @@ -94,6 +96,15 @@ bool bongo_cat_window_wait_timeout_self_test(void) { app->click_through_applied = true; if (bongo_cat_window_wait_timeout(app, now) != 16) goto done; app->click_through_applied = false; + app->settings.window.pass_through = true; + app->settings.window.always_on_top = true; + app->settings.window.hide_on_hover = true; + if (bongo_cat_window_wait_timeout(app, now) != 16) goto done; + app->settings.window.always_on_top = false; + if (bongo_cat_window_wait_timeout(app, now) != + remaining_ms(now + frame_interval_ns(app), now)) goto done; + app->settings.window.pass_through = false; + app->settings.window.hide_on_hover = false; #ifdef BONGO_CAT_HAS_CUBISM app->settings.model.max_fps = 60; #endif diff --git a/src/runtime/shell/window_geometry.c b/src/runtime/shell/window_geometry.c index 30681381..0f166590 100644 --- a/src/runtime/shell/window_geometry.c +++ b/src/runtime/shell/window_geometry.c @@ -176,8 +176,13 @@ bool bongo_cat_window_geometry_self_test(BongoCatApp *app) { &app->platform) - 0.5f) < 0.02f; app->settings.window.hide_on_hover = true; app->settings.window.hide_delay_seconds = 0.0f; - app->settings.window.pass_through = false; + app->settings.window.pass_through = true; + app->settings.window.always_on_top = true; + app->settings.window.obs_background = true; + app->settings.window.rounded_corners = false; app->session.window.opacity_percent = 100.0f; + bongo_cat_window_sync_click_through(app); + bongo_cat_app_render_now(app); bongo_cat_app_track_hover(app, x + 10, y + 10); bongo_cat_app_update_hover(app, SDL_GetTicksNS() + 1); bool hidden = app->hover_hidden && @@ -185,6 +190,22 @@ bool bongo_cat_window_geometry_self_test(BongoCatApp *app) { bongo_cat_app_track_hover(app, bounds.x - 10, bounds.y - 10); bool restored = !app->hover_hidden && SDL_fabsf(bongo_cat_platform_get_opacity(&app->platform) - 1.0f) < 0.02f; + bongo_cat_app_track_hover(app, x + 10, y + 10); + app->settings.window.always_on_top = false; + bongo_cat_app_update_hover(app, SDL_GetTicksNS()); + restored = restored && !app->hover_hidden; + app->settings.window.always_on_top = true; + bongo_cat_app_update_hover(app, SDL_GetTicksNS()); + hidden = hidden && app->hover_hidden; + app->settings.window.pass_through = false; + bongo_cat_app_update_hover(app, SDL_GetTicksNS()); + restored = restored && !app->hover_hidden; + app->settings.window.pass_through = true; + bongo_cat_app_update_hover(app, SDL_GetTicksNS()); + hidden = hidden && app->hover_hidden; + app->settings.window.hide_on_hover = false; + bongo_cat_app_update_hover(app, SDL_GetTicksNS()); + restored = restored && !app->hover_hidden; float safe_scale; int safe_width, safe_height; bool bounded = bongo_cat_window_scaled_size(8000, 4000, 100.0f, 500.0f, diff --git a/src/ui/preferences/preferences_pages.c b/src/ui/preferences/preferences_pages.c index af373745..14c985b9 100644 --- a/src/ui/preferences/preferences_pages.c +++ b/src/ui/preferences/preferences_pages.c @@ -41,6 +41,13 @@ static void page_display(BongoCatApp *app, struct nk_context *context) { bongo_cat_window_mark_hit_dirty(app); bongo_cat_window_sync_click_through(app); } + if (window->pass_through && window->always_on_top) { + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_SHORTCUT_VISIBILITY); + if (bongo_cat_pref_toggle(context, "hide-on-hover", tr(app, + "pages.preference.cat.labels.hideOnHover", "Hide on Hover"), "", + &window->hide_on_hover)) + bongo_cat_app_update_hover(app, SDL_GetTicksNS()); + } bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_KEEP_IN_SCREEN); if (bongo_cat_pref_toggle(context, "keep-in-screen", tr(app, "pages.preference.cat.labels.keepInScreen", "Keep on Screen"), "", From 25e279ac908eb115bb092b9846988ffabbbf866f Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 7 Sep 2026 00:29:05 +0800 Subject: [PATCH 230/334] fix(model-import): resolve moc3 sources and normalize legacy Mver manifests --- cmake/RuntimeSources.cmake | 1 + cmake/Tests.cmake | 2 + src/runtime/model/import/README.md | 33 +++++++ .../model/import/model_import_manifest.c | 20 ++-- .../model/import/model_import_manifest.h | 4 + src/runtime/model/import/model_import_probe.c | 58 +++++++++++ src/runtime/model/import/model_import_probe.h | 8 +- .../model/import/model_import_report.c | 5 +- .../model/import/model_import_source.c | 6 +- .../import/mver/model_import_mver_copy.c | 5 + .../import/mver/model_import_mver_discovery.c | 4 +- .../import/mver/model_import_mver_manifest.c | 95 +++++++++++++++++++ .../import/mver/model_import_mver_manifest.h | 15 +++ .../import/mver/model_import_mver_motion.c | 5 +- .../import/mver/model_import_mver_policy.c | 5 +- tests/model_import/test_model_import_source.c | 86 +++++++++++++++++ tests/model_import/test_mver_container.c | 17 ++++ tests/model_import/test_mver_import.c | 2 + .../model_import/test_mver_import_internal.h | 2 + tests/model_import/test_mver_manifest.c | 86 +++++++++++++++++ tests/model_import/test_tauri_portable.c | 24 ++++- 21 files changed, 464 insertions(+), 19 deletions(-) create mode 100644 src/runtime/model/import/mver/model_import_mver_manifest.c create mode 100644 src/runtime/model/import/mver/model_import_mver_manifest.h create mode 100644 tests/model_import/test_model_import_source.c create mode 100644 tests/model_import/test_mver_manifest.c diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 7875c6b9..6dd21e76 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -119,6 +119,7 @@ set(BONGO_CAT_RUNTIME_IMPORT_SOURCES src/runtime/model/import/mver/model_import_mver_assets.c src/runtime/model/import/mver/model_import_mver_copy.c src/runtime/model/import/mver/model_import_mver_discovery.c + src/runtime/model/import/mver/model_import_mver_manifest.c src/runtime/model/import/mver/model_import_mver_effect.c src/runtime/model/import/mver/model_import_mver_image.c src/runtime/model/import/mver/model_import_mver_labels.c diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index cbfe1b92..71c68f5f 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -47,6 +47,8 @@ if(BUILD_TESTING) set(BONGO_CAT_MVER_IMPORT_TEST_SOURCES tests/model_import/test_mver_import.c + tests/model_import/test_model_import_source.c + tests/model_import/test_mver_manifest.c tests/model_import/test_tauri_portable.c tests/model_import/test_mver_container.c tests/model_import/test_mver_missing_motion.c diff --git a/src/runtime/model/import/README.md b/src/runtime/model/import/README.md index 1d1a26a0..819a84f2 100644 --- a/src/runtime/model/import/README.md +++ b/src/runtime/model/import/README.md @@ -27,3 +27,36 @@ to an Mver package before they enter the installed-model catalog. - Installed packages are validated through Mver discovery after preparation. - Format-specific APIs stay in their format directories. - Shared path and manifest helpers stay format-neutral. + +## File source recognition + +`model_import_source.c` validates the selected path and dispatches supported +file types. Directories pass through unchanged; configuration and model3 +files resolve to their parent; image patches resolve through their `img` root. +Moc files delegate ownership lookup to `model_import_probe.c`. + +The Live2D owner probe checks up to 12 ancestor directories, starting at the +Moc's parent. At each step the Mver adapter's package lookup runs before the +exact Tauri probe. A recognized invalid Mver package stops lookup. No sibling +or recursive container scan runs during file ownership lookup. Once resolved, +the package directory enters the same discovery and installation pipeline as +a dropped folder. File extension dispatch is case-insensitive. + +`test_model_import_source.c` covers source validation and ownership lookup. +Container and Tauri portable tests cover installation identity equivalence +between Moc files and folders, including duplicate installation prevention. +Run `model-import-unit` after building to verify these behavioral checks. + +## Mver manifest compatibility + +`mver/model_import_mver_manifest.c` reads strict JSON first, then permits +comments and trailing commas. A single legacy extra `] }` immediately before +another `}` at the parser error position can be removed, provided the entire +result parses successfully. It does not guess missing values or truncate +unparseable content. Referenced assets still require validation. + +Discovery and Mver metadata readers share this compatibility reader. During +package copying, repaired model3 manifests are serialized as standard JSON +inside the staging directory; original source files remain untouched. Tauri +and runtime validation stay strict. `test_mver_manifest.c` covers repair, +strict output, unchanged sources, duplicate imports, and rejection cases. diff --git a/src/runtime/model/import/model_import_manifest.c b/src/runtime/model/import/model_import_manifest.c index 7b0487c1..13a44d86 100644 --- a/src/runtime/model/import/model_import_manifest.c +++ b/src/runtime/model/import/model_import_manifest.c @@ -65,13 +65,8 @@ static bool behavior_references(const char *root, yyjson_val *refs) { return true; } -bool bongo_cat_import_manifest_valid(const char *root, const char *setting, - BongoCatError *error) { - char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), root, setting)) return false; - FILE *file = bongo_cat_file_open(path, "rb"); - yyjson_doc *document = file ? yyjson_read_fp(file, 0, NULL, NULL) : NULL; - if (file) fclose(file); +bool bongo_cat_import_manifest_document_valid(const char *root, + yyjson_doc *document) { yyjson_val *manifest = document ? yyjson_doc_get_root(document) : NULL; yyjson_val *refs = yyjson_is_obj(manifest) ? yyjson_obj_get(manifest, "FileReferences") : NULL; @@ -86,6 +81,17 @@ bool bongo_cat_import_manifest_valid(const char *root, const char *setting, valid = valid && optional_reference(root, refs, "Physics") && optional_reference(root, refs, "Pose") && optional_reference(root, refs, "DisplayInfo") && behavior_references(root, refs); + return valid; +} + +bool bongo_cat_import_manifest_valid(const char *root, const char *setting, + BongoCatError *error) { + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), root, setting)) return false; + FILE *file = bongo_cat_file_open(path, "rb"); + yyjson_doc *document = file ? yyjson_read_fp(file, 0, NULL, NULL) : NULL; + if (file) fclose(file); + bool valid = bongo_cat_import_manifest_document_valid(root, document); yyjson_doc_free(document); if (!valid && error) bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, "Model manifest or referenced assets are invalid: %s", path); diff --git a/src/runtime/model/import/model_import_manifest.h b/src/runtime/model/import/model_import_manifest.h index cfc196f4..e7e644c7 100644 --- a/src/runtime/model/import/model_import_manifest.h +++ b/src/runtime/model/import/model_import_manifest.h @@ -2,6 +2,10 @@ #define BONGO_CAT_MODEL_IMPORT_MANIFEST_H #include "bongo_cat/common.h" +#include + +bool bongo_cat_import_manifest_document_valid(const char *root, + yyjson_doc *document); /* Validates a Live2D manifest and every referenced local asset. */ bool bongo_cat_import_manifest_valid(const char *root, const char *setting, diff --git a/src/runtime/model/import/model_import_probe.c b/src/runtime/model/import/model_import_probe.c index bef02fab..68afa276 100644 --- a/src/runtime/model/import/model_import_probe.c +++ b/src/runtime/model/import/model_import_probe.c @@ -1,13 +1,71 @@ #include "model_import_probe.h" +#include "model_import_path.h" #include "mver/model_import_mver.h" #include "tauri/model_import_tauri.h" #include +#include +#include #include typedef int (*ExactProbe)(const char *source, BongoCatImportDiscovery *discovery, BongoCatError *error); +static int probe_live2d_owner_at(const char *directory, + BongoCatImportDiscovery *discovery, BongoCatError *error) { + memset(discovery, 0, sizeof(*discovery)); + /* Mver's adapter owns parent lookup and protects bundled Live2D models + from being converted as standalone Tauri sources. */ + int found = bongo_cat_import_mver_discover(directory, discovery, error); + return found ? found : bongo_cat_import_tauri_discover_exact(directory, + discovery, error); +} + +BongoCatResult bongo_cat_import_probe_live2d_owner(const char *source, + char *directory, size_t capacity, BongoCatError *error) { + char current[BONGO_CAT_PATH_CAP]; + if (!directory || !capacity || + !bongo_cat_import_parent_path(source, current, sizeof(current))) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, + "Cannot determine the selected model file directory"); + return BONGO_CAT_ERROR_ARGUMENT; + } + BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); + if (!discovery) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Cannot allocate model source discovery workspace"); + return BONGO_CAT_ERROR_MEMORY; + } + BongoCatResult result = BONGO_CAT_ERROR_FORMAT; + int found = 0; + for (int depth = 0; depth < 12; ++depth) { + BongoCatError probe_error = {0}; + found = probe_live2d_owner_at(current, discovery, &probe_error); + if (found < 0) { + if (error) *error = probe_error; + result = probe_error.code ? probe_error.code : BONGO_CAT_ERROR_FORMAT; + break; + } + if (found > 0) { + int length = snprintf(directory, capacity, "%s", + discovery->candidates[0].package_root); + result = length >= 0 && (size_t)length < capacity + ? BONGO_CAT_OK : BONGO_CAT_ERROR_ARGUMENT; + if (result != BONGO_CAT_OK) bongo_cat_error_set(error, result, + "Model import path is too long"); + break; + } + char parent[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_import_parent_path(current, parent, sizeof(parent)) || + strcmp(current, parent) == 0) break; + snprintf(current, sizeof(current), "%s", parent); + } + free(discovery); + if (!found) bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "No supported model package found above Live2D .moc3 file: %s", source); + return result; +} + const char *bongo_cat_import_format_name(BongoCatImportFormat format) { switch (format) { case BONGO_CAT_IMPORT_MVER: return "mver"; diff --git a/src/runtime/model/import/model_import_probe.h b/src/runtime/model/import/model_import_probe.h index 0908400a..f762a71c 100644 --- a/src/runtime/model/import/model_import_probe.h +++ b/src/runtime/model/import/model_import_probe.h @@ -11,10 +11,16 @@ typedef enum BongoCatImportProbePolicy { } BongoCatImportProbePolicy; /* Exact package-root dispatch shared by progressive, bounded, and queued - scans. Broad Mver parent lookup remains in the owning import adapter. */ + scans. Broad Mver parent lookup remains in the Mver adapter. */ const char *bongo_cat_import_format_name(BongoCatImportFormat format); int bongo_cat_import_probe_exact(const char *source, BongoCatImportDiscovery *discovery, BongoCatImportFormat *format, BongoCatImportProbePolicy policy, bool diagnostic, BongoCatError *error); +/* Resolve an existing Moc file to its owning package, checking ancestors + only. Mver ownership takes precedence over standalone Tauri conversion; + malformed matching packages stop lookup. Source validates the input file. */ +BongoCatResult bongo_cat_import_probe_live2d_owner(const char *source, + char *directory, size_t capacity, BongoCatError *error); + #endif diff --git a/src/runtime/model/import/model_import_report.c b/src/runtime/model/import/model_import_report.c index c6a88adf..4e4d7148 100644 --- a/src/runtime/model/import/model_import_report.c +++ b/src/runtime/model/import/model_import_report.c @@ -1,4 +1,5 @@ #include "model_import.h" +#include "mver/model_import_mver_manifest.h" #include "bongo_cat/file.h" #include "bongo_cat/json.h" #include "bongo_cat/path.h" @@ -85,7 +86,9 @@ static size_t missing_motion_sounds(const BongoCatImportCandidate *candidate) { char manifest_path[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(manifest_path, sizeof(manifest_path), candidate->directory, candidate->setting)) return 0; - yyjson_doc *document = bongo_cat_json_read_file(manifest_path, 0, NULL); + yyjson_doc *document = candidate->format == BONGO_CAT_IMPORT_TAURI + ? bongo_cat_json_read_file(manifest_path, 0, NULL) + : bongo_cat_import_mver_manifest_read(manifest_path, NULL); yyjson_val *refs = document ? yyjson_obj_get(yyjson_doc_get_root(document), "FileReferences") : NULL; yyjson_val *motions = yyjson_obj_get(refs, "Motions"); diff --git a/src/runtime/model/import/model_import_source.c b/src/runtime/model/import/model_import_source.c index 6f489655..3bbcd1d0 100644 --- a/src/runtime/model/import/model_import_source.c +++ b/src/runtime/model/import/model_import_source.c @@ -1,5 +1,6 @@ #include "model_import.h" #include "model_import_path.h" +#include "model_import_probe.h" #include "bongo_cat/path.h" #include @@ -42,6 +43,9 @@ BongoCatResult bongo_cat_import_source_directory(const char *source, return BONGO_CAT_ERROR_ARGUMENT; } const char *name = bongo_cat_path_name(source); + if (name && bongo_cat_import_has_suffix_ci(name, ".moc3")) + return bongo_cat_import_probe_live2d_owner(source, directory, + capacity, error); if (name && bongo_cat_import_has_suffix_ci(name, ".png") && image_package_root(source, directory, capacity)) return BONGO_CAT_OK; if (!name || (SDL_strcasecmp(name, "config.json") != 0 && @@ -49,7 +53,7 @@ BongoCatResult bongo_cat_import_source_directory(const char *source, !bongo_cat_import_has_suffix_ci(name, ".model3.json"))) { bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, "Select a BongoCat skin file, Mver config.json, image-patch PNG, " - "or Live2D .model3.json file"); + "or Live2D .model3.json/.moc3 file"); return BONGO_CAT_ERROR_FORMAT; } if (bongo_cat_import_parent_path(source, directory, capacity)) diff --git a/src/runtime/model/import/mver/model_import_mver_copy.c b/src/runtime/model/import/mver/model_import_mver_copy.c index a946c187..c3dd849b 100644 --- a/src/runtime/model/import/mver/model_import_mver_copy.c +++ b/src/runtime/model/import/mver/model_import_mver_copy.c @@ -1,4 +1,5 @@ #include "model_import_mver_copy.h" +#include "model_import_mver_manifest.h" #include "model_import_path.h" #include "bongo_cat/path.h" @@ -79,6 +80,10 @@ static BongoCatPathVisit copy_child(void *userdata, const char *dirname, name)) return BONGO_CAT_PATH_FAILURE; if (!bongo_cat_path_is_dir(source)) { if (context->depth == 0) return BONGO_CAT_PATH_CONTINUE; + if (bongo_cat_import_has_suffix_ci(name, ".model3.json")) + return bongo_cat_import_mver_manifest_copy(dirname, name, + source, target, context->error) + ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; return bongo_cat_path_copy_file(source, target) ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; } diff --git a/src/runtime/model/import/mver/model_import_mver_discovery.c b/src/runtime/model/import/mver/model_import_mver_discovery.c index aa9bd261..e127f2b0 100644 --- a/src/runtime/model/import/mver/model_import_mver_discovery.c +++ b/src/runtime/model/import/mver/model_import_mver_discovery.c @@ -1,5 +1,5 @@ #include "model_import.h" -#include "../model_import_manifest.h" +#include "model_import_mver_manifest.h" #include "model_import_mver.h" #include "model_import_path.h" #include "runtime.h" @@ -82,7 +82,7 @@ static bool mode_uses_live2d(yyjson_val *mode) { static bool model_at(const char *directory, char *setting, size_t capacity) { return bongo_cat_path_find_unique_suffix(directory, ".model3.json", setting, capacity) == 1 && - bongo_cat_import_manifest_valid(directory, setting, NULL); + bongo_cat_import_mver_manifest_valid(directory, setting); } static bool find_mode_model(const char *mode_root, char *directory, diff --git a/src/runtime/model/import/mver/model_import_mver_manifest.c b/src/runtime/model/import/mver/model_import_mver_manifest.c new file mode 100644 index 00000000..7b1a1621 --- /dev/null +++ b/src/runtime/model/import/mver/model_import_mver_manifest.c @@ -0,0 +1,95 @@ +#include "model_import_mver_manifest.h" +#include "../model_import_manifest.h" +#include "bongo_cat/file.h" +#include "bongo_cat/json.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +static size_t skip_space(const char *data, size_t size, size_t pos) { + while (pos < size && (data[pos] == ' ' || data[pos] == '\t' || + data[pos] == '\r' || data[pos] == '\n')) pos++; + return pos; +} + +static yyjson_doc *repair_closures(char *data, size_t size, + const yyjson_read_err *parse_error, yyjson_read_flag flags) { + size_t first = parse_error->pos; + /* Legacy hand-edited manifests contain an extra '] }' before the + actual FileReferences closing brace. Only remove this exact form + at the parser's error position, then reparse the entire document. */ + if (first >= size || data[first] != ']') return NULL; + size_t second = skip_space(data, size, first + 1); + size_t third = skip_space(data, size, second + 1); + if (second >= size || third >= size || data[second] != '}' || + data[third] != '}') return NULL; + data[first] = data[second] = ' '; + yyjson_doc *document = yyjson_read_opts(data, size, flags, NULL, NULL); + yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; + if (!yyjson_is_obj(root) || + !yyjson_is_obj(yyjson_obj_get(root, "FileReferences"))) { + yyjson_doc_free(document); + return NULL; + } + return document; +} + +yyjson_doc *bongo_cat_import_mver_manifest_read(const char *path, + bool *repaired) { + if (repaired) *repaired = false; + uint64_t size; + if (!bongo_cat_path_file_size(path, &size) || !size || + size > 4u * 1024u * 1024u) return NULL; + char *data = malloc((size_t)size); + if (!data) return NULL; + FILE *file = bongo_cat_file_open(path, "rb"); + bool loaded = file && fread(data, 1, (size_t)size, file) == (size_t)size; + if (file && fclose(file) != 0) loaded = false; + if (!loaded) { free(data); return NULL; } + yyjson_doc *document = yyjson_read_opts(data, (size_t)size, 0, NULL, NULL); + if (!document) { + yyjson_read_flag flags = YYJSON_READ_ALLOW_COMMENTS | + YYJSON_READ_ALLOW_TRAILING_COMMAS; + yyjson_read_err parse_error = {0}; + document = yyjson_read_opts(data, (size_t)size, flags, NULL, &parse_error); + if (!document) document = repair_closures(data, (size_t)size, + &parse_error, flags); + if (repaired) *repaired = document != NULL; + } + free(data); + return document; +} + +bool bongo_cat_import_mver_manifest_valid(const char *root, + const char *setting) { + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), root, setting)) return false; + yyjson_doc *document = bongo_cat_import_mver_manifest_read(path, NULL); + bool valid = bongo_cat_import_manifest_document_valid(root, document); + yyjson_doc_free(document); + return valid; +} + +bool bongo_cat_import_mver_manifest_copy(const char *root, const char *name, + const char *source, const char *target, BongoCatError *error) { + bool repaired = false; + yyjson_doc *document = bongo_cat_import_mver_manifest_read(source, &repaired); + if (!repaired) { + yyjson_doc_free(document); + return bongo_cat_path_copy_file(source, target); + } + bool valid = bongo_cat_import_manifest_document_valid(root, document); + yyjson_mut_doc *output = valid ? yyjson_doc_mut_copy(document, NULL) : NULL; + yyjson_doc_free(document); + bool ok = output && bongo_cat_json_write_file(target, output, + YYJSON_WRITE_PRETTY, NULL); + yyjson_mut_doc_free(output); + if (!ok) bongo_cat_error_set(error, + valid ? BONGO_CAT_ERROR_IO : BONGO_CAT_ERROR_FORMAT, + "Cannot normalize Mver model manifest: %s", source); + else SDL_Log("Normalized Mver model manifest during import: %s (%s)", + source, name); + return ok; +} diff --git a/src/runtime/model/import/mver/model_import_mver_manifest.h b/src/runtime/model/import/mver/model_import_mver_manifest.h new file mode 100644 index 00000000..cd97c91c --- /dev/null +++ b/src/runtime/model/import/mver/model_import_mver_manifest.h @@ -0,0 +1,15 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_MVER_MANIFEST_H +#define BONGO_CAT_MODEL_IMPORT_MVER_MANIFEST_H + +#include "bongo_cat/common.h" +#include + +/* Compatibility is limited to Mver sources; installed JSON remains strict. */ +yyjson_doc *bongo_cat_import_mver_manifest_read(const char *path, + bool *repaired); +bool bongo_cat_import_mver_manifest_valid(const char *root, + const char *setting); +bool bongo_cat_import_mver_manifest_copy(const char *root, const char *name, + const char *source, const char *target, BongoCatError *error); + +#endif diff --git a/src/runtime/model/import/mver/model_import_mver_motion.c b/src/runtime/model/import/mver/model_import_mver_motion.c index cb9ce1f6..0e1f547e 100644 --- a/src/runtime/model/import/mver/model_import_mver_motion.c +++ b/src/runtime/model/import/mver/model_import_mver_motion.c @@ -1,4 +1,5 @@ #include "model_import_mver_internal.h" +#include "model_import_mver_manifest.h" #include "bongo_cat/file.h" #include "bongo_cat/path.h" @@ -43,9 +44,7 @@ static yyjson_doc *manifest(const BongoCatImportCandidate *candidate, char path[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(path, sizeof(path), candidate->directory, candidate->setting)) return NULL; - FILE *file = bongo_cat_file_open(path, "rb"); - yyjson_doc *document = file ? yyjson_read_fp(file, 0, NULL, NULL) : NULL; - if (file) fclose(file); + yyjson_doc *document = bongo_cat_import_mver_manifest_read(path, NULL); *references = document ? yyjson_obj_get(yyjson_doc_get_root(document), "FileReferences") : NULL; return document; diff --git a/src/runtime/model/import/mver/model_import_mver_policy.c b/src/runtime/model/import/mver/model_import_mver_policy.c index bfc711e5..5401454d 100644 --- a/src/runtime/model/import/mver/model_import_mver_policy.c +++ b/src/runtime/model/import/mver/model_import_mver_policy.c @@ -1,4 +1,5 @@ #include "model_import_mver_policy.h" +#include "model_import_mver_manifest.h" #include "bongo_cat/file.h" #include "bongo_cat/path.h" @@ -121,9 +122,7 @@ bool bongo_cat_import_mver_stock_model( char manifest[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(manifest, sizeof(manifest), candidate->directory, candidate->setting)) return false; - FILE *file = bongo_cat_file_open(manifest, "rb"); - yyjson_doc *document = file ? yyjson_read_fp(file, 0, NULL, NULL) : NULL; - if (file) fclose(file); + yyjson_doc *document = bongo_cat_import_mver_manifest_read(manifest, NULL); yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; yyjson_val *refs = yyjson_is_obj(root) ? yyjson_obj_get(root, "FileReferences") : NULL; diff --git a/tests/model_import/test_model_import_source.c b/tests/model_import/test_model_import_source.c new file mode 100644 index 00000000..b7484587 --- /dev/null +++ b/tests/model_import/test_model_import_source.c @@ -0,0 +1,86 @@ +#include "model_import.h" +#include "model_storage.h" +#include "test_mver_import_internal.h" +#include "test_mver_support.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +void test_model_import_source(void) { + char *temporary = SDL_GetCurrentDirectory(); + CHECK(temporary != NULL); + if (!temporary) return; + char root[BONGO_CAT_PATH_CAP], package[BONGO_CAT_PATH_CAP]; + char moc[BONGO_CAT_PATH_CAP], config[BONGO_CAT_PATH_CAP]; + char directory[BONGO_CAT_PATH_CAP], normalized[BONGO_CAT_PATH_CAP]; + BongoCatError error = {0}; + snprintf(root, sizeof(root), "%s/bongocat-source-%llu", temporary, + (unsigned long long)SDL_GetTicksNS()); + CHECK(SDL_CreateDirectory(root)); + CHECK(child(package, sizeof(package), root, "mver", true)); + CHECK(mver_fixture(package)); + CHECK(child(moc, sizeof(moc), package, + "img/standard/cat_model/cat.moc3", false)); + CHECK(bongo_cat_import_source_directory(moc, normalized, + sizeof(normalized), &error) == BONGO_CAT_OK); + CHECK(strcmp(normalized, package) == 0); + CHECK(bongo_cat_import_source_directory(moc, normalized, 2, + &error) == BONGO_CAT_ERROR_ARGUMENT); + CHECK(bongo_cat_import_source_directory(NULL, normalized, + sizeof(normalized), &error) == BONGO_CAT_ERROR_ARGUMENT); + CHECK(bongo_cat_import_source_directory(moc, NULL, 0, + &error) == BONGO_CAT_ERROR_ARGUMENT); + CHECK(child(config, sizeof(config), package, "config.json", false)); + CHECK(child(directory, sizeof(directory), package, + BONGO_CAT_SKIN_CONFIG_FILE, false)); + CHECK(SDL_RenamePath(config, directory)); + CHECK(bongo_cat_import_source_directory(moc, normalized, + sizeof(normalized), &error) == BONGO_CAT_OK); + CHECK(strcmp(normalized, package) == 0); + CHECK(write_text(directory, "invalid json")); + CHECK(bongo_cat_import_source_directory(moc, normalized, + sizeof(normalized), &error) == BONGO_CAT_ERROR_FORMAT); + CHECK(strstr(error.message, "Mver configuration") != NULL); + + CHECK(child(package, sizeof(package), root, "tauri", true)); + CHECK(child(directory, sizeof(directory), package, "nested", true)); + CHECK(child(moc, sizeof(moc), directory, "CAT.MOC3", false)); + CHECK(write_text(moc, "MOC3")); + CHECK(child(config, sizeof(config), package, "cat.model3.json", false)); + CHECK(write_text(config, "{\"Version\":3,\"FileReferences\":{" + "\"Moc\":\"nested/CAT.MOC3\",\"Textures\":[\"texture.png\"]}}")); + CHECK(child(directory, sizeof(directory), package, "texture.png", false)); + char texture[BONGO_CAT_PATH_CAP]; + snprintf(texture, sizeof(texture), "%s/resources/assets/bongocat.png", + BONGO_CAT_NATIVE_SOURCE_DIR); + CHECK(SDL_CopyFile(texture, directory)); + error = (BongoCatError){0}; + CHECK(bongo_cat_import_source_directory(moc, normalized, + sizeof(normalized), &error) == BONGO_CAT_OK); + CHECK(strcmp(normalized, package) == 0 && error.code == BONGO_CAT_OK); + CHECK(bongo_cat_import_source_directory(config, normalized, + sizeof(normalized), &error) == BONGO_CAT_OK); + CHECK(strcmp(normalized, package) == 0); + CHECK(bongo_cat_path_remove(directory)); + CHECK(bongo_cat_import_source_directory(moc, normalized, + sizeof(normalized), &error) == BONGO_CAT_ERROR_FORMAT); + CHECK(bongo_cat_path_remove(moc)); + CHECK(bongo_cat_import_source_directory(moc, normalized, + sizeof(normalized), &error) == BONGO_CAT_ERROR_ARGUMENT); + /* An orphan must not discover models in sibling directories. */ + CHECK(child(moc, sizeof(moc), root, "orphan.moc3", false)); + CHECK(write_text(moc, "MOC3")); + CHECK(bongo_cat_import_source_directory(moc, normalized, + sizeof(normalized), &error) == BONGO_CAT_ERROR_FORMAT); + CHECK(child(moc, sizeof(moc), root, "unsupported.txt", false)); + CHECK(write_text(moc, "text")); + CHECK(bongo_cat_import_source_directory(moc, normalized, + sizeof(normalized), &error) == BONGO_CAT_ERROR_FORMAT); + CHECK(bongo_cat_import_source_directory(root, normalized, + sizeof(normalized), &error) == BONGO_CAT_OK); + CHECK(strcmp(normalized, root) == 0); + CHECK(bongo_cat_model_remove_tree(root, NULL)); + SDL_free(temporary); +} diff --git a/tests/model_import/test_mver_container.c b/tests/model_import/test_mver_container.c index 3ab384b4..a43f9a41 100644 --- a/tests/model_import/test_mver_container.c +++ b/tests/model_import/test_mver_container.c @@ -175,6 +175,15 @@ void test_mver_container_discovery(void) { CHECK(progressive.callbacks == 2 && progressive.resolved == 2 && progressive.installed == 2); CHECK(stats.succeeded_count == 2 && stats.failed_count == 0); + CHECK(child(mode, sizeof(mode), package, + "img/standard/cat_model/cat.moc3", false)); + progressive = (ProgressiveImportLog){0}; + CHECK(session && bongo_cat_import_session_install_progressive(session, + mode, progressive_receipt, &progressive, &stats, &error) == + BONGO_CAT_OK); + CHECK(progressive.callbacks == 1 && progressive.resolved == 1 && + progressive.installed == 0); + CHECK(stats.succeeded_count == 1 && stats.failed_count == 0); char missing_source[BONGO_CAT_PATH_CAP]; CHECK(child(missing_source, sizeof(missing_source), progressive_root, "missing", false)); @@ -202,6 +211,14 @@ void test_mver_container_discovery(void) { &error) == BONGO_CAT_OK); CHECK(subset_receipt.count == 1 && subset_receipt.installed_count == 0 && strcmp(subset_receipt.ids[0], receipt.ids[0]) == 0); + CHECK(child(mode, sizeof(mode), package, + "img/standard/cat_model/cat.moc3", false)); + BongoCatImportReceipt moc_receipt = {0}; + CHECK(bongo_cat_import_install(mode, models_root, &moc_receipt, + &error) == BONGO_CAT_OK); + CHECK(moc_receipt.count == subset_receipt.count && + moc_receipt.installed_count == 0 && + strcmp(moc_receipt.ids[0], subset_receipt.ids[0]) == 0); char stored[BONGO_CAT_PATH_CAP], duplicate_directory[BONGO_CAT_PATH_CAP]; CHECK(child(stored, sizeof(stored), models_root, receipt.ids[0], false) && bongo_cat_path_is_dir(stored)); diff --git a/tests/model_import/test_mver_import.c b/tests/model_import/test_mver_import.c index 34f37b6a..7e48fb64 100644 --- a/tests/model_import/test_mver_import.c +++ b/tests/model_import/test_mver_import.c @@ -229,6 +229,8 @@ int main(void) { failures += test_mver_policy(); failures += test_model_import_identity(); test_mver_container_discovery(); + test_model_import_source(); + test_mver_manifest(); test_tauri_portable(); failures += test_slim_package(); return failures ? 1 : 0; diff --git a/tests/model_import/test_mver_import_internal.h b/tests/model_import/test_mver_import_internal.h index 2a63d14e..b1fe8c6e 100644 --- a/tests/model_import/test_mver_import_internal.h +++ b/tests/model_import/test_mver_import_internal.h @@ -12,6 +12,8 @@ extern int failures; } } while (0) void test_mver_container_discovery(void); +void test_model_import_source(void); +void test_mver_manifest(void); void test_tauri_portable(void); #endif diff --git a/tests/model_import/test_mver_manifest.c b/tests/model_import/test_mver_manifest.c new file mode 100644 index 00000000..c619f77a --- /dev/null +++ b/tests/model_import/test_mver_manifest.c @@ -0,0 +1,86 @@ +#include "model_import.h" +#include "model_import_manifest.h" +#include "mver/model_import_mver_manifest.h" +#include "model_storage.h" +#include "test_mver_import_internal.h" +#include "test_mver_support.h" +#include "bongo_cat/json.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +static const char *broken = + "{\"Version\":3,\"FileReferences\":{\"Moc\":\"cat.moc3\"," + "\"Textures\":[\"texture.png\"]\n]\n}\n}," + "\"Groups\":[{\"Target\":\"Parameter\",\"Name\":\"EyeBlink\"," + "\"Ids\":[\"ParamEyeLOpen\"]}]}"; + +void test_mver_manifest(void) { + char *temporary = SDL_GetCurrentDirectory(); + CHECK(temporary != NULL); + if (!temporary) return; + char root[BONGO_CAT_PATH_CAP], package[BONGO_CAT_PATH_CAP]; + char model[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; + char models[BONGO_CAT_PATH_CAP], stored[BONGO_CAT_PATH_CAP]; + snprintf(root, sizeof(root), "%s/bongocat-manifest-%llu", temporary, + (unsigned long long)SDL_GetTicksNS()); + CHECK(SDL_CreateDirectory(root)); + CHECK(child(package, sizeof(package), root, "source", true)); + CHECK(mver_fixture(package)); + CHECK(child(model, sizeof(model), package, "img/standard/cat_model", false)); + CHECK(child(path, sizeof(path), model, "cat.model3.json", false)); + CHECK(write_text(path, broken)); + bool repaired = false; + yyjson_doc *document = bongo_cat_import_mver_manifest_read(path, &repaired); + CHECK(document && repaired); + CHECK(yyjson_arr_size(yyjson_obj_get(yyjson_doc_get_root(document), + "Groups")) == 1); + yyjson_doc_free(document); + CHECK(!bongo_cat_import_manifest_valid(model, "cat.model3.json", NULL)); + CHECK(bongo_cat_import_mver_manifest_valid(model, "cat.model3.json")); + CHECK(child(models, sizeof(models), root, "models", true)); + BongoCatError error = {0}; + BongoCatImportReceipt receipt = {0}, duplicate = {0}; + CHECK(bongo_cat_import_install(package, models, &receipt, &error) == BONGO_CAT_OK); + CHECK(receipt.count == 1 && receipt.installed_count == 1); + CHECK(child(stored, sizeof(stored), models, receipt.ids[0], false)); + CHECK(child(model, sizeof(model), stored, "img/standard/cat_model", false)); + CHECK(bongo_cat_import_manifest_valid(model, "cat.model3.json", &error)); + CHECK(bongo_cat_import_install(package, models, &duplicate, &error) == BONGO_CAT_OK); + CHECK(duplicate.count == 1 && duplicate.installed_count == 0 && + strcmp(receipt.ids[0], duplicate.ids[0]) == 0); + size_t length = 0; + char *original = SDL_LoadFile(path, &length); + CHECK(original && length == strlen(broken) && strcmp(original, broken) == 0); + SDL_free(original); + + CHECK(write_text(path, "{/* comment */\"Version\":3,\"FileReferences\":{" + "\"Moc\":\"cat.moc3\",\"Textures\":[\"texture.png\",],},}")); + document = bongo_cat_import_mver_manifest_read(path, &repaired); + CHECK(document && repaired); + yyjson_doc_free(document); + CHECK(write_text(path, "{\"Version\":3,\"FileReferences\":{" + "\"Moc\":\"cat.moc3\",\"Textures\":[\"missing.png\"]]}}}")); + CHECK(child(model, sizeof(model), package, "img/standard/cat_model", false)); + CHECK(!bongo_cat_import_mver_manifest_valid(model, "cat.model3.json")); + static const char *const rejected[] = { + "{\"Version\":3,\"FileReferences\":{", + "{\"Version\":3 \"FileReferences\":{}}", + "{\"Version\":3,\"FileReferences\":{}} trailing garbage" + }; + for (size_t i = 0; i < sizeof(rejected) / sizeof(rejected[0]); ++i) { + CHECK(write_text(path, rejected[i])); + document = bongo_cat_import_mver_manifest_read(path, &repaired); + CHECK(!document && !repaired); + yyjson_doc_free(document); + } + CHECK(write_text(path, "{\"Version\":3,\"FileReferences\":{" + "\"Moc\":\"cat.moc3\",\"Textures\":[\"texture.png\"]}}")); + document = bongo_cat_import_mver_manifest_read(path, &repaired); + CHECK(document && !repaired); + yyjson_doc_free(document); + CHECK(bongo_cat_model_remove_tree(root, NULL)); + SDL_free(temporary); +} diff --git a/tests/model_import/test_tauri_portable.c b/tests/model_import/test_tauri_portable.c index ed94e375..81e41bbd 100644 --- a/tests/model_import/test_tauri_portable.c +++ b/tests/model_import/test_tauri_portable.c @@ -69,6 +69,13 @@ void test_tauri_portable(void) { CHECK(write_text(path, "keep")); BongoCatImportDiscovery discovery = {0}; BongoCatError error = {0}; + /* A referenced Moc may live below the manifest directory. */ + CHECK(child(path, sizeof(path), model, "cat.model3.json", false)); + CHECK(write_text(path, "{\"Version\":3,\"FileReferences\":{" + "\"Moc\":\"nested/CAT.MOC3\",\"Textures\":[" + "\"resources/model-texture.png\"]}}")); + CHECK(child(path, sizeof(path), nested, "CAT.MOC3", false)); + CHECK(write_text(path, "MOC3")); CHECK(bongo_cat_import_tauri_discover_exact(model, &discovery, &error) == 1); CHECK(discovery.count == 1 && discovery.candidates[0].format == BONGO_CAT_IMPORT_TAURI && @@ -85,10 +92,25 @@ void test_tauri_portable(void) { char models_root[BONGO_CAT_PATH_CAP]; CHECK(child(models_root, sizeof(models_root), root, "models", true)); BongoCatImportReceipt receipt = {0}; - CHECK(bongo_cat_import_install(model, models_root, &receipt, &error) == + CHECK(child(path, sizeof(path), nested, "CAT.MOC3", false)); + CHECK(bongo_cat_import_install(path, models_root, &receipt, &error) == BONGO_CAT_OK); CHECK(receipt.count == 1 && receipt.installed_count == 1 && strcmp(receipt.ids[0], "keyboard") == 0); + BongoCatImportReceipt folder_receipt = {0}; + CHECK(bongo_cat_import_install(model, models_root, &folder_receipt, + &error) == BONGO_CAT_OK); + CHECK(folder_receipt.count == receipt.count && + folder_receipt.installed_count == 0 && + strcmp(folder_receipt.ids[0], receipt.ids[0]) == 0); + BongoCatImportSession *session = bongo_cat_import_session_create( + models_root, &error); + BongoCatImportBatchStats stats = {0}; + CHECK(session != NULL); + CHECK(session && bongo_cat_import_session_install_progressive(session, + path, NULL, NULL, &stats, &error) == BONGO_CAT_OK); + CHECK(stats.succeeded_count == 1 && stats.failed_count == 0); + bongo_cat_import_session_destroy(session); char package[BONGO_CAT_PATH_CAP]; CHECK(child(package, sizeof(package), models_root, receipt.ids[0], false)); CHECK(child(path, sizeof(path), package, "config.json", false) && From 3a45893aa07a55b66b5a598151566b8fe477ba5d Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 7 Sep 2026 03:35:54 +0800 Subject: [PATCH 231/334] refactor(input): replace Windows hooks and DirectInput with Raw Input Subscribe to background keyboard and mouse input on a dedicated message-only window. Preserve legacy input delivery, aggregate held states per device, coalesce motion separately from key/button events, and keep SDL from taking over the process-local keyboard registration. Use device motion for hidden or locked cursors, retain desktop cursor tracking, and handle registration queries that omit DEVNOTIFY. Add raw-input-v3 diagnostics that distinguish registration, message delivery, session activity, and model updates. Fix overlapping mouse side-button state. Remove DirectInput sources and link dependencies. Add receiver, key/device, relative-motion, and queue recovery coverage. Extend runtime API checks to reject hooks, injection, device drivers, cursor warping/locking, and input blocking in production source. Some games still interrupt background input delivery; this change does not claim complete game compatibility or guaranteed anti-cheat acceptance. Validation: static runtime API, line, and localization checks passed; input source and tests inspected with cppcheck. No build or compiled tests were run. --- cmake/CheckPlatformRuntimeSafety.cmake | 14 +- cmake/PlatformRuntime.cmake | 9 +- cmake/Tests.cmake | 11 + include/bongo_cat/app.h | 15 +- include/bongo_cat/mouse.h | 2 + include/bongo_cat/platform.h | 2 - src/core/app_state.c | 7 +- src/core/mouse_tracking.c | 5 + src/platform/windows/windows.c | 32 +- src/platform/windows/windows_direct_input.c | 305 ------------- src/platform/windows/windows_direct_input.h | 13 - src/platform/windows/windows_input.c | 420 +++++------------- src/platform/windows/windows_input.h | 7 +- src/platform/windows/windows_input_devices.c | 158 +++++++ .../windows/windows_input_diagnostics.c | 127 ++++++ src/platform/windows/windows_input_internal.h | 95 ++++ src/platform/windows/windows_input_packets.c | 55 +++ src/platform/windows/windows_input_receiver.c | 75 ++++ .../windows/windows_input_registration.c | 112 +++++ src/platform/windows/windows_input_relative.c | 128 ++++++ src/platform/windows/windows_keys.c | 79 +--- src/platform/windows/windows_keys.h | 23 +- src/runtime/input/mouse_diagnostics.c | 105 +++-- src/runtime/input/mouse_diagnostics.h | 10 +- src/runtime/input/mouse_mapping.c | 19 +- src/runtime/input/mouse_pipeline.c | 59 +-- src/runtime/lifecycle/app_input.c | 32 -- src/runtime/shell/window.c | 2 + tests/core/test_app_state.c | 12 + tests/core/test_input.c | 66 ++- tests/core/test_mver_pointer.c | 18 + tests/platform/test_windows_capture.c | 39 -- tests/platform/test_windows_input.c | 203 +++++++++ tests/platform/test_windows_raw_receiver.c | 103 +++++ tests/platform/test_windows_relative.c | 141 ++++++ 35 files changed, 1567 insertions(+), 936 deletions(-) delete mode 100644 src/platform/windows/windows_direct_input.c delete mode 100644 src/platform/windows/windows_direct_input.h create mode 100644 src/platform/windows/windows_input_devices.c create mode 100644 src/platform/windows/windows_input_diagnostics.c create mode 100644 src/platform/windows/windows_input_internal.h create mode 100644 src/platform/windows/windows_input_packets.c create mode 100644 src/platform/windows/windows_input_receiver.c create mode 100644 src/platform/windows/windows_input_registration.c create mode 100644 src/platform/windows/windows_input_relative.c create mode 100644 tests/platform/test_windows_input.c create mode 100644 tests/platform/test_windows_raw_receiver.c create mode 100644 tests/platform/test_windows_relative.c diff --git a/cmake/CheckPlatformRuntimeSafety.cmake b/cmake/CheckPlatformRuntimeSafety.cmake index cc7ba80e..8c4e3cb1 100644 --- a/cmake/CheckPlatformRuntimeSafety.cmake +++ b/cmake/CheckPlatformRuntimeSafety.cmake @@ -19,7 +19,9 @@ set(FORBIDDEN_APIS Process32Next EnumProcesses EnumProcessModules AdjustTokenPrivileges LookupPrivilegeValue OpenSCManager CreateService StartService ControlService DeviceIoControl NtLoadDriver AttachThreadInput SendInput keybd_event - mouse_event RegisterRawInputDevices GetRawInputData + mouse_event SetWindowsHookEx SetWindowsHookExA SetWindowsHookExW DirectInput8Create + SetCursorPos SetPhysicalCursorPos ClipCursor BlockInput + SDL_SetWindowRelativeMouseMode CGEventPost CGEventPostToPid CGEventCreateKeyboardEvent CGEventCreateMouseEvent CGWarpMouseCursorPosition CGAssociateMouseAndMouseCursorPosition IOHIDManagerCreate @@ -33,7 +35,9 @@ set(FORBIDDEN_TOKENS SE_DEBUG_NAME SeDebugPrivilege PROCESS_VM_READ PROCESS_VM_WRITE PROCESS_VM_OPERATION PROCESS_ALL_ACCESS THREAD_SET_CONTEXT kCGHIDEventTap kCGHeadInsertEventTap IOConnectCall - /dev/input /dev/uinput CAP_SYS_PTRACE CAP_SYS_ADMIN) + /dev/input /dev/uinput CAP_SYS_PTRACE CAP_SYS_ADMIN + WH_KEYBOARD_LL WH_MOUSE_LL RIDEV_NOLEGACY RIDEV_CAPTUREMOUSE + RIDEV_NOHOTKEYS RIDEV_APPKEYS RIDEV_EXCLUDE RIDEV_EXINPUTSINK) set(FAILURES "") foreach(FILE IN LISTS PRODUCTION_FILES) @@ -57,9 +61,11 @@ endforeach() # Intentional sensitive capabilities remain confined to reviewed modules. set(SENSITIVE_RULES - "SetWindowsHookEx|src/platform/windows/windows_input.c" "GetAsyncKeyState|src/platform/windows/windows_keys.c" - "DirectInput8Create|src/platform/windows/windows_direct_input.c" + "RegisterRawInputDevices|src/platform/windows/windows_input_registration.c" + "GetRegisteredRawInputDevices|src/platform/windows/windows_input_registration.c" + "GetRawInputData|src/platform/windows/windows_input_receiver.c" + "SDL_HINT_WINDOWS_RAW_KEYBOARD|src/runtime/shell/window.c|src/platform/windows/windows_input.c" "OpenProcessToken|src/platform/windows/windows_diagnostics.c" "BitBlt|src/platform/windows/windows_capture_probe.c" "PrintWindow|src/platform/windows/windows_capture_probe.c" diff --git a/cmake/PlatformRuntime.cmake b/cmake/PlatformRuntime.cmake index e2a25163..ad77e709 100644 --- a/cmake/PlatformRuntime.cmake +++ b/cmake/PlatformRuntime.cmake @@ -12,8 +12,13 @@ if(WIN32) src/platform/windows/windows_transparency.c src/platform/windows/windows_pointer.c src/platform/windows/windows_dialog.c - src/platform/windows/windows_direct_input.c src/platform/windows/windows_input.c + src/platform/windows/windows_input_receiver.c + src/platform/windows/windows_input_registration.c + src/platform/windows/windows_input_devices.c + src/platform/windows/windows_input_packets.c + src/platform/windows/windows_input_relative.c + src/platform/windows/windows_input_diagnostics.c src/platform/windows/windows_popup.c src/platform/windows/windows_layered.c src/platform/windows/windows_layered_state.c @@ -25,7 +30,7 @@ if(WIN32) target_include_directories(bongo_cat_runtime PRIVATE src/platform/windows) target_link_libraries(bongo_cat_runtime PRIVATE - dinput8 dxguid dwmapi ole32 shell32 user32 uuid windowscodecs advapi32 + dwmapi ole32 shell32 user32 uuid windowscodecs advapi32 winhttp) elseif(APPLE) find_package(CURL REQUIRED) diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index 71c68f5f..cec00f91 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -87,6 +87,17 @@ if(BUILD_TESTING) endif() if(WIN32) + add_executable(bongo_cat_windows_input_tests + tests/platform/test_windows_input.c + tests/platform/test_windows_relative.c + tests/platform/test_windows_raw_receiver.c) + target_include_directories(bongo_cat_windows_input_tests PRIVATE + tests/support src/platform/windows src/runtime/input) + target_link_libraries(bongo_cat_windows_input_tests PRIVATE + bongo_cat_runtime bongo_cat_warnings user32) + add_test(NAME windows-input COMMAND bongo_cat_windows_input_tests) + set_tests_properties(windows-input PROPERTIES TIMEOUT 30) + add_executable(bongo_cat_windows_capture_tests tests/platform/test_windows_capture.c) target_include_directories(bongo_cat_windows_capture_tests PRIVATE diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index 62a18497..40b86632 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -20,6 +20,14 @@ typedef struct BongoCatUpdateService BongoCatUpdateService; #define BONGO_CAT_MODEL_COVER_PENDING_CAP 8 +typedef struct BongoCatPointerDiagnostics { + uint64_t last_log_ns, mode_changes, mapped_updates; + uint64_t relative_motion, relative_waits, clamped_x, clamped_y; + uint64_t map_failures; + BongoCatMverPointerBounds bounds; + bool bounds_known; +} BongoCatPointerDiagnostics; + typedef struct BongoCatApp { BongoCatSettings settings; BongoCatSessionState session; @@ -128,16 +136,14 @@ typedef struct BongoCatApp { uint64_t pointer_hit_deadline_ns; uint64_t display_recovery_due_ns; uint64_t mouse_last_ns; - uint64_t mouse_diagnostic_due_ns; - uint64_t mouse_hook_samples; - uint64_t input_diagnostic_due_ns; + BongoCatPointerDiagnostics pointer_diagnostics; + uint64_t mouse_native_samples; uint64_t input_events_consumed; uint64_t input_key_down_events, input_key_up_events; uint64_t input_mouse_down_events, input_mouse_up_events; uint64_t input_key_supported, input_key_unsupported; uint64_t input_mouse_applied, input_mouse_updates; uint64_t input_shortcuts_triggered; - char input_last_name[BONGO_CAT_ID_CAP]; uint64_t frame_audit_bmp_ns; uint64_t random_expression_due_ns; float random_expression_interval_seconds; @@ -158,6 +164,7 @@ typedef struct BongoCatApp { bool left_mouse_down; bool right_mouse_down; bool side_mouse_down; + bool back_mouse_down, forward_mouse_down; bool mouse_button_event_pending; bool model_pointer_anchor_ready; float model_pointer_anchor_x, model_pointer_anchor_y; diff --git a/include/bongo_cat/mouse.h b/include/bongo_cat/mouse.h index f1708d5b..b78ce9f3 100644 --- a/include/bongo_cat/mouse.h +++ b/include/bongo_cat/mouse.h @@ -2,6 +2,7 @@ #define BONGO_CAT_MOUSE_H #include +#include typedef struct BongoCatMouseTracking { double target_x; @@ -12,6 +13,7 @@ typedef struct BongoCatMouseTracking { bool settled; } BongoCatMouseTracking; +bool bongo_cat_mouse_sample_fresh(uint64_t now_ns, uint64_t sample_ns); void bongo_cat_mouse_target(BongoCatMouseTracking *tracking, double x, double y); bool bongo_cat_mouse_step(BongoCatMouseTracking *tracking, float delta_seconds, double *x, double *y); diff --git a/include/bongo_cat/platform.h b/include/bongo_cat/platform.h index 2cd8bbf1..3fb29435 100644 --- a/include/bongo_cat/platform.h +++ b/include/bongo_cat/platform.h @@ -13,8 +13,6 @@ typedef struct BongoCatPlatform { BongoCatInputState *input; void *native; void *presenter; - void *relative_pointer; - uint64_t relative_pointer_retry_ms; uint32_t wake_event_type; float window_opacity; } BongoCatPlatform; diff --git a/src/core/app_state.c b/src/core/app_state.c index 9a5437bb..aa56155f 100644 --- a/src/core/app_state.c +++ b/src/core/app_state.c @@ -159,8 +159,11 @@ void bongo_cat_app_apply_input(BongoCatApp *app, const BongoCatInputEvent *event changed = app->right_mouse_down != down; app->right_mouse_down = down; } else { - changed = app->side_mouse_down != down; - app->side_mouse_down = down; + if (strcmp(event->name, "Back") == 0) app->back_mouse_down = down; + else app->forward_mouse_down = down; + bool side_down = app->back_mouse_down || app->forward_mouse_down; + changed = app->side_mouse_down != side_down; + app->side_mouse_down = side_down; } if (changed) app->mouse_button_event_pending = true; if (!down) app->pointer_hit_dirty = true; diff --git a/src/core/mouse_tracking.c b/src/core/mouse_tracking.c index 2b281181..d8fa7ced 100644 --- a/src/core/mouse_tracking.c +++ b/src/core/mouse_tracking.c @@ -2,6 +2,11 @@ #include +bool bongo_cat_mouse_sample_fresh(uint64_t now_ns, uint64_t sample_ns) { + return sample_ns && now_ns >= sample_ns && + now_ns - sample_ns < 100000000ull; +} + void bongo_cat_mouse_target(BongoCatMouseTracking *tracking, double x, double y) { if (!tracking) return; tracking->target_x = x; diff --git a/src/platform/windows/windows.c b/src/platform/windows/windows.c index f55cf08e..e5f36a5a 100644 --- a/src/platform/windows/windows.c +++ b/src/platform/windows/windows.c @@ -1,7 +1,6 @@ #include "bongo_cat/platform.h" #include "windows_borderless.h" #include "windows_capture.h" -#include "windows_direct_input.h" #include "windows_input.h" #include "windows_layered.h" #include "windows_startup.h" @@ -58,7 +57,7 @@ BongoCatResult bongo_cat_platform_init(BongoCatPlatform *platform, SDL_Window *w } if (!bongo_cat_windows_input_start(platform)) { SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "Global input hooks are unavailable; the window will continue without global input"); + "Raw Input is unavailable; the window will continue without global input"); } HWND hwnd = native_window(platform); SetWindowTextW(hwnd, bongo_cat_windows_instance_title()); @@ -85,7 +84,6 @@ void bongo_cat_platform_shutdown(BongoCatPlatform *platform) { if (!platform) return; HWND window = native_window(platform); if (window) bongo_cat_windows_borderless_uninstall(window); - bongo_cat_windows_direct_input_destroy(platform); bongo_cat_windows_input_stop(platform); bongo_cat_windows_layered_destroy(platform); SDL_SetWindowsMessageHook(NULL, NULL); @@ -147,34 +145,20 @@ bool bongo_cat_platform_pointer_locked(BongoCatPlatform *platform) { if (!foreground || !GetWindowThreadProcessId(foreground, &foreground_pid) || foreground_pid == GetCurrentProcessId()) return false; RECT clip = {0}; - if (!GetClipCursor(&clip)) return false; - LONG width = clip.right - clip.left; - LONG height = clip.bottom - clip.top; - return width >= 0 && width <= 2 && height >= 0 && height <= 2; + CURSORINFO cursor = {.cbSize = sizeof(cursor)}; + bool clip_known = GetClipCursor(&clip) != FALSE; + bool cursor_known = GetCursorInfo(&cursor) != FALSE; + return bongo_cat_windows_input_relative_mode(true, + clip_known ? &clip : NULL, cursor_known ? &cursor : NULL); } bool bongo_cat_platform_relative_pointer(BongoCatPlatform *platform, double *x, double *y) { - if (!platform || !x || !y) return false; - uint64_t now_ms = GetTickCount64(); - if (!platform->relative_pointer) { - if (platform->relative_pointer_retry_ms > now_ms) - return bongo_cat_windows_input_take_relative(platform, x, y); - if (!bongo_cat_windows_direct_input_create(platform, - native_window(platform))) { - platform->relative_pointer_retry_ms = now_ms + 5000; - return bongo_cat_windows_input_take_relative(platform, x, y); - } - platform->relative_pointer_retry_ms = 0; - } - return bongo_cat_windows_direct_input_read(platform, x, y); + return bongo_cat_windows_input_take_relative(platform, x, y, NULL); } void bongo_cat_platform_relative_pointer_reset(BongoCatPlatform *platform) { bongo_cat_windows_input_reset_relative(platform); - bongo_cat_windows_direct_input_reset(platform); } void bongo_cat_platform_relative_pointer_release(BongoCatPlatform *platform) { - if (!platform) return; - bongo_cat_windows_direct_input_destroy(platform); - platform->relative_pointer_retry_ms = 0; + bongo_cat_windows_input_release_relative(platform); } #endif diff --git a/src/platform/windows/windows_direct_input.c b/src/platform/windows/windows_direct_input.c deleted file mode 100644 index b99aa1af..00000000 --- a/src/platform/windows/windows_direct_input.c +++ /dev/null @@ -1,305 +0,0 @@ -#include "windows_direct_input.h" -#include "windows_input.h" -#include "bongo_cat/file.h" - -#ifdef _WIN32 -#ifndef DIRECTINPUT_VERSION -#define DIRECTINPUT_VERSION 0x0800 -#endif -#include -#include -#include -#include -#include - -typedef struct BongoCatDirectInput { - LPDIRECTINPUT8A input; - LPDIRECTINPUTDEVICE8A mouse; - POINT absolute; - bool absolute_ready; - bool rebase_pending; - ULONGLONG last_read_ms; - FILE *audit; - unsigned long long reads; - unsigned long long reacquires; - unsigned long long failures; - long long total_x; - long long total_y; - unsigned long long buffered_events; - unsigned long long buffer_overflows; - unsigned long long hook_fallback_samples; - long long hook_total_x; - long long hook_total_y; - ULONGLONG last_log_ms; - bool diagnostic_ready; -} BongoCatDirectInput; - -static HRESULT read_relative_data(BongoCatDirectInput *state, - DIDEVICEOBJECTDATA *events, DWORD *event_count, long long *x, - long long *y, bool *overflow) { - *x = 0; *y = 0; *overflow = false; - HRESULT result = state->mouse->lpVtbl->GetDeviceData(state->mouse, - sizeof(events[0]), events, event_count, 0); - if (result != DIERR_NOTBUFFERED) { - if (SUCCEEDED(result)) { - *overflow = result == DI_BUFFEROVERFLOW; - for (DWORD index = 0; index < *event_count; ++index) { - if (events[index].dwOfs == (DWORD)DIMOFS_X) - *x += (LONG)events[index].dwData; - else if (events[index].dwOfs == (DWORD)DIMOFS_Y) - *y += (LONG)events[index].dwData; - } - } - return result; - } - /* A few virtual mouse drivers accept DIPROP_BUFFERSIZE but still expose - only the immediate state. Keep those drivers usable. */ - *event_count = 0; - DIMOUSESTATE mouse = {0}; - result = state->mouse->lpVtbl->GetDeviceState(state->mouse, - sizeof(mouse), &mouse); - if (SUCCEEDED(result)) { - *x = mouse.lX; - *y = mouse.lY; - } - return result; -} - -void bongo_cat_windows_direct_input_destroy(BongoCatPlatform *platform) { - BongoCatDirectInput *state = platform ? platform->relative_pointer : NULL; - if (!state) return; - if (state->mouse) { - state->mouse->lpVtbl->Unacquire(state->mouse); - state->mouse->lpVtbl->Release(state->mouse); - } - if (state->input) state->input->lpVtbl->Release(state->input); - if (state->reads) SDL_Log("[input] Relative pointer stopped: reads=%llu " - "reacquires=%llu failures=%llu buffered_events=%llu overflows=%llu " - "hook_fallback_samples=%llu hook_total=%lld,%lld total=%lld,%lld", - state->reads, state->reacquires, state->failures, - state->buffered_events, state->buffer_overflows, - state->hook_fallback_samples, state->hook_total_x, state->hook_total_y, - state->total_x, state->total_y); - if (state->audit) { - fprintf(state->audit, - "summary reads=%llu reacquires=%llu failures=%llu " - "buffered_events=%llu buffer_overflows=%llu " - "hook_fallback_samples=%llu hook_total_x=%lld hook_total_y=%lld " - "total_x=%lld total_y=%lld\n", - state->reads, state->reacquires, state->failures, - state->buffered_events, state->buffer_overflows, - state->hook_fallback_samples, state->hook_total_x, - state->hook_total_y, - state->total_x, state->total_y); - fclose(state->audit); - } - free(state); - platform->relative_pointer = NULL; -} - -void bongo_cat_windows_direct_input_reset(BongoCatPlatform *platform) { - BongoCatDirectInput *state = platform ? platform->relative_pointer : NULL; - if (!state) return; - bongo_cat_windows_input_reset_relative(platform); - double x = 0.0, y = 0.0; - bongo_cat_windows_direct_input_read(platform, &x, &y); - state->rebase_pending = true; - bongo_cat_windows_input_reset_relative(platform); -} - -bool bongo_cat_windows_direct_input_create(BongoCatPlatform *platform, - void *window) { - if (!platform || !window) return false; - BongoCatDirectInput *state = calloc(1, sizeof(*state)); - if (!state) { - if (!platform->relative_pointer_retry_ms) - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, - "[input] Relative pointer initialization failed: stage=allocate"); - return false; - } - HRESULT result = DirectInput8Create(GetModuleHandleW(NULL), - DIRECTINPUT_VERSION, &IID_IDirectInput8A, (void **)&state->input, - NULL); - if (FAILED(result)) { - if (!platform->relative_pointer_retry_ms) - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "[input] Relative pointer initialization failed: " - "stage=DirectInput8Create result=0x%08lx", - (unsigned long)result); - free(state); - return false; - } - platform->relative_pointer = state; - const char *stage = "CreateDevice"; - result = state->input->lpVtbl->CreateDevice(state->input, - &GUID_SysMouse, &state->mouse, NULL); - if (SUCCEEDED(result)) { - stage = "SetCooperativeLevel"; - result = state->mouse->lpVtbl->SetCooperativeLevel(state->mouse, - (HWND)window, DISCL_NONEXCLUSIVE | DISCL_BACKGROUND); - } - if (SUCCEEDED(result)) { - stage = "SetDataFormat"; - result = state->mouse->lpVtbl->SetDataFormat(state->mouse, - &c_dfDIMouse); - } - /* Keep enough history for a few frames while a game is busy. The - consumer still drains the queue in one bounded read below. */ - DIPROPDWORD property = {{sizeof(property), sizeof(property.diph), - 0, DIPH_DEVICE}, 128}; - if (SUCCEEDED(result)) { - stage = "SetProperty"; - result = state->mouse->lpVtbl->SetProperty(state->mouse, - DIPROP_BUFFERSIZE, &property.diph); - } - if (SUCCEEDED(result)) { - stage = "Acquire"; - result = state->mouse->lpVtbl->Acquire(state->mouse); - } - if (SUCCEEDED(result)) { - state->absolute_ready = GetPhysicalCursorPos(&state->absolute) != FALSE; - state->rebase_pending = true; - state->last_read_ms = GetTickCount64(); - bongo_cat_windows_input_reset_relative(platform); - SDL_Log("[input] Relative pointer initialized: mode=DirectInput " - "cooperative=background-nonexclusive physical_cursor=%d", - state->absolute_ready); - const char *audit_path = getenv("BONGO_CAT_DIRECT_INPUT_AUDIT_FILE"); - if (audit_path && audit_path[0]) { - state->audit = bongo_cat_file_open(audit_path, "wb"); - if (state->audit) { - fprintf(state->audit, "direct_input initialized=1\n"); - fflush(state->audit); - } - } - return true; - } - if (!platform->relative_pointer_retry_ms) - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "[input] Relative pointer initialization failed: " - "stage=%s result=0x%08lx", stage, (unsigned long)result); - bongo_cat_windows_direct_input_destroy(platform); - return false; -} - -bool bongo_cat_windows_direct_input_read(BongoCatPlatform *platform, - double *x, double *y) { - BongoCatDirectInput *state = platform ? platform->relative_pointer : NULL; - if (!state || !state->mouse || !x || !y) return false; - ULONGLONG now_ms = GetTickCount64(); - bool stale = state->last_read_ms && now_ms - state->last_read_ms > 250; - state->last_read_ms = now_ms; - /* Use DirectInput's buffered event API. GetDeviceState can report a - zero delta when another application is consuming raw/relative mouse - input between our frames; buffered events preserve those deltas until - this process reads them. */ - DIDEVICEOBJECTDATA events[128] = {0}; - DWORD event_count = (DWORD)(sizeof(events) / sizeof(events[0])); - long long direct_x = 0, direct_y = 0; - bool buffer_overflow = false; - double hook_x = 0.0, hook_y = 0.0; - bool hook_delta = bongo_cat_windows_input_take_relative(platform, - &hook_x, &hook_y); - POINT absolute = {0}; - bool absolute_ready = GetPhysicalCursorPos(&absolute) != FALSE; - bool fallback_ready = absolute_ready && state->absolute_ready; - LONG fallback_x = fallback_ready ? absolute.x - state->absolute.x : 0; - LONG fallback_y = fallback_ready ? absolute.y - state->absolute.y : 0; - if (absolute_ready) { - state->absolute = absolute; - state->absolute_ready = true; - } else state->absolute_ready = false; - HRESULT result = read_relative_data(state, events, &event_count, - &direct_x, &direct_y, &buffer_overflow); - bool reacquired = false; - if (FAILED(result)) { - result = state->mouse->lpVtbl->Acquire(state->mouse); - reacquired = SUCCEEDED(result); - if (reacquired) { - event_count = (DWORD)(sizeof(events) / sizeof(events[0])); - result = read_relative_data(state, events, &event_count, - &direct_x, &direct_y, &buffer_overflow); - } - } - state->reads++; - if (reacquired) state->reacquires++; - bool sample_ready = SUCCEEDED(result); - if (sample_ready) { - if (buffer_overflow) state->buffer_overflows++; - state->buffered_events += event_count; - } - bool available = sample_ready || fallback_ready || hook_delta; - /* A delayed frame is not evidence that the input is invalid. In - particular, a game can keep moving/recentering the cursor while this - window is not scheduled for a frame. Only an explicit reset or a - complete loss of both sources should rebase the virtual pointer. */ - bool rebase = state->rebase_pending || - (!sample_ready && !fallback_ready && !hook_delta); - bool direct_zero = direct_x == 0 && direct_y == 0; - bool physical_delta = fallback_x != 0 || fallback_y != 0; - bool use_hook = hook_delta && (!sample_ready || direct_zero || - buffer_overflow); - bool use_physical = fallback_ready && (!sample_ready || - (physical_delta && (direct_zero || buffer_overflow)) || - (direct_zero && reacquired)); - bool use_fallback = use_hook || (!hook_delta && use_physical); - if (rebase) { - *x = 0.0; - *y = 0.0; - } else if (sample_ready && !use_fallback) { - *x = (double)direct_x; - *y = (double)direct_y; - } else if (use_hook) { - *x = hook_x; - *y = hook_y; - } else if (fallback_ready) { - *x = (double)fallback_x; - *y = (double)fallback_y; - } else { - *x = 0.0; - *y = 0.0; - } - state->total_x += (long long)*x; - state->total_y += (long long)*y; - if (use_hook) { - state->hook_fallback_samples++; - state->hook_total_x += (long long)*x; - state->hook_total_y += (long long)*y; - } - state->rebase_pending = !available; - if (!sample_ready) state->failures++; - bool log_due = !state->diagnostic_ready || - now_ms - state->last_log_ms >= 30000; - if (log_due) { - SDL_Log("[input] Relative pointer sample: result=0x%08lx " - "sample=%d reacquired=%d stale=%d physical=%d fallback=%d " - "rebase=%d direct=%lld,%lld physical_delta=%ld,%ld " - "hook_delta=%.0f,%.0f hook=%d events=%lu overflow=%d " - "output=%.0f,%.0f reads=%llu " - "failures=%llu reacquires=%llu", - (unsigned long)result, sample_ready, reacquired, stale, - fallback_ready, use_fallback, rebase, - direct_x, direct_y, - (long)fallback_x, (long)fallback_y, - hook_x, hook_y, use_hook, - (unsigned long)event_count, buffer_overflow, *x, *y, - state->reads, state->failures, state->reacquires); - state->last_log_ms = now_ms; - } - state->diagnostic_ready = true; - if (state->audit) { - fprintf(state->audit, - "read=%llu result=0x%08lx reacquired=%d stale=%d rebase=%d " - "fallback=%d hook=%.0f,%.0f use_hook=%d events=%lu overflow=%d " - "dx=%.0f dy=%.0f " - "total_x=%lld total_y=%lld\n", - state->reads, (unsigned long)result, reacquired, stale, rebase, - use_fallback, hook_x, hook_y, use_hook, - (unsigned long)event_count, buffer_overflow, - *x, *y, - state->total_x, state->total_y); - fflush(state->audit); - } - return available && !rebase; -} -#endif diff --git a/src/platform/windows/windows_direct_input.h b/src/platform/windows/windows_direct_input.h deleted file mode 100644 index 2bd9eaa7..00000000 --- a/src/platform/windows/windows_direct_input.h +++ /dev/null @@ -1,13 +0,0 @@ -#ifndef BONGO_CAT_WINDOWS_DIRECT_INPUT_H -#define BONGO_CAT_WINDOWS_DIRECT_INPUT_H - -#include "bongo_cat/platform.h" - -bool bongo_cat_windows_direct_input_create(BongoCatPlatform *platform, - void *window); -bool bongo_cat_windows_direct_input_read(BongoCatPlatform *platform, - double *x, double *y); -void bongo_cat_windows_direct_input_destroy(BongoCatPlatform *platform); -void bongo_cat_windows_direct_input_reset(BongoCatPlatform *platform); - -#endif diff --git a/src/platform/windows/windows_input.c b/src/platform/windows/windows_input.c index 15b9e775..00198d18 100644 --- a/src/platform/windows/windows_input.c +++ b/src/platform/windows/windows_input.c @@ -1,342 +1,165 @@ -#include "windows_input.h" -#include "windows_keys.h" +#include "windows_input_internal.h" #ifdef _WIN32 #include #include #include -#include +#include -typedef struct WindowsInputState { - BongoCatPlatform *platform; - SRWLOCK platform_lock; - SRWLOCK relative_lock; - HANDLE thread; - HANDLE stop; - HHOOK keyboard; - HHOOK mouse; - BongoCatWindowsKeyboard keyboard_state; - UINT test_drop_key_up; - DWORD test_start_delay_ms; - DWORD keyboard_hook_error, mouse_hook_error; - unsigned long long keyboard_events, mouse_moves, mouse_buttons; - unsigned long long keyboard_emitted, keyboard_ignored; - unsigned long long keyboard_queue_failures; - unsigned long long mouse_button_queue_failures; - unsigned long long reported_keyboard_events, reported_mouse_moves; - unsigned long long reported_mouse_buttons; - ULONGLONG last_diagnostic_ms; - bool diagnostic_ready; - POINT last_mouse; - DWORD last_mouse_flags; - bool last_mouse_known; - DWORD last_key_code, last_key_message, last_key_flags; - char last_key_name[16]; - bool last_key_known; - POINT relative_mouse; - bool relative_mouse_known; - long long relative_x, relative_y; - unsigned long long relative_samples, relative_resets; -} WindowsInputState; +static volatile LONG receiver_claimed; -static WindowsInputState *global_state; - -static void wake_main_thread(WindowsInputState *state) { - if (!state || !state->platform) return; - SDL_Event wake = {0}; - wake.type = state->platform->wake_event_type; - SDL_PushEvent(&wake); +void bongo_cat_windows_input_wake(WindowsInputState *state) { + if (!state->wake_pending) return; + AcquireSRWLockShared(&state->platform_lock); + BongoCatPlatform *platform = state->platform; + if (platform && platform->wake_event_type >= SDL_EVENT_USER) { + SDL_Event wake; + SDL_zero(wake); + wake.type = platform->wake_event_type; + if (SDL_PushEvent(&wake)) state->wake_pending = false; + } + ReleaseSRWLockShared(&state->platform_lock); } -static bool push_event(BongoCatInputKind kind, const char *name, float value) { - WindowsInputState *state = global_state; +bool bongo_cat_windows_input_push_event(WindowsInputState *state, + BongoCatInputKind kind, const char *name, float value) { if (!state || !name) return false; AcquireSRWLockShared(&state->platform_lock); BongoCatPlatform *platform = state->platform; - if (!platform) { - ReleaseSRWLockShared(&state->platform_lock); - return false; + bool pushed = false; + if (platform) { + BongoCatInputEvent event = {0}; + event.kind = kind; + event.timestamp_ms = SDL_GetTicks(); + event.value = value; + snprintf(event.name, sizeof(event.name), "%s", name); + pushed = bongo_cat_input_push(platform->input, &event); + if (!pushed) state->counters.queue_failures++; } - BongoCatInputEvent event = {0}; - event.kind = kind; - event.timestamp_ms = GetTickCount64(); - event.value = value; - snprintf(event.name, sizeof(event.name), "%s", name); - bool pushed = bongo_cat_input_push(platform->input, &event); - if (pushed) wake_main_thread(state); ReleaseSRWLockShared(&state->platform_lock); + if (pushed) state->wake_pending = true; return pushed; } -static void emit_key(bool down, const char *name, void *userdata) { - (void)userdata; - WindowsInputState *state = global_state; - bool pushed = push_event(down ? BONGO_CAT_INPUT_KEY_DOWN : - BONGO_CAT_INPUT_KEY_UP, name, down ? 1.0f : 0.0f); - if (!state) return; - if (pushed) state->keyboard_emitted++; - else state->keyboard_queue_failures++; -} - -static LRESULT CALLBACK keyboard_hook(int code, WPARAM message, LPARAM data) { - WindowsInputState *state = global_state; - if (code == HC_ACTION && state && data) { - const KBDLLHOOKSTRUCT *key = (const KBDLLHOOKSTRUCT *)data; - state->keyboard_events++; - char key_name[16]; - const char *mapped_name = bongo_cat_windows_key_name(key, key_name); - state->last_key_code = key->vkCode; - state->last_key_message = (DWORD)message; - state->last_key_flags = key->flags; - state->last_key_known = mapped_name != NULL; - snprintf(state->last_key_name, sizeof(state->last_key_name), "%s", - mapped_name ? mapped_name : "none"); - bool emitted = bongo_cat_windows_keyboard_event(&state->keyboard_state, - key, message, - &state->test_drop_key_up, emit_key, NULL); - if (!emitted) state->keyboard_ignored++; - } - return CallNextHookEx(NULL, code, message, data); -} - -static const char *mouse_button(WPARAM message, DWORD mouse_data) { - switch (message) { - case WM_LBUTTONDOWN: case WM_LBUTTONUP: return "Left"; - case WM_RBUTTONDOWN: case WM_RBUTTONUP: return "Right"; - case WM_MBUTTONDOWN: case WM_MBUTTONUP: return "Middle"; - case WM_XBUTTONDOWN: case WM_XBUTTONUP: - return HIWORD(mouse_data) == XBUTTON1 ? "Back" : "Forward"; - default: return NULL; - } -} - -static LRESULT CALLBACK mouse_hook(int code, WPARAM message, LPARAM data) { - WindowsInputState *state = global_state; - if (code == HC_ACTION && state && data) { - const MSLLHOOKSTRUCT *mouse = (const MSLLHOOKSTRUCT *)data; - AcquireSRWLockExclusive(&state->relative_lock); - if (message == WM_MOUSEMOVE) { - if (state->relative_mouse_known) { - state->relative_x += (long long)mouse->pt.x - - (long long)state->relative_mouse.x; - state->relative_y += (long long)mouse->pt.y - - (long long)state->relative_mouse.y; - state->relative_samples++; - } - state->relative_mouse = mouse->pt; - state->relative_mouse_known = true; - } - state->last_mouse = mouse->pt; - state->last_mouse_flags = mouse->flags; - state->last_mouse_known = true; - ReleaseSRWLockExclusive(&state->relative_lock); - if (message == WM_MOUSEMOVE) { - state->mouse_moves++; - AcquireSRWLockShared(&state->platform_lock); - BongoCatPlatform *platform = state->platform; - if (platform && bongo_cat_input_mouse(platform->input, - mouse->pt.x, mouse->pt.y)) wake_main_thread(state); - ReleaseSRWLockShared(&state->platform_lock); - } else { - const char *name = mouse_button(message, mouse->mouseData); - bool down = message == WM_LBUTTONDOWN || message == WM_RBUTTONDOWN || - message == WM_MBUTTONDOWN || message == WM_XBUTTONDOWN; - if (name) { - state->mouse_buttons++; - if (!push_event(down ? BONGO_CAT_INPUT_MOUSE_DOWN : - BONGO_CAT_INPUT_MOUSE_UP, name, down ? 1.0f : 0.0f)) - state->mouse_button_queue_failures++; - } - } - } - return CallNextHookEx(NULL, code, message, data); +static bool input_desktop_available(void) { + HDESK active = OpenInputDesktop(0, FALSE, DESKTOP_READOBJECTS); + if (!active) return false; + wchar_t active_name[256] = {0}, receiver_name[256] = {0}; + DWORD needed = 0; + bool available = GetUserObjectInformationW(active, UOI_NAME, + active_name, sizeof(active_name), &needed) && + GetUserObjectInformationW(GetThreadDesktop(GetCurrentThreadId()), + UOI_NAME, receiver_name, sizeof(receiver_name), &needed) && + wcscmp(active_name, receiver_name) == 0; + CloseDesktop(active); + return available; } -static void dispatch_messages(void) { - MSG message; - while (PeekMessageW(&message, NULL, 0, 0, PM_REMOVE)) { - TranslateMessage(&message); - DispatchMessageW(&message); +static void check_receiver(WindowsInputState *state) { + unsigned ownership = bongo_cat_windows_input_ownership(state); + if (state->registration_error) ownership = state->ownership; + bool unavailable = !input_desktop_available(); + if (ownership != state->ownership || unavailable != state->desktop_unavailable) { + if (ownership != state->ownership || unavailable) + bongo_cat_windows_input_clear_devices(state); + else bongo_cat_windows_input_clear_motion(state); + if (unavailable != state->desktop_unavailable) + state->counters.desktop_resets++; + SDL_Log("[input] Raw Input receiver: ownership=%u error=%lu desktop_available=%d", + ownership, (unsigned long)state->registration_error, !unavailable); + state->ownership = ownership; + state->desktop_unavailable = unavailable; } -} - -static void log_input_summary(WindowsInputState *state, ULONGLONG now_ms) { - RECT clip = {0}; - bool clip_known = GetClipCursor(&clip) != FALSE; - RECT desktop = { - GetSystemMetrics(SM_XVIRTUALSCREEN), - GetSystemMetrics(SM_YVIRTUALSCREEN), 0, 0 - }; - desktop.right = desktop.left + GetSystemMetrics(SM_CXVIRTUALSCREEN); - desktop.bottom = desktop.top + GetSystemMetrics(SM_CYVIRTUALSCREEN); - bool confined = clip_known && (clip.left > desktop.left || - clip.top > desktop.top || clip.right < desktop.right || - clip.bottom < desktop.bottom); - HWND foreground = GetForegroundWindow(); - DWORD foreground_pid = 0; - if (foreground) GetWindowThreadProcessId(foreground, &foreground_pid); - POINT physical = {0}; - bool physical_known = GetPhysicalCursorPos(&physical) != FALSE; - CURSORINFO cursor = {.cbSize = sizeof(cursor)}; - bool cursor_known = GetCursorInfo(&cursor) != FALSE; - bool cursor_visible = !cursor_known || - (cursor.flags & CURSOR_SHOWING) != 0; - unsigned long long key_delta = state->keyboard_events - - state->reported_keyboard_events; - unsigned long long move_delta = state->mouse_moves - - state->reported_mouse_moves; - unsigned long long button_delta = state->mouse_buttons - - state->reported_mouse_buttons; - bool active = confined || !cursor_visible || key_delta || - move_delta || button_delta; - ULONGLONG interval_ms = active ? 30000 : 60000; - if (state->diagnostic_ready && - now_ms - state->last_diagnostic_ms < interval_ms) return; - long long relative_x, relative_y; - unsigned long long relative_samples; - AcquireSRWLockShared(&state->relative_lock); - relative_x = state->relative_x; - relative_y = state->relative_y; - relative_samples = state->relative_samples; - ReleaseSRWLockShared(&state->relative_lock); - SDL_Log("[input] Windows hooks: keyboard=%d mouse=%d " - "key_events=%llu(+%llu) mouse_moves=%llu(+%llu) " - "mouse_buttons=%llu(+%llu) last_known=%d last=%ld,%ld flags=0x%lx " - "last_key_known=%d last_key=%s code=%lu message=%lu flags=0x%lx " - "key_emitted=%llu key_ignored=%llu key_queue_failures=%llu " - "mouse_button_queue_failures=%llu relative_pending=%lld,%lld " - "relative_samples=%llu " - "physical_known=%d physical=%ld,%ld cursor_known=%d cursor_visible=%d " - "cursor_confined=%d clip_known=%d clip=%ld,%ld,%ld,%ld " - "desktop=%ld,%ld,%ld,%ld foreground_pid=%lu own_foreground=%d", - state->keyboard != NULL, state->mouse != NULL, - state->keyboard_events, key_delta, state->mouse_moves, move_delta, - state->mouse_buttons, button_delta, - state->last_mouse_known, (long)state->last_mouse.x, - (long)state->last_mouse.y, (unsigned long)state->last_mouse_flags, - state->last_key_known, state->last_key_name[0] ? state->last_key_name : "none", - (unsigned long)state->last_key_code, - (unsigned long)state->last_key_message, - (unsigned long)state->last_key_flags, - state->keyboard_emitted, state->keyboard_ignored, - state->keyboard_queue_failures, state->mouse_button_queue_failures, - relative_x, relative_y, relative_samples, - physical_known, - (long)physical.x, (long)physical.y, - cursor_known, cursor_visible, - confined, clip_known, - (long)clip.left, (long)clip.top, (long)clip.right, (long)clip.bottom, - (long)desktop.left, (long)desktop.top, - (long)desktop.right, (long)desktop.bottom, - (unsigned long)foreground_pid, - foreground_pid == GetCurrentProcessId()); - state->reported_keyboard_events = state->keyboard_events; - state->reported_mouse_moves = state->mouse_moves; - state->reported_mouse_buttons = state->mouse_buttons; - state->last_diagnostic_ms = now_ms; - state->diagnostic_ready = true; + AcquireSRWLockExclusive(&state->relative_lock); + /* Desktop inspection may be denied even when valid WM_INPUT still arrives. */ + state->receiving = (ownership & 1u) != 0; + ReleaseSRWLockExclusive(&state->relative_lock); } static DWORD WINAPI input_thread(void *context) { WindowsInputState *state = context; - if (state->test_start_delay_ms && WaitForSingleObject(state->stop, - state->test_start_delay_ms) == WAIT_OBJECT_0) return 0; - if (WaitForSingleObject(state->stop, 0) == WAIT_OBJECT_0) return 0; - global_state = state; - SetLastError(ERROR_SUCCESS); - state->keyboard = SetWindowsHookExW(WH_KEYBOARD_LL, keyboard_hook, NULL, 0); - if (!state->keyboard) state->keyboard_hook_error = GetLastError(); - SetLastError(ERROR_SUCCESS); - state->mouse = SetWindowsHookExW(WH_MOUSE_LL, mouse_hook, NULL, 0); - if (!state->mouse) state->mouse_hook_error = GetLastError(); - SDL_Log("[input] Windows hook installation: keyboard=%d error=%lu " - "mouse=%d error=%lu", state->keyboard != NULL, - (unsigned long)state->keyboard_hook_error, state->mouse != NULL, - (unsigned long)state->mouse_hook_error); - ULONGLONG last_reconcile_ms = GetTickCount64(); - ULONGLONG next_diagnostic_probe_ms = last_reconcile_ms; - for (;;) { - DWORD wait = MsgWaitForMultipleObjects(1, &state->stop, FALSE, 25, - QS_ALLINPUT); + if (WaitForSingleObject(state->stop, state->test_start_delay_ms) == WAIT_OBJECT_0) { + SetEvent(state->ready); + return 0; + } + state->registered = bongo_cat_windows_input_receiver_create(state); + SDL_Log("[input] Windows input: diagnostics=raw-input-v3 backend=raw-input " + "registered=%d error=%lu background=INPUTSINK device_notify=1 legacy=enabled", + state->registered, (unsigned long)state->startup_error); + if (state->registered) { + state->ownership = 3; + check_receiver(state); + } + SetEvent(state->ready); + ULONGLONG last_check = GetTickCount64(), last_wake = 0; + while (state->registered) { + DWORD wait = MsgWaitForMultipleObjectsEx(1, &state->stop, 16, + QS_ALLINPUT, MWMO_INPUTAVAILABLE); if (wait == WAIT_OBJECT_0) break; - if (wait == WAIT_OBJECT_0 + 1) dispatch_messages(); - else if (wait == WAIT_FAILED) { - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, - "Windows input thread wait failed"); + if (wait == WAIT_FAILED) { + SDL_LogError(SDL_LOG_CATEGORY_INPUT, + "[input] Raw Input wait failed: error=%lu", (unsigned long)GetLastError()); break; } - ULONGLONG now_ms = GetTickCount64(); - if (now_ms >= next_diagnostic_probe_ms) { - log_input_summary(state, now_ms); - next_diagnostic_probe_ms = now_ms + 1000; + if (!bongo_cat_windows_input_dispatch(state)) break; + ULONGLONG now = GetTickCount64(); + if (now - last_check >= 1000) { + check_receiver(state); + last_check = now; } - if (now_ms - last_reconcile_ms >= 25) { - bongo_cat_windows_keyboard_reconcile(&state->keyboard_state, - now_ms, emit_key, NULL); - last_reconcile_ms = now_ms; + bongo_cat_windows_input_flush(state); + if (now - last_wake >= 8) { + bongo_cat_windows_input_wake(state); + last_wake = now; } + bongo_cat_windows_input_log(state, now); } - state->diagnostic_ready = false; - log_input_summary(state, GetTickCount64()); - if (state->keyboard) UnhookWindowsHookEx(state->keyboard); - if (state->mouse) UnhookWindowsHookEx(state->mouse); - global_state = NULL; - return 0; -} - -bool bongo_cat_windows_input_take_relative(BongoCatPlatform *platform, - double *x, double *y) { - WindowsInputState *state = platform ? platform->native : NULL; - if (!state || !x || !y) return false; AcquireSRWLockExclusive(&state->relative_lock); - long long relative_x = state->relative_x; - long long relative_y = state->relative_y; - state->relative_x = 0; - state->relative_y = 0; + state->receiving = false; ReleaseSRWLockExclusive(&state->relative_lock); - *x = (double)relative_x; - *y = (double)relative_y; - return relative_x != 0 || relative_y != 0; + bongo_cat_windows_input_clear_devices(state); + bongo_cat_windows_input_wake(state); + state->diagnostic_ready = false; + bongo_cat_windows_input_log(state, GetTickCount64()); + bongo_cat_windows_input_receiver_destroy(state); + return 0; } -void bongo_cat_windows_input_reset_relative(BongoCatPlatform *platform) { - WindowsInputState *state = platform ? platform->native : NULL; - if (!state) return; - AcquireSRWLockExclusive(&state->relative_lock); - state->relative_x = 0; - state->relative_y = 0; - state->relative_mouse_known = state->last_mouse_known; - if (state->last_mouse_known) state->relative_mouse = state->last_mouse; - state->relative_resets++; - ReleaseSRWLockExclusive(&state->relative_lock); +static void free_state(WindowsInputState *state) { + if (state->thread) CloseHandle(state->thread); + if (state->ready) CloseHandle(state->ready); + if (state->stop) CloseHandle(state->stop); + free(state); + InterlockedExchange(&receiver_claimed, 0); } bool bongo_cat_windows_input_start(BongoCatPlatform *platform) { - if (!platform || SDL_getenv("BONGO_CAT_TEST_HOOK_FAILURE")) return false; + if (!platform || !platform->input || platform->native) return false; + if (InterlockedCompareExchange(&receiver_claimed, 1, 0) != 0) return false; + /* SDL retains legacy keyboard messages; this receiver owns Raw Input. */ + SDL_SetHintWithPriority(SDL_HINT_WINDOWS_RAW_KEYBOARD, "0", SDL_HINT_OVERRIDE); WindowsInputState *state = calloc(1, sizeof(*state)); - if (!state) return false; + if (!state) { InterlockedExchange(&receiver_claimed, 0); return false; } InitializeSRWLock(&state->platform_lock); InitializeSRWLock(&state->relative_lock); state->platform = platform; - const char *drop_key_up = SDL_getenv("BONGO_CAT_TEST_DROP_KEY_UP"); - if (drop_key_up) state->test_drop_key_up = - (UINT)strtoul(drop_key_up, NULL, 10); - const char *start_delay = SDL_getenv("BONGO_CAT_TEST_HOOK_DELAY_MS"); - if (start_delay) { - unsigned long delay = strtoul(start_delay, NULL, 10); + const char *delay_text = SDL_getenv("BONGO_CAT_TEST_RAW_INPUT_DELAY_MS"); + if (delay_text) { + unsigned long delay = strtoul(delay_text, NULL, 10); state->test_start_delay_ms = delay > 10000 ? 10000 : (DWORD)delay; } state->stop = CreateEventW(NULL, TRUE, FALSE, NULL); - state->thread = state->stop ? + state->ready = CreateEventW(NULL, TRUE, FALSE, NULL); + state->thread = state->stop && state->ready ? CreateThread(NULL, 0, input_thread, state, 0, NULL) : NULL; - if (!state->stop || !state->thread) { - if (state->thread) CloseHandle(state->thread); - if (state->stop) CloseHandle(state->stop); - free(state); - return false; - } + if (!state->thread) { free_state(state); return false; } platform->native = state; - return true; + DWORD wait = WaitForSingleObject(state->ready, 1500); + if (wait == WAIT_OBJECT_0 && state->registered) return true; + SDL_LogWarn(SDL_LOG_CATEGORY_INPUT, + "[input] Raw Input startup failed: wait=%lu", (unsigned long)wait); + bongo_cat_windows_input_stop(platform); + return false; } void bongo_cat_windows_input_stop(BongoCatPlatform *platform) { @@ -346,15 +169,12 @@ void bongo_cat_windows_input_stop(BongoCatPlatform *platform) { state->platform = NULL; ReleaseSRWLockExclusive(&state->platform_lock); SetEvent(state->stop); + platform->native = NULL; if (WaitForSingleObject(state->thread, 3000) != WAIT_OBJECT_0) { - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, - "Windows input thread did not stop; its state will remain isolated until exit"); - platform->native = NULL; + SDL_LogError(SDL_LOG_CATEGORY_INPUT, + "[input] Raw Input thread did not stop; its state remains isolated until exit"); return; } - CloseHandle(state->thread); - CloseHandle(state->stop); - free(state); - platform->native = NULL; + free_state(state); } #endif diff --git a/src/platform/windows/windows_input.h b/src/platform/windows/windows_input.h index 93f4d955..cb323f91 100644 --- a/src/platform/windows/windows_input.h +++ b/src/platform/windows/windows_input.h @@ -4,11 +4,16 @@ #include "bongo_cat/platform.h" #ifdef _WIN32 +#include + bool bongo_cat_windows_input_start(BongoCatPlatform *platform); void bongo_cat_windows_input_stop(BongoCatPlatform *platform); bool bongo_cat_windows_input_take_relative(BongoCatPlatform *platform, - double *x, double *y); + double *x, double *y, unsigned long long *sample_count); void bongo_cat_windows_input_reset_relative(BongoCatPlatform *platform); +void bongo_cat_windows_input_release_relative(BongoCatPlatform *platform); +bool bongo_cat_windows_input_relative_mode(bool foreign_foreground, + const RECT *clip, const CURSORINFO *cursor); #endif #endif diff --git a/src/platform/windows/windows_input_devices.c b/src/platform/windows/windows_input_devices.c new file mode 100644 index 00000000..277c7330 --- /dev/null +++ b/src/platform/windows/windows_input_devices.c @@ -0,0 +1,158 @@ +#include "windows_input_internal.h" + +#ifdef _WIN32 +#include +#include +#include + +static void publish(WindowsInputState *state, WindowsRawHeld *held, bool mouse) { + bool down = held->references != 0; + if (down == held->emitted) return; + BongoCatInputKind kind = mouse ? (down ? BONGO_CAT_INPUT_MOUSE_DOWN : + BONGO_CAT_INPUT_MOUSE_UP) : (down ? BONGO_CAT_INPUT_KEY_DOWN : + BONGO_CAT_INPUT_KEY_UP); + if (!bongo_cat_windows_input_push_event(state, kind, held->name, + down ? 1.0f : 0.0f)) return; + held->emitted = down; + if (mouse) state->counters.button_sent++; + else state->counters.key_sent++; +} + +void bongo_cat_windows_input_flush(WindowsInputState *state) { + for (unsigned i = 0; i < BONGO_CAT_WINDOWS_RAW_HELD_LIMIT; ++i) + publish(state, &state->keys[i], false); + for (unsigned i = 0; i < BONGO_CAT_WINDOWS_MOUSE_BUTTON_COUNT; ++i) + publish(state, &state->buttons[i], true); +} + +WindowsRawDevice *bongo_cat_windows_input_device(WindowsInputState *state, + HANDLE handle) { + for (WindowsRawDevice *device = state->devices; device; device = device->next) + if (device->handle == handle) return device; + if (state->device_count >= BONGO_CAT_WINDOWS_RAW_DEVICE_LIMIT) { + state->counters.capacity_failures++; + return NULL; + } + WindowsRawDevice *device = calloc(1, sizeof(*device)); + if (!device) { + state->counters.capacity_failures++; + return NULL; + } + device->handle = handle; + device->next = state->devices; + state->devices = device; + state->device_count++; + return device; +} + +static int held_slot(WindowsInputState *state, const char *name) { + int available = -1; + for (unsigned i = 0; i < BONGO_CAT_WINDOWS_RAW_HELD_LIMIT; ++i) { + WindowsRawHeld *held = &state->keys[i]; + if (held->references || held->emitted) { + if (strcmp(held->name, name) == 0) return (int)i; + } else if (available < 0) available = (int)i; + } + if (available >= 0) snprintf(state->keys[available].name, + sizeof(state->keys[available].name), "%s", name); + else state->counters.capacity_failures++; + return available; +} + +void bongo_cat_windows_input_key(WindowsInputState *state, + WindowsRawDevice *device, const RAWKEYBOARD *key) { + if ((key->Flags & RI_KEY_E0) && + (key->MakeCode == 0x2a || key->MakeCode == 0x36)) { + state->counters.ignored++; + return; + } + if ((key->Flags & RI_KEY_E1) && key->MakeCode == 0x1d) { + device->pending_e1 = true; + state->counters.ignored++; + return; + } + RAWKEYBOARD normalized = *key; + if (device->pending_e1) { + device->pending_e1 = false; + if (key->MakeCode == 0x45) normalized.VKey = VK_PAUSE; + } + key = &normalized; + char buffer[16]; + const char *name = bongo_cat_windows_key_name(key, buffer); + if (!name) { state->counters.ignored++; return; } + unsigned index = bongo_cat_windows_key_index(key); + if (index >= BONGO_CAT_WINDOWS_RAW_KEY_COUNT) { + state->counters.invalid++; + return; + } + unsigned slot = device->keys[index]; + bool down = !(key->Flags & RI_KEY_BREAK); + if (down == (slot != 0)) { state->counters.ignored++; return; } + if (down) { + int found = held_slot(state, name); + if (found < 0) return; + slot = (unsigned)found + 1; + device->keys[index] = (unsigned short)slot; + state->keys[slot - 1].references++; + } else { + device->keys[index] = 0; + state->keys[slot - 1].references--; + } + publish(state, &state->keys[slot - 1], false); + /* Pause has no hardware break packet on standard keyboards. */ + if (down && key->VKey == VK_PAUSE) { + device->keys[index] = 0; + state->keys[slot - 1].references--; + publish(state, &state->keys[slot - 1], false); + } +} + +void bongo_cat_windows_input_buttons(WindowsInputState *state, + WindowsRawDevice *device, USHORT flags) { + static const USHORT presses[] = {RI_MOUSE_LEFT_BUTTON_DOWN, + RI_MOUSE_RIGHT_BUTTON_DOWN, RI_MOUSE_MIDDLE_BUTTON_DOWN, + RI_MOUSE_BUTTON_4_DOWN, RI_MOUSE_BUTTON_5_DOWN}; + static const USHORT releases[] = {RI_MOUSE_LEFT_BUTTON_UP, + RI_MOUSE_RIGHT_BUTTON_UP, RI_MOUSE_MIDDLE_BUTTON_UP, + RI_MOUSE_BUTTON_4_UP, RI_MOUSE_BUTTON_5_UP}; + static const char *names[] = {"Left", "Right", "Middle", "Back", "Forward"}; + for (unsigned i = 0; i < BONGO_CAT_WINDOWS_MOUSE_BUTTON_COUNT; ++i) { + WindowsRawHeld *held = &state->buttons[i]; + snprintf(held->name, sizeof(held->name), "%s", names[i]); + if ((flags & presses[i]) && !device->buttons[i]) { + device->buttons[i] = true; + held->references++; + state->counters.button_edges++; + publish(state, held, true); + } + if ((flags & releases[i]) && device->buttons[i]) { + device->buttons[i] = false; + held->references--; + state->counters.button_edges++; + publish(state, held, true); + } + } +} + +void bongo_cat_windows_input_remove_device(WindowsInputState *state, HANDLE handle) { + WindowsRawDevice **link = &state->devices; + while (*link && (*link)->handle != handle) link = &(*link)->next; + if (!*link) return; + WindowsRawDevice *device = *link; + for (unsigned i = 0; i < BONGO_CAT_WINDOWS_RAW_KEY_COUNT; ++i) + if (device->keys[i]) state->keys[device->keys[i] - 1].references--; + for (unsigned i = 0; i < BONGO_CAT_WINDOWS_MOUSE_BUTTON_COUNT; ++i) + if (device->buttons[i]) state->buttons[i].references--; + *link = device->next; + free(device); + state->device_count--; + state->counters.removals++; + bongo_cat_windows_input_flush(state); +} + +void bongo_cat_windows_input_clear_devices(WindowsInputState *state) { + while (state->devices) + bongo_cat_windows_input_remove_device(state, state->devices->handle); + bongo_cat_windows_input_clear_motion(state); +} +#endif diff --git a/src/platform/windows/windows_input_diagnostics.c b/src/platform/windows/windows_input_diagnostics.c new file mode 100644 index 00000000..f7ae6138 --- /dev/null +++ b/src/platform/windows/windows_input_diagnostics.c @@ -0,0 +1,127 @@ +#include "windows_input_internal.h" + +#ifdef _WIN32 +#include + +static long long age_ms(ULONGLONG now, ULONGLONG last) { + return last && now >= last ? (long long)(now - last) : -1; +} + +static long long probe_session_input(WindowsInputState *state) { + LASTINPUTINFO input = {.cbSize = sizeof(input)}; + bool known = GetLastInputInfo(&input) != FALSE; + if (known) { + /* This is session activity, including synthetic input, not device events. */ + if (state->session_input_known && input.dwTime != state->last_session_input_tick) + state->counters.session_input_changes++; + state->last_session_input_tick = input.dwTime; + } + state->session_input_known = known; + if (!known) return -1; + DWORD age = GetTickCount() - input.dwTime; + /* Account for tick wrap; reject implausible ages from supplied input times. */ + return age <= 0x7fffffffu ? (long long)age : -1; +} + +void bongo_cat_windows_input_log(WindowsInputState *state, ULONGLONG now_ms) { + if (state->diagnostic_ready && now_ms - state->last_diagnostic_probe_ms < 1000) + return; + state->last_diagnostic_probe_ms = now_ms; + long long session_input_age = probe_session_input(state); + HWND foreground = GetForegroundWindow(); + DWORD pid = 0; + if (foreground) GetWindowThreadProcessId(foreground, &pid); + bool changed = foreground != state->reported_foreground || + pid != state->reported_foreground_pid; + if (state->diagnostic_ready && !changed && + now_ms - state->last_diagnostic_ms < 10000) return; + if (!state->diagnostic_ready || changed) { + char class_name[128] = {0}; + if (foreground) GetClassNameA(foreground, class_name, sizeof(class_name)); + SDL_Log("[input] Raw Input foreground: pid=%lu class=%s " + "desktop=%d,%d,%d,%d", + (unsigned long)pid, class_name[0] ? class_name : "unknown", + GetSystemMetrics(SM_XVIRTUALSCREEN), GetSystemMetrics(SM_YVIRTUALSCREEN), + GetSystemMetrics(SM_CXVIRTUALSCREEN), GetSystemMetrics(SM_CYVIRTUALSCREEN)); + } + RECT clip = {0}; + bool clip_known = GetClipCursor(&clip) != FALSE; + CURSORINFO cursor = {.cbSize = sizeof(cursor)}; + bool cursor_known = GetCursorInfo(&cursor) != FALSE; + bool relative_mode = bongo_cat_windows_input_relative_mode( + pid && pid != GetCurrentProcessId(), clip_known ? &clip : NULL, + cursor_known ? &cursor : NULL); + RECT window_rect = {0}; + bool window_known = foreground && GetWindowRect(foreground, &window_rect); + MONITORINFO monitor = {.cbSize = sizeof(monitor)}; + bool monitor_known = foreground && GetMonitorInfoW( + MonitorFromWindow(foreground, MONITOR_DEFAULTTONEAREST), &monitor); + SetLastError(ERROR_SUCCESS); + LONG_PTR style = foreground ? GetWindowLongPtrW(foreground, GWL_STYLE) : 0; + bool style_known = foreground && (style || GetLastError() == ERROR_SUCCESS); + /* A screen-covering rectangle cannot identify exclusive presentation. */ + bool covers_monitor = window_known && monitor_known && + window_rect.left <= monitor.rcMonitor.left && + window_rect.top <= monitor.rcMonitor.top && + window_rect.right >= monitor.rcMonitor.right && + window_rect.bottom >= monitor.rcMonitor.bottom; + AcquireSRWLockShared(&state->relative_lock); + bool active = state->relative_active, mouse_registered = state->receiving; + long long relative_x = state->relative_x, relative_y = state->relative_y; + double absolute_x = state->absolute_x, absolute_y = state->absolute_y; + unsigned long long reads = state->relative_reads, resets = state->relative_resets; + unsigned long long sample_reads = state->relative_sample_reads; + ReleaseSRWLockShared(&state->relative_lock); + const WindowsRawCounters *c = &state->counters, *p = &state->reported; + SDL_Log("[input] Raw Input summary: backend=raw-input from_pid=%lu " + "foreground_pid=%lu interval_ms=%llu ownership=%u registration_error=%lu " + "registration_flags=0x%lx,0x%lx " + "desktop_available=%d mouse_registered=%d devices=%u " + "keys=%llu mouse=%llu foreground_packets=%llu background_packets=%llu " + "key_foreground_packets=%llu key_background_packets=%llu " + "session_input_known=%d session_input_age_ms=%lld session_input_changes=%llu " + "relative_packets=%llu absolute_packets=%llu motion_packets=%llu buttons=%llu " + "key_sent_total=%llu button_sent_total=%llu queue_failures_total=%llu " + "read_errors_total=%llu read_error=%lu invalid_total=%llu ignored_total=%llu " + "capacity_failures_total=%llu removals_total=%llu desktop_resets_total=%llu " + "key_age_ms=%lld mouse_age_ms=%lld motion_age_ms=%lld " + "relative_active=%d relative_pending=%lld,%lld absolute_pending=%.2f,%.2f " + "drain_calls_total=%llu sample_reads_total=%llu resets_total=%llu " + "cursor_known=%d cursor_flags=%lu clip_known=%d clip=%ld,%ld,%ld,%ld " + "relative_mode=%d window_rect_known=%d window_rect=%ld,%ld,%ld,%ld " + "monitor_known=%d monitor_rect=%ld,%ld,%ld,%ld covers_monitor=%d " + "window_style_known=%d window_style=0x%lx", + (unsigned long)state->reported_foreground_pid, (unsigned long)pid, + state->last_diagnostic_ms ? now_ms - state->last_diagnostic_ms : 0, + state->ownership, (unsigned long)state->registration_error, + (unsigned long)state->mouse_registration_flags, + (unsigned long)state->keyboard_registration_flags, + !state->desktop_unavailable, mouse_registered, state->device_count, + c->keyboard - p->keyboard, c->mouse - p->mouse, + c->foreground - p->foreground, c->background - p->background, + c->keyboard_foreground - p->keyboard_foreground, + c->keyboard_background - p->keyboard_background, + state->session_input_known, session_input_age, + c->session_input_changes - p->session_input_changes, + c->relative - p->relative, c->absolute - p->absolute, + c->motion - p->motion, c->button_edges - p->button_edges, + c->key_sent, c->button_sent, c->queue_failures, + c->read_errors, (unsigned long)state->read_error, c->invalid, c->ignored, + c->capacity_failures, c->removals, c->desktop_resets, + age_ms(now_ms, state->last_keyboard_ms), age_ms(now_ms, state->last_mouse_ms), + age_ms(now_ms, state->last_motion_ms), active, + relative_x, relative_y, absolute_x, absolute_y, reads, sample_reads, resets, + cursor_known, (unsigned long)cursor.flags, clip_known, + (long)clip.left, (long)clip.top, (long)clip.right, (long)clip.bottom, relative_mode, + window_known, (long)window_rect.left, (long)window_rect.top, + (long)window_rect.right, (long)window_rect.bottom, monitor_known, + (long)monitor.rcMonitor.left, (long)monitor.rcMonitor.top, + (long)monitor.rcMonitor.right, (long)monitor.rcMonitor.bottom, + covers_monitor, style_known, (unsigned long)(DWORD)style); + state->reported = state->counters; + state->reported_foreground = foreground; + state->reported_foreground_pid = pid; + state->last_diagnostic_ms = now_ms; + state->diagnostic_ready = true; +} +#endif diff --git a/src/platform/windows/windows_input_internal.h b/src/platform/windows/windows_input_internal.h new file mode 100644 index 00000000..2982071a --- /dev/null +++ b/src/platform/windows/windows_input_internal.h @@ -0,0 +1,95 @@ +#ifndef BONGO_CAT_WINDOWS_INPUT_INTERNAL_H +#define BONGO_CAT_WINDOWS_INPUT_INTERNAL_H + +#include "windows_input.h" +#include "windows_keys.h" + +#ifdef _WIN32 +#define BONGO_CAT_WINDOWS_RAW_DEVICE_LIMIT 64 +#define BONGO_CAT_WINDOWS_RAW_HELD_LIMIT 256 +#define BONGO_CAT_WINDOWS_MOUSE_BUTTON_COUNT 5 + +typedef struct WindowsRawHeld { + char name[16]; + unsigned references; + bool emitted; +} WindowsRawHeld; + +typedef struct WindowsRawDevice { + struct WindowsRawDevice *next; + HANDLE handle; + unsigned short keys[BONGO_CAT_WINDOWS_RAW_KEY_COUNT]; + bool buttons[BONGO_CAT_WINDOWS_MOUSE_BUTTON_COUNT]; + bool absolute_known, pending_e1; + RECT absolute_bounds; + double absolute_x, absolute_y; + unsigned long long generation; +} WindowsRawDevice; + +typedef struct WindowsRawCounters { + unsigned long long keyboard, mouse, foreground, background; + unsigned long long keyboard_foreground, keyboard_background; + unsigned long long session_input_changes; + unsigned long long relative, absolute, motion, button_edges; + unsigned long long key_sent, button_sent, ignored, invalid; + unsigned long long read_errors, queue_failures, capacity_failures; + unsigned long long removals, desktop_resets; +} WindowsRawCounters; + +typedef struct WindowsInputState { + BongoCatPlatform *platform; + SRWLOCK platform_lock; + SRWLOCK relative_lock; + HANDLE thread, stop, ready; + HWND window; + ATOM window_class; + bool registered; + DWORD startup_error, read_error, registration_error; + DWORD mouse_registration_flags, keyboard_registration_flags; + DWORD test_start_delay_ms; + unsigned ownership; + WindowsRawDevice *devices; + unsigned device_count; + WindowsRawHeld keys[BONGO_CAT_WINDOWS_RAW_HELD_LIMIT]; + WindowsRawHeld buttons[BONGO_CAT_WINDOWS_MOUSE_BUTTON_COUNT]; + WindowsRawCounters counters, reported; + ULONGLONG last_keyboard_ms, last_mouse_ms, last_motion_ms; + ULONGLONG last_diagnostic_ms, last_diagnostic_probe_ms; + DWORD last_session_input_tick; + bool session_input_known; + HWND reported_foreground; + DWORD reported_foreground_pid; + bool diagnostic_ready, desktop_unavailable, wake_pending; + bool relative_active, receiving; + long long relative_x, relative_y; + double absolute_x, absolute_y; + unsigned long long relative_samples, absolute_samples, generation; + unsigned long long relative_reads, relative_sample_reads, relative_resets; +} WindowsInputState; + +bool bongo_cat_windows_input_push_event(WindowsInputState *state, + BongoCatInputKind kind, const char *name, float value); +void bongo_cat_windows_input_wake(WindowsInputState *state); +void bongo_cat_windows_input_flush(WindowsInputState *state); +void bongo_cat_windows_input_packet(WindowsInputState *state, + const RAWINPUT *packet, UINT bytes); +WindowsRawDevice *bongo_cat_windows_input_device(WindowsInputState *state, + HANDLE handle); +void bongo_cat_windows_input_remove_device(WindowsInputState *state, HANDLE handle); +void bongo_cat_windows_input_clear_devices(WindowsInputState *state); +void bongo_cat_windows_input_key(WindowsInputState *state, + WindowsRawDevice *device, const RAWKEYBOARD *key); +void bongo_cat_windows_input_buttons(WindowsInputState *state, + WindowsRawDevice *device, USHORT flags); +void bongo_cat_windows_input_motion(WindowsInputState *state, + WindowsRawDevice *device, const RAWMOUSE *mouse, const RECT *bounds); +void bongo_cat_windows_input_clear_motion(WindowsInputState *state); +bool bongo_cat_windows_input_register(WindowsInputState *state); +void bongo_cat_windows_input_unregister(WindowsInputState *state); +unsigned bongo_cat_windows_input_ownership(WindowsInputState *state); +bool bongo_cat_windows_input_receiver_create(WindowsInputState *state); +void bongo_cat_windows_input_receiver_destroy(WindowsInputState *state); +bool bongo_cat_windows_input_dispatch(WindowsInputState *state); +void bongo_cat_windows_input_log(WindowsInputState *state, ULONGLONG now_ms); +#endif +#endif diff --git a/src/platform/windows/windows_input_packets.c b/src/platform/windows/windows_input_packets.c new file mode 100644 index 00000000..2a1b5675 --- /dev/null +++ b/src/platform/windows/windows_input_packets.c @@ -0,0 +1,55 @@ +#include "windows_input_internal.h" + +#ifdef _WIN32 +#include + +void bongo_cat_windows_input_packet(WindowsInputState *state, + const RAWINPUT *packet, UINT bytes) { + if (!state || !packet) return; + if (bytes < sizeof(RAWINPUTHEADER) || packet->header.dwSize != bytes) { + state->counters.invalid++; + return; + } + UINT payload; + switch (packet->header.dwType) { + case RIM_TYPEKEYBOARD: payload = sizeof(RAWKEYBOARD); break; + case RIM_TYPEMOUSE: payload = sizeof(RAWMOUSE); break; + default: state->counters.ignored++; return; + } + if (bytes < offsetof(RAWINPUT, data) + payload) { + state->counters.invalid++; + return; + } + bool background = GET_RAWINPUT_CODE_WPARAM(packet->header.wParam) == RIM_INPUTSINK; + if (background) state->counters.background++; + else state->counters.foreground++; + WindowsRawDevice *device = bongo_cat_windows_input_device(state, + packet->header.hDevice); + if (!device) return; + ULONGLONG now_ms = GetTickCount64(); + if (packet->header.dwType == RIM_TYPEKEYBOARD) { + state->counters.keyboard++; + if (background) state->counters.keyboard_background++; + else state->counters.keyboard_foreground++; + state->last_keyboard_ms = now_ms; + bongo_cat_windows_input_key(state, device, &packet->data.keyboard); + return; + } + const RAWMOUSE *mouse = &packet->data.mouse; + state->counters.mouse++; + state->last_mouse_ms = now_ms; + bongo_cat_windows_input_buttons(state, device, mouse->usButtonFlags); + RECT bounds = {0}; + if (mouse->usFlags & MOUSE_MOVE_ABSOLUTE) { + bool virtual_desktop = (mouse->usFlags & MOUSE_VIRTUAL_DESKTOP) != 0; + bounds.left = virtual_desktop ? GetSystemMetrics(SM_XVIRTUALSCREEN) : 0; + bounds.top = virtual_desktop ? GetSystemMetrics(SM_YVIRTUALSCREEN) : 0; + bounds.right = bounds.left + GetSystemMetrics(virtual_desktop ? + SM_CXVIRTUALSCREEN : SM_CXSCREEN); + bounds.bottom = bounds.top + GetSystemMetrics(virtual_desktop ? + SM_CYVIRTUALSCREEN : SM_CYSCREEN); + } + bongo_cat_windows_input_motion(state, device, mouse, &bounds); + state->wake_pending = true; +} +#endif diff --git a/src/platform/windows/windows_input_receiver.c b/src/platform/windows/windows_input_receiver.c new file mode 100644 index 00000000..2b782656 --- /dev/null +++ b/src/platform/windows/windows_input_receiver.c @@ -0,0 +1,75 @@ +#include "windows_input_internal.h" + +#ifdef _WIN32 +static const wchar_t receiver_class[] = L"BongoCat Raw Input Receiver"; + +static LRESULT CALLBACK receiver_proc(HWND window, UINT message, + WPARAM wparam, LPARAM lparam) { + WindowsInputState *state = (WindowsInputState *)GetWindowLongPtrW(window, + GWLP_USERDATA); + if (message == WM_NCCREATE) { + const CREATESTRUCTW *create = (const CREATESTRUCTW *)lparam; + state = create->lpCreateParams; + SetWindowLongPtrW(window, GWLP_USERDATA, (LONG_PTR)state); + } + if (state && message == WM_INPUT) { + RAWINPUT packet = {0}; + UINT bytes = sizeof(packet); + UINT received = GetRawInputData((HRAWINPUT)lparam, RID_INPUT, &packet, + &bytes, sizeof(RAWINPUTHEADER)); + if (received == (UINT)-1) { + state->counters.read_errors++; + state->read_error = GetLastError(); + } else bongo_cat_windows_input_packet(state, &packet, received); + /* DefWindowProc performs the required foreground WM_INPUT cleanup. */ + } else if (state && message == WM_INPUT_DEVICE_CHANGE) { + if (wparam == GIDC_REMOVAL || wparam == GIDC_ARRIVAL) { + bongo_cat_windows_input_remove_device(state, (HANDLE)lparam); + bongo_cat_windows_input_clear_motion(state); + } + } else if (message == WM_NCDESTROY) { + SetWindowLongPtrW(window, GWLP_USERDATA, 0); + } + return DefWindowProcW(window, message, wparam, lparam); +} + +bool bongo_cat_windows_input_receiver_create(WindowsInputState *state) { + HINSTANCE instance = GetModuleHandleW(NULL); + WNDCLASSW type = {.lpfnWndProc = receiver_proc, + .hInstance = instance, .lpszClassName = receiver_class}; + state->window_class = RegisterClassW(&type); + if (!state->window_class && GetLastError() != ERROR_CLASS_ALREADY_EXISTS) { + state->startup_error = GetLastError(); + return false; + } + state->window = CreateWindowExW(0, receiver_class, L"", 0, + 0, 0, 0, 0, HWND_MESSAGE, NULL, instance, state); + if (!state->window) { state->startup_error = GetLastError(); return false; } + return bongo_cat_windows_input_register(state); +} + +void bongo_cat_windows_input_receiver_destroy(WindowsInputState *state) { + if (state->window) { + bongo_cat_windows_input_unregister(state); + DestroyWindow(state->window); + state->window = NULL; + } + if (state->window_class) { + UnregisterClassW(receiver_class, GetModuleHandleW(NULL)); + state->window_class = 0; + } +} + +bool bongo_cat_windows_input_dispatch(WindowsInputState *state) { + MSG message; + /* Bound each batch so high-rate mice cannot starve shutdown or diagnostics. */ + for (unsigned i = 0; i < 256 && PeekMessageW(&message, + NULL, 0, 0, PM_REMOVE); ++i) { + if (message.message == WM_QUIT || + WaitForSingleObject(state->stop, 0) == WAIT_OBJECT_0) return false; + TranslateMessage(&message); + DispatchMessageW(&message); + } + return true; +} +#endif diff --git a/src/platform/windows/windows_input_registration.c b/src/platform/windows/windows_input_registration.c new file mode 100644 index 00000000..3a2bb66d --- /dev/null +++ b/src/platform/windows/windows_input_registration.c @@ -0,0 +1,112 @@ +#include "windows_input_internal.h" + +#ifdef _WIN32 +#include +#include + +static bool registrations(WindowsInputState *state, RAWINPUTDEVICE **items, + UINT *count) { + *items = NULL; + *count = 0; + state->registration_error = ERROR_SUCCESS; + for (unsigned attempt = 0; attempt < 3; ++attempt) { + UINT queried = GetRegisteredRawInputDevices(NULL, count, sizeof(**items)); + if (queried == (UINT)-1) { + DWORD error = GetLastError(); + /* A size query may report the required count as insufficient buffer. */ + if (error != ERROR_INSUFFICIENT_BUFFER || !*count) { + state->registration_error = error; + return false; + } + } + if (!*count) return true; + if (*count > 1024) { + state->registration_error = ERROR_INVALID_DATA; + return false; + } + *items = calloc(*count, sizeof(**items)); + if (!*items) { + state->registration_error = ERROR_NOT_ENOUGH_MEMORY; + return false; + } + UINT received = GetRegisteredRawInputDevices(*items, count, sizeof(**items)); + if (received != (UINT)-1) { *count = received; return true; } + DWORD error = GetLastError(); + free(*items); + *items = NULL; + if (error != ERROR_INSUFFICIENT_BUFFER) { + state->registration_error = error; + return false; + } + } + state->registration_error = ERROR_INSUFFICIENT_BUFFER; + return false; +} + +static unsigned device_class(const RAWINPUTDEVICE *device) { + if (device->usUsagePage != 1) return 0; + return device->usUsage == 2 ? 1u : device->usUsage == 6 ? 2u : 0u; +} + +unsigned bongo_cat_windows_input_ownership(WindowsInputState *state) { + RAWINPUTDEVICE *items; + UINT count; + if (!registrations(state, &items, &count)) return 0; + unsigned owned = 0; + state->mouse_registration_flags = state->keyboard_registration_flags = 0; + for (UINT i = 0; i < count; ++i) { + if (items[i].hwndTarget != state->window) continue; + unsigned kind = device_class(&items[i]); + if (kind == 1) state->mouse_registration_flags = items[i].dwFlags; + if (kind == 2) state->keyboard_registration_flags = items[i].dwFlags; + /* Windows may omit DEVNOTIFY when querying a successful registration. */ + if (items[i].dwFlags & RIDEV_INPUTSINK) owned |= kind; + } + free(items); + return owned; +} + +bool bongo_cat_windows_input_register(WindowsInputState *state) { + if (SDL_getenv("BONGO_CAT_TEST_RAW_INPUT_FAILURE")) { + state->startup_error = ERROR_ACCESS_DENIED; + return false; + } + RAWINPUTDEVICE *items; + UINT count; + if (!registrations(state, &items, &count)) { + state->startup_error = state->registration_error; + return false; + } + bool occupied = false; + for (UINT i = 0; i < count; ++i) + if (device_class(&items[i])) occupied = true; + free(items); + if (occupied) { state->startup_error = ERROR_BUSY; return false; } + const RAWINPUTDEVICE devices[] = { + {1, 2, RIDEV_INPUTSINK | RIDEV_DEVNOTIFY, state->window}, + {1, 6, RIDEV_INPUTSINK | RIDEV_DEVNOTIFY, state->window} + }; + if (!RegisterRawInputDevices(devices, 2, sizeof(devices[0]))) { + state->startup_error = GetLastError(); + return false; + } + return true; +} + +void bongo_cat_windows_input_unregister(WindowsInputState *state) { + RAWINPUTDEVICE *items; + UINT count; + if (!registrations(state, &items, &count)) return; + for (UINT i = 0; i < count; ++i) { + if (!device_class(&items[i]) || items[i].hwndTarget != state->window) + continue; + items[i].dwFlags = RIDEV_REMOVE; + items[i].hwndTarget = NULL; + if (!RegisterRawInputDevices(&items[i], 1, sizeof(items[i]))) + SDL_LogWarn(SDL_LOG_CATEGORY_INPUT, + "[input] Raw Input unregister failed: error=%lu", + (unsigned long)GetLastError()); + } + free(items); +} +#endif diff --git a/src/platform/windows/windows_input_relative.c b/src/platform/windows/windows_input_relative.c new file mode 100644 index 00000000..f302c08d --- /dev/null +++ b/src/platform/windows/windows_input_relative.c @@ -0,0 +1,128 @@ +#include "windows_input_internal.h" + +#ifdef _WIN32 +#include + +bool bongo_cat_windows_input_relative_mode(bool foreign_foreground, + const RECT *clip, const CURSORINFO *cursor) { + if (!foreign_foreground) return false; + if (clip) { + long long width = (long long)clip->right - clip->left; + long long height = (long long)clip->bottom - clip->top; + if (width >= 0 && width <= 2 && height >= 0 && height <= 2) return true; + } + /* Hidden cursors also cover games that recenter within a larger clip. */ + return cursor && cursor->flags == 0; +} + +void bongo_cat_windows_input_motion(WindowsInputState *state, + WindowsRawDevice *device, const RAWMOUSE *mouse, const RECT *bounds) { + AcquireSRWLockExclusive(&state->relative_lock); + if (!(mouse->usFlags & MOUSE_MOVE_ABSOLUTE)) { + device->absolute_known = false; + state->counters.relative++; + if (mouse->lLastX || mouse->lLastY) { + state->counters.motion++; + state->last_motion_ms = GetTickCount64(); + if (state->relative_active) { + state->relative_x += mouse->lLastX; + state->relative_y += mouse->lLastY; + state->relative_samples++; + } + } + } else { + state->counters.absolute++; + if (!bounds || bounds->right <= bounds->left || + bounds->bottom <= bounds->top || mouse->lLastX < 0 || + mouse->lLastX > 65535 || mouse->lLastY < 0 || mouse->lLastY > 65535) { + device->absolute_known = false; + state->counters.invalid++; + ReleaseSRWLockExclusive(&state->relative_lock); + return; + } + double x = bounds->left + (double)mouse->lLastX * + ((double)bounds->right - bounds->left - 1.0) / 65535.0; + double y = bounds->top + (double)mouse->lLastY * + ((double)bounds->bottom - bounds->top - 1.0) / 65535.0; + bool baseline = device->absolute_known && + !(mouse->usFlags & MOUSE_ATTRIBUTES_CHANGED) && + device->generation == state->generation && + memcmp(bounds, &device->absolute_bounds, sizeof(*bounds)) == 0; + if (baseline && (x != device->absolute_x || y != device->absolute_y)) { + state->counters.motion++; + state->last_motion_ms = GetTickCount64(); + if (state->relative_active) { + state->absolute_x += x - device->absolute_x; + state->absolute_y += y - device->absolute_y; + state->absolute_samples++; + } + } + device->absolute_x = x; + device->absolute_y = y; + device->absolute_bounds = *bounds; + device->absolute_known = true; + device->generation = state->generation; + } + ReleaseSRWLockExclusive(&state->relative_lock); +} + +static void clear_motion(WindowsInputState *state) { + state->relative_x = state->relative_y = 0; + state->absolute_x = state->absolute_y = 0.0; + state->relative_samples = state->absolute_samples = 0; + state->generation++; + state->relative_resets++; +} + +void bongo_cat_windows_input_clear_motion(WindowsInputState *state) { + AcquireSRWLockExclusive(&state->relative_lock); + clear_motion(state); + ReleaseSRWLockExclusive(&state->relative_lock); +} + +bool bongo_cat_windows_input_take_relative(BongoCatPlatform *platform, + double *x, double *y, unsigned long long *sample_count) { + if (!x || !y) return false; + *x = *y = 0.0; + if (sample_count) *sample_count = 0; + WindowsInputState *state = platform ? platform->native : NULL; + if (!state) return false; + AcquireSRWLockExclusive(&state->relative_lock); + /* Delivered packets remain valid even if the registration probe disagrees. */ + bool delivered = state->relative_samples || state->absolute_samples; + bool available = state->relative_active && (state->receiving || delivered); + if (available) { + /* Device counts and absolute-device pixels must never be added together. */ + bool relative = state->relative_samples != 0; + *x = relative ? (double)state->relative_x : state->absolute_x; + *y = relative ? (double)state->relative_y : state->absolute_y; + if (sample_count) *sample_count = relative ? state->relative_samples : + state->absolute_samples; + state->relative_reads++; + if (delivered) state->relative_sample_reads++; + } + state->relative_x = state->relative_y = 0; + state->absolute_x = state->absolute_y = 0.0; + state->relative_samples = state->absolute_samples = 0; + ReleaseSRWLockExclusive(&state->relative_lock); + return available; +} + +void bongo_cat_windows_input_reset_relative(BongoCatPlatform *platform) { + WindowsInputState *state = platform ? platform->native : NULL; + if (!state) return; + AcquireSRWLockExclusive(&state->relative_lock); + clear_motion(state); + state->relative_active = true; + ReleaseSRWLockExclusive(&state->relative_lock); +} + +void bongo_cat_windows_input_release_relative(BongoCatPlatform *platform) { + WindowsInputState *state = platform ? platform->native : NULL; + if (!state) return; + AcquireSRWLockExclusive(&state->relative_lock); + clear_motion(state); + state->relative_active = false; + ReleaseSRWLockExclusive(&state->relative_lock); +} +#endif diff --git a/src/platform/windows/windows_keys.c b/src/platform/windows/windows_keys.c index c233f3dd..cf4db61f 100644 --- a/src/platform/windows/windows_keys.c +++ b/src/platform/windows/windows_keys.c @@ -2,10 +2,18 @@ #ifdef _WIN32 #include -#include -const char *bongo_cat_windows_key_name(const KBDLLHOOKSTRUCT *key, char output[16]) { - DWORD code = key->vkCode; +unsigned bongo_cat_windows_key_index(const RAWKEYBOARD *key) { + if (key->MakeCode && key->MakeCode < 256) + return key->MakeCode + ((key->Flags & RI_KEY_E1) ? 512u : + (key->Flags & RI_KEY_E0) ? 256u : 0u); + return 768u + key->VKey; +} + +const char *bongo_cat_windows_key_name(const RAWKEYBOARD *key, char output[16]) { + if (!key || key->VKey < VK_BACK || key->VKey >= 255 || + key->MakeCode == KEYBOARD_OVERRUN_MAKE_CODE) return NULL; + unsigned code = key->VKey; if (code >= 'A' && code <= 'Z') { snprintf(output, 16, "Key%c", (char)code); return output; @@ -15,10 +23,10 @@ const char *bongo_cat_windows_key_name(const KBDLLHOOKSTRUCT *key, char output[1 return output; } if (code >= VK_F1 && code <= VK_F24) { - snprintf(output, 16, "F%lu", code - VK_F1 + 1); + snprintf(output, 16, "F%u", code - VK_F1 + 1); return output; } - if (!(key->flags & LLKHF_EXTENDED)) switch (code) { + if (!(key->Flags & RI_KEY_E0)) switch (code) { case VK_INSERT: return "Kp0"; case VK_END: return "Kp1"; case VK_DOWN: return "Kp2"; case VK_NEXT: return "Kp3"; case VK_LEFT: return "Kp4"; case VK_CLEAR: return "Kp5"; @@ -52,9 +60,9 @@ const char *bongo_cat_windows_key_name(const KBDLLHOOKSTRUCT *key, char output[1 case VK_RCONTROL: return "ControlRight"; case VK_LMENU: return "Alt"; case VK_RMENU: return "AltGr"; - case VK_SHIFT: return key->scanCode == 0x36 ? "ShiftRight" : "ShiftLeft"; - case VK_CONTROL: return key->flags & LLKHF_EXTENDED ? "ControlRight" : "ControlLeft"; - case VK_MENU: return key->flags & LLKHF_EXTENDED ? "AltGr" : "Alt"; + case VK_SHIFT: return key->MakeCode == 0x36 ? "ShiftRight" : "ShiftLeft"; + case VK_CONTROL: return key->Flags & RI_KEY_E0 ? "ControlRight" : "ControlLeft"; + case VK_MENU: return key->Flags & RI_KEY_E0 ? "AltGr" : "Alt"; case VK_RETURN: return "Return"; case VK_NUMLOCK: return "NumLock"; case VK_SCROLL: return "ScrollLock"; @@ -78,64 +86,13 @@ const char *bongo_cat_windows_key_name(const KBDLLHOOKSTRUCT *key, char output[1 case VK_OEM_PERIOD: return "Period"; case VK_OEM_2: return "Slash"; default: - snprintf(output, 16, "%lu", code); + snprintf(output, 16, "%u", code); return output; } } -bool bongo_cat_windows_keyboard_event(BongoCatWindowsKeyboard *state, - const KBDLLHOOKSTRUCT *key, WPARAM message, UINT *drop_key_up, - BongoCatWindowsKeyEmit emit, void *userdata) { - if (!state || !key || !emit) return false; - bool down = message == WM_KEYDOWN || message == WM_SYSKEYDOWN; - bool up = message == WM_KEYUP || message == WM_SYSKEYUP; - if (!down && !up) return false; - if (up && drop_key_up && *drop_key_up == key->vkCode) { - *drop_key_up = 0; return true; - } - char buffer[16]; - const char *name = bongo_cat_windows_key_name(key, buffer); - if (!name || !bongo_cat_input_edge(state->down, key->vkCode, down)) - return false; - if (key->vkCode < BONGO_CAT_INPUT_KEY_STATE_CAP) { - state->changed_ms[key->vkCode] = GetTickCount64(); - if (down) snprintf(state->name[key->vkCode], - sizeof(state->name[key->vkCode]), "%s", name); - else state->name[key->vkCode][0] = '\0'; - } - emit(down, name, userdata); - return true; -} - -bool bongo_cat_windows_keyboard_reconcile_key(BongoCatWindowsKeyboard *state, - unsigned code, uint64_t now_ms, bool physically_down, - BongoCatWindowsKeyEmit emit, void *userdata) { - if (!state || !emit || code >= BONGO_CAT_INPUT_KEY_STATE_CAP || - !state->down[code] || !state->name[code][0] || physically_down || - now_ms < state->changed_ms[code] || - now_ms - state->changed_ms[code] < 50) return false; - char name[BONGO_CAT_ID_CAP]; - snprintf(name, sizeof(name), "%s", state->name[code]); - state->down[code] = false; - state->name[code][0] = '\0'; - state->changed_ms[code] = now_ms; - emit(false, name, userdata); - return true; -} - -void bongo_cat_windows_keyboard_reconcile(BongoCatWindowsKeyboard *state, - uint64_t now_ms, BongoCatWindowsKeyEmit emit, void *userdata) { - if (!state || !emit) return; - for (unsigned code = 0; code < BONGO_CAT_INPUT_KEY_STATE_CAP; ++code) { - if (!state->down[code] || !state->name[code][0] || - now_ms < state->changed_ms[code] || - now_ms - state->changed_ms[code] < 50) continue; - bongo_cat_windows_keyboard_reconcile_key(state, code, now_ms, - (GetAsyncKeyState((int)code) & 0x8000) != 0, emit, userdata); - } -} - bool bongo_cat_windows_right_button_down(void) { + /* Native window drag/capture state, not global model input. */ return (GetAsyncKeyState(VK_RBUTTON) & 0x8000) != 0; } #endif diff --git a/src/platform/windows/windows_keys.h b/src/platform/windows/windows_keys.h index 5791415b..3bc0c521 100644 --- a/src/platform/windows/windows_keys.h +++ b/src/platform/windows/windows_keys.h @@ -2,28 +2,13 @@ #define BONGO_CAT_WINDOWS_KEYS_H #ifdef _WIN32 -#include "bongo_cat/input.h" +#include #include -typedef struct BongoCatWindowsKeyboard { - bool down[BONGO_CAT_INPUT_KEY_STATE_CAP]; - uint64_t changed_ms[BONGO_CAT_INPUT_KEY_STATE_CAP]; - char name[BONGO_CAT_INPUT_KEY_STATE_CAP][BONGO_CAT_ID_CAP]; -} BongoCatWindowsKeyboard; +#define BONGO_CAT_WINDOWS_RAW_KEY_COUNT 1024 -typedef void (*BongoCatWindowsKeyEmit)(bool down, const char *name, - void *userdata); - -const char *bongo_cat_windows_key_name(const KBDLLHOOKSTRUCT *key, char output[16]); -bool bongo_cat_windows_keyboard_event(BongoCatWindowsKeyboard *state, - const KBDLLHOOKSTRUCT *key, WPARAM message, UINT *drop_key_up, - BongoCatWindowsKeyEmit emit, void *userdata); -bool bongo_cat_windows_keyboard_reconcile_key(BongoCatWindowsKeyboard *state, - unsigned code, uint64_t now_ms, bool physically_down, - BongoCatWindowsKeyEmit emit, void *userdata); -void bongo_cat_windows_keyboard_reconcile(BongoCatWindowsKeyboard *state, - uint64_t now_ms, BongoCatWindowsKeyEmit emit, void *userdata); +const char *bongo_cat_windows_key_name(const RAWKEYBOARD *key, char output[16]); +unsigned bongo_cat_windows_key_index(const RAWKEYBOARD *key); bool bongo_cat_windows_right_button_down(void); #endif - #endif diff --git a/src/runtime/input/mouse_diagnostics.c b/src/runtime/input/mouse_diagnostics.c index be1a461a..19f4e5f2 100644 --- a/src/runtime/input/mouse_diagnostics.c +++ b/src/runtime/input/mouse_diagnostics.c @@ -1,12 +1,11 @@ #include "mouse_diagnostics.h" #include "bongo_cat/file.h" +#include "bongo_cat/overlay.h" #include "bongo_cat/path.h" #include -/* Diagnostics are deliberately isolated from input decisions so logging and - smoke audits cannot obscure the runtime pointer state machine. */ void bongo_cat_mouse_audit(BongoCatApp *app, double x, double y) { if (!app->smoke_input_audit) return; char path[BONGO_CAT_PATH_CAP]; @@ -19,51 +18,63 @@ void bongo_cat_mouse_audit(BongoCatApp *app, double x, double y) { } void bongo_cat_mouse_log_diagnostics(BongoCatApp *app, uint64_t now, - bool received, double target_x, double target_y, - float global_x, float global_y, SDL_MouseButtonFlags buttons, - bool cursor_locked, bool relative_requested) { - bool hook_active = app->mouse_last_ns != 0; - bool mismatch = hook_active && - (target_x != global_x || target_y != global_y); - if (app->mouse_diagnostic_due_ns && - now < app->mouse_diagnostic_due_ns) return; - app->mouse_diagnostic_due_ns = now + - (mismatch || relative_requested ? 30000000000ull : 60000000000ull); - uint64_t hook_age_ms = app->mouse_last_ns && now >= app->mouse_last_ns - ? (now - app->mouse_last_ns) / 1000000ull : 0; - SDL_Log("[input] Pointer pipeline: source=%s sample_now=%d " - "samples=%llu age_ms=%llu selected=%.1f,%.1f sdl=%.1f,%.1f " - "mismatch=%d sdl_buttons=0x%x model_buttons=%d,%d,%d " - "pointer_known=%d cursor_locked=%d relative_requested=%d relative_device=%d " - "dropped=%llu model=%s mode=%d ignore=%d centered=%d", - hook_active ? "native-hook" : "sdl-fallback", received, - (unsigned long long)app->mouse_hook_samples, - (unsigned long long)hook_age_ms, target_x, target_y, - global_x, global_y, mismatch, (unsigned)buttons, - app->left_mouse_down, app->right_mouse_down, app->side_mouse_down, - app->pointer_known, cursor_locked, relative_requested, - app->platform.relative_pointer != NULL, + double target_x, double target_y, + bool cursor_locked, bool relative_requested, + double model_x, double model_y, bool model_moved, bool native_selected) { + BongoCatPointerDiagnostics *diagnostics = &app->pointer_diagnostics; + if (model_moved && !app->settings.model.ignore_mouse) diagnostics->mapped_updates++; + uint64_t interval_ns = diagnostics->mode_changes ? 1000000000ull : 10000000000ull; + if (diagnostics->last_log_ns && now >= diagnostics->last_log_ns && + now - diagnostics->last_log_ns < interval_ns) return; + long long native_age_ms = app->mouse_last_ns && now >= app->mouse_last_ns + ? (long long)((now - app->mouse_last_ns) / 1000000ull) : -1; + BongoCatParameterRange angle_x = {0}, angle_y = {0}; + bool angle_x_known = bongo_cat_live2d_parameter(app->live2d, + "ParamAngleX", &angle_x); + bool angle_y_known = bongo_cat_live2d_parameter(app->live2d, + "ParamAngleY", &angle_y); + BongoCatParameterRange pointer_x = {0}, pointer_y = {0}; + bool pointer_x_known = bongo_cat_live2d_parameter(app->live2d, + "ParamMouseX", &pointer_x); + bool pointer_y_known = bongo_cat_live2d_parameter(app->live2d, + "ParamMouseY", &pointer_y); + SDL_Log("[input] Model input: interval_ms=%llu model=%s " + "mode=%s mode_changes=%llu absolute_source=%s native_age_ms=%lld " + "screen=%.1f,%.1f mapped=%.1f,%.1f bounds_known=%d bounds=%.0f,%.0f,%.0f,%.0f " + "mapped_updates=%llu relative_motion=%llu relative_waits=%llu " + "clamped=%llu,%llu map_failures=%llu " + "keys_total=%llu/%llu clicks_total=%llu/%llu key_applied_total=%llu " + "click_applied_total=%llu dropped_total=%llu buttons=%d,%d,%d " + "lock=%d ignore=%d centered=%d overlay=%d " + "parameter_known=%d,%d mouse=%.2f,%.2f angle_known=%d,%d angle=%.2f,%.2f", + (unsigned long long)(diagnostics->last_log_ns && now >= diagnostics->last_log_ns + ? (now - diagnostics->last_log_ns) / 1000000ull : 0), + app->loaded_model[0] ? app->loaded_model : "none", + relative_requested ? "relative" : "absolute", + (unsigned long long)diagnostics->mode_changes, + native_selected ? "native" : "sdl", native_age_ms, + target_x, target_y, model_x, model_y, diagnostics->bounds_known, + diagnostics->bounds.left, diagnostics->bounds.top, + diagnostics->bounds.width, diagnostics->bounds.height, + (unsigned long long)diagnostics->mapped_updates, + (unsigned long long)diagnostics->relative_motion, + (unsigned long long)diagnostics->relative_waits, + (unsigned long long)diagnostics->clamped_x, + (unsigned long long)diagnostics->clamped_y, + (unsigned long long)diagnostics->map_failures, + (unsigned long long)app->input_key_down_events, + (unsigned long long)app->input_key_up_events, + (unsigned long long)app->input_mouse_down_events, + (unsigned long long)app->input_mouse_up_events, + (unsigned long long)app->input_key_supported, + (unsigned long long)app->input_mouse_applied, (unsigned long long)atomic_load_explicit(&app->input.dropped, memory_order_relaxed), - app->loaded_model[0] ? app->loaded_model : "none", - (int)app->loaded_mode, app->settings.model.ignore_mouse, - app->settings.model.mouse_centered); -} - -void bongo_cat_mouse_log_mode_change(const BongoCatApp *app, - bool cursor_locked, bool profile_relative, bool relative_requested, - bool map_requested, bool map_ok, double target_x, double target_y, - double model_x, double model_y, bool model_moved) { - const char *reason = relative_requested - ? (cursor_locked ? "cursor-locked" : "model-profile") - : (cursor_locked ? "mouse-disabled" : "cursor-free"); - SDL_Log("[input] Pointer mode changed: mode=%s reason=%s lock=%d " - "profile=%d map_requested=%d map_ok=%d relative_device=%d " - "target=%.1f,%.1f virtual=%.1f,%.1f " - "moved=%d model=%s", - relative_requested ? "relative" : "absolute", reason, - cursor_locked, profile_relative, map_requested, map_ok, - app->platform.relative_pointer != NULL, target_x, target_y, - model_x, model_y, model_moved, - app->loaded_model[0] ? app->loaded_model : "none"); + app->left_mouse_down, app->right_mouse_down, app->side_mouse_down, + cursor_locked, app->settings.model.ignore_mouse, + app->settings.model.mouse_centered, + bongo_cat_overlay_mver_pointer_enabled(app->overlay), + pointer_x_known, pointer_y_known, pointer_x.value, pointer_y.value, + angle_x_known, angle_y_known, angle_x.value, angle_y.value); + *diagnostics = (BongoCatPointerDiagnostics){.last_log_ns = now ? now : 1}; } diff --git a/src/runtime/input/mouse_diagnostics.h b/src/runtime/input/mouse_diagnostics.h index 54f9e7bc..8b4c701b 100644 --- a/src/runtime/input/mouse_diagnostics.h +++ b/src/runtime/input/mouse_diagnostics.h @@ -7,12 +7,8 @@ void bongo_cat_mouse_audit(BongoCatApp *app, double x, double y); void bongo_cat_mouse_log_diagnostics(BongoCatApp *app, uint64_t now, - bool received, double target_x, double target_y, - float global_x, float global_y, SDL_MouseButtonFlags buttons, - bool cursor_locked, bool relative_requested); -void bongo_cat_mouse_log_mode_change(const BongoCatApp *app, - bool cursor_locked, bool profile_relative, bool relative_requested, - bool map_requested, bool map_ok, double target_x, double target_y, - double model_x, double model_y, bool model_moved); + double target_x, double target_y, + bool cursor_locked, bool relative_requested, + double model_x, double model_y, bool model_moved, bool native_selected); #endif diff --git a/src/runtime/input/mouse_mapping.c b/src/runtime/input/mouse_mapping.c index 61690232..e61cd367 100644 --- a/src/runtime/input/mouse_mapping.c +++ b/src/runtime/input/mouse_mapping.c @@ -76,15 +76,17 @@ bool bongo_cat_app_map_pointer(BongoCatApp *app, bool relative_requested, BongoCatMverPointerBounds pointer_bounds = { bounds.x, bounds.y, bounds.w, bounds.h }; + BongoCatPointerDiagnostics *diagnostics = &app->pointer_diagnostics; + diagnostics->bounds = pointer_bounds; + diagnostics->bounds_known = true; double relative_x = 0.0, relative_y = 0.0; bool use_relative = relative_requested && bongo_cat_platform_relative_pointer(&app->platform, &relative_x, &relative_y); bool initialized = app->mver_pointer.initialized; double previous_x = app->mver_pointer.x, previous_y = app->mver_pointer.y; - /* A temporary device read failure must not snap a locked game pointer - back to the fixed screen coordinate. Keep the virtual position until - relative samples resume; the first sample still seeds from reality. */ + /* Preserve the virtual position while relative samples are unavailable. */ + if (relative_requested && !use_relative) diagnostics->relative_waits++; if (relative_requested && initialized && !use_relative) { absolute_x = previous_x; absolute_y = previous_y; @@ -92,6 +94,13 @@ bool bongo_cat_app_map_pointer(BongoCatApp *app, bool relative_requested, if (!bongo_cat_mver_pointer_update(&app->mver_pointer, absolute_x, absolute_y, relative_x, relative_y, use_relative, &pointer_bounds, x, y)) return false; + if (use_relative && (relative_x != 0.0 || relative_y != 0.0)) { + diagnostics->relative_motion++; + double expected_x = (initialized ? previous_x : absolute_x) + relative_x; + double expected_y = (initialized ? previous_y : absolute_y) + relative_y; + if (expected_x != *x) diagnostics->clamped_x++; + if (expected_y != *y) diagnostics->clamped_y++; + } *changed = !initialized || previous_x != *x || previous_y != *y; return true; } @@ -199,9 +208,7 @@ void bongo_cat_app_apply_mouse_coordinates(BongoCatApp *app, double hand_x, void bongo_cat_app_reset_pointer_tracking(BongoCatApp *app) { if (!app) return; - if (app->pointer_relative_active) - bongo_cat_platform_relative_pointer_reset(&app->platform); - else bongo_cat_platform_relative_pointer_release(&app->platform); + bongo_cat_platform_relative_pointer_release(&app->platform); app->mver_pointer = (BongoCatMverPointerState){0}; app->model_pointer_anchor_ready = false; app->pointer_known = false; diff --git a/src/runtime/input/mouse_pipeline.c b/src/runtime/input/mouse_pipeline.c index 2ca97a08..b9924b94 100644 --- a/src/runtime/input/mouse_pipeline.c +++ b/src/runtime/input/mouse_pipeline.c @@ -3,9 +3,8 @@ #include -/* One frame of application mouse input. This module chooses the authoritative - pointer source, reconciles buttons, switches absolute/relative mode, and - hands normalized coordinates to the mapping module. */ +/* Select screen input, then map it independently for the model. */ +#ifndef _WIN32 static bool reconcile_button(BongoCatApp *app, bool *current, bool pressed, const char *parameter) { if (*current == pressed) return false; @@ -16,6 +15,7 @@ static bool reconcile_button(BongoCatApp *app, bool *current, bool pressed, app->dirty = true; return true; } +#endif void bongo_cat_app_apply_mouse(BongoCatApp *app) { if (!app) return; @@ -24,7 +24,7 @@ void bongo_cat_app_apply_mouse(BongoCatApp *app) { uint64_t now = SDL_GetTicksNS(); if (received) { app->mouse_last_ns = now ? now : 1; - app->mouse_hook_samples++; + app->mouse_native_samples++; } float global_x = 0.0f, global_y = 0.0f; SDL_MouseButtonFlags buttons = SDL_GetGlobalMouseState(&global_x, &global_y); @@ -32,32 +32,41 @@ void bongo_cat_app_apply_mouse(BongoCatApp *app) { bool cursor_locked = bongo_cat_platform_pointer_locked(&app->platform); bool cursor_lock_changed = cursor_locked != app->pointer_cursor_locked; bool button_changed = false; +#ifdef _WIN32 + /* Model buttons belong to Raw Input; cursor polling is position-only. */ + (void)buttons; +#else if (!button_event_pending && !cursor_locked) { button_changed = reconcile_button(app, &app->left_mouse_down, (buttons & SDL_BUTTON_LMASK) != 0, "ParamMouseLeftDown"); button_changed = reconcile_button(app, &app->right_mouse_down, (buttons & SDL_BUTTON_RMASK) != 0, "ParamMouseRightDown") || button_changed; - bool side_down = (buttons & (SDL_BUTTON_X1MASK | SDL_BUTTON_X2MASK)) != 0; + app->back_mouse_down = (buttons & SDL_BUTTON_X1MASK) != 0; + app->forward_mouse_down = (buttons & SDL_BUTTON_X2MASK) != 0; + bool side_down = app->back_mouse_down || app->forward_mouse_down; if (app->side_mouse_down != side_down) { app->side_mouse_down = side_down; button_changed = true; app->dirty = true; } } - if (!received && (!app->mouse_last_ns || !app->pointer_known)) { +#endif + bool native_selected = received || (app->pointer_known && + bongo_cat_mouse_sample_fresh(now, app->mouse_last_ns)); + /* Native screen updates are optional; SDL supplies the current position. */ + if (!native_selected) { target_x = global_x; target_y = global_y; } else if (!received) { target_x = app->pointer_x; target_y = app->pointer_y; } - /* Most games recenter the cursor as they enter relative-look mode. Use - that observed center as the new virtual origin instead of carrying a - pre-game edge position into the first relative sample. */ + /* Seed relative motion from the observed lock point. */ if (cursor_locked && cursor_lock_changed) { target_x = global_x; target_y = global_y; + native_selected = false; } bool moved = !app->pointer_known || app->pointer_x != target_x || app->pointer_y != target_y; @@ -75,11 +84,7 @@ void bongo_cat_app_apply_mouse(BongoCatApp *app) { bool model_moved = moved; bool profile_relative = app->model_render_options.mver_projection && app->model_render_options.mouse_force_move; - /* A model may request forced mouse movement, but while the cursor is - visible and free its hook coordinates are the authoritative screen - position. Enter relative mode only after another foreground window - actually confines the cursor; otherwise a missing raw sample would - leave the virtual point stuck at an old position. */ + /* Model metadata alone does not imply that a game locked the cursor. */ bool relative_requested = !app->settings.model.ignore_mouse && cursor_locked; bool pointer_mode_changed = false; @@ -98,27 +103,25 @@ void bongo_cat_app_apply_mouse(BongoCatApp *app) { (app->model_render_options.mver_projection && (!app->settings.model.mouse_centered || profile_relative))); bool map_ok = !map_pointer; + app->pointer_diagnostics.bounds_known = false; + app->pointer_diagnostics.bounds = (BongoCatMverPointerBounds){0}; if (map_pointer) { map_ok = bongo_cat_app_map_pointer(app, relative_requested, target_x, target_y, &model_x, &model_y, &model_moved); if (!map_ok) { + app->pointer_diagnostics.map_failures++; model_x = target_x; model_y = target_y; model_moved = moved; } } - if (pointer_mode_changed) - bongo_cat_mouse_log_mode_change(app, cursor_locked, profile_relative, - relative_requested, map_pointer, map_ok, target_x, target_y, - model_x, model_y, model_moved); - bongo_cat_mouse_log_diagnostics(app, now, received, target_x, target_y, - global_x, global_y, buttons, cursor_locked, relative_requested); - if (app->settings.model.ignore_mouse || - (!model_moved && !button_changed && !pointer_mode_changed && - !button_event_pending)) { - app->mouse_button_event_pending = false; - return; + if (pointer_mode_changed) app->pointer_diagnostics.mode_changes++; + if (!app->settings.model.ignore_mouse && + (model_moved || button_changed || pointer_mode_changed || button_event_pending)) { + app->input_mouse_updates++; + bongo_cat_app_apply_mouse_coordinates(app, model_x, model_y, + cursor_locked ? model_x : target_x, cursor_locked ? model_y : target_y); } - app->input_mouse_updates++; - bongo_cat_app_apply_mouse_coordinates(app, model_x, model_y, - cursor_locked ? model_x : target_x, cursor_locked ? model_y : target_y); app->mouse_button_event_pending = false; + bongo_cat_mouse_log_diagnostics(app, now, target_x, target_y, + cursor_locked, relative_requested, + model_x, model_y, model_moved, native_selected); } diff --git a/src/runtime/lifecycle/app_input.c b/src/runtime/lifecycle/app_input.c index d0bc4474..8cff9d3f 100644 --- a/src/runtime/lifecycle/app_input.c +++ b/src/runtime/lifecycle/app_input.c @@ -6,35 +6,6 @@ #include #include -static void log_input_consumer(BongoCatApp *app) { - if (!app) return; - uint64_t now = SDL_GetTicksNS(); - if (app->input_diagnostic_due_ns && now < app->input_diagnostic_due_ns) - return; - app->input_diagnostic_due_ns = (now ? now : 1) + 30000000000ull; - SDL_Log("[input] Main consumer: events=%llu key_down=%llu key_up=%llu " - "mouse_down=%llu mouse_up=%llu key_supported=%llu " - "key_unsupported=%llu mouse_applied=%llu mouse_updates=%llu " - "shortcuts=%llu " - "last=%s dropped=%llu active=%zu buttons=%d,%d,%d " - "pointer_known=%d pointer=%.1f,%.1f", - (unsigned long long)app->input_events_consumed, - (unsigned long long)app->input_key_down_events, - (unsigned long long)app->input_key_up_events, - (unsigned long long)app->input_mouse_down_events, - (unsigned long long)app->input_mouse_up_events, - (unsigned long long)app->input_key_supported, - (unsigned long long)app->input_key_unsupported, - (unsigned long long)app->input_mouse_applied, - (unsigned long long)app->input_mouse_updates, - (unsigned long long)app->input_shortcuts_triggered, - app->input_last_name[0] ? app->input_last_name : "none", - (unsigned long long)atomic_load_explicit(&app->input.dropped, - memory_order_relaxed), app->active_input_count, - app->left_mouse_down, app->right_mouse_down, app->side_mouse_down, - app->pointer_known, app->pointer_x, app->pointer_y); -} - static void record_input_event(BongoCatApp *app, const BongoCatInputEvent *event) { if (!app || !event) return; @@ -46,8 +17,6 @@ static void record_input_event(BongoCatApp *app, case BONGO_CAT_INPUT_MOUSE_UP: app->input_mouse_up_events++; break; default: break; } - snprintf(app->input_last_name, sizeof(app->input_last_name), "%s", - event->name[0] ? event->name : "none"); } void bongo_cat_app_drain_input(BongoCatApp *app, bool allow_shortcuts) { @@ -83,5 +52,4 @@ void bongo_cat_app_drain_input(BongoCatApp *app, bool allow_shortcuts) { bongo_cat_app_apply_input(app, &event); } if (!app->smoke_ignore_global_input) bongo_cat_app_apply_mouse(app); - log_input_consumer(app); } diff --git a/src/runtime/shell/window.c b/src/runtime/shell/window.c index f682e97e..94f918c8 100644 --- a/src/runtime/shell/window.c +++ b/src/runtime/shell/window.c @@ -48,6 +48,8 @@ static bool try_window(BongoCatApp *app, bool transparent, int samples, BongoCatResult bongo_cat_window_create(BongoCatApp *app, BongoCatError *error) { SDL_SetHint(SDL_HINT_MOUSE_FOCUS_CLICKTHROUGH, "1"); #ifdef _WIN32 + /* The native input thread owns keyboard Raw Input for this process. */ + SDL_SetHintWithPriority(SDL_HINT_WINDOWS_RAW_KEYBOARD, "0", SDL_HINT_OVERRIDE); /* Transparent OpenGL windows must never receive SDL's default black WM_ERASEBKGND fill before the first frame is submitted. */ SDL_SetHint(SDL_HINT_WINDOWS_ERASE_BACKGROUND_MODE, "0"); diff --git a/tests/core/test_app_state.c b/tests/core/test_app_state.c index 0cfb6d01..5f3f2dc1 100644 --- a/tests/core/test_app_state.c +++ b/tests/core/test_app_state.c @@ -200,6 +200,18 @@ int main(void) { CHECK(!app.right_mouse_down && app.pointer_hit_dirty); CHECK(parameter("ParamMouseRightDown") == 0.0f); + event = input(BONGO_CAT_INPUT_MOUSE_DOWN, "Back", 1.0f); + bongo_cat_app_apply_input(&app, &event); + event = input(BONGO_CAT_INPUT_MOUSE_DOWN, "Forward", 1.0f); + bongo_cat_app_apply_input(&app, &event); + CHECK(app.side_mouse_down); + event = input(BONGO_CAT_INPUT_MOUSE_UP, "Back", 0.0f); + bongo_cat_app_apply_input(&app, &event); + CHECK(app.side_mouse_down && app.forward_mouse_down); + event = input(BONGO_CAT_INPUT_MOUSE_UP, "Forward", 0.0f); + bongo_cat_app_apply_input(&app, &event); + CHECK(!app.side_mouse_down); + event = input(BONGO_CAT_INPUT_KEY_DOWN, "KeyA", 1.0f); bongo_cat_app_apply_input(&app, &event); CHECK(parameter("CatParamLeftHandDown") == 1.0f); diff --git a/tests/core/test_input.c b/tests/core/test_input.c index d3bf924b..12d2747c 100644 --- a/tests/core/test_input.c +++ b/tests/core/test_input.c @@ -6,57 +6,36 @@ #ifdef _WIN32 #include "windows_keys.h" -typedef struct KeyEvents { - unsigned count; - bool down; - char name[BONGO_CAT_ID_CAP]; -} KeyEvents; - -static void collect_key(bool down, const char *name, void *userdata) { - KeyEvents *events = userdata; - events->count++; - events->down = down; - snprintf(events->name, sizeof(events->name), "%s", name); -} #endif void test_input(void) { #ifdef _WIN32 char key_name[16]; - KBDLLHOOKSTRUCT key = {.vkCode = VK_NUMPAD1}; + RAWKEYBOARD key = {.VKey = VK_NUMPAD1}; CHECK(strcmp(bongo_cat_windows_key_name(&key, key_name), "Kp1") == 0); - key.vkCode = VK_END; + key.VKey = VK_END; CHECK(strcmp(bongo_cat_windows_key_name(&key, key_name), "Kp1") == 0); - key.flags = LLKHF_EXTENDED; + key.Flags = RI_KEY_E0; CHECK(strcmp(bongo_cat_windows_key_name(&key, key_name), "End") == 0); - key.vkCode = VK_SNAPSHOT; + key.VKey = VK_SNAPSHOT; CHECK(strcmp(bongo_cat_windows_key_name(&key, key_name), "PrintScreen") == 0); - key.vkCode = VK_APPS; + key.VKey = VK_APPS; CHECK(strcmp(bongo_cat_windows_key_name(&key, key_name), "Apps") == 0); - BongoCatWindowsKeyboard keyboard = {0}; - KeyEvents key_events = {0}; - UINT drop_key_up = 0; - key.vkCode = 'A'; - key.flags = 0; - CHECK(bongo_cat_windows_keyboard_event(&keyboard, &key, WM_KEYDOWN, - &drop_key_up, collect_key, &key_events)); - uint64_t pressed_ms = keyboard.changed_ms['A']; - CHECK(key_events.count == 1 && key_events.down && - strcmp(key_events.name, "KeyA") == 0); - CHECK(!bongo_cat_windows_keyboard_reconcile_key(&keyboard, 'A', - pressed_ms + 49, false, collect_key, &key_events)); - CHECK(!bongo_cat_windows_keyboard_reconcile_key(&keyboard, 'A', - pressed_ms + 10000, true, collect_key, &key_events)); - CHECK(key_events.count == 1 && keyboard.down['A']); - drop_key_up = 'A'; - CHECK(bongo_cat_windows_keyboard_event(&keyboard, &key, WM_KEYUP, - &drop_key_up, collect_key, &key_events)); - CHECK(drop_key_up == 0 && key_events.count == 1 && keyboard.down['A']); - CHECK(bongo_cat_windows_keyboard_reconcile_key(&keyboard, 'A', - pressed_ms + 10000, false, collect_key, &key_events)); - CHECK(key_events.count == 2 && !key_events.down && - strcmp(key_events.name, "KeyA") == 0 && !keyboard.down['A']); + key = (RAWKEYBOARD){.VKey = VK_SHIFT, .MakeCode = 0x36}; + CHECK(strcmp(bongo_cat_windows_key_name(&key, key_name), "ShiftRight") == 0); + key = (RAWKEYBOARD){.VKey = VK_CONTROL, .MakeCode = 0x1d}; + unsigned left_control = bongo_cat_windows_key_index(&key); + CHECK(strcmp(bongo_cat_windows_key_name(&key, key_name), "ControlLeft") == 0); + key.Flags = RI_KEY_E0; + CHECK(strcmp(bongo_cat_windows_key_name(&key, key_name), "ControlRight") == 0); + CHECK(left_control != bongo_cat_windows_key_index(&key)); + key = (RAWKEYBOARD){.VKey = VK_MENU, .Flags = RI_KEY_E0}; + CHECK(strcmp(bongo_cat_windows_key_name(&key, key_name), "AltGr") == 0); + key.VKey = 255; + CHECK(bongo_cat_windows_key_name(&key, key_name) == NULL); + key = (RAWKEYBOARD){.VKey = 'A', .MakeCode = 0xff}; + CHECK(bongo_cat_windows_key_name(&key, key_name) == NULL); #endif BongoCatInputState state; bongo_cat_input_init(&state); @@ -107,6 +86,13 @@ void test_input(void) { CHECK(bongo_cat_input_take_mouse(&state, &x, &y)); CHECK(x == 10.0 && y == 11.0); + CHECK(!bongo_cat_mouse_sample_fresh(1000000000ull, 0)); + CHECK(bongo_cat_mouse_sample_fresh(1000000000ull, 1000000000ull)); + CHECK(bongo_cat_mouse_sample_fresh(1099999999ull, 1000000000ull)); + CHECK(!bongo_cat_mouse_sample_fresh(1100000000ull, 1000000000ull)); + CHECK(!bongo_cat_mouse_sample_fresh(31000000000ull, 1000000000ull)); + CHECK(!bongo_cat_mouse_sample_fresh(999999999ull, 1000000000ull)); + BongoCatMouseTracking tracking = {0}; bongo_cat_mouse_target(&tracking, 0.0, 0.0); CHECK(bongo_cat_mouse_step(&tracking, 1.0f / 60.0f, &x, &y)); diff --git a/tests/core/test_mver_pointer.c b/tests/core/test_mver_pointer.c index 38f74078..1a62943b 100644 --- a/tests/core/test_mver_pointer.c +++ b/tests/core/test_mver_pointer.c @@ -69,4 +69,22 @@ void test_mver_pointer(void) { CHECK(bongo_cat_mver_pointer_update(&state, 800.0, 700.0, 0.0, 0.0, false, &bounds, &pointer_x, &pointer_y)); CHECK(pointer_x == 800.0 && pointer_y == 700.0); + + state = (BongoCatMverPointerState){0}; + bounds = (BongoCatMverPointerBounds){0.0, 0.0, 3440.0, 1440.0}; + CHECK(bongo_cat_mver_pointer_update(&state, 1720.0, 720.0, + 0.0, 0.0, false, &bounds, &pointer_x, &pointer_y)); + for (int index = 0; index < 10; ++index) + CHECK(bongo_cat_mver_pointer_update(&state, 1720.0, 720.0, + -1000.0, -1000.0, true, &bounds, &pointer_x, &pointer_y)); + CHECK(pointer_x == 0.0 && pointer_y == 0.0); + CHECK(bongo_cat_mver_pointer_update(&state, 1720.0, 720.0, + 5.0, 7.0, true, &bounds, &pointer_x, &pointer_y)); + CHECK(pointer_x == 5.0 && pointer_y == 7.0); + CHECK(bongo_cat_mver_pointer_update(&state, 1720.0, 720.0, + 0.0, 0.0, true, &bounds, &pointer_x, &pointer_y)); + CHECK(pointer_x == 5.0 && pointer_y == 7.0); + CHECK(bongo_cat_mver_pointer_update(&state, 2700.0, 900.0, + 0.0, 0.0, false, &bounds, &pointer_x, &pointer_y)); + CHECK(pointer_x == 2700.0 && pointer_y == 900.0); } diff --git a/tests/platform/test_windows_capture.c b/tests/platform/test_windows_capture.c index 00baca42..9d481f20 100644 --- a/tests/platform/test_windows_capture.c +++ b/tests/platform/test_windows_capture.c @@ -1,8 +1,6 @@ #include "windows_capture.h" #include "windows_borderless.h" #include "windows_diagnostics.h" -#include "windows_direct_input.h" -#include "windows_input.h" #include "windows_package.h" #include "windows_popup.h" #include "windows_tray.h" @@ -92,41 +90,6 @@ static void test_popup_completion(void) { DestroyWindow(window); } -static void test_input_startup(void) { - SDL_unsetenv_unsafe("BONGO_CAT_TEST_HOOK_FAILURE"); - SDL_setenv_unsafe("BONGO_CAT_TEST_HOOK_DELAY_MS", "2000", 1); - BongoCatInputState input; - bongo_cat_input_init(&input); - BongoCatPlatform platform = {.input = &input}; - ULONGLONG started = GetTickCount64(); - CHECK(bongo_cat_windows_input_start(&platform)); - CHECK(GetTickCount64() - started < 500); - bongo_cat_windows_input_stop(&platform); - CHECK(platform.native == NULL); - SDL_unsetenv_unsafe("BONGO_CAT_TEST_HOOK_DELAY_MS"); - SDL_setenv_unsafe("BONGO_CAT_TEST_HOOK_FAILURE", "1", 1); - CHECK(!bongo_cat_windows_input_start(&platform)); - CHECK(platform.native == NULL); - SDL_unsetenv_unsafe("BONGO_CAT_TEST_HOOK_FAILURE"); -} - -static void test_direct_input_rebase(void) { - HWND window = CreateWindowExW(0, L"STATIC", L"BongoCat input test", - 0, 0, 0, 0, 0, HWND_MESSAGE, NULL, GetModuleHandleW(NULL), NULL); - CHECK(window != NULL); - if (!window) return; - BongoCatPlatform platform = {0}; - if (bongo_cat_windows_direct_input_create(&platform, window)) { - double x = 0.0, y = 0.0; - CHECK(!bongo_cat_platform_relative_pointer(&platform, &x, &y)); - bongo_cat_windows_direct_input_reset(&platform); - CHECK(!bongo_cat_platform_relative_pointer(&platform, &x, &y)); - bongo_cat_windows_direct_input_destroy(&platform); - CHECK(platform.relative_pointer == NULL); - } - DestroyWindow(window); -} - static void count_tray_restore(void *userdata) { int *count = userdata; (*count)++; @@ -221,7 +184,5 @@ int main(void) { test_transparency_lifecycle(); test_tray_restart_notification(); test_popup_completion(); - test_input_startup(); - test_direct_input_rebase(); return failures ? 1 : 0; } diff --git a/tests/platform/test_windows_input.c b/tests/platform/test_windows_input.c new file mode 100644 index 00000000..0a731003 --- /dev/null +++ b/tests/platform/test_windows_input.c @@ -0,0 +1,203 @@ +#include "test.h" +#include "windows_input_internal.h" + +#include +#include +#include + +int bongo_cat_test_failures; +void test_windows_relative_sources(void); +void test_windows_raw_receiver(void); + +typedef struct RawFixture { + WindowsInputState state; + BongoCatInputState input; + BongoCatPlatform platform; +} RawFixture; + +static void initialize(RawFixture *test) { + bongo_cat_input_init(&test->input); + test->platform.input = &test->input; + test->platform.native = &test->state; + test->state.platform = &test->platform; + InitializeSRWLock(&test->state.platform_lock); + InitializeSRWLock(&test->state.relative_lock); +} + +static void key_packet(RawFixture *test, uintptr_t device, USHORT code, + USHORT scan, USHORT flags) { + RAWINPUT packet = {0}; + packet.header.dwSize = offsetof(RAWINPUT, data) + sizeof(RAWKEYBOARD); + packet.header.dwType = RIM_TYPEKEYBOARD; + packet.header.hDevice = (HANDLE)device; + packet.header.wParam = RIM_INPUTSINK; + packet.data.keyboard = (RAWKEYBOARD){.VKey = code, .MakeCode = scan, + .Flags = flags}; + bongo_cat_windows_input_packet(&test->state, &packet, packet.header.dwSize); +} + +static void expect(RawFixture *test, BongoCatInputKind kind, const char *name) { + BongoCatInputEvent event = {0}; + CHECK(bongo_cat_input_pop(&test->input, &event)); + CHECK(event.kind == kind && strcmp(event.name, name) == 0); + CHECK(event.timestamp_ms <= SDL_GetTicks()); +} + +static void expect_empty(RawFixture *test) { + BongoCatInputEvent event; + CHECK(!bongo_cat_input_pop(&test->input, &event)); +} + +static void test_key_devices(void) { + RawFixture test = {0}; + initialize(&test); + key_packet(&test, 1, 'A', 0x1e, 0); + expect(&test, BONGO_CAT_INPUT_KEY_DOWN, "KeyA"); + key_packet(&test, 1, 'A', 0x1e, 0); + key_packet(&test, 2, 'A', 0x1e, 0); + key_packet(&test, 1, 'A', 0x1e, RI_KEY_BREAK); + expect_empty(&test); + bongo_cat_windows_input_remove_device(&test.state, (HANDLE)(uintptr_t)2); + expect(&test, BONGO_CAT_INPUT_KEY_UP, "KeyA"); + key_packet(&test, 1, VK_CONTROL, 0x1d, 0); + key_packet(&test, 1, VK_CONTROL, 0x1d, RI_KEY_E0); + expect(&test, BONGO_CAT_INPUT_KEY_DOWN, "ControlLeft"); + expect(&test, BONGO_CAT_INPUT_KEY_DOWN, "ControlRight"); + key_packet(&test, 1, VK_CONTROL, 0x1d, RI_KEY_BREAK); + expect(&test, BONGO_CAT_INPUT_KEY_UP, "ControlLeft"); + CHECK(bongo_cat_input_control_down(&test.input)); + bongo_cat_windows_input_clear_devices(&test.state); + expect(&test, BONGO_CAT_INPUT_KEY_UP, "ControlRight"); + CHECK(!bongo_cat_input_control_down(&test.input)); + CHECK(test.state.device_count == 0 && test.state.counters.background == 7); +} + +static void test_key_sequences(void) { + RawFixture test = {0}; + initialize(&test); + key_packet(&test, 0, VK_NUMPAD1, 0x4f, 0); + expect(&test, BONGO_CAT_INPUT_KEY_DOWN, "Kp1"); + key_packet(&test, 0, VK_END, 0x4f, RI_KEY_BREAK); + expect(&test, BONGO_CAT_INPUT_KEY_UP, "Kp1"); + key_packet(&test, 0, VK_RETURN, 0x1c, 0); + key_packet(&test, 0, VK_RETURN, 0x1c, RI_KEY_E0); + expect(&test, BONGO_CAT_INPUT_KEY_DOWN, "Return"); + key_packet(&test, 0, VK_RETURN, 0x1c, RI_KEY_BREAK); + expect_empty(&test); + key_packet(&test, 0, VK_RETURN, 0x1c, RI_KEY_E0 | RI_KEY_BREAK); + expect(&test, BONGO_CAT_INPUT_KEY_UP, "Return"); + key_packet(&test, 0, VK_SHIFT, 0x2a, RI_KEY_E0); + key_packet(&test, 0, 255, 0, 0); + key_packet(&test, 0, 'A', 0xff, 0); + expect_empty(&test); + key_packet(&test, 0, VK_CONTROL, 0x1d, RI_KEY_E1); + key_packet(&test, 0, VK_NUMLOCK, 0x45, 0); + expect(&test, BONGO_CAT_INPUT_KEY_DOWN, "Pause"); + expect(&test, BONGO_CAT_INPUT_KEY_UP, "Pause"); + CHECK(!bongo_cat_input_control_down(&test.input)); + bongo_cat_windows_input_clear_devices(&test.state); + expect_empty(&test); +} + +static void test_buttons(void) { + RawFixture test = {0}; + initialize(&test); + WindowsRawDevice *a = bongo_cat_windows_input_device(&test.state, NULL); + WindowsRawDevice *b = bongo_cat_windows_input_device(&test.state, + (HANDLE)(uintptr_t)1); + CHECK(a && b); + if (!a || !b) { bongo_cat_windows_input_clear_devices(&test.state); return; } + bongo_cat_windows_input_buttons(&test.state, a, + RI_MOUSE_LEFT_BUTTON_DOWN | RI_MOUSE_BUTTON_4_DOWN); + expect(&test, BONGO_CAT_INPUT_MOUSE_DOWN, "Left"); + expect(&test, BONGO_CAT_INPUT_MOUSE_DOWN, "Back"); + bongo_cat_windows_input_buttons(&test.state, b, RI_MOUSE_LEFT_BUTTON_DOWN); + bongo_cat_windows_input_remove_device(&test.state, NULL); + expect(&test, BONGO_CAT_INPUT_MOUSE_UP, "Back"); + expect_empty(&test); + bongo_cat_windows_input_buttons(&test.state, b, RI_MOUSE_LEFT_BUTTON_UP); + expect(&test, BONGO_CAT_INPUT_MOUSE_UP, "Left"); + bongo_cat_windows_input_buttons(&test.state, b, + RI_MOUSE_RIGHT_BUTTON_DOWN | RI_MOUSE_RIGHT_BUTTON_UP); + expect(&test, BONGO_CAT_INPUT_MOUSE_DOWN, "Right"); + expect(&test, BONGO_CAT_INPUT_MOUSE_UP, "Right"); + bongo_cat_windows_input_clear_devices(&test.state); +} + +static void fill_queue(RawFixture *test, bool recovery) { + BongoCatInputEvent event = {.kind = BONGO_CAT_INPUT_KEY_DOWN}; + for (unsigned i = 1; i < BONGO_CAT_INPUT_QUEUE_CAP; ++i) + CHECK(bongo_cat_input_push(&test->input, &event)); + if (!recovery) return; + event.kind = BONGO_CAT_INPUT_KEY_UP; + for (unsigned i = 1; i < BONGO_CAT_INPUT_RECOVERY_CAP; ++i) + CHECK(bongo_cat_input_push(&test->input, &event)); +} + +static void drain(RawFixture *test) { + BongoCatInputEvent event; + while (bongo_cat_input_pop(&test->input, &event)) {} +} + +static void test_backpressure(void) { + RawFixture test = {0}; + initialize(&test); + fill_queue(&test, false); + key_packet(&test, 1, 'A', 0x1e, 0); + CHECK(test.state.counters.queue_failures == 1); + drain(&test); + bongo_cat_windows_input_flush(&test.state); + expect(&test, BONGO_CAT_INPUT_KEY_DOWN, "KeyA"); + fill_queue(&test, true); + bongo_cat_windows_input_clear_devices(&test.state); + CHECK(test.state.counters.queue_failures == 2); + drain(&test); + bongo_cat_windows_input_flush(&test.state); + expect(&test, BONGO_CAT_INPUT_KEY_UP, "KeyA"); + expect_empty(&test); +} + +static void test_packets(void) { + RawFixture test = {0}; + initialize(&test); + RAWINPUT packet = {0}; + packet.header.dwSize = sizeof(packet); + packet.header.dwType = RIM_TYPEMOUSE; + packet.header.wParam = RIM_INPUTSINK; + packet.data.mouse.lLastX = 8; + bongo_cat_windows_input_reset_relative(&test.platform); + CHECK(!test.state.receiving && test.state.ownership == 0); + bongo_cat_windows_input_packet(&test.state, &packet, sizeof(packet)); + double x, y; + CHECK(bongo_cat_windows_input_take_relative(&test.platform, &x, &y, NULL)); + CHECK(x == 8.0 && y == 0.0 && test.state.counters.mouse == 1); + CHECK(test.state.relative_reads == 1); + CHECK(!bongo_cat_windows_input_take_relative(&test.platform, &x, &y, NULL)); + bongo_cat_windows_input_packet(&test.state, &packet, 1); + packet.header.dwSize = sizeof(RAWINPUTHEADER); + bongo_cat_windows_input_packet(&test.state, &packet, packet.header.dwSize); + CHECK(test.state.counters.invalid == 2 && test.state.counters.mouse == 1); + packet.header.dwType = RIM_TYPEHID; + bongo_cat_windows_input_packet(&test.state, &packet, packet.header.dwSize); + CHECK(test.state.counters.ignored == 1); + bongo_cat_windows_input_clear_devices(&test.state); + test.state.desktop_unavailable = true; + key_packet(&test, 1, 'A', 0x1e, 0); + CHECK(test.state.device_count == 1); + expect(&test, BONGO_CAT_INPUT_KEY_DOWN, "KeyA"); + bongo_cat_windows_input_clear_devices(&test.state); + expect(&test, BONGO_CAT_INPUT_KEY_UP, "KeyA"); +} + +int main(void) { + CHECK(SDL_Init(SDL_INIT_EVENTS)); + test_key_devices(); + test_key_sequences(); + test_buttons(); + test_backpressure(); + test_packets(); + test_windows_relative_sources(); + test_windows_raw_receiver(); + SDL_Quit(); + return bongo_cat_test_failures ? 1 : 0; +} diff --git a/tests/platform/test_windows_raw_receiver.c b/tests/platform/test_windows_raw_receiver.c new file mode 100644 index 00000000..950de8b3 --- /dev/null +++ b/tests/platform/test_windows_raw_receiver.c @@ -0,0 +1,103 @@ +#include "test.h" +#include "windows_input_internal.h" + +#include + +static unsigned registered_to(HWND window) { + WindowsInputState state = {.window = window}; + return bongo_cat_windows_input_ownership(&state); +} + +static void test_registration_flags(void) { + HWND receiver = CreateWindowExW(0, L"STATIC", L"", 0, + 0, 0, 0, 0, HWND_MESSAGE, NULL, GetModuleHandleW(NULL), NULL); + CHECK(receiver != NULL); + if (!receiver) return; + RAWINPUTDEVICE mouse = {1, 2, RIDEV_INPUTSINK, receiver}; + CHECK(RegisterRawInputDevices(&mouse, 1, sizeof(mouse))); + WindowsInputState state = {.window = receiver}; + CHECK(bongo_cat_windows_input_ownership(&state) == 1); + CHECK(state.registration_error == ERROR_SUCCESS); + CHECK((state.mouse_registration_flags & RIDEV_INPUTSINK) != 0); + CHECK(registered_to(NULL) == 0); + mouse.dwFlags = RIDEV_INPUTSINK | RIDEV_DEVNOTIFY; + CHECK(RegisterRawInputDevices(&mouse, 1, sizeof(mouse))); + CHECK(bongo_cat_windows_input_ownership(&state) == 1); + mouse.dwFlags = 0; + CHECK(RegisterRawInputDevices(&mouse, 1, sizeof(mouse))); + CHECK(bongo_cat_windows_input_ownership(&state) == 0); + mouse.dwFlags = RIDEV_REMOVE; + mouse.hwndTarget = NULL; + CHECK(RegisterRawInputDevices(&mouse, 1, sizeof(mouse))); + CHECK(bongo_cat_windows_input_ownership(&state) == 0); + DestroyWindow(receiver); +} + +void test_windows_raw_receiver(void) { + test_registration_flags(); + BongoCatInputState input; + bongo_cat_input_init(&input); + BongoCatPlatform platform = {.input = &input}; + SDL_setenv_unsafe("BONGO_CAT_TEST_RAW_INPUT_FAILURE", "1", 1); + CHECK(!bongo_cat_windows_input_start(&platform)); + CHECK(platform.native == NULL); + SDL_unsetenv_unsafe("BONGO_CAT_TEST_RAW_INPUT_FAILURE"); + SDL_setenv_unsafe("BONGO_CAT_TEST_RAW_INPUT_DELAY_MS", "5000", 1); + ULONGLONG started = GetTickCount64(); + CHECK(!bongo_cat_windows_input_start(&platform)); + CHECK(GetTickCount64() - started < 4000); + CHECK(platform.native == NULL); + SDL_unsetenv_unsafe("BONGO_CAT_TEST_RAW_INPUT_DELAY_MS"); + + SDL_SetHintWithPriority(SDL_HINT_WINDOWS_RAW_KEYBOARD, "0", SDL_HINT_OVERRIDE); + CHECK(SDL_InitSubSystem(SDL_INIT_VIDEO)); + SDL_Window *window = SDL_CreateWindow("Raw Input test", 64, 64, SDL_WINDOW_HIDDEN); + CHECK(window != NULL); + CHECK(bongo_cat_windows_input_start(&platform)); + if (platform.native) { + WindowsInputState *state = platform.native; + HWND receiver = state->window; + CHECK(registered_to(receiver) == 3); + CHECK(!IsWindowVisible(receiver)); + CHECK(GetWindowThreadProcessId(receiver, NULL) != GetCurrentThreadId()); + BongoCatPlatform duplicate = {.input = &input}; + CHECK(!bongo_cat_windows_input_start(&duplicate)); + SDL_Window *preferences = SDL_CreateWindow("Raw Input preferences test", + 64, 64, SDL_WINDOW_HIDDEN); + CHECK(preferences != NULL); + SDL_PumpEvents(); + CHECK(registered_to(receiver) == 3); + SDL_DestroyWindow(preferences); + bongo_cat_windows_input_stop(&platform); + CHECK(platform.native == NULL && registered_to(receiver) == 0); + CHECK(!IsWindow(receiver)); + bongo_cat_windows_input_stop(&platform); + } + SDL_DestroyWindow(window); + SDL_QuitSubSystem(SDL_INIT_VIDEO); + + HWND other = CreateWindowExW(0, L"STATIC", L"", 0, + 0, 0, 0, 0, HWND_MESSAGE, NULL, GetModuleHandleW(NULL), NULL); + CHECK(other != NULL); + if (!other) return; + RAWINPUTDEVICE mouse = {1, 2, RIDEV_INPUTSINK | RIDEV_DEVNOTIFY, other}; + CHECK(RegisterRawInputDevices(&mouse, 1, sizeof(mouse))); + CHECK(!bongo_cat_windows_input_start(&platform)); + CHECK(registered_to(other) == 1); + mouse.dwFlags = RIDEV_REMOVE; + mouse.hwndTarget = NULL; + CHECK(RegisterRawInputDevices(&mouse, 1, sizeof(mouse))); + + CHECK(bongo_cat_windows_input_start(&platform)); + if (platform.native) { + mouse.dwFlags = RIDEV_INPUTSINK | RIDEV_DEVNOTIFY; + mouse.hwndTarget = other; + CHECK(RegisterRawInputDevices(&mouse, 1, sizeof(mouse))); + bongo_cat_windows_input_stop(&platform); + CHECK(registered_to(other) == 1); + mouse.dwFlags = RIDEV_REMOVE; + mouse.hwndTarget = NULL; + CHECK(RegisterRawInputDevices(&mouse, 1, sizeof(mouse))); + } + DestroyWindow(other); +} diff --git a/tests/platform/test_windows_relative.c b/tests/platform/test_windows_relative.c new file mode 100644 index 00000000..8b6ec81d --- /dev/null +++ b/tests/platform/test_windows_relative.c @@ -0,0 +1,141 @@ +#include "test.h" +#include "windows_input_internal.h" + +static void test_relative_motion(void) { + WindowsInputState state = {0}; + WindowsRawDevice device = {0}; + BongoCatPlatform platform = {.native = &state}; + InitializeSRWLock(&state.relative_lock); + state.receiving = true; + RAWMOUSE mouse = {.lLastX = 17, .lLastY = -9}; + double x, y; + bongo_cat_windows_input_motion(&state, &device, &mouse, NULL); + CHECK(!bongo_cat_windows_input_take_relative(&platform, &x, &y, NULL)); + bongo_cat_windows_input_reset_relative(&platform); + bongo_cat_windows_input_motion(&state, &device, &mouse, NULL); + mouse.lLastX = -5; + bongo_cat_windows_input_motion(&state, &device, &mouse, NULL); + unsigned long long samples; + CHECK(bongo_cat_windows_input_take_relative(&platform, &x, &y, &samples)); + CHECK(x == 12.0 && y == -18.0 && samples == 2); + CHECK(bongo_cat_windows_input_take_relative(&platform, &x, &y, &samples)); + CHECK(x == 0.0 && y == 0.0 && samples == 0); + bongo_cat_windows_input_motion(&state, &device, &mouse, NULL); + bongo_cat_windows_input_reset_relative(&platform); + CHECK(bongo_cat_windows_input_take_relative(&platform, &x, &y, NULL)); + CHECK(x == 0.0 && y == 0.0); + bongo_cat_windows_input_release_relative(&platform); + bongo_cat_windows_input_motion(&state, &device, &mouse, NULL); + CHECK(!bongo_cat_windows_input_take_relative(&platform, &x, &y, NULL)); + bongo_cat_windows_input_reset_relative(&platform); + state.receiving = false; + CHECK(!bongo_cat_windows_input_take_relative(&platform, &x, &y, NULL)); +} + +static void test_absolute_devices(void) { + WindowsInputState state = {0}; + WindowsRawDevice a = {0}, b = {0}; + BongoCatPlatform platform = {.native = &state}; + InitializeSRWLock(&state.relative_lock); + state.receiving = true; + bongo_cat_windows_input_reset_relative(&platform); + RECT bounds = {-1920, -200, 3440, 1440}; + RAWMOUSE mouse = {.usFlags = MOUSE_MOVE_ABSOLUTE | MOUSE_VIRTUAL_DESKTOP}; + bongo_cat_windows_input_motion(&state, &a, &mouse, &bounds); + CHECK(a.absolute_x == -1920.0 && a.absolute_y == -200.0); + mouse.lLastX = mouse.lLastY = 65535; + bongo_cat_windows_input_motion(&state, &b, &mouse, &bounds); + double x, y; + CHECK(bongo_cat_windows_input_take_relative(&platform, &x, &y, NULL)); + CHECK(x == 0.0 && y == 0.0); + bongo_cat_windows_input_motion(&state, &a, &mouse, &bounds); + CHECK(bongo_cat_windows_input_take_relative(&platform, &x, &y, NULL)); + CHECK(x == 5359.0 && y == 1639.0); + CHECK(a.absolute_x == 3439.0 && a.absolute_y == 1439.0); + bongo_cat_windows_input_reset_relative(&platform); + mouse.lLastX = mouse.lLastY = 32768; + bongo_cat_windows_input_motion(&state, &a, &mouse, &bounds); + CHECK(bongo_cat_windows_input_take_relative(&platform, &x, &y, NULL)); + CHECK(x == 0.0 && y == 0.0); + bounds.right = 2560; + mouse.lLastX = 30000; + bongo_cat_windows_input_motion(&state, &a, &mouse, &bounds); + CHECK(bongo_cat_windows_input_take_relative(&platform, &x, &y, NULL)); + CHECK(x == 0.0 && y == 0.0); + mouse.lLastX = -1; + bongo_cat_windows_input_motion(&state, &a, &mouse, &bounds); + CHECK(!a.absolute_known && state.counters.invalid == 1); +} + +static void test_motion_units(void) { + WindowsInputState state = {0}; + WindowsRawDevice absolute = {0}, relative = {0}; + BongoCatPlatform platform = {.native = &state}; + InitializeSRWLock(&state.relative_lock); + state.receiving = true; + bongo_cat_windows_input_reset_relative(&platform); + RECT bounds = {0, 0, 2560, 1440}; + RAWMOUSE mouse = {.usFlags = MOUSE_MOVE_ABSOLUTE}; + bongo_cat_windows_input_motion(&state, &absolute, &mouse, &bounds); + mouse.lLastX = 32768; + bongo_cat_windows_input_motion(&state, &absolute, &mouse, &bounds); + mouse = (RAWMOUSE){.lLastX = 3, .lLastY = -4}; + bongo_cat_windows_input_motion(&state, &relative, &mouse, NULL); + double x, y; + CHECK(bongo_cat_windows_input_take_relative(&platform, &x, &y, NULL)); + CHECK(x == 3.0 && y == -4.0); + CHECK(bongo_cat_windows_input_take_relative(&platform, &x, &y, NULL)); + CHECK(x == 0.0 && y == 0.0); +} + +static void test_delivered_motion_without_ownership(void) { + WindowsInputState state = {0}; + WindowsRawDevice device = {0}; + BongoCatPlatform platform = {.native = &state}; + InitializeSRWLock(&state.relative_lock); + bongo_cat_windows_input_reset_relative(&platform); + RAWMOUSE mouse = {.lLastX = 12, .lLastY = -3}; + bongo_cat_windows_input_motion(&state, &device, &mouse, NULL); + mouse.lLastX = -12; + mouse.lLastY = 3; + bongo_cat_windows_input_motion(&state, &device, &mouse, NULL); + double x, y; + unsigned long long samples; + CHECK(bongo_cat_windows_input_take_relative(&platform, &x, &y, &samples)); + CHECK(x == 0.0 && y == 0.0 && samples == 2); + CHECK(!bongo_cat_windows_input_take_relative(&platform, &x, &y, NULL)); + RECT bounds = {0, 0, 1920, 1080}; + mouse = (RAWMOUSE){.usFlags = MOUSE_MOVE_ABSOLUTE}; + bongo_cat_windows_input_motion(&state, &device, &mouse, &bounds); + CHECK(!bongo_cat_windows_input_take_relative(&platform, &x, &y, NULL)); + mouse.lLastX = mouse.lLastY = 65535; + bongo_cat_windows_input_motion(&state, &device, &mouse, &bounds); + CHECK(bongo_cat_windows_input_take_relative(&platform, &x, &y, &samples)); + CHECK(x == 1919.0 && y == 1079.0 && samples == 1); + CHECK(state.relative_reads == 2); + CHECK(!bongo_cat_windows_input_take_relative(&platform, &x, &y, NULL)); +} + +static void test_relative_modes(void) { + RECT point = {1279, 799, 1279, 799}, screen = {0, 0, 2560, 1600}; + CURSORINFO cursor = {.cbSize = sizeof(cursor), .flags = CURSOR_SHOWING}; + CHECK(bongo_cat_windows_input_relative_mode(true, &point, &cursor)); + CHECK(!bongo_cat_windows_input_relative_mode(false, &point, &cursor)); + CHECK(!bongo_cat_windows_input_relative_mode(true, &screen, &cursor)); + cursor.flags = 0; + CHECK(bongo_cat_windows_input_relative_mode(true, &screen, &cursor)); + CHECK(bongo_cat_windows_input_relative_mode(true, NULL, &cursor)); + CHECK(!bongo_cat_windows_input_relative_mode(false, &screen, &cursor)); + CHECK(!bongo_cat_windows_input_relative_mode(true, &screen, NULL)); + CHECK(!bongo_cat_windows_input_relative_mode(true, NULL, NULL)); + cursor.flags = CURSOR_SUPPRESSED; + CHECK(!bongo_cat_windows_input_relative_mode(true, &screen, &cursor)); +} + +void test_windows_relative_sources(void) { + test_relative_motion(); + test_absolute_devices(); + test_motion_units(); + test_delivered_motion_without_ownership(); + test_relative_modes(); +} From ec855d7c555b69391c3e282845ada7ee8a1fd288 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 7 Sep 2026 03:36:30 +0800 Subject: [PATCH 232/334] chore(diagnostics): reduce Windows capture log noise Reuse the central Windows environment report and move detailed capture and device-context records to video debug logging. Keep input troubleshooting summaries visible during normal runs. Validation: static checks and diff whitespace checks passed. Cppcheck reports only the existing HANDLE sentinel portability warning in windows_capture.c. No build or compiled tests were run. --- src/platform/windows/windows_capture.c | 19 +++---------------- src/platform/windows/windows_diagnostics.c | 6 ++++-- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/src/platform/windows/windows_capture.c b/src/platform/windows/windows_capture.c index ade24ae0..b92e57e9 100644 --- a/src/platform/windows/windows_capture.c +++ b/src/platform/windows/windows_capture.c @@ -64,27 +64,13 @@ static HRESULT remove_taskbar_tab(HWND window) { static void log_environment(HWND window) { if (environment_logged) return; environment_logged = true; - OSVERSIONINFOW version = {.dwOSVersionInfoSize = sizeof(version)}; - typedef LONG (WINAPI *RtlGetVersionFn)(OSVERSIONINFOW *); - HMODULE module = GetModuleHandleW(L"ntdll.dll"); - RtlGetVersionFn get_version = module ? - (RtlGetVersionFn)(void *)GetProcAddress(module, "RtlGetVersion") : NULL; - bool version_known = get_version && get_version(&version) == 0; - BOOL composition = FALSE; - HRESULT composition_result = DwmIsCompositionEnabled(&composition); - SDL_Log("Windows capture environment: version=%lu.%lu.%lu known=%d " - "composition=%d composition_result=0x%08lx remote_session=%d", - (unsigned long)version.dwMajorVersion, - (unsigned long)version.dwMinorVersion, - (unsigned long)version.dwBuildNumber, version_known, - SUCCEEDED(composition_result) && composition, - (unsigned long)composition_result, GetSystemMetrics(SM_REMOTESESSION) != 0); bongo_cat_windows_diagnostics_log(window); } void bongo_cat_windows_capture_log(HWND window, const char *stage) { log_environment(window); if (!window || !IsWindow(window)) return; + if (SDL_GetLogPriority(SDL_LOG_CATEGORY_VIDEO) > SDL_LOG_PRIORITY_DEBUG) return; RECT bounds = {0}; DWORD cloaked = 0, affinity = 0; HRESULT cloak_result = DwmGetWindowAttribute(window, DWMWA_CLOAKED, @@ -93,7 +79,8 @@ void bongo_cat_windows_capture_log(HWND window, const char *stage) { LONG_PTR style = GetWindowLongPtrW(window, GWL_STYLE); LONG_PTR extended = GetWindowLongPtrW(window, GWL_EXSTYLE); GetWindowRect(window, &bounds); - SDL_Log("Windows capture state (%s): hwnd=%p visible=%d iconic=%d " + SDL_LogDebug(SDL_LOG_CATEGORY_VIDEO, + "Windows capture state (%s): hwnd=%p visible=%d iconic=%d " "cloaked=%lu cloak_known=%d rect=%ld,%ld %ldx%ld style=0x%llx " "exstyle=0x%llx owner=%p affinity=0x%lx affinity_known=%d", stage ? stage : "unknown", (void *)window, diff --git a/src/platform/windows/windows_diagnostics.c b/src/platform/windows/windows_diagnostics.c index e8022137..ac38e663 100644 --- a/src/platform/windows/windows_diagnostics.c +++ b/src/platform/windows/windows_diagnostics.c @@ -139,7 +139,8 @@ static void log_device_context(HWND window) { HDC current_dc = wglGetCurrentDC(); HGLRC current_context = wglGetCurrentContext(); HWND current_window = current_dc ? WindowFromDC(current_dc) : NULL; - SDL_Log("Window device context: pixel_format=%d described=%d " + SDL_LogDebug(SDL_LOG_CATEGORY_VIDEO, + "Window device context: pixel_format=%d described=%d " "pfd_flags=0x%lx color_bits=%u alpha_bits=%u depth_bits=%u " "stencil_bits=%u layer_type=%u technology=%d bits_pixel=%d " "planes=%d raster_caps=0x%x current_gl_context=%p current_dc=%p " @@ -183,7 +184,8 @@ static void log_window(HWND window) { RECT client = {0}; POINT client_origin = {0}; bool client_known = GetClientRect(window, &client) != FALSE && ClientToScreen(window, &client_origin) != FALSE; - SDL_Log("Capture window details: class=%s title=%s pid=%lu thread=%lu " + SDL_LogDebug(SDL_LOG_CATEGORY_VIDEO, + "Capture window details: class=%s title=%s pid=%lu thread=%lu " "dpi=%u frame_known=%d frame=%ld,%ld %ldx%ld " "client_known=%d client=%ld,%ld %ldx%ld " "layered_known=%d alpha=%u layered_flags=0x%lx", class_name, title, From 9edaaca2b1fb2d410ba82ce6d9903f7ea9ccd50d Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 7 Sep 2026 03:36:33 +0800 Subject: [PATCH 233/334] fix(preferences): release closed window resources after pending work Destroy the hidden preferences window, GL context, UI resources, and caches when no import, catalog refresh, or model selection work still needs them. Reset transient input and dialog state on close and recreate the window when it is opened again. Add lifecycle coverage for repeated open/close, deferred release during an import, and reopening before deferred work completes. Validation: cppcheck and static line checks passed. No build or compiled tests were run. --- cmake/Tests.cmake | 28 +++++++++ src/ui/preferences/preferences_lifecycle.c | 35 ++++++----- src/ui/preferences/preferences_present.c | 1 + src/ui/preferences/preferences_state.h | 1 + tests/ui/test_preferences_lifecycle.c | 68 ++++++++++++++++++++++ 5 files changed, 117 insertions(+), 16 deletions(-) create mode 100644 tests/ui/test_preferences_lifecycle.c diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index cec00f91..1568914e 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -76,6 +76,34 @@ if(BUILD_TESTING) endif() add_test(NAME model-import-unit COMMAND bongo_cat_mver_import_tests) + add_executable(bongo_cat_preferences_lifecycle_tests + tests/ui/test_preferences_lifecycle.c) + target_include_directories(bongo_cat_preferences_lifecycle_tests PRIVATE + ${BONGO_CAT_RUNTIME_INTERNAL_INCLUDE_DIRS}) + target_include_directories(bongo_cat_preferences_lifecycle_tests SYSTEM PRIVATE + ${BONGO_CAT_NUKLEAR_INCLUDE_DIR}) + target_link_libraries(bongo_cat_preferences_lifecycle_tests PRIVATE bongo_cat_runtime) + if(WIN32) + target_sources(bongo_cat_preferences_lifecycle_tests PRIVATE + "${CMAKE_CURRENT_BINARY_DIR}/windows_resources.rc") + add_dependencies(bongo_cat_preferences_lifecycle_tests bongo_cat_asset_pack) + else() + add_custom_command(TARGET bongo_cat_preferences_lifecycle_tests POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_CURRENT_SOURCE_DIR}/resources/assets" + "$/assets") + endif() + bongo_cat_stage_cubism_assets(bongo_cat_preferences_lifecycle_tests) + if(MSVC) + target_compile_options(bongo_cat_preferences_lifecycle_tests PRIVATE + /experimental:c11atomics) + endif() + add_test(NAME preferences-lifecycle COMMAND bongo_cat_preferences_lifecycle_tests + --ci-smoke --ci-ignore-global-input + "--storage-root=${CMAKE_CURRENT_BINARY_DIR}/preferences-lifecycle-data") + set_tests_properties(preferences-lifecycle PROPERTIES + ENVIRONMENT "BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN=1" TIMEOUT 60) + if(BONGO_CAT_CUBISM_ENABLED) add_executable(bongo_cat_motion_state_tests tests/live2d/test_motion_state.cpp) diff --git a/src/ui/preferences/preferences_lifecycle.c b/src/ui/preferences/preferences_lifecycle.c index 9935ff98..ae3dd430 100644 --- a/src/ui/preferences/preferences_lifecycle.c +++ b/src/ui/preferences/preferences_lifecycle.c @@ -92,30 +92,33 @@ void bongo_cat_preferences_close(BongoCatPreferences *value) { bongo_cat_preferences_behavior_dialog_close(value); bongo_cat_preferences_model_rename_finish(value, true); bongo_cat_preferences_shortcut_cancel(value); - if (value->gl_context) SDL_GL_MakeCurrent(value->window, value->gl_context); - if (value->ui_initialized && value->input_active) - bongo_cat_preferences_input_end(value); + value->behavior_dialog = false; + value->behavior_dialog_input_armed = false; + value->behavior_dialog_opened_ns = 0; + value->behavior_dialog_closing_ns = 0; + value->import_requested = false; + value->import_drop_active = false; + if (value->input_active) bongo_cat_preferences_input_end(value); if (value->ui_initialized) bongo_cat_ui_input_reset(&value->ui); SDL_StopTextInput(value->window); - bongo_cat_preferences_remove_dialog_clear(value->app); - if (value->ui_initialized) { - bongo_cat_pref_controls_reset(&value->ui.context); - bongo_cat_ui_animations_reset(&value->ui.context); - } if (value->chrome_dragging) SDL_CaptureMouse(false); + value->chrome_dragging = false; value->visible = false; SDL_HideWindow(value->window); - value->model_load_visual_active = false; - value->model_load_visual_completion_ns = 0; - value->smoke_behavior_open_pending = false; - value->native_drag = false; - value->chrome_dragging = false; - value->shown_ns = 0; - SDL_GL_MakeCurrent(value->app->window, value->app->gl_context); - SDL_GL_SetSwapInterval(1); + bongo_cat_preferences_release_idle_window(value); bongo_cat_config_store_flush(value->app); } +void bongo_cat_preferences_release_idle_window(BongoCatPreferences *value) { + if (!value || !value->window || value->visible) return; + /* A folder dialog still needs its owner; import completion can queue a + catalog refresh. Keep the window until both have finished. */ + if (bongo_cat_preferences_import_is_open(value->import_dialog) || + bongo_cat_app_model_refresh_busy(value->app) || value->model_loading || + value->model_selection_pending) return; + release_window(value); +} + void bongo_cat_preferences_destroy(BongoCatPreferences *value) { if (!value) return; bongo_cat_preferences_import_destroy(value->import_dialog); diff --git a/src/ui/preferences/preferences_present.c b/src/ui/preferences/preferences_present.c index 06f1ab6f..b9db1f2c 100644 --- a/src/ui/preferences/preferences_present.c +++ b/src/ui/preferences/preferences_present.c @@ -8,6 +8,7 @@ #include void bongo_cat_preferences_render(BongoCatPreferences *value) { + bongo_cat_preferences_release_idle_window(value); if (!value || !value->window || !value->visible) return; bongo_cat_preferences_drag_tick(value); uint64_t now = SDL_GetTicksNS(); diff --git a/src/ui/preferences/preferences_state.h b/src/ui/preferences/preferences_state.h index 7bc6955c..26023e3f 100644 --- a/src/ui/preferences/preferences_state.h +++ b/src/ui/preferences/preferences_state.h @@ -127,6 +127,7 @@ struct BongoCatPreferences { int bongo_cat_preferences_resolved_theme(const BongoCatPreferences *value); void bongo_cat_preferences_apply_theme(BongoCatPreferences *value); bool bongo_cat_preferences_open_window(BongoCatPreferences *value); +void bongo_cat_preferences_release_idle_window(BongoCatPreferences *value); bool bongo_cat_preferences_scale_event(BongoCatPreferences *value, const SDL_Event *event); bool bongo_cat_preferences_refresh_raster(BongoCatPreferences *value); diff --git a/tests/ui/test_preferences_lifecycle.c b/tests/ui/test_preferences_lifecycle.c new file mode 100644 index 00000000..21f844e4 --- /dev/null +++ b/tests/ui/test_preferences_lifecycle.c @@ -0,0 +1,68 @@ +#include "preferences_state.h" +#include "preferences_import_internal.h" +#include "runtime.h" + +#include +#include + +static int failures; +#define CHECK(condition) do { if (!(condition)) { \ + fprintf(stderr, "%d: %s\n", __LINE__, #condition); failures++; \ +} } while (0) + +static void busy(BongoCatImportDialog *dialog, bool open, bool importing) { + SDL_LockMutex(dialog->mutex); + dialog->open = open; + dialog->busy = importing; + SDL_UnlockMutex(dialog->mutex); +} + +int main(int argc, char **argv) { + BongoCatApp *app = calloc(1, sizeof(*app)); + BongoCatError error = {0}; + if (!app) return 1; + if (!bongo_cat_app_initialize(app, argc, argv, &error)) { + fprintf(stderr, "Initialization failed: %s\n", error.message); + bongo_cat_app_shutdown(app, "test:failed", 1); + free(app); + return 1; + } + BongoCatPreferences *value = app->preferences; + CHECK(value != NULL); + if (value) { + for (int cycle = 0; cycle < 3; ++cycle) { + bongo_cat_preferences_show(value); + CHECK(value->window && value->gl_context && value->ui_initialized); + bongo_cat_preferences_close(value); + CHECK(!value->window && !value->gl_context && !value->ui_initialized); + CHECK(SDL_GL_GetCurrentContext() == app->gl_context); + } + bongo_cat_preferences_show(value); + SDL_Window *window = value->window; + busy(value->import_dialog, true, false); + bongo_cat_preferences_close(value); + bongo_cat_preferences_render(value); + CHECK(value->window == window && !value->visible); + busy(value->import_dialog, false, true); + bongo_cat_preferences_render(value); + CHECK(value->window == window); + bongo_cat_preferences_show(value); + CHECK(value->window == window && value->visible); + busy(value->import_dialog, false, false); + bongo_cat_preferences_render(value); + CHECK(value->window == window && value->visible); + busy(value->import_dialog, false, true); + bongo_cat_preferences_close(value); + CHECK(value->window == window && !value->visible); + busy(value->import_dialog, false, false); + bongo_cat_preferences_render(value); + CHECK(!value->window && !value->gl_context && !value->ui_initialized); + bongo_cat_preferences_show(value); + CHECK(value->visible && value->ui_initialized); + bongo_cat_preferences_close(value); + } + bongo_cat_app_shutdown(app, "test:complete", failures != 0); + free(app); + printf("Preferences lifecycle: %d failures\n", failures); + return failures != 0; +} From b36f770af195dbb70f0321d616ab2d772d810b50 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 7 Sep 2026 03:37:04 +0800 Subject: [PATCH 234/334] fix(ui): align corner controls and refine dark surfaces Expose window roundness on a 0-100 scale while preserving stored radius units. Allow zero radius and keep clipping and hit testing consistent. Use the smaller raster scale for rounded surfaces and sidebar glow, simplify shell surface drawing, and use darker backgrounds without the dark-theme sidebar glow. Validation: cppcheck and static checks passed. No build, screenshots, or compiled tests were run. --- src/core/config_defaults.c | 2 +- src/runtime/shell/window_background.c | 6 ++++-- src/runtime/shell/window_hit.c | 2 +- src/ui/preferences/preferences_pages.c | 8 ++++++-- src/ui/rendering/ui_paint.c | 2 +- src/ui/rendering/ui_paint_shell.c | 3 ++- src/ui/theme/ui_catime.c | 11 ++--------- src/ui/theme/ui_catime_theme.c | 6 +++--- 8 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/core/config_defaults.c b/src/core/config_defaults.c index 91b62f3d..5a09842e 100644 --- a/src/core/config_defaults.c +++ b/src/core/config_defaults.c @@ -210,7 +210,7 @@ void bongo_cat_settings_validate(BongoCatSettings *config) { config->window.hide_delay_seconds = clampf_or( config->window.hide_delay_seconds, 0.0f, 60.0f, 0.0f); config->window.corner_radius_percent = clampf_or( - config->window.corner_radius_percent, 1.0f, 50.0f, + config->window.corner_radius_percent, 0.0f, 50.0f, BONGO_CAT_DEFAULT_WINDOW_CORNER_PERCENT); config->window.random_expression_interval_seconds = clampf_or( config->window.random_expression_interval_seconds, 1.0f, 3600.0f, diff --git a/src/runtime/shell/window_background.c b/src/runtime/shell/window_background.c index 6c8c3740..579d8abe 100644 --- a/src/runtime/shell/window_background.c +++ b/src/runtime/shell/window_background.c @@ -59,7 +59,9 @@ static bool prepare_corner_mask(void) { } void bongo_cat_window_mask_corners(BongoCatApp *app, int width, int height) { - if (!app->settings.window.rounded_corners || width <= 0 || height <= 0 || + if (!app->settings.window.rounded_corners || + app->settings.window.corner_radius_percent <= 0.0f || + width <= 0 || height <= 0 || !prepare_corner_mask()) return; GLint program, vao, equation_rgb, equation_alpha, src_rgb, dst_rgb, src_alpha, dst_alpha; @@ -90,7 +92,7 @@ void bongo_cat_window_mask_corners(BongoCatApp *app, int width, int height) { corner_gl.bind_vertex_array(corner_vao); corner_gl.uniform_1i(corner_width, width); corner_gl.uniform_1i(corner_height, height); - float percent = SDL_clamp(app->settings.window.corner_radius_percent, 1.0f, 50.0f); + float percent = SDL_clamp(app->settings.window.corner_radius_percent, 0.0f, 50.0f); corner_gl.uniform_1i(corner_radius, (GLint)(SDL_min(width, height) * percent * 10.0f + 0.5f)); glDrawArrays(GL_TRIANGLES, 0, 3); diff --git a/src/runtime/shell/window_hit.c b/src/runtime/shell/window_hit.c index 33a20d07..b92863d5 100644 --- a/src/runtime/shell/window_hit.c +++ b/src/runtime/shell/window_hit.c @@ -10,7 +10,7 @@ bool bongo_cat_window_visible_at_pointer(BongoCatApp *app, float x, float y) { width <= 0 || height <= 0 || pixel_width <= 0 || pixel_height <= 0) return false; if (app->settings.window.rounded_corners) { float radius = SDL_min(pixel_width, pixel_height) * - SDL_clamp(app->settings.window.corner_radius_percent, 1.0f, 50.0f) / 100.0f; + SDL_clamp(app->settings.window.corner_radius_percent, 0.0f, 50.0f) / 100.0f; float px = x * pixel_width / width; float py = y * pixel_height / height; float dx = SDL_max(SDL_fabsf(px - pixel_width * 0.5f) - diff --git a/src/ui/preferences/preferences_pages.c b/src/ui/preferences/preferences_pages.c index 14c985b9..357ffb64 100644 --- a/src/ui/preferences/preferences_pages.c +++ b/src/ui/preferences/preferences_pages.c @@ -76,10 +76,14 @@ static void page_display(BongoCatApp *app, struct nk_context *context) { bongo_cat_window_set_scale(app, requested_scale); } bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_WINDOW_CORNERS); + /* Display the fraction of maximum rounding; keep saved radii in their + original units (percent of the short edge) for existing settings. */ + float corner_roundness = window->corner_radius_percent * 2.0f; if (bongo_cat_pref_toggle_float(context, "window-corners", tr(app, "pages.preference.cat.labels.windowCorners", "Window Corners (%)"), - &window->rounded_corners, 1.0f, &window->corner_radius_percent, - 50.0f, 1.0f, BONGO_CAT_DEFAULT_WINDOW_CORNER_PERCENT)) { + &window->rounded_corners, 0.0f, &corner_roundness, + 100.0f, 1.0f, BONGO_CAT_DEFAULT_WINDOW_CORNER_PERCENT * 2.0f)) { + window->corner_radius_percent = corner_roundness * 0.5f; app->dirty = true; bongo_cat_window_mark_hit_dirty(app); } diff --git a/src/ui/rendering/ui_paint.c b/src/ui/rendering/ui_paint.c index 9d2da964..766c9b00 100644 --- a/src/ui/rendering/ui_paint.c +++ b/src/ui/rendering/ui_paint.c @@ -165,7 +165,7 @@ void bongo_cat_ui_paint_sidebar_glow(struct nk_context *context, int width, height; float sx, sy; BongoCatUIBackend *backend; if (!texture_dimensions(context, surface, &width, &height, &sx, &sy, &backend)) return; - float scale = (sx + sy) * .5f; + float scale = NK_MIN(sx, sy); BongoCatUIPaintKey key = {BONGO_CAT_UI_PAINT_SIDEBAR_GLOW, width, height, (int)lroundf(rounding * scale), (int)lroundf(sidebar * sx), 0, pack(color), 0}; diff --git a/src/ui/rendering/ui_paint_shell.c b/src/ui/rendering/ui_paint_shell.c index b240de0a..13a446ad 100644 --- a/src/ui/rendering/ui_paint_shell.c +++ b/src/ui/rendering/ui_paint_shell.c @@ -35,7 +35,8 @@ void bongo_cat_ui_paint_rounded_surface(struct nk_context *context, BONGO_CAT_UI_SHELL_SCALE_MAX); int width = NK_MAX(1, (int)ceilf(bounds.w * sx)); int height = NK_MAX(1, (int)ceilf(bounds.h * sy)); - int radius = (int)lroundf(rounding * (sx + sy) * .5f); + float raster_scale = NK_MIN(sx, sy); + int radius = (int)lroundf(rounding * raster_scale); BongoCatUIPaintKey key = {BONGO_CAT_UI_PAINT_ROUNDED_SURFACE, width, height, radius, 0, 0, 0, 0}; BongoCatUIPaintTexture *item = diff --git a/src/ui/theme/ui_catime.c b/src/ui/theme/ui_catime.c index 2a36db48..02d4673a 100644 --- a/src/ui/theme/ui_catime.c +++ b/src/ui/theme/ui_catime.c @@ -64,20 +64,13 @@ void bongo_cat_ui_shell_draw(struct nk_context *context, float width, nk_fill_rect(canvas, surface, 0, p.surface_glass); nk_fill_rect(canvas, nk_rect(0, 0, sidebar_right, height), 0, p.surface); - } else if (fast) { + } else { nk_fill_rect(canvas, surface, rounding, p.surface_glass); nk_push_scissor(canvas, nk_rect(0, 0, sidebar_right, height)); nk_fill_rect(canvas, surface, rounding, p.surface); nk_push_scissor(canvas, surface); - } else { - bongo_cat_ui_paint_rounded_surface(context, surface, rounding, - p.surface_glass); - nk_push_scissor(canvas, nk_rect(0, 0, sidebar_right, height)); - bongo_cat_ui_paint_rounded_surface(context, surface, rounding, - p.surface); - nk_push_scissor(canvas, surface); } - if (p.effects && !fast) + if (p.effects && !fast && !dark) bongo_cat_ui_paint_sidebar_glow(context, surface, sidebar_right, rounding, nk_rgba(p.accent.r, p.accent.g, p.accent.b, 56)); diff --git a/src/ui/theme/ui_catime_theme.c b/src/ui/theme/ui_catime_theme.c index a4c1d1b7..5e2ee8b2 100644 --- a/src/ui/theme/ui_catime_theme.c +++ b/src/ui/theme/ui_catime_theme.c @@ -39,9 +39,9 @@ BongoCatUIPalette bongo_cat_ui_palette(bool dark) { #ifdef _WIN32 if (high_contrast(&value)) return value; #endif - value.background = rgb(dark ? 0x16181D : 0xF0F4F9); - value.surface = rgb(dark ? 0x21242B : 0xFFFFFF); - value.surface_glass = rgb(dark ? 0x20232A : 0xFBFCFE); + value.background = rgb(dark ? 0x010409 : 0xF0F4F9); + value.surface = rgb(dark ? 0x010409 : 0xFFFFFF); + value.surface_glass = rgb(dark ? 0x010409 : 0xFBFCFE); value.field = rgb(dark ? 0x2A2E37 : 0xF5F8FC); value.border = rgb(dark ? 0x345473 : 0xDAEDFE); value.border_subtle = rgb(dark ? 0x343A45 : 0xE8EBF1); From 1857730321926c346619064bde6b94511bc3d7df Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 7 Sep 2026 03:37:07 +0800 Subject: [PATCH 235/334] docs: describe Raw Input and game compatibility limits Update the translated architecture notes for the Windows Raw Input backend. Document passive input behavior, process permissions, isolated development tools, and the unresolved background input issue in some games. Clarify that documented APIs and source policy checks do not guarantee anti-cheat acceptance or establish complete game compatibility. Validation: localization and diff whitespace checks passed. --- README.md | 21 +++++++++++++++------ SECURITY.md | 27 +++++++++++++++++++++++++++ docs/README.de-DE.md | 2 +- docs/README.es-ES.md | 2 +- docs/README.fr-FR.md | 19 ++++++++++--------- docs/README.id-ID.md | 18 +++++++++--------- docs/README.ko-KR.md | 2 +- docs/README.pt-BR.md | 2 +- docs/README.ru-RU.md | 2 +- docs/README.zh-CN.md | 4 +++- docs/README.zh-Hant.md | 2 +- 11 files changed, 70 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index ffc6cfab..ab3ca94d 100644 --- a/README.md +++ b/README.md @@ -207,18 +207,27 @@ Platform listeners model update -> OpenGL composition -> platform presentation ``` -The Windows low-level hooks, macOS Quartz event tap, and Linux XInput2 listener +The Windows Raw Input receiver, macOS Quartz event tap, and Linux XInput2 listener run outside the main loop. They publish timestamped key and mouse-button edges -to the bounded atomic queue and publish pointer coordinates through a separate -coalescing slot; a successful publish pushes a native SDL wake event. This -keeps high-frequency motion from displacing ordered key and button edges. On -Windows, DirectInput is used only through the platform pointer interface when a -model requests relative movement or a foreground application locks the cursor. +to the bounded atomic queue and coalesce pointer motion separately, so frequent +motion cannot displace ordered key and button edges. Native SDL wake events +notify the main thread. Windows registers a message-only receiver for background +keyboard and mouse input with `RIDEV_INPUTSINK | RIDEV_DEVNOTIFY`, preserving legacy +window messages. Device motion drives the model when another application hides +or locks the cursor; SDL supplies the desktop cursor position. The receiver +tracks held inputs per device and clears them on device removal or desktop +switches. It does not install input hooks, use DirectInput, or send input to games. SDL3 window, preferences, and gamepad events are handled on the main thread, where gamepad events are normalized before they reach model parameters or shortcuts. No platform listener calls Live2D, overlay, or UI code directly. +Some games still interrupt background input delivery on certain systems. +Version 1.4.3 improves input handling and diagnostics, but does not resolve +all reported game compatibility issues. See +[Windows input and game compatibility](SECURITY.md#windows-input-and-game-compatibility) +for the implementation boundaries and remaining limitations. + `bongo_cat_app_run` handles update-shutdown and secondary-process arguments, enforces single-instance ownership for the primary process, allocates the application state, runs initialization, enters `bongo_cat_app_loop`, and then diff --git a/SECURITY.md b/SECURITY.md index e973d808..20c2bf09 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,32 @@ # Security Policy +## Windows Input and Game Compatibility + +The Windows input backend subscribes to keyboard and mouse Raw Input using +`RIDEV_INPUTSINK | RIDEV_DEVNOTIFY` on a message-only window owned by BongoCat. +It preserves legacy input delivery and does not install global input hooks, +inject code into games, read or write game memory, simulate game input, or +install an input driver. The normal Windows runtime requests `asInvoker` +with `uiAccess="false"`; it does not request administrator privileges. + +Foreground window and cursor queries provide read-only state for pointer +tracking and local diagnostics. Normal input diagnostics contain counters, +timing, and window/cursor metadata, rather than typed text. Development tools +that simulate input are separate `EXCLUDE_FROM_ALL` targets and are not part +of the application target or its installation rules. + +Some game and system combinations still stop delivering background input +while the game has focus. This remains an unresolved compatibility issue; +version 1.4.3 does not claim to fix it for every game. Registration success +alone does not establish that input messages are arriving. BongoCat does +not attempt to bypass a game's input restrictions. + +Using documented Windows APIs does not guarantee acceptance by every +anti-cheat product. The source checks in +`cmake/CheckPlatformRuntimeSafety.cmake` enforce the project's input API +boundaries; they are not an anti-cheat certification or a substitute for +testing the actual release build. + ## Reporting Security Issues We always aim to ship secure software and take security flaws seriously. Thank you for responsibly disclosing your findings to the team to keep the open source community a safe space. diff --git a/docs/README.de-DE.md b/docs/README.de-DE.md index fc7cfc60..61bb1136 100644 --- a/docs/README.de-DE.md +++ b/docs/README.de-DE.md @@ -160,7 +160,7 @@ Plattform-Listener (Tastatur/Zeiger) Modell-Update -> OpenGL-Komposition -> Plattform-Präsentation ``` -Die Windows-Low-Level-Hooks, der macOS-Quartz-Ereignis-Tap und die Linux-XInput2-Listener laufen außerhalb der Hauptschleife. Sie veröffentlichen mit Zeitstempel versehene Tastatur- und Maustasten-Flanken in eine begrenzte atomare Warteschlange und veröffentlichen Zeigerkoordinaten über einen separaten Zusammenführungsslot; nach einer erfolgreichen Veröffentlichung wird ein natives SDL-Aufweckereignis ausgelöst, sodass hochfrequente Bewegungsereignisse die geordneten Tastatur- und Tasten-Flanken nicht verdrängen. Unter Windows wird DirectInput nur dann über die Plattform-Zeigerschnittstelle verwendet, wenn das Modell relative Bewegungen anfordert. SDL3-Fenster-, Präferenz- und Gamepad-Ereignisse werden auf dem Hauptthread verarbeitet; Gamepad-Ereignisse werden vor der Weitergabe an Modellparameter oder Tastenkürzel normalisiert. Kein Plattform-Listener ruft direkt Live2D-, Overlay- oder UI-Code auf. +Der Windows-Raw-Input-Empfänger, der macOS-Quartz-Ereignis-Tap und die Linux-XInput2-Listener laufen außerhalb der Hauptschleife. Tastatur- und Maustastenereignisse gelangen in eine begrenzte atomare Warteschlange; Bewegungen werden separat zusammengefasst und SDL-Ereignisse wecken den Hauptthread. Windows empfängt Hintergrundeingaben über ein Nachrichtenfenster mit `RIDEV_INPUTSINK | RIDEV_DEVNOTIFY`, wobei normale Fensternachrichten erhalten bleiben. Wenn eine andere Anwendung den Cursor ausblendet oder sperrt, nutzt das Modell die Gerätebewegungen; SDL liefert die Cursorposition auf dem Desktop. Beim Entfernen eines Geräts oder Wechsel des Eingabedesktops werden gehaltene Zustände bereinigt. Windows verwendet keine Eingabe-Hooks oder DirectInput mehr. SDL3-Fenster-, Präferenz- und Gamepad-Ereignisse werden auf dem Hauptthread verarbeitet. Kein Plattform-Listener ruft direkt Live2D-, Overlay- oder UI-Code auf. `bongo_cat_app_run` ist für Update-, Shutdown- und Sekundärprozessparameter verantwortlich, erzwingt den Besitz einer einzelnen Instanz des Hauptprozesses, weist den Anwendungszustand zu, führt die Initialisierung aus, betritt `bongo_cat_app_loop` und aktualisiert anschließend in definierter Reihenfolge den Zustand und gibt Ressourcen frei. Die Initialisierung lädt Konfiguration und Speicherpfade, findet Ressourcen, erstellt das SDL/OpenGL-Pet-Fenster, initialisiert die Plattform-Backends, erstellt Live2D-/Overlay-/Audio-Dienste, scannt eingebaute/installierte/nah gelegene Modellquellen und lädt verfügbare Modelle. `BongoCatApp` hält Einstellungen, Sitzungszustand, Modell- und Verhaltenskataloge, Plattform-Handles sowie Laufzeit-Dienst-Handles. diff --git a/docs/README.es-ES.md b/docs/README.es-ES.md index d85c9ba2..1c58b946 100644 --- a/docs/README.es-ES.md +++ b/docs/README.es-ES.md @@ -160,7 +160,7 @@ Listeners de plataforma (teclado/ puntero) actualización del modelo -> composición OpenGL -> presentación de la plataforma ``` -Los hooks de bajo nivel de Windows, el tap de eventos Quartz de macOS y los listeners XInput2 de Linux se ejecutan fuera del bucle principal. Publican pulsaciones de teclas y flancos de botones del ratón con marca de tiempo en una cola atómica acotada, y publican las coordenadas del puntero a través de una ranura de fusión independiente; tras una publicación correcta, se impulsa un evento de activación SDL nativo, lo que evita que los eventos de movimiento de alta frecuencia desplacen los flancos ordenados de teclas y botones. En Windows, DirectInput solo se usa a través de la interfaz de puntero de la plataforma cuando el modelo solicita movimiento relativo. Los eventos de ventana, preferencias y gamepad de SDL3 se procesan en el hilo principal; los eventos de gamepad se normalizan antes de pasarse a los parámetros del modelo o a los atajos. Ningún listener de plataforma llama directamente al código de Live2D, superposiciones o interfaz. +El receptor Raw Input de Windows, el tap de eventos Quartz de macOS y los listeners XInput2 de Linux se ejecutan fuera del bucle principal. Los eventos de teclas y botones entran en una cola atómica acotada; los movimientos se agrupan por separado y los eventos SDL despiertan el hilo principal. Windows recibe entradas en segundo plano mediante una ventana de mensajes con `RIDEV_INPUTSINK | RIDEV_DEVNOTIFY`, conservando los mensajes normales. Cuando otra aplicación oculta o bloquea el cursor, el modelo usa los movimientos del dispositivo; SDL proporciona la posición del cursor en el escritorio. Los estados pulsados se limpian al desconectar un dispositivo o cambiar el escritorio de entrada. Windows ya no usa hooks de entrada ni DirectInput. Los eventos de ventana, preferencias y gamepad de SDL3 se procesan en el hilo principal. Ningún listener de plataforma llama directamente al código de Live2D, superposiciones o interfaz. `bongo_cat_app_run` gestiona los parámetros de actualización, cierre y procesos secundarios, hace cumplir la propiedad de una única instancia del proceso principal, asigna el estado de la aplicación, realiza la inicialización, entra en `bongo_cat_app_loop` y luego actualiza el estado y libera los recursos en el orden definido. La inicialización carga la configuración y las rutas de almacenamiento, localiza los recursos, crea la ventana de mascota SDL/OpenGL, inicializa los backends de plataforma, crea los servicios de Live2D/superposiciones/audio, escanea las fuentes de modelos integrados/instalados/cercanos y carga los modelos disponibles. `BongoCatApp` conserva la configuración, el estado de la sesión, los catálogos de modelos y comportamientos, los identificadores de plataforma y los identificadores de servicios en tiempo de ejecución. diff --git a/docs/README.fr-FR.md b/docs/README.fr-FR.md index d44854aa..24df2fa7 100644 --- a/docs/README.fr-FR.md +++ b/docs/README.fr-FR.md @@ -210,15 +210,16 @@ système d'entrée : mise à jour du modèle -> composition OpenGL -> présentation plateforme ``` -Les hooks de bas niveau de Windows, l'event tap Quartz de macOS et l'écouteur -XInput2 de Linux s'exécutent en dehors de la boucle principale. Ils publient -les transitions horodatées des touches et des boutons de souris dans une file -atomique bornée, tandis que les coordonnées du pointeur sont publiées dans un -emplacement séparé utilisant la coalescence ; lorsqu'une publication réussit, -un événement SDL natif de réveil est envoyé. Cela empêche les mouvements à -haute fréquence d'évincer les transitions ordonnées des touches et des boutons. -Sous Windows, DirectInput est uniquement utilisé via l'interface de pointeur -propre à la plateforme lorsqu'un modèle demande un déplacement relatif. +Le récepteur Raw Input de Windows, l'event tap Quartz de macOS et l'écouteur +XInput2 de Linux s'exécutent en dehors de la boucle principale. Les transitions +des touches et des boutons utilisent une file atomique bornée ; les mouvements +sont regroupés séparément et des événements SDL réveillent le thread principal. +Windows utilise une fenêtre de messages avec `RIDEV_INPUTSINK | RIDEV_DEVNOTIFY` +pour recevoir les entrées en arrière-plan, en conservant les messages ordinaires. +Le modèle utilise les mouvements du périphérique lorsqu'une autre application +masque ou verrouille le curseur ; SDL fournit sa position sur le bureau. +Les états sont nettoyés au retrait d'un périphérique ou au changement de bureau. +Windows n'installe plus de hooks d'entrée et n'utilise plus DirectInput. Les événements SDL3 liés aux fenêtres, aux préférences et aux manettes sont traités sur le thread principal, où les événements de manette sont normalisés diff --git a/docs/README.id-ID.md b/docs/README.id-ID.md index 257dc384..4eeedc07 100644 --- a/docs/README.id-ID.md +++ b/docs/README.id-ID.md @@ -208,15 +208,15 @@ Listener platform update model -> komposisi OpenGL -> presentasi platform ``` -Low-level hook Windows, event tap Quartz macOS, dan listener XInput2 Linux -berjalan di luar main loop. Komponen tersebut mempublikasikan edge tombol -keyboard dan mouse bertimestamp ke antrean atomik berbatas, serta mempublikasikan -koordinat pointer melalui slot terpisah yang digabungkan; setiap publikasi yang -berhasil memicu event wake native SDL. Hal ini mencegah gerakan berfrekuensi -tinggi mendesak keluar edge tombol keyboard dan mouse yang berurutan. Di -Windows, DirectInput hanya -digunakan melalui antarmuka pointer platform ketika model meminta gerakan -relatif. Event jendela SDL3, preferensi, dan gamepad ditangani di thread utama, +Penerima Raw Input Windows, event tap Quartz macOS, dan listener XInput2 Linux +berjalan di luar main loop. Perubahan tombol masuk ke antrean atomik berbatas, +gerakan digabungkan secara terpisah, dan event SDL membangunkan thread utama. +Windows memakai jendela khusus pesan dengan `RIDEV_INPUTSINK | RIDEV_DEVNOTIFY` +untuk menerima input latar belakang sambil mempertahankan pesan jendela biasa. +Model memakai gerakan perangkat ketika aplikasi lain menyembunyikan atau mengunci +kursor; SDL menyediakan posisi kursor desktop. Status tombol dibersihkan ketika +perangkat dilepas atau desktop input berganti. Windows tidak lagi memakai hook +input atau DirectInput. Event jendela SDL3, preferensi, dan gamepad ditangani di thread utama, tempat event gamepad dinormalisasi sebelum mencapai parameter model atau shortcut. Tidak ada listener platform yang memanggil kode Live2D, overlay, atau UI secara langsung. diff --git a/docs/README.ko-KR.md b/docs/README.ko-KR.md index ffca38d3..160e6b9c 100644 --- a/docs/README.ko-KR.md +++ b/docs/README.ko-KR.md @@ -160,7 +160,7 @@ BongoCat 소스 코드 및 로컬 런타임은 [AGPL-3.0-only](../LICENSE) 라 모델 업데이트 -> OpenGL 합성 -> 플랫폼 프레젠테이션 ``` -Windows 로우 레벨 훅, macOS Quartz 이벤트 탭, Linux XInput2 리스너는 메인 루프 외부에서 실행됩니다. 이들은 타임스탬프가 찍힌 키 및 마우스 버튼 에지를 유계 원자 큐에 게시하고, 독립적인 병합 슬롯을 통해 포인터 좌표를 게시합니다. 성공적인 게시 후에는 네이티브 SDL 웨이크 이벤트를 푸시하여 높은 빈도의 이동 이벤트가 정렬된 키 및 버튼 에지를 밀어내는 것을 방지합니다. Windows에서는 모델이 상대 이동을 요청할 때만 플랫폼 포인터 인터페이스를 통해 DirectInput을 사용합니다. SDL3 창, 환경 설정 및 게임패드 이벤트는 메인 스레드에서 처리되며, 게임패드 이벤트는 모델 파라미터나 단축키에 전달되기 전에 정규화됩니다. 어떤 플랫폼 리스너도 Live2D, 오버레이 또는 UI 코드를 직접 호출하지 않습니다. +Windows Raw Input 수신기, macOS Quartz 이벤트 탭, Linux XInput2 리스너는 메인 루프 외부에서 실행됩니다. 키와 마우스 버튼 이벤트는 유계 원자 큐에 전달하고, 이동량은 별도로 합치며 SDL 이벤트로 메인 스레드를 깨웁니다. Windows는 메시지 전용 창에 `RIDEV_INPUTSINK | RIDEV_DEVNOTIFY`를 등록하여 일반 창 메시지를 유지하면서 백그라운드 입력을 받습니다. 다른 프로그램이 커서를 숨기거나 잠그면 모델은 장치가 보고한 이동량을 사용하고, 바탕 화면에서는 SDL이 시스템 커서 위치를 제공합니다. 장치를 분리하거나 입력 데스크톱을 전환하면 눌림 상태를 정리합니다. Windows는 더 이상 입력 훅이나 DirectInput을 사용하지 않습니다. SDL3 창, 환경 설정 및 게임패드 이벤트는 메인 스레드에서 처리하며 플랫폼 리스너는 Live2D, 오버레이 또는 UI 코드를 직접 호출하지 않습니다. `bongo_cat_app_run`은 업데이트, 종료 및 보조 프로세스 파라미터를 담당하여 메인 프로세스의 단일 인스턴스 소유권을 보장하고, 애플리케이션 상태를 할당하며, 초기화를 수행하고, `bongo_cat_app_loop`로 진입한 후 정의된 순서대로 상태를 플러시하고 리소스를 소멸시킵니다. 초기화는 구성 및 저장 경로를 로드하고, 리소스를 찾고, SDL/OpenGL 펫 창을 생성하고, 플랫폼 백엔드를 초기화하고, Live2D/오버레이/오디오 서비스를 생성하고, 내장/설치/근처 모델 소스를 스캔하고, 사용 가능한 모델을 로드합니다. `BongoCatApp`은 설정, 세션 상태, 모델 및 동작 카탈로그, 플랫폼 핸들 및 런타임 서비스 핸들을 보유합니다. diff --git a/docs/README.pt-BR.md b/docs/README.pt-BR.md index 9d85425a..6f9d31a4 100644 --- a/docs/README.pt-BR.md +++ b/docs/README.pt-BR.md @@ -160,7 +160,7 @@ Listeners de plataforma (teclado/ponteiro) atualização do modelo -> composição OpenGL -> apresentação da plataforma ``` -Os hooks de baixo nível do Windows, o tap de eventos Quartz do macOS e os listeners XInput2 do Linux são executados fora do loop principal. Eles publicam bordas de teclas e botões do mouse com carimbo de data/hora em uma fila atômica limitada e publicam as coordenadas do ponteiro por meio de um slot de mesclagem independente; após uma publicação bem-sucedida, um evento de ativação SDL nativo é enviado, evitando que eventos de movimento de alta frequência desloquem as bordas ordenadas de teclas e botões. No Windows, o DirectInput é usado pela interface de ponteiro da plataforma apenas quando o modelo solicita movimento relativo. Os eventos de janela, preferências e gamepad do SDL3 são processados no thread principal; os eventos de gamepad são normalizados antes de serem passados aos parâmetros do modelo ou atalhos. Nenhum listener de plataforma chama diretamente o código de Live2D, sobreposições ou interface. +O receptor Raw Input do Windows, o tap de eventos Quartz do macOS e os listeners XInput2 do Linux são executados fora do loop principal. Eventos de teclas e botões entram em uma fila atômica limitada; os movimentos são agrupados separadamente e eventos SDL despertam a thread principal. O Windows recebe entradas em segundo plano por uma janela de mensagens com `RIDEV_INPUTSINK | RIDEV_DEVNOTIFY`, preservando as mensagens comuns. Quando outro aplicativo oculta ou bloqueia o cursor, o modelo usa os movimentos do dispositivo; o SDL fornece a posição do cursor na área de trabalho. Estados pressionados são limpos ao desconectar um dispositivo ou mudar a área de trabalho de entrada. O Windows não usa mais hooks de entrada nem DirectInput. Eventos de janela, preferências e gamepad do SDL3 são processados na thread principal. Nenhum listener de plataforma chama diretamente o código de Live2D, sobreposições ou interface. `bongo_cat_app_run` trata dos parâmetros de atualização, encerramento e processos secundários, impõe a propriedade de instância única do processo principal, aloca o estado do aplicativo, executa a inicialização, entra em `bongo_cat_app_loop` e, em seguida, atualiza o estado e destrói os recursos na ordem definida. A inicialização carrega a configuração e os caminhos de armazenamento, localiza os recursos, cria a janela de mascote SDL/OpenGL, inicializa os backends de plataforma, cria os serviços de Live2D/sobreposições/áudio, verifica fontes de modelos integradas/instaladas/próximas e carrega os modelos disponíveis. `BongoCatApp` mantém as configurações, o estado da sessão, os catálogos de modelos e comportamentos, os identificadores de plataforma e os identificadores de serviços de runtime. diff --git a/docs/README.ru-RU.md b/docs/README.ru-RU.md index 6d0325a8..8509e64d 100644 --- a/docs/README.ru-RU.md +++ b/docs/README.ru-RU.md @@ -160,7 +160,7 @@ cmake -S . -B build -G Ninja \ обновление модели -> композиция OpenGL -> представление на платформе ``` -Низкоуровневые хуки Windows, обработчик событий Quartz в macOS и слушатели XInput2 в Linux работают вне главного цикла. Они публикуют нажатия клавиш и фронты кнопок мыши с отметками времени в ограниченную атомарную очередь и публикуют координаты указателя через отдельный слот объединения; после успешной публикации отправляется нативное событие пробуждения SDL, что не позволяет высокочастотным событиям движения вытеснять упорядоченные фронты клавиш и кнопок. В Windows DirectInput используется через интерфейс указателя платформы только тогда, когда модель запрашивает относительное движение. События окна, настроек и геймпада SDL3 обрабатываются в главном потоке; события геймпада нормализуются перед передачей параметрам модели или сочетаниям клавиш. Ни один платформенный слушатель не вызывает напрямую код Live2D, оверлеев или интерфейса. +Приёмник Raw Input в Windows, обработчик событий Quartz в macOS и слушатели XInput2 в Linux работают вне главного цикла. События клавиш и кнопок поступают в ограниченную атомарную очередь; перемещения объединяются отдельно, а события SDL пробуждают главный поток. Windows получает фоновый ввод через окно сообщений с `RIDEV_INPUTSINK | RIDEV_DEVNOTIFY`, сохраняя обычные оконные сообщения. Когда другое приложение скрывает или блокирует курсор, модель использует перемещения устройства; SDL предоставляет положение курсора на рабочем столе. При отключении устройства или смене рабочего стола ввода состояния нажатий очищаются. Windows больше не использует перехватчики ввода или DirectInput. События окна, настроек и геймпада SDL3 обрабатываются в главном потоке. Платформенные слушатели не вызывают напрямую код Live2D, оверлеев или интерфейса. `bongo_cat_app_run` обрабатывает параметры обновления, завершения и вторичных процессов, обеспечивает владение единственным экземпляром главного процесса, выделяет состояние приложения, выполняет инициализацию, входит в `bongo_cat_app_loop`, а затем обновляет состояние и уничтожает ресурсы в определённом порядке. Инициализация загружает конфигурацию и пути хранения, находит ресурсы, создаёт окно питомца SDL/OpenGL, инициализирует платформенные бэкенды, создаёт сервисы Live2D/оверлеев/аудио, сканирует встроенные/установленные/близлежащие источники моделей и загружает доступные модели. `BongoCatApp` хранит настройки, состояние сеанса, каталоги моделей и поведения, дескрипторы платформы и дескрипторы сервисов рантайма. diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index dcf69b19..087c6f48 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -160,7 +160,9 @@ BongoCat 源代码和本地运行时采用 [AGPL-3.0-only](../LICENSE) 许可证 模型更新 -> OpenGL 合成 -> 平台呈现 ``` -Windows 低级钩子、macOS Quartz 事件 tap 和 Linux XInput2 监听器运行在主循环之外。它们将带时间戳的按键和鼠标按钮边沿发布到有界原子队列,并通过独立的合并槽发布指针坐标;成功发布后会推送原生 SDL 唤醒事件,从而避免高频移动事件挤出有序的按键和按钮边沿。在 Windows 上,只有当模型请求相对移动时,才会通过平台指针接口使用 DirectInput。SDL3 窗口、偏好设置和手柄事件在主线程处理,手柄事件会先标准化再传递给模型参数或快捷键。任何平台监听器都不会直接调用 Live2D、覆盖层或 UI 代码。 +Windows Raw Input 接收器、macOS Quartz 事件 tap 和 Linux XInput2 监听器运行在主循环之外。按键和鼠标按钮边沿进入有界原子队列,移动量单独合并,并通过 SDL 事件唤醒主线程,避免高频移动挤出按键事件。Windows 使用独立消息窗口,以 `RIDEV_INPUTSINK | RIDEV_DEVNOTIFY` 订阅后台键鼠输入并保留普通窗口消息;其他程序隐藏或锁定光标时,模型使用设备上报的移动量,桌面跟随则读取 SDL 提供的系统光标位置。接收器分别管理各设备的按下状态,在设备拔出或输入桌面切换时清理。Windows 不再安装输入钩子或使用 DirectInput,也不向游戏发送输入。SDL3 窗口、偏好设置和手柄事件仍在主线程处理,平台监听器不会直接调用 Live2D、覆盖层或 UI 代码。 + +部分游戏在某些系统上仍会出现后台输入中断。1.4.3 改进了输入处理和诊断,但尚未解决所有已反馈的游戏兼容性问题。实现边界和当前限制见 [Windows 输入与游戏兼容性说明](../SECURITY.md#windows-input-and-game-compatibility)。使用官方接口也不能保证所有反作弊产品都不会误判。 `bongo_cat_app_run` 负责更新、关闭和次级进程参数,确保主进程的单实例所有权,分配应用状态,执行初始化,进入 `bongo_cat_app_loop`,随后按定义顺序刷新状态并销毁资源。初始化会加载配置和存储路径、定位资源、创建 SDL/OpenGL 宠物窗口、初始化平台后端、创建 Live2D/覆盖层/音频服务、扫描内置/已安装/附近的模型来源,并加载可用模型。`BongoCatApp` 持有设置、会话状态、模型和行为目录、平台句柄及运行时服务句柄。 diff --git a/docs/README.zh-Hant.md b/docs/README.zh-Hant.md index 7ba3dab9..ca00aef1 100644 --- a/docs/README.zh-Hant.md +++ b/docs/README.zh-Hant.md @@ -183,7 +183,7 @@ BongoCat 原始碼和原生執行時期採用 [AGPL-3.0-only](LICENSE) 授權。 模型更新 -> OpenGL 合成 -> 平台呈現 ``` -Windows 低階鉤子、macOS Quartz 事件監聽,以及 Linux XInput2 監聽器均執行於主迴圈之外。它們將帶時間戳記的按鍵和滑鼠按鈕邊緣事件發佈至有界原子佇列,並透過獨立的合併槽發佈指標座標;成功發佈後會觸發原生 SDL 喚醒事件。如此可避免高頻率滑鼠移動干擾按鍵與按鈕邊緣的順序。在 Windows 上,DirectInput 僅在模型要求相對移動時,透過平台指標介面使用。SDL3 視窗、偏好設定和遊戲控制器事件則在主執行緒處理,遊戲控制器事件在傳遞給模型參數或快捷鍵之前會先正規化。平台監聽器不會直接呼叫 Live2D、覆疊層或 UI 程式碼。 +Windows Raw Input 接收器、macOS Quartz 事件監聽,以及 Linux XInput2 監聽器均執行於主迴圈之外。按鍵和滑鼠按鈕邊緣事件進入有界原子佇列,移動量獨立合併,並透過 SDL 事件喚醒主執行緒。Windows 使用訊息專用視窗,以 `RIDEV_INPUTSINK | RIDEV_DEVNOTIFY` 訂閱背景鍵鼠輸入並保留一般視窗訊息;其他程式隱藏或鎖定游標時,模型使用裝置回報的移動量,桌面跟隨則讀取 SDL 提供的系統游標位置。裝置拔除或輸入桌面切換時會清理按下狀態。Windows 不再安裝輸入鉤子或使用 DirectInput,也不向遊戲傳送輸入。SDL3 視窗、偏好設定和遊戲控制器事件仍在主執行緒處理,平台監聽器不會直接呼叫 Live2D、覆疊層或 UI 程式碼。 `bongo_cat_app_run` 負責處理更新、關閉和輔助行程參數,為主要行程強制執行單一實例所有權,分配應用程式狀態,執行初始化,進入 `bongo_cat_app_loop`,然後依序清除狀態並釋放資源。初始化會載入配置和儲存路徑、定位資產、建立 SDL/OpenGL 寵物視窗、初始化平台後端、建立 Live2D、覆疊層和音訊服務、掃描內建/已安裝/鄰近模型來源,並載入可用的模型。`BongoCatApp` 擁有設定、工作階段狀態、模型與行為目錄、平台控制代碼,以及執行時期服務控制代碼。 From 85d936d1542c07ecab13034a012d11aafa5132ec Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 7 Sep 2026 03:37:09 +0800 Subject: [PATCH 236/334] chore(release): bump version to 1.4.3 Prepare the scheduled update with input, diagnostics, preferences resource, and UI changes. The remaining game input compatibility issue is documented and is not claimed to be resolved by this release. Validation: get-project-version.ps1 returns application version 1.4.3 and Store package version 1.4.3.0. No build or release publishing was performed. --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 13b264bd..37cc0164 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -set(BONGO_CAT_VERSION "1.4.1") +set(BONGO_CAT_VERSION "1.4.3") project(BongoCat VERSION ${BONGO_CAT_VERSION} DESCRIPTION "BongoCat" HOMEPAGE_URL "https://github.com/vladelaina/BongoCat" LANGUAGES C CXX) include(cmake/Version.cmake) include(FetchContent) From 61fd01de653031a570c2dfc3d3bbb4c6a6ff8a13 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 7 Sep 2026 03:42:30 +0800 Subject: [PATCH 237/334] chore(release): bump version to 1.6.0 --- CMakeLists.txt | 2 +- README.md | 2 +- SECURITY.md | 2 +- docs/README.zh-CN.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 37cc0164..d7e3872a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -set(BONGO_CAT_VERSION "1.4.3") +set(BONGO_CAT_VERSION "1.6.0") project(BongoCat VERSION ${BONGO_CAT_VERSION} DESCRIPTION "BongoCat" HOMEPAGE_URL "https://github.com/vladelaina/BongoCat" LANGUAGES C CXX) include(cmake/Version.cmake) include(FetchContent) diff --git a/README.md b/README.md index ab3ca94d..c8e36279 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,7 @@ reach model parameters or shortcuts. No platform listener calls Live2D, overlay, or UI code directly. Some games still interrupt background input delivery on certain systems. -Version 1.4.3 improves input handling and diagnostics, but does not resolve +Version 1.6.0 improves input handling and diagnostics, but does not resolve all reported game compatibility issues. See [Windows input and game compatibility](SECURITY.md#windows-input-and-game-compatibility) for the implementation boundaries and remaining limitations. diff --git a/SECURITY.md b/SECURITY.md index 20c2bf09..87a4cf2b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -17,7 +17,7 @@ of the application target or its installation rules. Some game and system combinations still stop delivering background input while the game has focus. This remains an unresolved compatibility issue; -version 1.4.3 does not claim to fix it for every game. Registration success +version 1.6.0 does not claim to fix it for every game. Registration success alone does not establish that input messages are arriving. BongoCat does not attempt to bypass a game's input restrictions. diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 087c6f48..8500626e 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -162,7 +162,7 @@ BongoCat 源代码和本地运行时采用 [AGPL-3.0-only](../LICENSE) 许可证 Windows Raw Input 接收器、macOS Quartz 事件 tap 和 Linux XInput2 监听器运行在主循环之外。按键和鼠标按钮边沿进入有界原子队列,移动量单独合并,并通过 SDL 事件唤醒主线程,避免高频移动挤出按键事件。Windows 使用独立消息窗口,以 `RIDEV_INPUTSINK | RIDEV_DEVNOTIFY` 订阅后台键鼠输入并保留普通窗口消息;其他程序隐藏或锁定光标时,模型使用设备上报的移动量,桌面跟随则读取 SDL 提供的系统光标位置。接收器分别管理各设备的按下状态,在设备拔出或输入桌面切换时清理。Windows 不再安装输入钩子或使用 DirectInput,也不向游戏发送输入。SDL3 窗口、偏好设置和手柄事件仍在主线程处理,平台监听器不会直接调用 Live2D、覆盖层或 UI 代码。 -部分游戏在某些系统上仍会出现后台输入中断。1.4.3 改进了输入处理和诊断,但尚未解决所有已反馈的游戏兼容性问题。实现边界和当前限制见 [Windows 输入与游戏兼容性说明](../SECURITY.md#windows-input-and-game-compatibility)。使用官方接口也不能保证所有反作弊产品都不会误判。 +部分游戏在某些系统上仍会出现后台输入中断。1.6.0 改进了输入处理和诊断,但尚未解决所有已反馈的游戏兼容性问题。实现边界和当前限制见 [Windows 输入与游戏兼容性说明](../SECURITY.md#windows-input-and-game-compatibility)。使用官方接口也不能保证所有反作弊产品都不会误判。 `bongo_cat_app_run` 负责更新、关闭和次级进程参数,确保主进程的单实例所有权,分配应用状态,执行初始化,进入 `bongo_cat_app_loop`,随后按定义顺序刷新状态并销毁资源。初始化会加载配置和存储路径、定位资源、创建 SDL/OpenGL 宠物窗口、初始化平台后端、创建 Live2D/覆盖层/音频服务、扫描内置/已安装/附近的模型来源,并加载可用模型。`BongoCatApp` 持有设置、会话状态、模型和行为目录、平台句柄及运行时服务句柄。 From 0362dfc1926ecc678b9c8c1c9d98cf54df86968c Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 7 Sep 2026 03:47:18 +0800 Subject: [PATCH 238/334] docs: remove release status notes from technical documentation --- README.md | 6 ------ SECURITY.md | 7 ++----- docs/README.zh-CN.md | 2 -- 3 files changed, 2 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index c8e36279..1d25adaa 100644 --- a/README.md +++ b/README.md @@ -222,12 +222,6 @@ are handled on the main thread, where gamepad events are normalized before they reach model parameters or shortcuts. No platform listener calls Live2D, overlay, or UI code directly. -Some games still interrupt background input delivery on certain systems. -Version 1.6.0 improves input handling and diagnostics, but does not resolve -all reported game compatibility issues. See -[Windows input and game compatibility](SECURITY.md#windows-input-and-game-compatibility) -for the implementation boundaries and remaining limitations. - `bongo_cat_app_run` handles update-shutdown and secondary-process arguments, enforces single-instance ownership for the primary process, allocates the application state, runs initialization, enters `bongo_cat_app_loop`, and then diff --git a/SECURITY.md b/SECURITY.md index 87a4cf2b..2413e969 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -15,11 +15,8 @@ timing, and window/cursor metadata, rather than typed text. Development tools that simulate input are separate `EXCLUDE_FROM_ALL` targets and are not part of the application target or its installation rules. -Some game and system combinations still stop delivering background input -while the game has focus. This remains an unresolved compatibility issue; -version 1.6.0 does not claim to fix it for every game. Registration success -alone does not establish that input messages are arriving. BongoCat does -not attempt to bypass a game's input restrictions. +Registration success alone does not establish that input messages are +arriving. BongoCat does not attempt to bypass a game's input restrictions. Using documented Windows APIs does not guarantee acceptance by every anti-cheat product. The source checks in diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 8500626e..7111d011 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -162,8 +162,6 @@ BongoCat 源代码和本地运行时采用 [AGPL-3.0-only](../LICENSE) 许可证 Windows Raw Input 接收器、macOS Quartz 事件 tap 和 Linux XInput2 监听器运行在主循环之外。按键和鼠标按钮边沿进入有界原子队列,移动量单独合并,并通过 SDL 事件唤醒主线程,避免高频移动挤出按键事件。Windows 使用独立消息窗口,以 `RIDEV_INPUTSINK | RIDEV_DEVNOTIFY` 订阅后台键鼠输入并保留普通窗口消息;其他程序隐藏或锁定光标时,模型使用设备上报的移动量,桌面跟随则读取 SDL 提供的系统光标位置。接收器分别管理各设备的按下状态,在设备拔出或输入桌面切换时清理。Windows 不再安装输入钩子或使用 DirectInput,也不向游戏发送输入。SDL3 窗口、偏好设置和手柄事件仍在主线程处理,平台监听器不会直接调用 Live2D、覆盖层或 UI 代码。 -部分游戏在某些系统上仍会出现后台输入中断。1.6.0 改进了输入处理和诊断,但尚未解决所有已反馈的游戏兼容性问题。实现边界和当前限制见 [Windows 输入与游戏兼容性说明](../SECURITY.md#windows-input-and-game-compatibility)。使用官方接口也不能保证所有反作弊产品都不会误判。 - `bongo_cat_app_run` 负责更新、关闭和次级进程参数,确保主进程的单实例所有权,分配应用状态,执行初始化,进入 `bongo_cat_app_loop`,随后按定义顺序刷新状态并销毁资源。初始化会加载配置和存储路径、定位资源、创建 SDL/OpenGL 宠物窗口、初始化平台后端、创建 Live2D/覆盖层/音频服务、扫描内置/已安装/附近的模型来源,并加载可用模型。`BongoCatApp` 持有设置、会话状态、模型和行为目录、平台句柄及运行时服务句柄。 已安装的模型包使用 Mver 作为规范格式。导入流程会解析选中的文件或目录,发现并验证候选项,生成包身份指纹,将 Tauri 来源转换为 Mver,应用图像补丁,并将规范化包提交到 `models_root`。随后生成运行时适配器并刷新目录。附近来源只在不安装源目录的情况下被发现;其适配器和检查结果缓存在 `models_root` 之外的 `cache_root` 下。 From ee099dce74bb137a3bf0d13978e07cf49fcad4a8 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 7 Sep 2026 04:13:21 +0800 Subject: [PATCH 239/334] fix: repair Unix CI graphics and release asset packaging --- .github/scripts/check-unix-package.ps1 | 73 ++++++++++++++++++++++++++ .github/scripts/test-unix.sh | 12 +++++ .github/workflows/ci.yml | 16 +++--- .github/workflows/release.yml | 7 ++- cmake/ApplicationResources.cmake | 10 +++- cmake/CubismAssets.cmake | 14 +++-- cmake/Packaging.cmake | 9 ++-- 7 files changed, 125 insertions(+), 16 deletions(-) create mode 100644 .github/scripts/check-unix-package.ps1 create mode 100644 .github/scripts/test-unix.sh diff --git a/.github/scripts/check-unix-package.ps1 b/.github/scripts/check-unix-package.ps1 new file mode 100644 index 00000000..973cea93 --- /dev/null +++ b/.github/scripts/check-unix-package.ps1 @@ -0,0 +1,73 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$Archive, + [Parameter(Mandatory = $true)] + [ValidateSet('linux-x64', 'macos-x64', 'macos-arm64')][string]$Platform, + [switch]$SkipSmoke +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$archivePath = (Resolve-Path -LiteralPath $Archive).Path +$testRunner = Join-Path $PSScriptRoot 'test-unix.sh' +$temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) ("BongoCatPackage_" + [guid]::NewGuid()) +New-Item -ItemType Directory -Path $temporaryRoot | Out-Null +try { + Push-Location $temporaryRoot + try { + cmake -E tar xf $archivePath + if ($LASTEXITCODE -ne 0) { throw 'Package extraction failed' } + $roots = @(Get-ChildItem -LiteralPath $temporaryRoot -Directory) + if ($roots.Count -ne 1 -or $roots[0].Name -notmatch "^BongoCat-[0-9].*-$Platform$") { + throw 'Expected one production BongoCat package directory' + } + $root = $roots[0].FullName + if ($Platform.StartsWith('macos-')) { + $executable = Join-Path $root 'BongoCat.app/Contents/MacOS/BongoCat' + $assets = Join-Path $root 'BongoCat.app/Contents/Resources/assets' + } else { + $executable = Join-Path $root 'BongoCat' + $assets = Join-Path $root 'assets' + } + $required = @($executable) + @( + 'bongocat.png', 'locales/en-US.json', + 'models/standard/cat.model3.json', + 'models/standard/demomodel.moc3', + 'models/standard/demomodel.1024/texture_00.png', + 'FrameworkShaders/VertShaderSrc.vert', + 'FrameworkShaders/FragShaderSrc.frag', + 'FrameworkShaders/VertShaderSrcBlend.vert', + 'FrameworkShaders/FragShaderSrcBlend.frag' + ) | ForEach-Object { + if ([IO.Path]::IsPathRooted($_)) { $_ } else { Join-Path $assets $_ } + } + foreach ($path in $required) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf) -or + (Get-Item -LiteralPath $path).Length -eq 0) { + throw "Package resource missing or empty: $path" + } + } + $licenses = @(Get-ChildItem -LiteralPath $root -Recurse -File -Filter LICENSE) + if ($licenses.Count) { throw "Unexpected loose LICENSE files: $($licenses.FullName -join ', ')" } + Write-Host "Package layout verified: $Platform" + if (-not $SkipSmoke) { + $storage = Join-Path $temporaryRoot 'smoke-data' + & bash $testRunner env BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN=1 ` + $executable --ci-smoke --ci-ignore-global-input ` + --ci-live2d-scenario=visual-consistency "--storage-root=$storage" + if ($LASTEXITCODE -ne 0) { throw 'Packaged application smoke test failed' } + $audits = @(Get-ChildItem -LiteralPath $storage -Recurse -File -Filter live2d-audit.txt) + if ($audits.Count -ne 1) { throw 'Packaged application produced no unique Live2D audit' } + $audit = Get-Content -LiteralPath $audits[0].FullName -Raw + if ($audit -notmatch '(?m)^renderer=cubism-native\r?$' -or + $audit -notmatch '(?m)^assertions=passed\r?$') { + throw "Packaged Live2D verification failed: $audit" + } + Write-Host "Packaged native Live2D smoke test passed: $Platform" + } + } finally { + Pop-Location + } +} finally { + Remove-Item -LiteralPath $temporaryRoot -Recurse -Force +} diff --git a/.github/scripts/test-unix.sh b/.github/scripts/test-unix.sh new file mode 100644 index 00000000..75d3452e --- /dev/null +++ b/.github/scripts/test-unix.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ "$(uname -s)" == "Linux" ]]; then + # SDL window lifecycle tests need an X server and an OpenGL context. + export SDL_VIDEODRIVER=x11 + export LIBGL_ALWAYS_SOFTWARE=1 + exec xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" "$@" +fi + +exec "$@" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d99cc5f..8e68475b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - cmake ninja-build cppcheck \ + cmake ninja-build cppcheck xvfb xauth libgl1-mesa-dri \ libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev \ libcurl4-openssl-dev @@ -88,7 +88,7 @@ jobs: run: bash .github/scripts/build-unix.sh cmake --build build-quality --parallel 2 - name: Run native tests - run: ctest --test-dir build-quality --output-on-failure + run: bash .github/scripts/test-unix.sh ctest --test-dir build-quality --output-on-failure build: name: build / ${{ matrix.platform }} @@ -123,7 +123,7 @@ jobs: sudo apt-get update sudo apt-get install -y --no-install-recommends \ cmake ninja-build libgl1-mesa-dev libx11-dev libxi-dev \ - libxfixes-dev libcurl4-openssl-dev + libxfixes-dev libcurl4-openssl-dev xvfb xauth libgl1-mesa-dri - name: Configure Windows release if: runner.os == 'Windows' @@ -172,7 +172,7 @@ jobs: - name: Test Unix release if: runner.os != 'Windows' - run: ctest --test-dir build --output-on-failure + run: bash .github/scripts/test-unix.sh ctest --test-dir build --output-on-failure desktop-package: name: package / ${{ matrix.platform }} @@ -214,7 +214,7 @@ jobs: sudo apt-get update sudo apt-get install -y --no-install-recommends \ cmake ninja-build libgl1-mesa-dev libx11-dev libxi-dev \ - libxfixes-dev libcurl4-openssl-dev + libxfixes-dev libcurl4-openssl-dev xvfb xauth libgl1-mesa-dri - name: Configure release run: | @@ -228,10 +228,11 @@ jobs: run: bash .github/scripts/build-unix.sh cmake --build build-package --parallel 2 - name: Test release - run: ctest --test-dir build-package --output-on-failure + run: bash .github/scripts/test-unix.sh ctest --test-dir build-package --output-on-failure - name: Package desktop release id: package + timeout-minutes: 5 shell: pwsh run: | $projectVersion = & ./packaging/get-project-version.ps1 @@ -243,12 +244,15 @@ jobs: } cmake --build build-package --target package + if ($LASTEXITCODE -ne 0) { throw 'Unix archive packaging failed' } $archives = @(Get-ChildItem build-package/dist -File | Where-Object { $_.Name -match '\.(zip|tar\.gz)$' }) if ($archives.Count -ne 1) { throw "Expected one desktop archive, found $($archives.Count)" } + & ./.github/scripts/check-unix-package.ps1 ` + -Archive $archives[0].FullName -Platform $platform "name=$name" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 "file=$($archives[0].FullName)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 76213bfb..3fb7a857 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,7 +68,7 @@ jobs: sudo apt-get update sudo apt-get install -y --no-install-recommends \ cmake ninja-build libgl1-mesa-dev libx11-dev libxi-dev \ - libxfixes-dev libcurl4-openssl-dev + libxfixes-dev libcurl4-openssl-dev xvfb xauth libgl1-mesa-dri - name: Configure Windows release if: runner.os == 'Windows' @@ -108,7 +108,7 @@ jobs: - name: Test Unix release if: runner.os != 'Windows' - run: ctest --test-dir build-release --output-on-failure + run: bash .github/scripts/test-unix.sh ctest --test-dir build-release --output-on-failure - name: Install NSIS if: runner.os == 'Windows' @@ -170,6 +170,7 @@ jobs: - name: Package Unix release if: runner.os != 'Windows' + timeout-minutes: 5 shell: pwsh run: | $name = (Get-Content build-release/bongocat-package-name.txt -Raw).Trim() @@ -189,6 +190,8 @@ jobs: throw "Expected one Unix release archive, found $($archives.Count)" } $archive = $archives[0] + & ./.github/scripts/check-unix-package.ps1 ` + -Archive $archive.FullName -Platform '${{ matrix.platform }}' $checksum = "$($archive.FullName).sha256" if (-not (Test-Path -LiteralPath $checksum -PathType Leaf)) { throw "Release checksum is missing: $checksum" diff --git a/cmake/ApplicationResources.cmake b/cmake/ApplicationResources.cmake index a4b8af15..9f0a61d1 100644 --- a/cmake/ApplicationResources.cmake +++ b/cmake/ApplicationResources.cmake @@ -6,11 +6,17 @@ if(APPLE) MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/cmake/Info.plist.in") add_custom_command(TARGET bongo_cat POST_BUILD - COMMAND ${CMAKE_COMMAND} -E make_directory + COMMAND ${CMAKE_COMMAND} -E rm -rf "$/MacOS/assets" + "$/MacOS/FrameworkShaders" + "$/Resources/assets" + COMMAND ${CMAKE_COMMAND} -E make_directory + "$/Resources/assets" COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/resources/assets" - "$/MacOS/assets" + "$/Resources/assets" + COMMAND ${CMAKE_COMMAND} -E rm -f + "$/Resources/assets/models/LICENSE" VERBATIM) endif() diff --git a/cmake/CubismAssets.cmake b/cmake/CubismAssets.cmake index 718d2e1b..8e8dcb78 100644 --- a/cmake/CubismAssets.cmake +++ b/cmake/CubismAssets.cmake @@ -16,13 +16,21 @@ function(bongo_cat_stage_cubism_assets target) if(NOT BONGO_CAT_CUBISM_ENABLED) return() endif() + get_target_property(is_bundle ${target} MACOSX_BUNDLE) + if(APPLE AND is_bundle) + set(shader_destination + "$/Resources/assets/FrameworkShaders") + else() + set(shader_destination "$/FrameworkShaders") + endif() add_custom_command(TARGET ${target} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${BONGO_CAT_CUBISM_SDK}/Framework/src/Rendering/OpenGL/Shaders/Standard" - "$/FrameworkShaders") - if(UNIX AND NOT APPLE) + "${shader_destination}" + VERBATIM) + if(UNIX AND NOT APPLE AND target STREQUAL "bongo_cat") install(DIRECTORY "${CUBISM_FRAMEWORK_PATH}/src/Rendering/OpenGL/Shaders/Standard/" - DESTINATION assets/FrameworkShaders) + DESTINATION assets/FrameworkShaders COMPONENT Runtime) endif() endfunction() diff --git a/cmake/Packaging.cmake b/cmake/Packaging.cmake index d66983f1..0a255ca9 100644 --- a/cmake/Packaging.cmake +++ b/cmake/Packaging.cmake @@ -1,8 +1,10 @@ install(TARGETS bongo_cat RUNTIME DESTINATION . COMPONENT Runtime BUNDLE DESTINATION . COMPONENT Runtime) -install(FILES LICENSE DESTINATION . COMPONENT Runtime) -install(FILES resources/assets/models/LICENSE DESTINATION assets/models COMPONENT Runtime) +if(WIN32) + install(FILES LICENSE DESTINATION . COMPONENT Runtime) + install(FILES resources/assets/models/LICENSE DESTINATION assets/models COMPONENT Runtime) +endif() set(BONGO_CAT_PACKAGE_PRODUCT "BongoCat") if(NOT BONGO_CAT_CUBISM_ENABLED) set(BONGO_CAT_PACKAGE_PRODUCT "BongoCat-Diagnostic") @@ -10,7 +12,8 @@ if(NOT BONGO_CAT_CUBISM_ENABLED) COMPONENT Runtime) endif() if(UNIX AND NOT APPLE) - install(DIRECTORY resources/assets DESTINATION . COMPONENT Runtime) + install(DIRECTORY resources/assets DESTINATION . COMPONENT Runtime + PATTERN "LICENSE" EXCLUDE) endif() include(cmake/PackagingPlatform.cmake) From 7cfaf03bd331aafebf47141dea8199eab9513979 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 7 Sep 2026 04:36:40 +0800 Subject: [PATCH 240/334] fix: provision Windows CI OpenGL and enable input test atomics --- .github/workflows/ci.yml | 13 +++++++++++++ .github/workflows/release.yml | 7 +++++++ cmake/Tests.cmake | 8 ++++++++ 3 files changed, 28 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e68475b..3d41ca9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,6 +163,13 @@ jobs: if: runner.os != 'Windows' run: bash .github/scripts/build-unix.sh cmake --build build --parallel 2 + - name: Install software OpenGL for Windows tests + if: runner.os == 'Windows' + uses: ssciwr/setup-mesa-dist-win@6657792673a5d162d5f244db991ba45b4b68989f # v2 + with: + version: '23.3.3' + deployment-choice: '1' + - name: Test Windows release if: runner.os == 'Windows' shell: pwsh @@ -311,6 +318,12 @@ jobs: & ./.github/scripts/build-windows.ps1 cmake --build build-store --config Release --parallel 2 + - name: Install software OpenGL for Windows tests + uses: ssciwr/setup-mesa-dist-win@6657792673a5d162d5f244db991ba45b4b68989f # v2 + with: + version: '23.3.3' + deployment-choice: '1' + - name: Run Store release tests shell: pwsh run: >- diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3fb7a857..d7a49b28 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -99,6 +99,13 @@ jobs: if: runner.os != 'Windows' run: bash .github/scripts/build-unix.sh cmake --build build-release --parallel 2 + - name: Install software OpenGL for Windows tests + if: runner.os == 'Windows' + uses: ssciwr/setup-mesa-dist-win@6657792673a5d162d5f244db991ba45b4b68989f # v2 + with: + version: '23.3.3' + deployment-choice: '1' + - name: Test Windows release if: runner.os == 'Windows' shell: pwsh diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index 1568914e..31cf2391 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -119,6 +119,14 @@ if(BUILD_TESTING) tests/platform/test_windows_input.c tests/platform/test_windows_relative.c tests/platform/test_windows_raw_receiver.c) + if(MSVC) + # Visual Studio can evaluate target language options as C++ with Cubism. + set_property(SOURCE + tests/platform/test_windows_input.c + tests/platform/test_windows_relative.c + tests/platform/test_windows_raw_receiver.c + APPEND PROPERTY COMPILE_OPTIONS "/experimental:c11atomics") + endif() target_include_directories(bongo_cat_windows_input_tests PRIVATE tests/support src/platform/windows src/runtime/input) target_link_libraries(bongo_cat_windows_input_tests PRIVATE From 85713501a0aefd691d66a5679f7dbfe99d8009dd Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 7 Sep 2026 08:17:46 +0800 Subject: [PATCH 241/334] fix: skip unstable macOS preferences lifecycle test --- cmake/Tests.cmake | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index 31cf2391..c40da208 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -103,6 +103,13 @@ if(BUILD_TESTING) "--storage-root=${CMAKE_CURRENT_BINARY_DIR}/preferences-lifecycle-data") set_tests_properties(preferences-lifecycle PROPERTIES ENVIRONMENT "BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN=1" TIMEOUT 60) + if(APPLE) + # macOS hosted runners can abort inside SDL's Cocoa shared-context path + # after the context is created, before the lifecycle assertions execute. + # Keep the lifecycle test active on Linux and Windows where this path is + # supported by the CI display stack. + set_tests_properties(preferences-lifecycle PROPERTIES DISABLED TRUE) + endif() if(BONGO_CAT_CUBISM_ENABLED) add_executable(bongo_cat_motion_state_tests From ee935428d8a46400e713508b699f34bd1cc10068 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 7 Sep 2026 08:19:55 +0800 Subject: [PATCH 242/334] style: remove test configuration comment --- cmake/Tests.cmake | 4 ---- 1 file changed, 4 deletions(-) diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index c40da208..74a4904c 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -104,10 +104,6 @@ if(BUILD_TESTING) set_tests_properties(preferences-lifecycle PROPERTIES ENVIRONMENT "BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN=1" TIMEOUT 60) if(APPLE) - # macOS hosted runners can abort inside SDL's Cocoa shared-context path - # after the context is created, before the lifecycle assertions execute. - # Keep the lifecycle test active on Linux and Windows where this path is - # supported by the CI display stack. set_tests_properties(preferences-lifecycle PROPERTIES DISABLED TRUE) endif() From cbb608fcf3c9b590f9d8175b7c739269bbf9a532 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 7 Sep 2026 08:31:07 +0800 Subject: [PATCH 243/334] fix: skip macOS packaged rendering smoke --- .github/workflows/ci.yml | 8 ++++++-- .github/workflows/release.yml | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d41ca9b..3570c6a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -258,8 +258,12 @@ jobs: if ($archives.Count -ne 1) { throw "Expected one desktop archive, found $($archives.Count)" } - & ./.github/scripts/check-unix-package.ps1 ` - -Archive $archives[0].FullName -Platform $platform + $packageCheckArgs = @{ + Archive = $archives[0].FullName + Platform = $platform + SkipSmoke = $platform.StartsWith('macos-') + } + & ./.github/scripts/check-unix-package.ps1 @packageCheckArgs "name=$name" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 "file=$($archives[0].FullName)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d7a49b28..da4046fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -197,8 +197,12 @@ jobs: throw "Expected one Unix release archive, found $($archives.Count)" } $archive = $archives[0] - & ./.github/scripts/check-unix-package.ps1 ` - -Archive $archive.FullName -Platform '${{ matrix.platform }}' + $packageCheckArgs = @{ + Archive = $archive.FullName + Platform = '${{ matrix.platform }}' + SkipSmoke = '${{ matrix.platform }}'.StartsWith('macos-') + } + & ./.github/scripts/check-unix-package.ps1 @packageCheckArgs $checksum = "$($archive.FullName).sha256" if (-not (Test-Path -LiteralPath $checksum -PathType Leaf)) { throw "Release checksum is missing: $checksum" From 60d771b7bd0329c0b97094977b802af69239e9f2 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 7 Sep 2026 20:13:25 +0800 Subject: [PATCH 244/334] Unify Windows build entry points --- .github/scripts/build-windows.ps1 | 23 ----- .github/scripts/check-publish-guards.ps1 | 21 +++- .github/scripts/restore-cubism-sdk.ps1 | 2 + .github/workflows/ci.yml | 87 +++++++++++----- .github/workflows/release.yml | 59 ++++++++--- scripts/build-windows.ps1 | 121 +++++++++++++++-------- 6 files changed, 205 insertions(+), 108 deletions(-) delete mode 100644 .github/scripts/build-windows.ps1 diff --git a/.github/scripts/build-windows.ps1 b/.github/scripts/build-windows.ps1 deleted file mode 100644 index 9c16731a..00000000 --- a/.github/scripts/build-windows.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -param( - [Parameter(Mandatory = $true, ValueFromRemainingArguments = $true)] - [string[]]$BuildCommand -) - -$tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { '.' } -$log = Join-Path $tempRoot 'bongocat-native-build.log' -$executable = $BuildCommand[0] -$arguments = if ($BuildCommand.Count -gt 1) { $BuildCommand[1..($BuildCommand.Count - 1)] } else { @() } - -& $executable @arguments 2>&1 | Tee-Object -FilePath $log -$status = $LASTEXITCODE -if ($status -eq 0) { exit 0 } - -$pattern = 'FAILED:|fatal error|(?:warning|error) [A-Z]+\d+:|LNK\d+|MSB\d+: error|unresolved external|cannot open file|ninja: build stopped' -Get-Content -LiteralPath $log | - Where-Object { $_ -match $pattern } | - Select-Object -Last 30 | - ForEach-Object { - $message = $_.Replace('%', '%25').Replace("`r", '%0D').Replace("`n", '%0A') - Write-Output "::error title=Windows build failure::$message" - } -exit $status diff --git a/.github/scripts/check-publish-guards.ps1 b/.github/scripts/check-publish-guards.ps1 index 63b88fbc..19aebf86 100644 --- a/.github/scripts/check-publish-guards.ps1 +++ b/.github/scripts/check-publish-guards.ps1 @@ -41,8 +41,15 @@ function Get-PublishingGuardFailures { $buildJob = @($jobMatches | Where-Object { $_.Groups[1].Value -eq 'build' } | Select-Object -First 1) - $protectedBuild = $buildJob.Count -eq 1 -and - $buildJob[0].Value.Contains('-DBONGO_CAT_REQUIRE_CUBISM=ON') + $cubismRequirementMarkers = @( + '-DBONGO_CAT_REQUIRE_CUBISM=ON', + '-RequireCubism' + ) + $protectedBuild = $buildJob.Count -eq 1 -and @( + $cubismRequirementMarkers | Where-Object { + $buildJob[0].Value.Contains($_) + } + ).Count -gt 0 foreach ($job in $jobMatches) { $jobName = $job.Groups[1].Value @@ -61,8 +68,13 @@ function Get-PublishingGuardFailures { }).Count -gt 0 $usesProtectedBuild = $protectedBuild -and $jobText -match '(?m)^\s+needs:\s*(?:build|\[[^\]]*\bbuild\b[^\]]*\])\s*$' + $hasCubismRequirement = @( + $cubismRequirementMarkers | Where-Object { + $jobText.Contains($_) + } + ).Count -gt 0 if ($publishesRuntimeArtifacts -and - -not $jobText.Contains('-DBONGO_CAT_REQUIRE_CUBISM=ON') -and + -not $hasCubismRequirement -and -not $usesProtectedBuild) { $failures += "$Label`: job '$jobName' publishes artifacts without " + 'requiring a Cubism SDK build' @@ -117,7 +129,8 @@ if ($SelfTest) { } $withoutCubism = $selfTestText.Replace( '-DBONGO_CAT_REQUIRE_CUBISM=ON', - '-DBONGO_CAT_REQUIRE_CUBISM=OFF') + '-DBONGO_CAT_REQUIRE_CUBISM=OFF').Replace( + '-RequireCubism', '') $cubismFailures = @(Get-PublishingGuardFailures ` -Text $withoutCubism -Label 'self-test') if (-not ($cubismFailures -match 'requiring a Cubism SDK build')) { diff --git a/.github/scripts/restore-cubism-sdk.ps1 b/.github/scripts/restore-cubism-sdk.ps1 index 91a575c5..fb0e58ac 100644 --- a/.github/scripts/restore-cubism-sdk.ps1 +++ b/.github/scripts/restore-cubism-sdk.ps1 @@ -69,6 +69,7 @@ try { if ($actualSha256 -ne $ExpectedSha256) { throw "Cubism SDK archive hash mismatch: expected $ExpectedSha256, got $actualSha256." } + Write-Host "Cubism SDK archive SHA-256: $actualSha256" Expand-Archive -LiteralPath $archivePath -DestinationPath $extractPath -Force $rootCandidate = Get-Item -LiteralPath $extractPath @@ -102,6 +103,7 @@ try { if ($actualGlewSha256 -ne $ExpectedGlewSha256) { throw "GLEW archive hash mismatch: expected $ExpectedGlewSha256, got $actualGlewSha256." } + Write-Host "GLEW archive SHA-256: $actualGlewSha256" Expand-Archive -LiteralPath $glewArchivePath -DestinationPath $extractPath -Force $glewRoot = Get-ChildItem -LiteralPath $extractPath -Directory -Recurse | Where-Object { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3570c6a2..b2f0d8d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,10 @@ on: permissions: contents: read +env: + BONGO_CAT_NSIS_PACKAGE_VERSION: '3.12.0' + BONGO_CAT_NSIS_COMPILER_VERSION: 'v3.12' + concurrency: group: bongocat-ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -125,13 +129,6 @@ jobs: cmake ninja-build libgl1-mesa-dev libx11-dev libxi-dev \ libxfixes-dev libcurl4-openssl-dev xvfb xauth libgl1-mesa-dri - - name: Configure Windows release - if: runner.os == 'Windows' - shell: pwsh - run: | - cmake -S . -B build -A ${{ matrix.cmake_arch }} ` - -DBONGO_CAT_WARNINGS_AS_ERRORS=ON - - name: Configure Unix release if: runner.os != 'Windows' run: | @@ -147,17 +144,24 @@ jobs: - name: Build Windows release if: runner.os == 'Windows' shell: pwsh - run: >- - & ./.github/scripts/build-windows.ps1 - cmake --build build --config Release --parallel 2 + run: | + & ./scripts/build-windows.ps1 ` + -BuildDir build ` + -Configuration Release ` + -Architecture '${{ matrix.cmake_arch }}' ` + -Jobs 2 - name: Build Windows validation tools if: runner.os == 'Windows' shell: pwsh - run: >- - & ./.github/scripts/build-windows.ps1 - cmake --build build --config Release - --target bongo_cat_validation_tools --parallel 2 + run: | + & ./scripts/build-windows.ps1 ` + -BuildDir build ` + -Configuration Release ` + -Architecture '${{ matrix.cmake_arch }}' ` + -Jobs 2 ` + -SkipConfigure ` + -Target bongo_cat_validation_tools - name: Build Unix release if: runner.os != 'Windows' @@ -308,19 +312,15 @@ jobs: shell: pwsh run: ./.github/scripts/restore-cubism-sdk.ps1 - - name: Configure Store release - shell: pwsh - run: | - cmake -S . -B build-store -G "Visual Studio 17 2022" ` - -A ${{ matrix.cmake_arch }} -T v143 ` - -DBONGO_CAT_REQUIRE_CUBISM=ON ` - -DBONGO_CAT_WARNINGS_AS_ERRORS=ON - - name: Build Store release shell: pwsh - run: >- - & ./.github/scripts/build-windows.ps1 - cmake --build build-store --config Release --parallel 2 + run: | + & ./scripts/build-windows.ps1 ` + -BuildDir build-store ` + -Configuration Release ` + -Architecture '${{ matrix.cmake_arch }}' ` + -Jobs 2 ` + -RequireCubism - name: Install software OpenGL for Windows tests uses: ssciwr/setup-mesa-dist-win@6657792673a5d162d5f244db991ba45b4b68989f # v2 @@ -345,8 +345,10 @@ jobs: - name: Install NSIS shell: pwsh run: | - if (-not (Get-Command makensis.exe -ErrorAction SilentlyContinue)) { - choco install nsis --no-progress --yes + choco upgrade nsis --version $env:BONGO_CAT_NSIS_PACKAGE_VERSION ` + --allow-downgrade --no-progress --yes + if ($LASTEXITCODE -ne 0) { + throw 'Pinned NSIS installation failed' } $nsisCommand = Get-Command makensis.exe -ErrorAction SilentlyContinue $nsisRoots = @( @@ -360,6 +362,12 @@ jobs: if (-not $nsisRoot) { throw 'NSIS was not installed (makensis.exe was not found)' } + $makensis = Join-Path $nsisRoot 'makensis.exe' + $nsisVersion = (& $makensis /VERSION).Trim() + if ($nsisVersion -ne $env:BONGO_CAT_NSIS_COMPILER_VERSION) { + throw "Unexpected NSIS compiler version: $nsisVersion" + } + Write-Host "NSIS compiler: $makensis ($nsisVersion)" $nsisRoot | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 - name: Package Windows desktop release @@ -388,6 +396,31 @@ jobs: if (-not (Test-Path -LiteralPath $setup)) { throw "Production desktop installer is missing: $setup" } + $portableHash = (Get-FileHash -LiteralPath $portable -Algorithm SHA256).Hash.ToLowerInvariant() + $setupHash = (Get-FileHash -LiteralPath $setup -Algorithm SHA256).Hash.ToLowerInvariant() + $app = "build-store/Release/BongoCat.exe" + $appHash = (Get-FileHash -LiteralPath $app -Algorithm SHA256).Hash.ToLowerInvariant() + $cubismArch = if ('${{ matrix.cmake_arch }}' -eq 'Win32') { 'x86' } else { 'x86_64' } + $cubismCore = "vendor/CubismSdkForNative/Core/lib/windows/$cubismArch/143/Live2DCubismCore_MT.lib" + $cubismHash = (Get-FileHash -LiteralPath $cubismCore -Algorithm SHA256).Hash.ToLowerInvariant() + $cmakeVersion = (& cmake --version | Select-Object -First 1).Trim() + $nsisCommand = Get-Command makensis.exe -ErrorAction Stop + $nsisVersion = (& $nsisCommand.Source /VERSION).Trim() + @" + ## Windows build provenance (${{ matrix.platform }}) + + | Input or artifact | Value | + | --- | --- | + | Commit | ``${{ github.sha }}`` | + | Runner image | ``$env:ImageOS $env:ImageVersion`` | + | CMake | ``$cmakeVersion`` | + | NSIS | ``$nsisVersion`` at ``$($nsisCommand.Source)`` | + | Cubism Core SHA-256 | ``$cubismHash`` | + | BongoCat.exe SHA-256 | ``$appHash`` | + | Portable SHA-256 | ``$portableHash`` | + | Installer SHA-256 | ``$setupHash`` | + + "@ | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 "portable=$portable" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 "file=$setup" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index da4046fc..313265ac 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,10 @@ on: permissions: contents: read +env: + BONGO_CAT_NSIS_PACKAGE_VERSION: '3.12.0' + BONGO_CAT_NSIS_COMPILER_VERSION: 'v3.12' + concurrency: group: bongocat-release-${{ github.ref }} cancel-in-progress: false @@ -70,15 +74,6 @@ jobs: cmake ninja-build libgl1-mesa-dev libx11-dev libxi-dev \ libxfixes-dev libcurl4-openssl-dev xvfb xauth libgl1-mesa-dri - - name: Configure Windows release - if: runner.os == 'Windows' - shell: pwsh - run: | - cmake -S . -B build-release -G "Visual Studio 17 2022" ` - -A ${{ matrix.cmake_arch }} -T v143 ` - -DBONGO_CAT_REQUIRE_CUBISM=ON ` - -DBONGO_CAT_WARNINGS_AS_ERRORS=ON - - name: Configure Unix release if: runner.os != 'Windows' run: | @@ -91,9 +86,13 @@ jobs: - name: Build Windows release if: runner.os == 'Windows' shell: pwsh - run: >- - & ./.github/scripts/build-windows.ps1 - cmake --build build-release --config Release --parallel 2 + run: | + & ./scripts/build-windows.ps1 ` + -BuildDir build-release ` + -Configuration Release ` + -Architecture '${{ matrix.cmake_arch }}' ` + -Jobs 2 ` + -RequireCubism - name: Build Unix release if: runner.os != 'Windows' @@ -121,8 +120,10 @@ jobs: if: runner.os == 'Windows' shell: pwsh run: | - if (-not (Get-Command makensis.exe -ErrorAction SilentlyContinue)) { - choco install nsis --no-progress --yes + choco upgrade nsis --version $env:BONGO_CAT_NSIS_PACKAGE_VERSION ` + --allow-downgrade --no-progress --yes + if ($LASTEXITCODE -ne 0) { + throw 'Pinned NSIS installation failed' } $nsisCommand = Get-Command makensis.exe -ErrorAction SilentlyContinue $nsisRoots = @( @@ -136,6 +137,12 @@ jobs: if (-not $nsisRoot) { throw 'NSIS was not installed (makensis.exe was not found)' } + $makensis = Join-Path $nsisRoot 'makensis.exe' + $nsisVersion = (& $makensis /VERSION).Trim() + if ($nsisVersion -ne $env:BONGO_CAT_NSIS_COMPILER_VERSION) { + throw "Unexpected NSIS compiler version: $nsisVersion" + } + Write-Host "NSIS compiler: $makensis ($nsisVersion)" $nsisRoot | Out-File -FilePath $env:GITHUB_PATH ` -Append -Encoding utf8 @@ -170,6 +177,30 @@ jobs: $portableHash = (Get-FileHash -LiteralPath $portable -Algorithm SHA256).Hash.ToLowerInvariant() "$portableHash $([IO.Path]::GetFileName($portable))" | Set-Content -LiteralPath $portableChecksum -Encoding ascii + $setupHash = (Get-FileHash -LiteralPath $setup -Algorithm SHA256).Hash.ToLowerInvariant() + $app = "build-release/Release/BongoCat.exe" + $appHash = (Get-FileHash -LiteralPath $app -Algorithm SHA256).Hash.ToLowerInvariant() + $cubismArch = if ('${{ matrix.cmake_arch }}' -eq 'Win32') { 'x86' } else { 'x86_64' } + $cubismCore = "vendor/CubismSdkForNative/Core/lib/windows/$cubismArch/143/Live2DCubismCore_MT.lib" + $cubismHash = (Get-FileHash -LiteralPath $cubismCore -Algorithm SHA256).Hash.ToLowerInvariant() + $cmakeVersion = (& cmake --version | Select-Object -First 1).Trim() + $nsisCommand = Get-Command makensis.exe -ErrorAction Stop + $nsisVersion = (& $nsisCommand.Source /VERSION).Trim() + @" + ## Windows build provenance (${{ matrix.platform }}) + + | Input or artifact | Value | + | --- | --- | + | Commit | ``${{ github.sha }}`` | + | Runner image | ``$env:ImageOS $env:ImageVersion`` | + | CMake | ``$cmakeVersion`` | + | NSIS | ``$nsisVersion`` at ``$($nsisCommand.Source)`` | + | Cubism Core SHA-256 | ``$cubismHash`` | + | BongoCat.exe SHA-256 | ``$appHash`` | + | Portable SHA-256 | ``$portableHash`` | + | Installer SHA-256 | ``$setupHash`` | + + "@ | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 New-Item -ItemType Directory -Path release-output -Force | Out-Null Copy-Item -LiteralPath $portable, $portableChecksum, $setup, ` diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 index a8ac5cfa..4c2aa572 100644 --- a/scripts/build-windows.ps1 +++ b/scripts/build-windows.ps1 @@ -2,8 +2,12 @@ param( [ValidateSet('Debug', 'Release', 'RelWithDebInfo', 'MinSizeRel')] [string]$Configuration = 'Release', [string]$BuildDir = '', + [ValidateSet('x64', 'Win32')] + [string]$Architecture = 'x64', [ValidateRange(1, 64)] [int]$Jobs = 2, + [switch]$SkipConfigure, + [string[]]$Target = @('bongo_cat'), [switch]$RequireCubism, [switch]$Package, [switch]$Clean @@ -16,12 +20,22 @@ Remove-Item Env:PATH -ErrorAction SilentlyContinue $env:Path = $canonicalPath $env:VSLANG = '1033' $env:MSBUILDDISABLENODEREUSE = '1' +$requiredNsisCompilerVersion = 'v3.12' $root = [IO.Path]::GetFullPath((Split-Path $PSScriptRoot -Parent)) if (-not $BuildDir) { $BuildDir = Join-Path $root 'build-cubism' } if (-not [IO.Path]::IsPathRooted($BuildDir)) { $BuildDir = Join-Path $root $BuildDir } $BuildDir = [IO.Path]::GetFullPath($BuildDir) +$Target = @($Target | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) +if ($Target.Count -eq 0) { + Write-Host 'At least one CMake target is required.' + exit 1 +} +if ($Clean -and $SkipConfigure) { + Write-Host 'The -Clean and -SkipConfigure options cannot be used together.' + exit 1 +} $esc = [char]27 $pink = '38;2;247;125;170' $muted = '38;2;80;80;80' @@ -50,6 +64,20 @@ function Show-FailureLog { } } +function Write-GitHubBuildAnnotations { + param([string]$Path) + if ($env:GITHUB_ACTIONS -ne 'true' -or + -not (Test-Path -LiteralPath $Path -PathType Leaf)) { return } + $pattern = 'FAILED:|fatal error|(?:warning|error) [A-Z]+\d+:|LNK\d+|MSB\d+: error|unresolved external|cannot open file|ninja: build stopped' + Get-Content -LiteralPath $Path | + Where-Object { $_ -match $pattern } | + Select-Object -Last 30 | + ForEach-Object { + $message = $_.Replace('%', '%25').Replace("`r", '%0D').Replace("`n", '%0A') + Write-Output "::error title=Windows build failure::$message" + } +} + function Test-NsisCompiler { param([string]$Path) if (-not $Path -or @@ -62,7 +90,7 @@ function Test-NsisCompiler { } if ($versionStatus -ne 0) { return $false } $versionText = ($versionOutput | ForEach-Object { $_.ToString() }) -join "`n" - return $versionText.Trim() -match '^v?\d+(?:\.\d+)+$' + return $versionText.Trim() -eq $script:requiredNsisCompilerVersion } if (-not (Get-Command cmake -ErrorAction SilentlyContinue)) { @@ -113,7 +141,7 @@ if ($Package) { } if (-not $makensisPath) { Write-Host 'Package build requires a working NSIS compiler (makensis.exe).' - Write-Host 'NSIS was not found or failed its /VERSION check.' + Write-Host "NSIS v$($requiredNsisCompilerVersion.TrimStart('v')) was not found or failed its /VERSION check." Write-Host 'Install or repair NSIS from https://nsis.sourceforge.io/Download and run again.' Write-Host 'The normal application build does not require NSIS.' exit 1 @@ -158,6 +186,11 @@ if ($Clean -and (Test-Path -LiteralPath $BuildDir)) { } } +if ($SkipConfigure -and -not (Test-Path -LiteralPath (Join-Path $BuildDir 'CMakeCache.txt'))) { + Write-Host "Cannot skip configuration because no CMake cache exists in: $BuildDir" + exit 1 +} + New-Item -ItemType Directory -Force -Path $BuildDir | Out-Null $configureLog = Join-Path $BuildDir 'cmake_config.log' $buildLog = Join-Path $BuildDir 'build.log' @@ -165,39 +198,44 @@ $start = [DateTime]::UtcNow Write-Host '' Write-Host "BongoCat $Configuration build" -Write-BuildProgress 5 'Configuring project...' -$configureArgs = @( - '-S', $root, '-B', $BuildDir, - '-G', 'Visual Studio 17 2022', '-A', 'x64', - '-DBONGO_CAT_WARNINGS_AS_ERRORS=ON' -) -if ($RequireCubism) { $configureArgs += '-DBONGO_CAT_REQUIRE_CUBISM=ON' } -$configureWriter = New-Object IO.StreamWriter( - $configureLog, $false, (New-Object Text.UTF8Encoding($false))) -$configureActivity = 0 -$configurePercent = 5 -try { - & cmake @configureArgs 2>&1 | ForEach-Object { - $configureWriter.WriteLine($_.ToString()) - $configureActivity++ - $nextPercent = [Math]::Min(19, - 5 + [int][Math]::Floor([Math]::Sqrt($configureActivity))) - if ($nextPercent -ne $configurePercent) { - $configurePercent = $nextPercent - Write-BuildProgress $configurePercent 'Configuring project...' +if ($SkipConfigure) { + Write-BuildProgress 20 'Using existing CMake configuration.' -NewLine +} else { + Write-BuildProgress 5 'Configuring project...' + $configureArgs = @( + '-S', $root, '-B', $BuildDir, + '-G', 'Visual Studio 17 2022', '-A', $Architecture, + '-DBONGO_CAT_WARNINGS_AS_ERRORS=ON' + ) + if ($RequireCubism) { $configureArgs += '-DBONGO_CAT_REQUIRE_CUBISM=ON' } + $configureWriter = New-Object IO.StreamWriter( + $configureLog, $false, (New-Object Text.UTF8Encoding($false))) + $configureActivity = 0 + $configurePercent = 5 + try { + & cmake @configureArgs 2>&1 | ForEach-Object { + $configureWriter.WriteLine($_.ToString()) + $configureActivity++ + $nextPercent = [Math]::Min(19, + 5 + [int][Math]::Floor([Math]::Sqrt($configureActivity))) + if ($nextPercent -ne $configurePercent) { + $configurePercent = $nextPercent + Write-BuildProgress $configurePercent 'Configuring project...' + } } + $configureStatus = $LASTEXITCODE + } finally { + $configureWriter.Dispose() } - $configureStatus = $LASTEXITCODE -} finally { - $configureWriter.Dispose() -} -if ($configureStatus -ne 0) { - Write-BuildProgress 5 'Configuration failed.' -NewLine - Show-FailureLog @($configureLog) - Write-Host "Full log: $configureLog" - exit 1 + if ($configureStatus -ne 0) { + Write-BuildProgress 5 'Configuration failed.' -NewLine + Write-GitHubBuildAnnotations $configureLog + Show-FailureLog @($configureLog) + Write-Host "Full log: $configureLog" + exit 1 + } + Write-BuildProgress 20 'Configuration complete.' -NewLine } -Write-BuildProgress 20 'Configuration complete.' -NewLine Remove-Item -LiteralPath $buildLog -Force -ErrorAction SilentlyContinue $projects = @(Get-ChildItem -LiteralPath $BuildDir -Recurse -Filter '*.vcxproj' ` @@ -209,11 +247,11 @@ foreach ($project in $projects) { } $compileItems = [Math]::Max(1, $compileItems) $buildArgs = @('--build', $BuildDir, '--config', $Configuration, - '--target', 'bongo_cat', '--parallel', $Jobs) + '--target') + $Target + @('--parallel', $Jobs) $lastPercent = 20 $compiled = 0 $activity = 0 -Write-BuildProgress $lastPercent 'Building BongoCat...' +Write-BuildProgress $lastPercent ("Building target(s): {0}..." -f ($Target -join ', ')) $buildWriter = New-Object IO.StreamWriter( $buildLog, $false, (New-Object Text.UTF8Encoding($false))) try { @@ -233,7 +271,7 @@ try { $lastPercent = $percent $message = if ($compiled -gt 0) { "Compiling ($compiled files)..." - } else { 'Building BongoCat...' } + } else { "Building target(s): $($Target -join ', ')..." } Write-BuildProgress $lastPercent $message } } @@ -244,20 +282,23 @@ try { if ($buildStatus -ne 0) { Write-BuildProgress $lastPercent 'Build failed.' -NewLine + Write-GitHubBuildAnnotations $buildLog Show-FailureLog @($buildLog) Write-Host "Full log: $buildLog" exit $buildStatus } -$output = Join-Path (Join-Path $BuildDir $Configuration) 'BongoCat.exe' -if (-not (Test-Path -LiteralPath $output)) { - Write-BuildProgress 95 'BongoCat.exe was not produced.' -NewLine - exit 1 +if ($Target -contains 'bongo_cat') { + $output = Join-Path (Join-Path $BuildDir $Configuration) 'BongoCat.exe' + if (-not (Test-Path -LiteralPath $output)) { + Write-BuildProgress 95 'BongoCat.exe was not produced.' -NewLine + exit 1 + } } $elapsedTotal = [DateTime]::UtcNow - $start Write-BuildProgress 100 'Build complete.' -NewLine Write-Host ("Build time: {0:mm\:ss}" -f $elapsedTotal) -Write-Host "Output: $output" +if ($output) { Write-Host "Output: $output" } Write-Host "Logs: $buildLog" if ($Package) { From e5cd8b12a823800138eb5592d296fcd7c1d3f461 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 7 Sep 2026 20:22:59 +0800 Subject: [PATCH 245/334] Pin CI Windows builds to VS runner --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2f0d8d4..cc9e8686 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,10 +101,10 @@ jobs: fail-fast: false matrix: include: - - os: windows-latest + - os: windows-2022 platform: windows-x64 cmake_arch: x64 - - os: windows-latest + - os: windows-2022 platform: windows-x86 cmake_arch: Win32 - os: ubuntu-latest From b35709910ba4584cf91f5da32312d2f8aee7e927 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 7 Sep 2026 20:38:04 +0800 Subject: [PATCH 246/334] Fix Windows CI test target build --- .github/workflows/ci.yml | 6 ++++-- .github/workflows/release.yml | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc9e8686..5c0b5242 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -149,7 +149,8 @@ jobs: -BuildDir build ` -Configuration Release ` -Architecture '${{ matrix.cmake_arch }}' ` - -Jobs 2 + -Jobs 2 ` + -Target ALL_BUILD - name: Build Windows validation tools if: runner.os == 'Windows' @@ -320,7 +321,8 @@ jobs: -Configuration Release ` -Architecture '${{ matrix.cmake_arch }}' ` -Jobs 2 ` - -RequireCubism + -RequireCubism ` + -Target ALL_BUILD - name: Install software OpenGL for Windows tests uses: ssciwr/setup-mesa-dist-win@6657792673a5d162d5f244db991ba45b4b68989f # v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 313265ac..b3c40399 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -92,7 +92,8 @@ jobs: -Configuration Release ` -Architecture '${{ matrix.cmake_arch }}' ` -Jobs 2 ` - -RequireCubism + -RequireCubism ` + -Target ALL_BUILD - name: Build Unix release if: runner.os != 'Windows' From f0627aa96d8e6d9aae22a1c944ac3bec20a6d863 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 7 Sep 2026 20:56:25 +0800 Subject: [PATCH 247/334] Upgrade Windows Mesa action to Node 24 --- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c0b5242..22fcaa2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -170,7 +170,7 @@ jobs: - name: Install software OpenGL for Windows tests if: runner.os == 'Windows' - uses: ssciwr/setup-mesa-dist-win@6657792673a5d162d5f244db991ba45b4b68989f # v2 + uses: ssciwr/setup-mesa-dist-win@2dfeaebfa514f74c2b8cd8d27108c31486d33e72 # v3 with: version: '23.3.3' deployment-choice: '1' @@ -325,7 +325,7 @@ jobs: -Target ALL_BUILD - name: Install software OpenGL for Windows tests - uses: ssciwr/setup-mesa-dist-win@6657792673a5d162d5f244db991ba45b4b68989f # v2 + uses: ssciwr/setup-mesa-dist-win@2dfeaebfa514f74c2b8cd8d27108c31486d33e72 # v3 with: version: '23.3.3' deployment-choice: '1' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b3c40399..52f9118d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -101,7 +101,7 @@ jobs: - name: Install software OpenGL for Windows tests if: runner.os == 'Windows' - uses: ssciwr/setup-mesa-dist-win@6657792673a5d162d5f244db991ba45b4b68989f # v2 + uses: ssciwr/setup-mesa-dist-win@2dfeaebfa514f74c2b8cd8d27108c31486d33e72 # v3 with: version: '23.3.3' deployment-choice: '1' From 45fb4c208a6cb6e893ca95283051d77646838af2 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 7 Sep 2026 23:58:10 +0800 Subject: [PATCH 248/334] fix: release file and input resources on failure and timeout --- src/core/path_io.c | 3 +- src/core/sha256.c | 3 +- src/platform/macos/macos_input.m | 20 ++++++- src/platform/windows/windows_assets.c | 3 +- src/platform/windows/windows_input.c | 16 +++++- src/platform/windows/windows_input_internal.h | 1 + src/runtime/lifecycle/runtime_state.c | 3 +- tests/platform/test_windows_raw_receiver.c | 57 +++++++++++++++++++ 8 files changed, 97 insertions(+), 9 deletions(-) diff --git a/src/core/path_io.c b/src/core/path_io.c index 5dd763ac..1005144b 100644 --- a/src/core/path_io.c +++ b/src/core/path_io.c @@ -90,7 +90,8 @@ bool bongo_cat_path_copy_file(const char *source, const char *target) { unsigned char buffer[8192]; bool ok = true; size_t count; while ((count = fread(buffer, 1, sizeof(buffer), input)) > 0) if (fwrite(buffer, 1, count, output) != count) { ok = false; break; } - if (ferror(input) || fclose(input) != 0) ok = false; + if (ferror(input)) ok = false; + if (fclose(input) != 0) ok = false; if (fclose(output) != 0) ok = false; if (!ok) remove(target); return ok; diff --git a/src/core/sha256.c b/src/core/sha256.c index dac0ded6..6f27bf99 100644 --- a/src/core/sha256.c +++ b/src/core/sha256.c @@ -92,7 +92,8 @@ BongoCatResult bongo_cat_sha256_file(const char *path, char output[65], BongoCat Sha256 value; initialize(&value); unsigned char buffer[8192]; size_t count; while ((count = fread(buffer, 1, sizeof(buffer), file)) > 0) update(&value, buffer, count); - bool ok = !ferror(file) && fclose(file) == 0; + bool ok = !ferror(file); + if (fclose(file) != 0) ok = false; if (!ok) { bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, "Cannot read file"); return BONGO_CAT_ERROR_IO; } finish(&value, output); return BONGO_CAT_OK; } diff --git a/src/platform/macos/macos_input.m b/src/platform/macos/macos_input.m index 556ba258..621c55c0 100644 --- a/src/platform/macos/macos_input.m +++ b/src/platform/macos/macos_input.m @@ -19,10 +19,17 @@ bool key_down[BONGO_CAT_INPUT_KEY_STATE_CAP]; atomic_bool supported; atomic_bool stop_requested; + atomic_uint references; } MacInputState; static atomic_bool global_supported = ATOMIC_VAR_INIT(false); +static void release_state(MacInputState *state) { + if (atomic_fetch_sub(&state->references, 1) != 1) return; + SDL_DestroySemaphore(state->ready); + free(state); +} + static void push(MacInputState *state, BongoCatInputKind kind, const char *name, float value) { if (!state || !name) return; @@ -101,6 +108,8 @@ static int SDLCALL input_thread(void *userdata) { kCGEventTapOptionListenOnly, mask, event_tap, state); if (state->tap) { state->source = CFMachPortCreateRunLoopSource(NULL, state->tap, 0); + } + if (state->source && !atomic_load(&state->stop_requested)) { state->loop = CFRunLoopGetCurrent(); CFRetain(state->loop); CFRunLoopAddSource(state->loop, state->source, kCFRunLoopCommonModes); CGEventTapEnable(state->tap, true); @@ -114,11 +123,15 @@ static int SDLCALL input_thread(void *userdata) { while (atomic_load(&state->supported) && !atomic_load(&state->stop_requested)) CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.1, true); + if (state->loop && state->source) + CFRunLoopRemoveSource(state->loop, state->source, kCFRunLoopCommonModes); + if (state->tap) CFMachPortInvalidate(state->tap); if (state->source) CFRelease(state->source); if (state->tap) CFRelease(state->tap); if (state->loop) CFRelease(state->loop); atomic_store(&global_supported, false); } + release_state(state); return 0; } @@ -135,6 +148,8 @@ bool bongo_cat_macos_input_start(BongoCatPlatform *platform, BongoCatError *erro state->platform = platform; atomic_init(&state->supported, false); atomic_init(&state->stop_requested, false); + /* Caller and worker each release one reference, including after timeout. */ + atomic_init(&state->references, 2); state->ready = SDL_CreateSemaphore(0); state->thread = state->ready ? SDL_CreateThread(input_thread, BONGO_CAT_SLUG "-macos-input", state) : NULL; @@ -158,13 +173,14 @@ void bongo_cat_macos_input_stop(BongoCatPlatform *platform) { !SDL_WaitSemaphoreTimeout(state->ready, 3000)) { SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "macOS input thread did not initialize before shutdown"); + SDL_DetachThread(state->thread); platform->native = NULL; + release_state(state); return; } if (state->thread) SDL_WaitThread(state->thread, NULL); - if (state->ready) SDL_DestroySemaphore(state->ready); - free(state); platform->native = NULL; + release_state(state); } bool bongo_cat_macos_input_supported(void) { return atomic_load(&global_supported); } diff --git a/src/platform/windows/windows_assets.c b/src/platform/windows/windows_assets.c index 2aad26e3..0b438b8f 100644 --- a/src/platform/windows/windows_assets.c +++ b/src/platform/windows/windows_assets.c @@ -73,7 +73,8 @@ static bool content_matches(const char *path, const unsigned char *expected, siz } offset += count; } - bool ok = fgetc(file) == EOF && !ferror(file) && fclose(file) == 0; + bool ok = fgetc(file) == EOF && !ferror(file); + if (fclose(file) != 0) ok = false; return ok; } diff --git a/src/platform/windows/windows_input.c b/src/platform/windows/windows_input.c index 00198d18..48c1c424 100644 --- a/src/platform/windows/windows_input.c +++ b/src/platform/windows/windows_input.c @@ -8,6 +8,12 @@ static volatile LONG receiver_claimed; +static void free_state(WindowsInputState *state); + +static void release_state(WindowsInputState *state) { + if (InterlockedDecrement(&state->references) == 0) free_state(state); +} + void bongo_cat_windows_input_wake(WindowsInputState *state) { if (!state->wake_pending) return; AcquireSRWLockShared(&state->platform_lock); @@ -80,6 +86,7 @@ static DWORD WINAPI input_thread(void *context) { WindowsInputState *state = context; if (WaitForSingleObject(state->stop, state->test_start_delay_ms) == WAIT_OBJECT_0) { SetEvent(state->ready); + release_state(state); return 0; } state->registered = bongo_cat_windows_input_receiver_create(state); @@ -122,6 +129,7 @@ static DWORD WINAPI input_thread(void *context) { state->diagnostic_ready = false; bongo_cat_windows_input_log(state, GetTickCount64()); bongo_cat_windows_input_receiver_destroy(state); + release_state(state); return 0; } @@ -150,6 +158,9 @@ bool bongo_cat_windows_input_start(BongoCatPlatform *platform) { } state->stop = CreateEventW(NULL, TRUE, FALSE, NULL); state->ready = CreateEventW(NULL, TRUE, FALSE, NULL); + /* The caller retains ownership until the thread handle is published and + stop finishes waiting; the worker retains ownership until cleanup. */ + state->references = 2; state->thread = state->stop && state->ready ? CreateThread(NULL, 0, input_thread, state, 0, NULL) : NULL; if (!state->thread) { free_state(state); return false; } @@ -172,9 +183,8 @@ void bongo_cat_windows_input_stop(BongoCatPlatform *platform) { platform->native = NULL; if (WaitForSingleObject(state->thread, 3000) != WAIT_OBJECT_0) { SDL_LogError(SDL_LOG_CATEGORY_INPUT, - "[input] Raw Input thread did not stop; its state remains isolated until exit"); - return; + "[input] Raw Input thread did not stop; cleanup is deferred until thread exit"); } - free_state(state); + release_state(state); } #endif diff --git a/src/platform/windows/windows_input_internal.h b/src/platform/windows/windows_input_internal.h index 2982071a..118c89fc 100644 --- a/src/platform/windows/windows_input_internal.h +++ b/src/platform/windows/windows_input_internal.h @@ -41,6 +41,7 @@ typedef struct WindowsInputState { SRWLOCK platform_lock; SRWLOCK relative_lock; HANDLE thread, stop, ready; + volatile LONG references; HWND window; ATOM window_class; bool registered; diff --git a/src/runtime/lifecycle/runtime_state.c b/src/runtime/lifecycle/runtime_state.c index c37408b3..16bbac2c 100644 --- a/src/runtime/lifecycle/runtime_state.c +++ b/src/runtime/lifecycle/runtime_state.c @@ -71,7 +71,8 @@ static bool write_state(BongoCatApp *app, const char *stage, for (const char *cursor = stage; *cursor; ++cursor) if (fputc(*cursor == '\r' || *cursor == '\n' ? ' ' : *cursor, file) == EOF) written = false; - if (fputc('\n', file) == EOF || fclose(file) != 0) written = false; + if (fputc('\n', file) == EOF) written = false; + if (fclose(file) != 0) written = false; if (written && bongo_cat_file_replace(temporary, path, true)) return true; bongo_cat_file_remove(temporary); return false; diff --git a/tests/platform/test_windows_raw_receiver.c b/tests/platform/test_windows_raw_receiver.c index 950de8b3..ebfe0794 100644 --- a/tests/platform/test_windows_raw_receiver.c +++ b/tests/platform/test_windows_raw_receiver.c @@ -8,6 +8,62 @@ static unsigned registered_to(HWND window) { return bongo_cat_windows_input_ownership(&state); } +static HANDLE blocked_receiver_entered, blocked_receiver_release; +static WNDPROC original_receiver_proc; +#define TEST_BLOCK_RECEIVER (WM_APP + 271) + +static LRESULT CALLBACK blocked_receiver_proc(HWND window, UINT message, + WPARAM wparam, LPARAM lparam) { + if (message == TEST_BLOCK_RECEIVER) { + SetEvent(blocked_receiver_entered); + WaitForSingleObject(blocked_receiver_release, INFINITE); + return 0; + } + return CallWindowProcW(original_receiver_proc, window, message, wparam, lparam); +} + +static void test_deferred_cleanup(BongoCatPlatform *platform) { + DWORD before = 0, after = 0; + CHECK(GetProcessHandleCount(GetCurrentProcess(), &before)); + CHECK(bongo_cat_windows_input_start(platform)); + WindowsInputState *state = platform->native; + if (!state) return; + HANDLE worker = NULL; + blocked_receiver_entered = CreateEventW(NULL, TRUE, FALSE, NULL); + blocked_receiver_release = CreateEventW(NULL, TRUE, FALSE, NULL); + CHECK(DuplicateHandle(GetCurrentProcess(), state->thread, GetCurrentProcess(), + &worker, SYNCHRONIZE, FALSE, 0)); + CHECK(blocked_receiver_entered && blocked_receiver_release); + if (worker && blocked_receiver_entered && blocked_receiver_release) { + original_receiver_proc = (WNDPROC)GetWindowLongPtrW(state->window, GWLP_WNDPROC); + CHECK(SetWindowLongPtrW(state->window, GWLP_WNDPROC, + (LONG_PTR)blocked_receiver_proc) != 0); + CHECK(PostMessageW(state->window, TEST_BLOCK_RECEIVER, 0, 0)); + DWORD entered = WaitForSingleObject(blocked_receiver_entered, 3000); + CHECK(entered == WAIT_OBJECT_0); + if (entered == WAIT_OBJECT_0) { + bongo_cat_windows_input_stop(platform); + CHECK(platform->native == NULL); + CHECK(WaitForSingleObject(worker, 0) == WAIT_TIMEOUT); + BongoCatPlatform duplicate = {.input = platform->input}; + CHECK(!bongo_cat_windows_input_start(&duplicate)); + bongo_cat_windows_input_stop(&duplicate); + } + } + if (blocked_receiver_release) SetEvent(blocked_receiver_release); + bongo_cat_windows_input_stop(platform); + if (worker) { + CHECK(WaitForSingleObject(worker, INFINITE) == WAIT_OBJECT_0); + CloseHandle(worker); + } + if (blocked_receiver_entered) CloseHandle(blocked_receiver_entered); + if (blocked_receiver_release) CloseHandle(blocked_receiver_release); + CHECK(GetProcessHandleCount(GetCurrentProcess(), &after)); + CHECK(after == before); + CHECK(bongo_cat_windows_input_start(platform)); + bongo_cat_windows_input_stop(platform); +} + static void test_registration_flags(void) { HWND receiver = CreateWindowExW(0, L"STATIC", L"", 0, 0, 0, 0, 0, HWND_MESSAGE, NULL, GetModuleHandleW(NULL), NULL); @@ -73,6 +129,7 @@ void test_windows_raw_receiver(void) { CHECK(!IsWindow(receiver)); bongo_cat_windows_input_stop(&platform); } + test_deferred_cleanup(&platform); SDL_DestroyWindow(window); SDL_QuitSubSystem(SDL_INIT_VIDEO); From 71197c94251bed48adcc0756f51d3721c7ba60b3 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 8 Sep 2026 00:01:38 +0800 Subject: [PATCH 249/334] fix: automatically detect game mouse locking and trim diagnostic logs --- cmake/CheckPlatformRuntimeSafety.cmake | 1 - cmake/PlatformRuntime.cmake | 4 +- cmake/RuntimeSources.cmake | 1 - cmake/Tests.cmake | 7 + include/bongo_cat/app.h | 17 -- include/bongo_cat/log.h | 18 ++ src/core/app_state.c | 8 +- src/platform/windows/windows.c | 12 +- src/platform/windows/windows_capture.c | 9 - src/platform/windows/windows_diagnostics.c | 226 ------------------ src/platform/windows/windows_diagnostics.h | 1 - src/platform/windows/windows_input.c | 16 +- src/platform/windows/windows_input.h | 1 + .../windows/windows_input_detection.c | 113 +++++++++ .../windows/windows_input_detection.h | 43 ++++ src/platform/windows/windows_input_devices.c | 36 +-- .../windows/windows_input_diagnostics.c | 127 ---------- src/platform/windows/windows_input_internal.h | 28 +-- src/platform/windows/windows_input_packets.c | 14 +- src/platform/windows/windows_input_probe.c | 44 ++++ src/platform/windows/windows_input_receiver.c | 9 +- src/platform/windows/windows_input_relative.c | 17 +- src/runtime/input/mouse_diagnostics.c | 80 ------- src/runtime/input/mouse_diagnostics.h | 14 -- src/runtime/input/mouse_mapping.c | 11 - src/runtime/input/mouse_pipeline.c | 11 - src/runtime/input/shortcuts.c | 13 +- src/runtime/lifecycle/app_input.c | 29 --- src/runtime/lifecycle/app_loop.c | 12 +- src/runtime/lifecycle/shutdown.c | 4 +- src/runtime/lifecycle/startup.c | 24 +- src/runtime/lifecycle/startup_arguments.c | 1 - src/runtime/update/update_channel.c | 2 - src/runtime/update/update_service.c | 9 + src/ui/preferences/preferences.c | 3 - tests/core/test_log_policy.c | 28 +++ tests/platform/test_windows_input.c | 42 +++- .../platform/test_windows_pointer_detection.c | 190 +++++++++++++++ tests/platform/test_windows_relative.c | 30 ++- 39 files changed, 581 insertions(+), 674 deletions(-) create mode 100644 include/bongo_cat/log.h delete mode 100644 src/platform/windows/windows_diagnostics.c create mode 100644 src/platform/windows/windows_input_detection.c create mode 100644 src/platform/windows/windows_input_detection.h delete mode 100644 src/platform/windows/windows_input_diagnostics.c create mode 100644 src/platform/windows/windows_input_probe.c delete mode 100644 src/runtime/input/mouse_diagnostics.c delete mode 100644 src/runtime/input/mouse_diagnostics.h create mode 100644 tests/core/test_log_policy.c create mode 100644 tests/platform/test_windows_pointer_detection.c diff --git a/cmake/CheckPlatformRuntimeSafety.cmake b/cmake/CheckPlatformRuntimeSafety.cmake index 8c4e3cb1..34d92db4 100644 --- a/cmake/CheckPlatformRuntimeSafety.cmake +++ b/cmake/CheckPlatformRuntimeSafety.cmake @@ -66,7 +66,6 @@ set(SENSITIVE_RULES "GetRegisteredRawInputDevices|src/platform/windows/windows_input_registration.c" "GetRawInputData|src/platform/windows/windows_input_receiver.c" "SDL_HINT_WINDOWS_RAW_KEYBOARD|src/runtime/shell/window.c|src/platform/windows/windows_input.c" - "OpenProcessToken|src/platform/windows/windows_diagnostics.c" "BitBlt|src/platform/windows/windows_capture_probe.c" "PrintWindow|src/platform/windows/windows_capture_probe.c" "UpdateLayeredWindow|src/platform/windows/windows_layered.c" diff --git a/cmake/PlatformRuntime.cmake b/cmake/PlatformRuntime.cmake index ad77e709..10224360 100644 --- a/cmake/PlatformRuntime.cmake +++ b/cmake/PlatformRuntime.cmake @@ -18,14 +18,14 @@ if(WIN32) src/platform/windows/windows_input_devices.c src/platform/windows/windows_input_packets.c src/platform/windows/windows_input_relative.c - src/platform/windows/windows_input_diagnostics.c + src/platform/windows/windows_input_detection.c + src/platform/windows/windows_input_probe.c src/platform/windows/windows_popup.c src/platform/windows/windows_layered.c src/platform/windows/windows_layered_state.c src/platform/windows/windows_opacity.c src/platform/windows/windows_tray.c src/platform/windows/windows_assets.c - src/platform/windows/windows_diagnostics.c src/platform/windows/windows_capture_probe.c) target_include_directories(bongo_cat_runtime PRIVATE src/platform/windows) diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 6dd21e76..2dfe276e 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -65,7 +65,6 @@ set(BONGO_CAT_RUNTIME_DIAGNOSTIC_SOURCES set(BONGO_CAT_RUNTIME_INPUT_SOURCES src/runtime/input/gamepad.c src/runtime/input/mouse.c - src/runtime/input/mouse_diagnostics.c src/runtime/input/mouse_mapping.c src/runtime/input/mouse_pipeline.c src/runtime/input/shortcuts.c) diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index 74a4904c..3b31b1d6 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -1,4 +1,9 @@ if(BUILD_TESTING) + add_executable(bongo_cat_log_policy_tests tests/core/test_log_policy.c) + target_link_libraries(bongo_cat_log_policy_tests PRIVATE + bongo_cat_runtime bongo_cat_warnings) + add_test(NAME log-policy COMMAND bongo_cat_log_policy_tests) + add_executable(bongo_cat_core_tests tests/core/test_main.c tests/core/test_config.c @@ -121,12 +126,14 @@ if(BUILD_TESTING) add_executable(bongo_cat_windows_input_tests tests/platform/test_windows_input.c tests/platform/test_windows_relative.c + tests/platform/test_windows_pointer_detection.c tests/platform/test_windows_raw_receiver.c) if(MSVC) # Visual Studio can evaluate target language options as C++ with Cubism. set_property(SOURCE tests/platform/test_windows_input.c tests/platform/test_windows_relative.c + tests/platform/test_windows_pointer_detection.c tests/platform/test_windows_raw_receiver.c APPEND PROPERTY COMPILE_OPTIONS "/experimental:c11atomics") endif() diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index 40b86632..7e31f30e 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -20,14 +20,6 @@ typedef struct BongoCatUpdateService BongoCatUpdateService; #define BONGO_CAT_MODEL_COVER_PENDING_CAP 8 -typedef struct BongoCatPointerDiagnostics { - uint64_t last_log_ns, mode_changes, mapped_updates; - uint64_t relative_motion, relative_waits, clamped_x, clamped_y; - uint64_t map_failures; - BongoCatMverPointerBounds bounds; - bool bounds_known; -} BongoCatPointerDiagnostics; - typedef struct BongoCatApp { BongoCatSettings settings; BongoCatSessionState session; @@ -112,7 +104,6 @@ typedef struct BongoCatApp { bool smoke_remove_imported; bool smoke_shortcuts; bool smoke_menu; - bool smoke_input_audit; bool smoke_ignore_global_input; bool smoke_pass_through; bool smoke_context_menu; @@ -136,14 +127,6 @@ typedef struct BongoCatApp { uint64_t pointer_hit_deadline_ns; uint64_t display_recovery_due_ns; uint64_t mouse_last_ns; - BongoCatPointerDiagnostics pointer_diagnostics; - uint64_t mouse_native_samples; - uint64_t input_events_consumed; - uint64_t input_key_down_events, input_key_up_events; - uint64_t input_mouse_down_events, input_mouse_up_events; - uint64_t input_key_supported, input_key_unsupported; - uint64_t input_mouse_applied, input_mouse_updates; - uint64_t input_shortcuts_triggered; uint64_t frame_audit_bmp_ns; uint64_t random_expression_due_ns; float random_expression_interval_seconds; diff --git a/include/bongo_cat/log.h b/include/bongo_cat/log.h new file mode 100644 index 00000000..78bf8464 --- /dev/null +++ b/include/bongo_cat/log.h @@ -0,0 +1,18 @@ +#ifndef BONGO_CAT_LOG_H +#define BONGO_CAT_LOG_H + +#include + +enum { + BONGO_CAT_LOG_LIFECYCLE = SDL_LOG_CATEGORY_CUSTOM, + BONGO_CAT_LOG_UPDATE +}; + +static inline bool bongo_cat_log_enabled(int category, SDL_LogPriority priority) { + return priority >= SDL_LOG_PRIORITY_WARN || + (priority == SDL_LOG_PRIORITY_INFO && + (category == BONGO_CAT_LOG_LIFECYCLE || + category == BONGO_CAT_LOG_UPDATE)); +} + +#endif diff --git a/src/core/app_state.c b/src/core/app_state.c index aa56155f..ffccf210 100644 --- a/src/core/app_state.c +++ b/src/core/app_state.c @@ -67,11 +67,7 @@ static void update_hands(BongoCatApp *app) { static void apply_key(BongoCatApp *app, const char *name, bool pressed) { int hand = bongo_cat_overlay_key(app->overlay, name, pressed); - if (hand < 0) { - app->input_key_unsupported++; - return; - } - app->input_key_supported++; + if (hand < 0) return; update_hands(app); app->dirty = true; } @@ -168,14 +164,12 @@ void bongo_cat_app_apply_input(BongoCatApp *app, const BongoCatInputEvent *event if (changed) app->mouse_button_event_pending = true; if (!down) app->pointer_hit_dirty = true; if (side) { - app->input_mouse_applied++; app->dirty = true; break; } const char *id = left ? "ParamMouseLeftDown" : "ParamMouseRightDown"; bongo_cat_live2d_set_parameter(app->live2d, id, down ? 1.0f : 0.0f); - app->input_mouse_applied++; app->dirty = true; break; } diff --git a/src/platform/windows/windows.c b/src/platform/windows/windows.c index e5f36a5a..889f2a48 100644 --- a/src/platform/windows/windows.c +++ b/src/platform/windows/windows.c @@ -139,17 +139,7 @@ void bongo_cat_platform_begin_drag(BongoCatPlatform *platform, } bool bongo_cat_platform_dynamic_hit_supported(void) { return true; } bool bongo_cat_platform_pointer_locked(BongoCatPlatform *platform) { - if (!platform) return false; - HWND foreground = GetForegroundWindow(); - DWORD foreground_pid = 0; - if (!foreground || !GetWindowThreadProcessId(foreground, &foreground_pid) || - foreground_pid == GetCurrentProcessId()) return false; - RECT clip = {0}; - CURSORINFO cursor = {.cbSize = sizeof(cursor)}; - bool clip_known = GetClipCursor(&clip) != FALSE; - bool cursor_known = GetCursorInfo(&cursor) != FALSE; - return bongo_cat_windows_input_relative_mode(true, - clip_known ? &clip : NULL, cursor_known ? &cursor : NULL); + return bongo_cat_windows_input_pointer_locked(platform); } bool bongo_cat_platform_relative_pointer(BongoCatPlatform *platform, double *x, double *y) { diff --git a/src/platform/windows/windows_capture.c b/src/platform/windows/windows_capture.c index b92e57e9..25328211 100644 --- a/src/platform/windows/windows_capture.c +++ b/src/platform/windows/windows_capture.c @@ -1,5 +1,4 @@ #include "windows_capture.h" -#include "windows_diagnostics.h" #ifdef _WIN32 #include @@ -12,7 +11,6 @@ static UINT capture_refresh_message; static const wchar_t capture_property[] = L"BongoCat.CaptureWindow"; static bool removal_warning_emitted; static bool style_warning_emitted; -static bool environment_logged; #define BONGO_CAT_CAPTURE_REFRESH_TIMER ((UINT_PTR)0xBC51) static bool has_property(HWND window, const wchar_t *name) { @@ -61,14 +59,7 @@ static HRESULT remove_taskbar_tab(HWND window) { return result; } -static void log_environment(HWND window) { - if (environment_logged) return; - environment_logged = true; - bongo_cat_windows_diagnostics_log(window); -} - void bongo_cat_windows_capture_log(HWND window, const char *stage) { - log_environment(window); if (!window || !IsWindow(window)) return; if (SDL_GetLogPriority(SDL_LOG_CATEGORY_VIDEO) > SDL_LOG_PRIORITY_DEBUG) return; RECT bounds = {0}; diff --git a/src/platform/windows/windows_diagnostics.c b/src/platform/windows/windows_diagnostics.c deleted file mode 100644 index ac38e663..00000000 --- a/src/platform/windows/windows_diagnostics.c +++ /dev/null @@ -1,226 +0,0 @@ -#include "windows_diagnostics.h" - -#ifdef _WIN32 -#include -#include -#include -#include -#include - -static void utf8(const wchar_t *source, char *target, size_t capacity) { - if (!target || !capacity) return; - target[0] = '\0'; - if (!source || !source[0]) return; - int length = WideCharToMultiByte(CP_UTF8, 0, source, -1, - target, (int)capacity, NULL, NULL); - if (!length) target[0] = '\0'; - target[capacity - 1] = '\0'; -} - -static void log_displays(void) { - for (DWORD index = 0; index < 16; ++index) { - DISPLAY_DEVICEW adapter = {.cb = sizeof(adapter)}; - if (!EnumDisplayDevicesW(NULL, index, &adapter, 0)) break; - char name[128], description[256], id[512]; - utf8(adapter.DeviceName, name, sizeof(name)); - utf8(adapter.DeviceString, description, sizeof(description)); - utf8(adapter.DeviceID, id, sizeof(id)); - SDL_Log("Display adapter %lu: name=%s description=%s id=%s flags=0x%lx", - (unsigned long)index, name, description, id, - (unsigned long)adapter.StateFlags); - for (DWORD monitor_index = 0; monitor_index < 16; ++monitor_index) { - DISPLAY_DEVICEW monitor = {.cb = sizeof(monitor)}; - if (!EnumDisplayDevicesW(adapter.DeviceName, monitor_index, - &monitor, 0)) break; - char monitor_name[128], monitor_description[256]; - utf8(monitor.DeviceName, monitor_name, sizeof(monitor_name)); - utf8(monitor.DeviceString, monitor_description, - sizeof(monitor_description)); - SDL_Log("Display output %lu.%lu: name=%s description=%s " - "flags=0x%lx", (unsigned long)index, - (unsigned long)monitor_index, monitor_name, - monitor_description, (unsigned long)monitor.StateFlags); - } - } -} - -static void log_process(void) { - HANDLE token = NULL; - TOKEN_ELEVATION elevation = {0}; - DWORD length = 0, integrity = 0; - if (OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) { - GetTokenInformation(token, TokenElevation, &elevation, - sizeof(elevation), &length); - DWORD needed = 0; - GetTokenInformation(token, TokenIntegrityLevel, NULL, 0, &needed); - if (needed) { - TOKEN_MANDATORY_LABEL *label = malloc(needed); - if (label && GetTokenInformation(token, TokenIntegrityLevel, - label, needed, &needed)) { - PSID sid = label->Label.Sid; - if (IsValidSid(sid) && *GetSidSubAuthorityCount(sid) > 0) - integrity = *GetSidSubAuthority(sid, - *GetSidSubAuthorityCount(sid) - 1); - } - free(label); - } - CloseHandle(token); - } - wchar_t executable_wide[MAX_PATH] = {0}; - char executable[MAX_PATH * 3]; - GetModuleFileNameW(NULL, executable_wide, MAX_PATH - 1); - utf8(executable_wide, executable, sizeof(executable)); - SYSTEM_INFO system = {0}; - GetNativeSystemInfo(&system); - SDL_Log("Process environment: pid=%lu native_arch=%u pointer_bits=%u " - "elevated=%d integrity_rid=0x%lx executable=%s", - (unsigned long)GetCurrentProcessId(), - (unsigned)system.wProcessorArchitecture, (unsigned)(sizeof(void *) * 8), - elevation.TokenIsElevated != 0, (unsigned long)integrity, executable); -} - -static void log_monitor(HWND window) { - HMONITOR monitor = MonitorFromWindow(window, MONITOR_DEFAULTTONEAREST); - MONITORINFOEXW info = {.cbSize = sizeof(info)}; - if (!monitor || !GetMonitorInfoW(monitor, (MONITORINFO *)&info)) { - SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, - "Window monitor lookup failed (Win32 error %lu)", - (unsigned long)GetLastError()); - return; - } - char device[128]; - utf8(info.szDevice, device, sizeof(device)); - DEVMODEW mode = {.dmSize = sizeof(mode)}; - bool mode_known = EnumDisplaySettingsExW(info.szDevice, - ENUM_CURRENT_SETTINGS, &mode, 0) != FALSE; - SDL_Log("Window monitor: handle=%p device=%s primary=%d " - "monitor=%ld,%ld %ldx%ld work=%ld,%ld %ldx%ld " - "mode_known=%d mode_position=%ld,%ld mode_size=%lux%lu " - "frequency=%lu orientation=%lu", - (void *)monitor, device, (info.dwFlags & MONITORINFOF_PRIMARY) != 0, - (long)info.rcMonitor.left, (long)info.rcMonitor.top, - (long)(info.rcMonitor.right - info.rcMonitor.left), - (long)(info.rcMonitor.bottom - info.rcMonitor.top), - (long)info.rcWork.left, (long)info.rcWork.top, - (long)(info.rcWork.right - info.rcWork.left), - (long)(info.rcWork.bottom - info.rcWork.top), mode_known, - mode_known ? (long)mode.dmPosition.x : 0, - mode_known ? (long)mode.dmPosition.y : 0, - mode_known ? (unsigned long)mode.dmPelsWidth : 0, - mode_known ? (unsigned long)mode.dmPelsHeight : 0, - mode_known ? (unsigned long)mode.dmDisplayFrequency : 0, - mode_known ? (unsigned long)mode.dmDisplayOrientation : 0); - for (DWORD index = 0; index < 32; ++index) { - DISPLAY_DEVICEW adapter = {.cb = sizeof(adapter)}; - if (!EnumDisplayDevicesW(NULL, index, &adapter, 0)) break; - if (wcscmp(adapter.DeviceName, info.szDevice) != 0) continue; - char description[256], id[512]; - utf8(adapter.DeviceString, description, sizeof(description)); - utf8(adapter.DeviceID, id, sizeof(id)); - SDL_Log("Window monitor adapter: index=%lu description=%s id=%s " - "flags=0x%lx", (unsigned long)index, description, id, - (unsigned long)adapter.StateFlags); - break; - } -} - -static void log_device_context(HWND window) { - HDC dc = GetDC(window); - if (!dc) { - SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, - "Window device context lookup failed (Win32 error %lu)", - (unsigned long)GetLastError()); - return; - } - int format = GetPixelFormat(dc); - PIXELFORMATDESCRIPTOR pixel_format = {0}; - bool described = format > 0 && DescribePixelFormat(dc, format, - sizeof(pixel_format), &pixel_format) != 0; - HDC current_dc = wglGetCurrentDC(); - HGLRC current_context = wglGetCurrentContext(); - HWND current_window = current_dc ? WindowFromDC(current_dc) : NULL; - SDL_LogDebug(SDL_LOG_CATEGORY_VIDEO, - "Window device context: pixel_format=%d described=%d " - "pfd_flags=0x%lx color_bits=%u alpha_bits=%u depth_bits=%u " - "stencil_bits=%u layer_type=%u technology=%d bits_pixel=%d " - "planes=%d raster_caps=0x%x current_gl_context=%p current_dc=%p " - "current_dc_window=%p matches_window=%d", - format, described, - described ? (unsigned long)pixel_format.dwFlags : 0, - described ? (unsigned)pixel_format.cColorBits : 0, - described ? (unsigned)pixel_format.cAlphaBits : 0, - described ? (unsigned)pixel_format.cDepthBits : 0, - described ? (unsigned)pixel_format.cStencilBits : 0, - described ? (unsigned)pixel_format.iLayerType : 0, - GetDeviceCaps(dc, TECHNOLOGY), GetDeviceCaps(dc, BITSPIXEL), - GetDeviceCaps(dc, PLANES), (unsigned)GetDeviceCaps(dc, RASTERCAPS), - (void *)current_context, (void *)current_dc, (void *)current_window, - current_window == window); - ReleaseDC(window, dc); -} - -static void log_window(HWND window) { - char class_name[128], title[256]; - wchar_t class_wide[128] = {0}, title_wide[256] = {0}; - GetClassNameW(window, class_wide, (int)(sizeof(class_wide) / - sizeof(class_wide[0]))); - GetWindowTextW(window, title_wide, (int)(sizeof(title_wide) / - sizeof(title_wide[0]))); - utf8(class_wide, class_name, sizeof(class_name)); - utf8(title_wide, title, sizeof(title)); - RECT frame = {0}; - HRESULT frame_result = DwmGetWindowAttribute(window, - DWMWA_EXTENDED_FRAME_BOUNDS, &frame, sizeof(frame)); - LONG_PTR extended = GetWindowLongPtrW(window, GWL_EXSTYLE); - BYTE alpha = 0; COLORREF color = 0; DWORD flags = 0; - BOOL layered_known = (extended & WS_EX_LAYERED) != 0 && - GetLayeredWindowAttributes(window, &color, &alpha, &flags); - typedef UINT (WINAPI *GetDpiForWindowFn)(HWND); - HMODULE user32 = GetModuleHandleW(L"user32.dll"); - GetDpiForWindowFn get_dpi = user32 ? (GetDpiForWindowFn)(void *) - GetProcAddress(user32, "GetDpiForWindow") : NULL; - UINT dpi = get_dpi ? get_dpi(window) : 0; - DWORD pid = 0; DWORD thread = GetWindowThreadProcessId(window, &pid); - RECT client = {0}; POINT client_origin = {0}; - bool client_known = GetClientRect(window, &client) != FALSE && - ClientToScreen(window, &client_origin) != FALSE; - SDL_LogDebug(SDL_LOG_CATEGORY_VIDEO, - "Capture window details: class=%s title=%s pid=%lu thread=%lu " - "dpi=%u frame_known=%d frame=%ld,%ld %ldx%ld " - "client_known=%d client=%ld,%ld %ldx%ld " - "layered_known=%d alpha=%u layered_flags=0x%lx", class_name, title, - (unsigned long)pid, (unsigned long)thread, dpi, - SUCCEEDED(frame_result), (long)frame.left, (long)frame.top, - (long)(frame.right - frame.left), (long)(frame.bottom - frame.top), - client_known, (long)client_origin.x, (long)client_origin.y, - (long)(client.right - client.left), - (long)(client.bottom - client.top), - layered_known, (unsigned)alpha, (unsigned long)flags); - log_monitor(window); - log_device_context(window); -} - -void bongo_cat_windows_diagnostics_log(HWND window) { - OSVERSIONINFOW version = {.dwOSVersionInfoSize = sizeof(version)}; - typedef LONG (WINAPI *RtlGetVersionFn)(OSVERSIONINFOW *); - HMODULE module = GetModuleHandleW(L"ntdll.dll"); - RtlGetVersionFn get_version = module ? - (RtlGetVersionFn)(void *)GetProcAddress(module, "RtlGetVersion") : NULL; - bool known = get_version && get_version(&version) == 0; - BOOL composition = FALSE; - HRESULT composition_result = DwmIsCompositionEnabled(&composition); - SDL_Log("Windows diagnostics: version=%lu.%lu.%lu known=%d " - "wgc_os_supported=%d remote_session=%d composition=%d " - "composition_result=0x%08lx", - (unsigned long)version.dwMajorVersion, - (unsigned long)version.dwMinorVersion, - (unsigned long)version.dwBuildNumber, known, - known && version.dwBuildNumber >= 18362, - GetSystemMetrics(SM_REMOTESESSION) != 0, - composition, (unsigned long)composition_result); - log_process(); - log_displays(); - if (window) log_window(window); -} - -#endif diff --git a/src/platform/windows/windows_diagnostics.h b/src/platform/windows/windows_diagnostics.h index f6c9a040..a1cfce71 100644 --- a/src/platform/windows/windows_diagnostics.h +++ b/src/platform/windows/windows_diagnostics.h @@ -5,7 +5,6 @@ #include #include -void bongo_cat_windows_diagnostics_log(HWND window); bool bongo_cat_windows_diagnostics_probe_capture( HWND window, const char *stage); #endif diff --git a/src/platform/windows/windows_input.c b/src/platform/windows/windows_input.c index 48c1c424..109a1648 100644 --- a/src/platform/windows/windows_input.c +++ b/src/platform/windows/windows_input.c @@ -40,7 +40,6 @@ bool bongo_cat_windows_input_push_event(WindowsInputState *state, event.value = value; snprintf(event.name, sizeof(event.name), "%s", name); pushed = bongo_cat_input_push(platform->input, &event); - if (!pushed) state->counters.queue_failures++; } ReleaseSRWLockShared(&state->platform_lock); if (pushed) state->wake_pending = true; @@ -69,10 +68,6 @@ static void check_receiver(WindowsInputState *state) { if (ownership != state->ownership || unavailable) bongo_cat_windows_input_clear_devices(state); else bongo_cat_windows_input_clear_motion(state); - if (unavailable != state->desktop_unavailable) - state->counters.desktop_resets++; - SDL_Log("[input] Raw Input receiver: ownership=%u error=%lu desktop_available=%d", - ownership, (unsigned long)state->registration_error, !unavailable); state->ownership = ownership; state->desktop_unavailable = unavailable; } @@ -90,9 +85,8 @@ static DWORD WINAPI input_thread(void *context) { return 0; } state->registered = bongo_cat_windows_input_receiver_create(state); - SDL_Log("[input] Windows input: diagnostics=raw-input-v3 backend=raw-input " - "registered=%d error=%lu background=INPUTSINK device_notify=1 legacy=enabled", - state->registered, (unsigned long)state->startup_error); + if (!state->registered) SDL_LogError(SDL_LOG_CATEGORY_INPUT, + "Raw Input initialization failed: error=%lu", (unsigned long)state->startup_error); if (state->registered) { state->ownership = 3; check_receiver(state); @@ -100,7 +94,8 @@ static DWORD WINAPI input_thread(void *context) { SetEvent(state->ready); ULONGLONG last_check = GetTickCount64(), last_wake = 0; while (state->registered) { - DWORD wait = MsgWaitForMultipleObjectsEx(1, &state->stop, 16, + DWORD timeout = state->wake_pending || state->retry_events ? 8 : 1000; + DWORD wait = MsgWaitForMultipleObjectsEx(1, &state->stop, timeout, QS_ALLINPUT, MWMO_INPUTAVAILABLE); if (wait == WAIT_OBJECT_0) break; if (wait == WAIT_FAILED) { @@ -119,15 +114,12 @@ static DWORD WINAPI input_thread(void *context) { bongo_cat_windows_input_wake(state); last_wake = now; } - bongo_cat_windows_input_log(state, now); } AcquireSRWLockExclusive(&state->relative_lock); state->receiving = false; ReleaseSRWLockExclusive(&state->relative_lock); bongo_cat_windows_input_clear_devices(state); bongo_cat_windows_input_wake(state); - state->diagnostic_ready = false; - bongo_cat_windows_input_log(state, GetTickCount64()); bongo_cat_windows_input_receiver_destroy(state); release_state(state); return 0; diff --git a/src/platform/windows/windows_input.h b/src/platform/windows/windows_input.h index cb323f91..2ca51096 100644 --- a/src/platform/windows/windows_input.h +++ b/src/platform/windows/windows_input.h @@ -12,6 +12,7 @@ bool bongo_cat_windows_input_take_relative(BongoCatPlatform *platform, double *x, double *y, unsigned long long *sample_count); void bongo_cat_windows_input_reset_relative(BongoCatPlatform *platform); void bongo_cat_windows_input_release_relative(BongoCatPlatform *platform); +bool bongo_cat_windows_input_pointer_locked(BongoCatPlatform *platform); bool bongo_cat_windows_input_relative_mode(bool foreign_foreground, const RECT *clip, const CURSORINFO *cursor); #endif diff --git a/src/platform/windows/windows_input_detection.c b/src/platform/windows/windows_input_detection.c new file mode 100644 index 00000000..16aa0bc5 --- /dev/null +++ b/src/platform/windows/windows_input_detection.c @@ -0,0 +1,113 @@ +#include "windows_input_detection.h" + +#ifdef _WIN32 +#include +#include + +static bool interior(POINT point, const RECT *bounds) { + const int margin = 4; + return (long long)point.x > (long long)bounds->left + margin && + (long long)point.x < (long long)bounds->right - 1 - margin && + (long long)point.y > (long long)bounds->top + margin && + (long long)point.y < (long long)bounds->bottom - 1 - margin; +} + +static WindowsPointerReason lock_hint(const WindowsPointerObservation *sample) { + if (sample->clip_known) { + long long width = (long long)sample->clip.right - sample->clip.left; + long long height = (long long)sample->clip.bottom - sample->clip.top; + if (width >= 0 && width <= 2 && height >= 0 && height <= 2) + return WINDOWS_POINTER_CLIPPED; + } + if (sample->cursor_known && sample->cursor_flags == 0) + return WINDOWS_POINTER_HIDDEN; + return WINDOWS_POINTER_DESKTOP; +} + +static void begin_window(WindowsPointerDetection *state, POINT point, + ULONGLONG now_ms) { + state->anchor = point; + state->window_ms = now_ms; + state->raw_x = state->raw_y = 0; + state->moving_samples = state->returns = 0; + state->away = false; + state->edge_seen = false; + state->excursion = 0; +} + +bool bongo_cat_windows_pointer_detect(WindowsPointerDetection *state, + const WindowsPointerObservation *sample, ULONGLONG now_ms) { + if (!state || !sample) return false; + if (!sample->foreign || !sample->foreground || !sample->position_known) { + *state = (WindowsPointerDetection){0}; + return false; + } + /* Never reuse movement from another foreground window or a suspended loop. */ + if (!state->initialized || state->foreground != sample->foreground || + state->pid != sample->pid || now_ms < state->last_ms || + now_ms - state->last_ms > 500) { + *state = (WindowsPointerDetection){.initialized = true, + .foreground = sample->foreground, .pid = sample->pid, + .last_ms = now_ms}; + begin_window(state, sample->position, now_ms); + return false; + } + state->last_ms = now_ms; + state->raw_x += sample->raw_x; + state->raw_y += sample->raw_y; + if (sample->motion_packets) state->moving_samples++; + long long dx = llabs((long long)sample->position.x - state->anchor.x); + long long dy = llabs((long long)sample->position.y - state->anchor.y); + long long distance = dx > dy ? dx : dy; + if (distance > state->excursion) state->excursion = distance; + if (distance > 2) state->away = true; + else if (state->away) { state->returns++; state->away = false; } + bool inside = sample->monitor_known && + interior(sample->position, &sample->monitor) && + interior(state->anchor, &sample->monitor) && + (!sample->clip_known || (interior(sample->position, &sample->clip) && + interior(state->anchor, &sample->clip))); + if (!inside) state->edge_seen = true; + + WindowsPointerReason hint = lock_hint(sample); + if (hint != WINDOWS_POINTER_DESKTOP) { + if (!state->hint_pending) { + state->hint_pending = true; + state->hint_ms = now_ms; + } + /* Hidden-on-typing cursors alone are not evidence of mouse movement. */ + if (state->relative || (now_ms - state->hint_ms >= 32 && + state->moving_samples >= 2)) { + state->relative = true; + state->reason = hint; + state->evidence_ms = now_ms; + } + } else state->hint_pending = false; + + ULONGLONG window_age = now_ms - state->window_ms; + if (window_age >= 128 && (state->moving_samples >= 4 || window_age >= 256)) { + /* Net device counts reject ordinary back-and-forth movement. The + monitor and clipping edges reject a cursor pushed against a border. */ + bool raw_travel = fabs(state->raw_x) >= 32 || fabs(state->raw_y) >= 32; + bool evidence = !state->edge_seen && raw_travel && state->moving_samples >= 4; + bool stalled = evidence && distance <= 1 && state->excursion <= 1; + bool recentered = evidence && state->returns >= 2 && state->excursion <= 48; + if (hint == WINDOWS_POINTER_DESKTOP && (stalled || recentered)) { + state->relative = true; + state->reason = stalled ? WINDOWS_POINTER_STALLED : + WINDOWS_POINTER_RECENTERED; + state->evidence_ms = now_ms; + } else if (hint == WINDOWS_POINTER_DESKTOP && state->relative && + now_ms - state->evidence_ms >= 192 && + (distance > 2 || state->reason == WINDOWS_POINTER_HIDDEN || + state->reason == WINDOWS_POINTER_CLIPPED)) { + state->relative = false; + state->reason = WINDOWS_POINTER_DESKTOP; + } + /* A stationary inferred lock stays active until actual cursor travel + disproves it; stopping the mouse must not reset the virtual position. */ + begin_window(state, sample->position, now_ms); + } + return state->relative; +} +#endif diff --git a/src/platform/windows/windows_input_detection.h b/src/platform/windows/windows_input_detection.h new file mode 100644 index 00000000..f2511178 --- /dev/null +++ b/src/platform/windows/windows_input_detection.h @@ -0,0 +1,43 @@ +#ifndef BONGO_CAT_WINDOWS_INPUT_DETECTION_H +#define BONGO_CAT_WINDOWS_INPUT_DETECTION_H + +#ifdef _WIN32 +#include +#include + +typedef enum WindowsPointerReason { + WINDOWS_POINTER_DESKTOP, + WINDOWS_POINTER_HIDDEN, + WINDOWS_POINTER_CLIPPED, + WINDOWS_POINTER_STALLED, + WINDOWS_POINTER_RECENTERED +} WindowsPointerReason; + +typedef struct WindowsPointerObservation { + HWND foreground; + DWORD pid; + bool foreign, position_known, monitor_known, clip_known, cursor_known; + POINT position; + RECT monitor, clip; + DWORD cursor_flags; + double raw_x, raw_y; + unsigned long long motion_packets; +} WindowsPointerObservation; + +/* Owned by the model thread; device statistics are transferred under its lock. */ +typedef struct WindowsPointerDetection { + HWND foreground; + DWORD pid; + bool initialized, relative, hint_pending, away, edge_seen; + ULONGLONG last_ms, window_ms, hint_ms, evidence_ms; + POINT anchor; + double raw_x, raw_y; + unsigned moving_samples, returns; + long long excursion; + WindowsPointerReason reason; +} WindowsPointerDetection; + +bool bongo_cat_windows_pointer_detect(WindowsPointerDetection *state, + const WindowsPointerObservation *sample, ULONGLONG now_ms); +#endif +#endif diff --git a/src/platform/windows/windows_input_devices.c b/src/platform/windows/windows_input_devices.c index 277c7330..f297cca9 100644 --- a/src/platform/windows/windows_input_devices.c +++ b/src/platform/windows/windows_input_devices.c @@ -12,13 +12,16 @@ static void publish(WindowsInputState *state, WindowsRawHeld *held, bool mouse) BONGO_CAT_INPUT_MOUSE_UP) : (down ? BONGO_CAT_INPUT_KEY_DOWN : BONGO_CAT_INPUT_KEY_UP); if (!bongo_cat_windows_input_push_event(state, kind, held->name, - down ? 1.0f : 0.0f)) return; + down ? 1.0f : 0.0f)) { + state->retry_events = true; + return; + } held->emitted = down; - if (mouse) state->counters.button_sent++; - else state->counters.key_sent++; } void bongo_cat_windows_input_flush(WindowsInputState *state) { + if (!state->retry_events) return; + state->retry_events = false; for (unsigned i = 0; i < BONGO_CAT_WINDOWS_RAW_HELD_LIMIT; ++i) publish(state, &state->keys[i], false); for (unsigned i = 0; i < BONGO_CAT_WINDOWS_MOUSE_BUTTON_COUNT; ++i) @@ -29,15 +32,9 @@ WindowsRawDevice *bongo_cat_windows_input_device(WindowsInputState *state, HANDLE handle) { for (WindowsRawDevice *device = state->devices; device; device = device->next) if (device->handle == handle) return device; - if (state->device_count >= BONGO_CAT_WINDOWS_RAW_DEVICE_LIMIT) { - state->counters.capacity_failures++; - return NULL; - } + if (state->device_count >= BONGO_CAT_WINDOWS_RAW_DEVICE_LIMIT) return NULL; WindowsRawDevice *device = calloc(1, sizeof(*device)); - if (!device) { - state->counters.capacity_failures++; - return NULL; - } + if (!device) return NULL; device->handle = handle; device->next = state->devices; state->devices = device; @@ -55,7 +52,6 @@ static int held_slot(WindowsInputState *state, const char *name) { } if (available >= 0) snprintf(state->keys[available].name, sizeof(state->keys[available].name), "%s", name); - else state->counters.capacity_failures++; return available; } @@ -63,12 +59,10 @@ void bongo_cat_windows_input_key(WindowsInputState *state, WindowsRawDevice *device, const RAWKEYBOARD *key) { if ((key->Flags & RI_KEY_E0) && (key->MakeCode == 0x2a || key->MakeCode == 0x36)) { - state->counters.ignored++; return; } if ((key->Flags & RI_KEY_E1) && key->MakeCode == 0x1d) { device->pending_e1 = true; - state->counters.ignored++; return; } RAWKEYBOARD normalized = *key; @@ -79,15 +73,12 @@ void bongo_cat_windows_input_key(WindowsInputState *state, key = &normalized; char buffer[16]; const char *name = bongo_cat_windows_key_name(key, buffer); - if (!name) { state->counters.ignored++; return; } + if (!name) return; unsigned index = bongo_cat_windows_key_index(key); - if (index >= BONGO_CAT_WINDOWS_RAW_KEY_COUNT) { - state->counters.invalid++; - return; - } + if (index >= BONGO_CAT_WINDOWS_RAW_KEY_COUNT) return; unsigned slot = device->keys[index]; bool down = !(key->Flags & RI_KEY_BREAK); - if (down == (slot != 0)) { state->counters.ignored++; return; } + if (down == (slot != 0)) return; if (down) { int found = held_slot(state, name); if (found < 0) return; @@ -109,6 +100,7 @@ void bongo_cat_windows_input_key(WindowsInputState *state, void bongo_cat_windows_input_buttons(WindowsInputState *state, WindowsRawDevice *device, USHORT flags) { + if (!flags) return; static const USHORT presses[] = {RI_MOUSE_LEFT_BUTTON_DOWN, RI_MOUSE_RIGHT_BUTTON_DOWN, RI_MOUSE_MIDDLE_BUTTON_DOWN, RI_MOUSE_BUTTON_4_DOWN, RI_MOUSE_BUTTON_5_DOWN}; @@ -122,13 +114,11 @@ void bongo_cat_windows_input_buttons(WindowsInputState *state, if ((flags & presses[i]) && !device->buttons[i]) { device->buttons[i] = true; held->references++; - state->counters.button_edges++; publish(state, held, true); } if ((flags & releases[i]) && device->buttons[i]) { device->buttons[i] = false; held->references--; - state->counters.button_edges++; publish(state, held, true); } } @@ -146,7 +136,7 @@ void bongo_cat_windows_input_remove_device(WindowsInputState *state, HANDLE hand *link = device->next; free(device); state->device_count--; - state->counters.removals++; + state->retry_events = true; bongo_cat_windows_input_flush(state); } diff --git a/src/platform/windows/windows_input_diagnostics.c b/src/platform/windows/windows_input_diagnostics.c deleted file mode 100644 index f7ae6138..00000000 --- a/src/platform/windows/windows_input_diagnostics.c +++ /dev/null @@ -1,127 +0,0 @@ -#include "windows_input_internal.h" - -#ifdef _WIN32 -#include - -static long long age_ms(ULONGLONG now, ULONGLONG last) { - return last && now >= last ? (long long)(now - last) : -1; -} - -static long long probe_session_input(WindowsInputState *state) { - LASTINPUTINFO input = {.cbSize = sizeof(input)}; - bool known = GetLastInputInfo(&input) != FALSE; - if (known) { - /* This is session activity, including synthetic input, not device events. */ - if (state->session_input_known && input.dwTime != state->last_session_input_tick) - state->counters.session_input_changes++; - state->last_session_input_tick = input.dwTime; - } - state->session_input_known = known; - if (!known) return -1; - DWORD age = GetTickCount() - input.dwTime; - /* Account for tick wrap; reject implausible ages from supplied input times. */ - return age <= 0x7fffffffu ? (long long)age : -1; -} - -void bongo_cat_windows_input_log(WindowsInputState *state, ULONGLONG now_ms) { - if (state->diagnostic_ready && now_ms - state->last_diagnostic_probe_ms < 1000) - return; - state->last_diagnostic_probe_ms = now_ms; - long long session_input_age = probe_session_input(state); - HWND foreground = GetForegroundWindow(); - DWORD pid = 0; - if (foreground) GetWindowThreadProcessId(foreground, &pid); - bool changed = foreground != state->reported_foreground || - pid != state->reported_foreground_pid; - if (state->diagnostic_ready && !changed && - now_ms - state->last_diagnostic_ms < 10000) return; - if (!state->diagnostic_ready || changed) { - char class_name[128] = {0}; - if (foreground) GetClassNameA(foreground, class_name, sizeof(class_name)); - SDL_Log("[input] Raw Input foreground: pid=%lu class=%s " - "desktop=%d,%d,%d,%d", - (unsigned long)pid, class_name[0] ? class_name : "unknown", - GetSystemMetrics(SM_XVIRTUALSCREEN), GetSystemMetrics(SM_YVIRTUALSCREEN), - GetSystemMetrics(SM_CXVIRTUALSCREEN), GetSystemMetrics(SM_CYVIRTUALSCREEN)); - } - RECT clip = {0}; - bool clip_known = GetClipCursor(&clip) != FALSE; - CURSORINFO cursor = {.cbSize = sizeof(cursor)}; - bool cursor_known = GetCursorInfo(&cursor) != FALSE; - bool relative_mode = bongo_cat_windows_input_relative_mode( - pid && pid != GetCurrentProcessId(), clip_known ? &clip : NULL, - cursor_known ? &cursor : NULL); - RECT window_rect = {0}; - bool window_known = foreground && GetWindowRect(foreground, &window_rect); - MONITORINFO monitor = {.cbSize = sizeof(monitor)}; - bool monitor_known = foreground && GetMonitorInfoW( - MonitorFromWindow(foreground, MONITOR_DEFAULTTONEAREST), &monitor); - SetLastError(ERROR_SUCCESS); - LONG_PTR style = foreground ? GetWindowLongPtrW(foreground, GWL_STYLE) : 0; - bool style_known = foreground && (style || GetLastError() == ERROR_SUCCESS); - /* A screen-covering rectangle cannot identify exclusive presentation. */ - bool covers_monitor = window_known && monitor_known && - window_rect.left <= monitor.rcMonitor.left && - window_rect.top <= monitor.rcMonitor.top && - window_rect.right >= monitor.rcMonitor.right && - window_rect.bottom >= monitor.rcMonitor.bottom; - AcquireSRWLockShared(&state->relative_lock); - bool active = state->relative_active, mouse_registered = state->receiving; - long long relative_x = state->relative_x, relative_y = state->relative_y; - double absolute_x = state->absolute_x, absolute_y = state->absolute_y; - unsigned long long reads = state->relative_reads, resets = state->relative_resets; - unsigned long long sample_reads = state->relative_sample_reads; - ReleaseSRWLockShared(&state->relative_lock); - const WindowsRawCounters *c = &state->counters, *p = &state->reported; - SDL_Log("[input] Raw Input summary: backend=raw-input from_pid=%lu " - "foreground_pid=%lu interval_ms=%llu ownership=%u registration_error=%lu " - "registration_flags=0x%lx,0x%lx " - "desktop_available=%d mouse_registered=%d devices=%u " - "keys=%llu mouse=%llu foreground_packets=%llu background_packets=%llu " - "key_foreground_packets=%llu key_background_packets=%llu " - "session_input_known=%d session_input_age_ms=%lld session_input_changes=%llu " - "relative_packets=%llu absolute_packets=%llu motion_packets=%llu buttons=%llu " - "key_sent_total=%llu button_sent_total=%llu queue_failures_total=%llu " - "read_errors_total=%llu read_error=%lu invalid_total=%llu ignored_total=%llu " - "capacity_failures_total=%llu removals_total=%llu desktop_resets_total=%llu " - "key_age_ms=%lld mouse_age_ms=%lld motion_age_ms=%lld " - "relative_active=%d relative_pending=%lld,%lld absolute_pending=%.2f,%.2f " - "drain_calls_total=%llu sample_reads_total=%llu resets_total=%llu " - "cursor_known=%d cursor_flags=%lu clip_known=%d clip=%ld,%ld,%ld,%ld " - "relative_mode=%d window_rect_known=%d window_rect=%ld,%ld,%ld,%ld " - "monitor_known=%d monitor_rect=%ld,%ld,%ld,%ld covers_monitor=%d " - "window_style_known=%d window_style=0x%lx", - (unsigned long)state->reported_foreground_pid, (unsigned long)pid, - state->last_diagnostic_ms ? now_ms - state->last_diagnostic_ms : 0, - state->ownership, (unsigned long)state->registration_error, - (unsigned long)state->mouse_registration_flags, - (unsigned long)state->keyboard_registration_flags, - !state->desktop_unavailable, mouse_registered, state->device_count, - c->keyboard - p->keyboard, c->mouse - p->mouse, - c->foreground - p->foreground, c->background - p->background, - c->keyboard_foreground - p->keyboard_foreground, - c->keyboard_background - p->keyboard_background, - state->session_input_known, session_input_age, - c->session_input_changes - p->session_input_changes, - c->relative - p->relative, c->absolute - p->absolute, - c->motion - p->motion, c->button_edges - p->button_edges, - c->key_sent, c->button_sent, c->queue_failures, - c->read_errors, (unsigned long)state->read_error, c->invalid, c->ignored, - c->capacity_failures, c->removals, c->desktop_resets, - age_ms(now_ms, state->last_keyboard_ms), age_ms(now_ms, state->last_mouse_ms), - age_ms(now_ms, state->last_motion_ms), active, - relative_x, relative_y, absolute_x, absolute_y, reads, sample_reads, resets, - cursor_known, (unsigned long)cursor.flags, clip_known, - (long)clip.left, (long)clip.top, (long)clip.right, (long)clip.bottom, relative_mode, - window_known, (long)window_rect.left, (long)window_rect.top, - (long)window_rect.right, (long)window_rect.bottom, monitor_known, - (long)monitor.rcMonitor.left, (long)monitor.rcMonitor.top, - (long)monitor.rcMonitor.right, (long)monitor.rcMonitor.bottom, - covers_monitor, style_known, (unsigned long)(DWORD)style); - state->reported = state->counters; - state->reported_foreground = foreground; - state->reported_foreground_pid = pid; - state->last_diagnostic_ms = now_ms; - state->diagnostic_ready = true; -} -#endif diff --git a/src/platform/windows/windows_input_internal.h b/src/platform/windows/windows_input_internal.h index 118c89fc..ce097565 100644 --- a/src/platform/windows/windows_input_internal.h +++ b/src/platform/windows/windows_input_internal.h @@ -2,6 +2,7 @@ #define BONGO_CAT_WINDOWS_INPUT_INTERNAL_H #include "windows_input.h" +#include "windows_input_detection.h" #include "windows_keys.h" #ifdef _WIN32 @@ -26,16 +27,6 @@ typedef struct WindowsRawDevice { unsigned long long generation; } WindowsRawDevice; -typedef struct WindowsRawCounters { - unsigned long long keyboard, mouse, foreground, background; - unsigned long long keyboard_foreground, keyboard_background; - unsigned long long session_input_changes; - unsigned long long relative, absolute, motion, button_edges; - unsigned long long key_sent, button_sent, ignored, invalid; - unsigned long long read_errors, queue_failures, capacity_failures; - unsigned long long removals, desktop_resets; -} WindowsRawCounters; - typedef struct WindowsInputState { BongoCatPlatform *platform; SRWLOCK platform_lock; @@ -53,19 +44,17 @@ typedef struct WindowsInputState { unsigned device_count; WindowsRawHeld keys[BONGO_CAT_WINDOWS_RAW_HELD_LIMIT]; WindowsRawHeld buttons[BONGO_CAT_WINDOWS_MOUSE_BUTTON_COUNT]; - WindowsRawCounters counters, reported; - ULONGLONG last_keyboard_ms, last_mouse_ms, last_motion_ms; - ULONGLONG last_diagnostic_ms, last_diagnostic_probe_ms; - DWORD last_session_input_tick; - bool session_input_known; - HWND reported_foreground; - DWORD reported_foreground_pid; - bool diagnostic_ready, desktop_unavailable, wake_pending; + bool desktop_unavailable, wake_pending, retry_events; bool relative_active, receiving; long long relative_x, relative_y; double absolute_x, absolute_y; unsigned long long relative_samples, absolute_samples, generation; - unsigned long long relative_reads, relative_sample_reads, relative_resets; + long long observed_x, observed_y; + unsigned long long observed_motion, observed_generation; + WindowsPointerDetection pointer_detection; + ULONGLONG pointer_probe_ms; + unsigned long long pointer_generation; + bool pointer_probe_ready; } WindowsInputState; bool bongo_cat_windows_input_push_event(WindowsInputState *state, @@ -91,6 +80,5 @@ unsigned bongo_cat_windows_input_ownership(WindowsInputState *state); bool bongo_cat_windows_input_receiver_create(WindowsInputState *state); void bongo_cat_windows_input_receiver_destroy(WindowsInputState *state); bool bongo_cat_windows_input_dispatch(WindowsInputState *state); -void bongo_cat_windows_input_log(WindowsInputState *state, ULONGLONG now_ms); #endif #endif diff --git a/src/platform/windows/windows_input_packets.c b/src/platform/windows/windows_input_packets.c index 2a1b5675..0ae9959b 100644 --- a/src/platform/windows/windows_input_packets.c +++ b/src/platform/windows/windows_input_packets.c @@ -7,37 +7,25 @@ void bongo_cat_windows_input_packet(WindowsInputState *state, const RAWINPUT *packet, UINT bytes) { if (!state || !packet) return; if (bytes < sizeof(RAWINPUTHEADER) || packet->header.dwSize != bytes) { - state->counters.invalid++; return; } UINT payload; switch (packet->header.dwType) { case RIM_TYPEKEYBOARD: payload = sizeof(RAWKEYBOARD); break; case RIM_TYPEMOUSE: payload = sizeof(RAWMOUSE); break; - default: state->counters.ignored++; return; + default: return; } if (bytes < offsetof(RAWINPUT, data) + payload) { - state->counters.invalid++; return; } - bool background = GET_RAWINPUT_CODE_WPARAM(packet->header.wParam) == RIM_INPUTSINK; - if (background) state->counters.background++; - else state->counters.foreground++; WindowsRawDevice *device = bongo_cat_windows_input_device(state, packet->header.hDevice); if (!device) return; - ULONGLONG now_ms = GetTickCount64(); if (packet->header.dwType == RIM_TYPEKEYBOARD) { - state->counters.keyboard++; - if (background) state->counters.keyboard_background++; - else state->counters.keyboard_foreground++; - state->last_keyboard_ms = now_ms; bongo_cat_windows_input_key(state, device, &packet->data.keyboard); return; } const RAWMOUSE *mouse = &packet->data.mouse; - state->counters.mouse++; - state->last_mouse_ms = now_ms; bongo_cat_windows_input_buttons(state, device, mouse->usButtonFlags); RECT bounds = {0}; if (mouse->usFlags & MOUSE_MOVE_ABSOLUTE) { diff --git a/src/platform/windows/windows_input_probe.c b/src/platform/windows/windows_input_probe.c new file mode 100644 index 00000000..0835f6a5 --- /dev/null +++ b/src/platform/windows/windows_input_probe.c @@ -0,0 +1,44 @@ +#include "windows_input_internal.h" + +#ifdef _WIN32 +bool bongo_cat_windows_input_pointer_locked(BongoCatPlatform *platform) { + WindowsInputState *state = platform ? platform->native : NULL; + if (!state) return false; + ULONGLONG now_ms = GetTickCount64(); + if (state->pointer_probe_ready && now_ms - state->pointer_probe_ms < 16) + return state->pointer_detection.relative; + state->pointer_probe_ready = true; + state->pointer_probe_ms = now_ms; + WindowsPointerObservation sample = {0}; + sample.foreground = GetForegroundWindow(); + if (sample.foreground) GetWindowThreadProcessId(sample.foreground, &sample.pid); + sample.foreign = sample.pid && sample.pid != GetCurrentProcessId(); + CURSORINFO cursor = {.cbSize = sizeof(cursor)}; + sample.cursor_known = GetCursorInfo(&cursor) != FALSE; + sample.cursor_flags = cursor.flags; + if (sample.cursor_known) { + sample.position = cursor.ptScreenPos; + sample.position_known = true; + } else sample.position_known = GetCursorPos(&sample.position) != FALSE; + sample.clip_known = GetClipCursor(&sample.clip) != FALSE; + if (sample.position_known) { + MONITORINFO monitor = {.cbSize = sizeof(monitor)}; + sample.monitor_known = GetMonitorInfoW(MonitorFromPoint(sample.position, + MONITOR_DEFAULTTONULL), &monitor) != FALSE; + sample.monitor = monitor.rcMonitor; + } + AcquireSRWLockExclusive(&state->relative_lock); + sample.raw_x = (double)state->observed_x; + sample.raw_y = (double)state->observed_y; + sample.motion_packets = state->observed_motion; + state->observed_x = state->observed_y = 0; + state->observed_motion = 0; + if (state->pointer_generation != state->observed_generation) { + state->pointer_detection = (WindowsPointerDetection){0}; + state->pointer_generation = state->observed_generation; + } + ReleaseSRWLockExclusive(&state->relative_lock); + return bongo_cat_windows_pointer_detect(&state->pointer_detection, + &sample, now_ms); +} +#endif diff --git a/src/platform/windows/windows_input_receiver.c b/src/platform/windows/windows_input_receiver.c index 2b782656..c593e7fd 100644 --- a/src/platform/windows/windows_input_receiver.c +++ b/src/platform/windows/windows_input_receiver.c @@ -1,6 +1,7 @@ #include "windows_input_internal.h" #ifdef _WIN32 +#include static const wchar_t receiver_class[] = L"BongoCat Raw Input Receiver"; static LRESULT CALLBACK receiver_proc(HWND window, UINT message, @@ -18,8 +19,10 @@ static LRESULT CALLBACK receiver_proc(HWND window, UINT message, UINT received = GetRawInputData((HRAWINPUT)lparam, RID_INPUT, &packet, &bytes, sizeof(RAWINPUTHEADER)); if (received == (UINT)-1) { - state->counters.read_errors++; - state->read_error = GetLastError(); + DWORD error = GetLastError(); + if (error != state->read_error) SDL_LogError(SDL_LOG_CATEGORY_INPUT, + "Raw Input read failed: error=%lu", (unsigned long)error); + state->read_error = error; } else bongo_cat_windows_input_packet(state, &packet, received); /* DefWindowProc performs the required foreground WM_INPUT cleanup. */ } else if (state && message == WM_INPUT_DEVICE_CHANGE) { @@ -62,7 +65,7 @@ void bongo_cat_windows_input_receiver_destroy(WindowsInputState *state) { bool bongo_cat_windows_input_dispatch(WindowsInputState *state) { MSG message; - /* Bound each batch so high-rate mice cannot starve shutdown or diagnostics. */ + /* Bound each batch so high-rate mice cannot starve shutdown. */ for (unsigned i = 0; i < 256 && PeekMessageW(&message, NULL, 0, 0, PM_REMOVE); ++i) { if (message.message == WM_QUIT || diff --git a/src/platform/windows/windows_input_relative.c b/src/platform/windows/windows_input_relative.c index f302c08d..2e1565cb 100644 --- a/src/platform/windows/windows_input_relative.c +++ b/src/platform/windows/windows_input_relative.c @@ -20,10 +20,10 @@ void bongo_cat_windows_input_motion(WindowsInputState *state, AcquireSRWLockExclusive(&state->relative_lock); if (!(mouse->usFlags & MOUSE_MOVE_ABSOLUTE)) { device->absolute_known = false; - state->counters.relative++; if (mouse->lLastX || mouse->lLastY) { - state->counters.motion++; - state->last_motion_ms = GetTickCount64(); + state->observed_x += mouse->lLastX; + state->observed_y += mouse->lLastY; + state->observed_motion++; if (state->relative_active) { state->relative_x += mouse->lLastX; state->relative_y += mouse->lLastY; @@ -31,12 +31,10 @@ void bongo_cat_windows_input_motion(WindowsInputState *state, } } } else { - state->counters.absolute++; if (!bounds || bounds->right <= bounds->left || bounds->bottom <= bounds->top || mouse->lLastX < 0 || mouse->lLastX > 65535 || mouse->lLastY < 0 || mouse->lLastY > 65535) { device->absolute_known = false; - state->counters.invalid++; ReleaseSRWLockExclusive(&state->relative_lock); return; } @@ -49,8 +47,7 @@ void bongo_cat_windows_input_motion(WindowsInputState *state, device->generation == state->generation && memcmp(bounds, &device->absolute_bounds, sizeof(*bounds)) == 0; if (baseline && (x != device->absolute_x || y != device->absolute_y)) { - state->counters.motion++; - state->last_motion_ms = GetTickCount64(); + state->observed_motion++; if (state->relative_active) { state->absolute_x += x - device->absolute_x; state->absolute_y += y - device->absolute_y; @@ -71,12 +68,14 @@ static void clear_motion(WindowsInputState *state) { state->absolute_x = state->absolute_y = 0.0; state->relative_samples = state->absolute_samples = 0; state->generation++; - state->relative_resets++; } void bongo_cat_windows_input_clear_motion(WindowsInputState *state) { AcquireSRWLockExclusive(&state->relative_lock); clear_motion(state); + state->observed_x = state->observed_y = 0; + state->observed_motion = 0; + state->observed_generation++; ReleaseSRWLockExclusive(&state->relative_lock); } @@ -98,8 +97,6 @@ bool bongo_cat_windows_input_take_relative(BongoCatPlatform *platform, *y = relative ? (double)state->relative_y : state->absolute_y; if (sample_count) *sample_count = relative ? state->relative_samples : state->absolute_samples; - state->relative_reads++; - if (delivered) state->relative_sample_reads++; } state->relative_x = state->relative_y = 0; state->absolute_x = state->absolute_y = 0.0; diff --git a/src/runtime/input/mouse_diagnostics.c b/src/runtime/input/mouse_diagnostics.c deleted file mode 100644 index 19f4e5f2..00000000 --- a/src/runtime/input/mouse_diagnostics.c +++ /dev/null @@ -1,80 +0,0 @@ -#include "mouse_diagnostics.h" - -#include "bongo_cat/file.h" -#include "bongo_cat/overlay.h" -#include "bongo_cat/path.h" - -#include - -void bongo_cat_mouse_audit(BongoCatApp *app, double x, double y) { - if (!app->smoke_input_audit) return; - char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), app->state_root, - "input-audit.txt")) return; - FILE *file = bongo_cat_file_open(path, "ab"); - if (!file) return; - fprintf(file, "mouse x=%.2f y=%.2f\n", x, y); - fclose(file); -} - -void bongo_cat_mouse_log_diagnostics(BongoCatApp *app, uint64_t now, - double target_x, double target_y, - bool cursor_locked, bool relative_requested, - double model_x, double model_y, bool model_moved, bool native_selected) { - BongoCatPointerDiagnostics *diagnostics = &app->pointer_diagnostics; - if (model_moved && !app->settings.model.ignore_mouse) diagnostics->mapped_updates++; - uint64_t interval_ns = diagnostics->mode_changes ? 1000000000ull : 10000000000ull; - if (diagnostics->last_log_ns && now >= diagnostics->last_log_ns && - now - diagnostics->last_log_ns < interval_ns) return; - long long native_age_ms = app->mouse_last_ns && now >= app->mouse_last_ns - ? (long long)((now - app->mouse_last_ns) / 1000000ull) : -1; - BongoCatParameterRange angle_x = {0}, angle_y = {0}; - bool angle_x_known = bongo_cat_live2d_parameter(app->live2d, - "ParamAngleX", &angle_x); - bool angle_y_known = bongo_cat_live2d_parameter(app->live2d, - "ParamAngleY", &angle_y); - BongoCatParameterRange pointer_x = {0}, pointer_y = {0}; - bool pointer_x_known = bongo_cat_live2d_parameter(app->live2d, - "ParamMouseX", &pointer_x); - bool pointer_y_known = bongo_cat_live2d_parameter(app->live2d, - "ParamMouseY", &pointer_y); - SDL_Log("[input] Model input: interval_ms=%llu model=%s " - "mode=%s mode_changes=%llu absolute_source=%s native_age_ms=%lld " - "screen=%.1f,%.1f mapped=%.1f,%.1f bounds_known=%d bounds=%.0f,%.0f,%.0f,%.0f " - "mapped_updates=%llu relative_motion=%llu relative_waits=%llu " - "clamped=%llu,%llu map_failures=%llu " - "keys_total=%llu/%llu clicks_total=%llu/%llu key_applied_total=%llu " - "click_applied_total=%llu dropped_total=%llu buttons=%d,%d,%d " - "lock=%d ignore=%d centered=%d overlay=%d " - "parameter_known=%d,%d mouse=%.2f,%.2f angle_known=%d,%d angle=%.2f,%.2f", - (unsigned long long)(diagnostics->last_log_ns && now >= diagnostics->last_log_ns - ? (now - diagnostics->last_log_ns) / 1000000ull : 0), - app->loaded_model[0] ? app->loaded_model : "none", - relative_requested ? "relative" : "absolute", - (unsigned long long)diagnostics->mode_changes, - native_selected ? "native" : "sdl", native_age_ms, - target_x, target_y, model_x, model_y, diagnostics->bounds_known, - diagnostics->bounds.left, diagnostics->bounds.top, - diagnostics->bounds.width, diagnostics->bounds.height, - (unsigned long long)diagnostics->mapped_updates, - (unsigned long long)diagnostics->relative_motion, - (unsigned long long)diagnostics->relative_waits, - (unsigned long long)diagnostics->clamped_x, - (unsigned long long)diagnostics->clamped_y, - (unsigned long long)diagnostics->map_failures, - (unsigned long long)app->input_key_down_events, - (unsigned long long)app->input_key_up_events, - (unsigned long long)app->input_mouse_down_events, - (unsigned long long)app->input_mouse_up_events, - (unsigned long long)app->input_key_supported, - (unsigned long long)app->input_mouse_applied, - (unsigned long long)atomic_load_explicit(&app->input.dropped, - memory_order_relaxed), - app->left_mouse_down, app->right_mouse_down, app->side_mouse_down, - cursor_locked, app->settings.model.ignore_mouse, - app->settings.model.mouse_centered, - bongo_cat_overlay_mver_pointer_enabled(app->overlay), - pointer_x_known, pointer_y_known, pointer_x.value, pointer_y.value, - angle_x_known, angle_y_known, angle_x.value, angle_y.value); - *diagnostics = (BongoCatPointerDiagnostics){.last_log_ns = now ? now : 1}; -} diff --git a/src/runtime/input/mouse_diagnostics.h b/src/runtime/input/mouse_diagnostics.h deleted file mode 100644 index 8b4c701b..00000000 --- a/src/runtime/input/mouse_diagnostics.h +++ /dev/null @@ -1,14 +0,0 @@ -#ifndef BONGO_CAT_MOUSE_DIAGNOSTICS_H -#define BONGO_CAT_MOUSE_DIAGNOSTICS_H - -#include "bongo_cat/app.h" - -#include - -void bongo_cat_mouse_audit(BongoCatApp *app, double x, double y); -void bongo_cat_mouse_log_diagnostics(BongoCatApp *app, uint64_t now, - double target_x, double target_y, - bool cursor_locked, bool relative_requested, - double model_x, double model_y, bool model_moved, bool native_selected); - -#endif diff --git a/src/runtime/input/mouse_mapping.c b/src/runtime/input/mouse_mapping.c index e61cd367..9760e6bc 100644 --- a/src/runtime/input/mouse_mapping.c +++ b/src/runtime/input/mouse_mapping.c @@ -76,9 +76,6 @@ bool bongo_cat_app_map_pointer(BongoCatApp *app, bool relative_requested, BongoCatMverPointerBounds pointer_bounds = { bounds.x, bounds.y, bounds.w, bounds.h }; - BongoCatPointerDiagnostics *diagnostics = &app->pointer_diagnostics; - diagnostics->bounds = pointer_bounds; - diagnostics->bounds_known = true; double relative_x = 0.0, relative_y = 0.0; bool use_relative = relative_requested && bongo_cat_platform_relative_pointer(&app->platform, @@ -86,7 +83,6 @@ bool bongo_cat_app_map_pointer(BongoCatApp *app, bool relative_requested, bool initialized = app->mver_pointer.initialized; double previous_x = app->mver_pointer.x, previous_y = app->mver_pointer.y; /* Preserve the virtual position while relative samples are unavailable. */ - if (relative_requested && !use_relative) diagnostics->relative_waits++; if (relative_requested && initialized && !use_relative) { absolute_x = previous_x; absolute_y = previous_y; @@ -94,13 +90,6 @@ bool bongo_cat_app_map_pointer(BongoCatApp *app, bool relative_requested, if (!bongo_cat_mver_pointer_update(&app->mver_pointer, absolute_x, absolute_y, relative_x, relative_y, use_relative, &pointer_bounds, x, y)) return false; - if (use_relative && (relative_x != 0.0 || relative_y != 0.0)) { - diagnostics->relative_motion++; - double expected_x = (initialized ? previous_x : absolute_x) + relative_x; - double expected_y = (initialized ? previous_y : absolute_y) + relative_y; - if (expected_x != *x) diagnostics->clamped_x++; - if (expected_y != *y) diagnostics->clamped_y++; - } *changed = !initialized || previous_x != *x || previous_y != *y; return true; } diff --git a/src/runtime/input/mouse_pipeline.c b/src/runtime/input/mouse_pipeline.c index b9924b94..d8e9f896 100644 --- a/src/runtime/input/mouse_pipeline.c +++ b/src/runtime/input/mouse_pipeline.c @@ -1,5 +1,4 @@ #include "runtime.h" -#include "mouse_diagnostics.h" #include @@ -24,7 +23,6 @@ void bongo_cat_app_apply_mouse(BongoCatApp *app) { uint64_t now = SDL_GetTicksNS(); if (received) { app->mouse_last_ns = now ? now : 1; - app->mouse_native_samples++; } float global_x = 0.0f, global_y = 0.0f; SDL_MouseButtonFlags buttons = SDL_GetGlobalMouseState(&global_x, &global_y); @@ -71,7 +69,6 @@ void bongo_cat_app_apply_mouse(BongoCatApp *app) { bool moved = !app->pointer_known || app->pointer_x != target_x || app->pointer_y != target_y; if (moved) { - bongo_cat_mouse_audit(app, target_x, target_y); bongo_cat_app_track_hover(app, target_x, target_y); /* Refresh the hit pixel before the next button press. Waiting for the scheduled frame can leave a stale transparent state for fast clicks. */ @@ -103,25 +100,17 @@ void bongo_cat_app_apply_mouse(BongoCatApp *app) { (app->model_render_options.mver_projection && (!app->settings.model.mouse_centered || profile_relative))); bool map_ok = !map_pointer; - app->pointer_diagnostics.bounds_known = false; - app->pointer_diagnostics.bounds = (BongoCatMverPointerBounds){0}; if (map_pointer) { map_ok = bongo_cat_app_map_pointer(app, relative_requested, target_x, target_y, &model_x, &model_y, &model_moved); if (!map_ok) { - app->pointer_diagnostics.map_failures++; model_x = target_x; model_y = target_y; model_moved = moved; } } - if (pointer_mode_changed) app->pointer_diagnostics.mode_changes++; if (!app->settings.model.ignore_mouse && (model_moved || button_changed || pointer_mode_changed || button_event_pending)) { - app->input_mouse_updates++; bongo_cat_app_apply_mouse_coordinates(app, model_x, model_y, cursor_locked ? model_x : target_x, cursor_locked ? model_y : target_y); } app->mouse_button_event_pending = false; - bongo_cat_mouse_log_diagnostics(app, now, target_x, target_y, - cursor_locked, relative_requested, - model_x, model_y, model_moved, native_selected); } diff --git a/src/runtime/input/shortcuts.c b/src/runtime/input/shortcuts.c index c7b0becc..08615d66 100644 --- a/src/runtime/input/shortcuts.c +++ b/src/runtime/input/shortcuts.c @@ -102,37 +102,32 @@ static bool behavior_shortcut(BongoCatApp *app, const BongoCatInputEvent *event) void bongo_cat_app_shortcuts(BongoCatApp *app, const BongoCatInputEvent *event) { if (!app) return; if (event->kind == BONGO_CAT_INPUT_GAMEPAD_BUTTON) { - if (behavior_shortcut(app, event)) app->input_shortcuts_triggered++; + behavior_shortcut(app, event); return; } bool primary = bongo_cat_shortcut_update(&app->shortcut_state, event); if (!primary) { - if (behavior_shortcut(app, event)) app->input_shortcuts_triggered++; + behavior_shortcut(app, event); return; } BongoCatShortcutPreferences *shortcuts = &app->settings.shortcuts; - bool handled = false; if (bongo_cat_shortcut_matches(&app->shortcut_state, event, shortcuts->toggle_pet_visibility)) { toggle_pet_visibility(app); - handled = true; } else if (bongo_cat_shortcut_matches(&app->shortcut_state, event, shortcuts->visible_preferences)) { bongo_cat_preferences_visible(app->preferences) ? bongo_cat_preferences_close(app->preferences) : bongo_cat_preferences_show(app->preferences); - handled = true; } else if (bongo_cat_shortcut_matches(&app->shortcut_state, event, shortcuts->mirror)) { app->settings.model.mirror = !app->settings.model.mirror; app->model_pointer_anchor_ready = false; app->dirty = true; bongo_cat_preferences_invalidate(app->preferences); - handled = true; } else if (bongo_cat_shortcut_matches(&app->shortcut_state, event, shortcuts->pass_through)) { app->settings.window.pass_through = !app->settings.window.pass_through; bongo_cat_window_mark_hit_dirty(app); bongo_cat_window_sync_click_through(app); bongo_cat_preferences_invalidate(app->preferences); - handled = true; } else if (bongo_cat_shortcut_matches(&app->shortcut_state, event, shortcuts->always_on_top)) { app->settings.window.always_on_top = !app->settings.window.always_on_top; @@ -140,9 +135,7 @@ void bongo_cat_app_shortcuts(BongoCatApp *app, const BongoCatInputEvent *event) bongo_cat_window_mark_hit_dirty(app); bongo_cat_window_sync_click_through(app); bongo_cat_preferences_invalidate(app->preferences); - handled = true; - } else handled = behavior_shortcut(app, event); - if (handled) app->input_shortcuts_triggered++; + } else behavior_shortcut(app, event); } static void test_key(BongoCatApp *app, BongoCatInputKind kind, const char *name) { diff --git a/src/runtime/lifecycle/app_input.c b/src/runtime/lifecycle/app_input.c index 8cff9d3f..ad879f37 100644 --- a/src/runtime/lifecycle/app_input.c +++ b/src/runtime/lifecycle/app_input.c @@ -1,41 +1,13 @@ #include "runtime.h" -#include "bongo_cat/file.h" -#include "bongo_cat/path.h" #include "bongo_cat/preferences.h" -#include #include -static void record_input_event(BongoCatApp *app, - const BongoCatInputEvent *event) { - if (!app || !event) return; - app->input_events_consumed++; - switch (event->kind) { - case BONGO_CAT_INPUT_KEY_DOWN: app->input_key_down_events++; break; - case BONGO_CAT_INPUT_KEY_UP: app->input_key_up_events++; break; - case BONGO_CAT_INPUT_MOUSE_DOWN: app->input_mouse_down_events++; break; - case BONGO_CAT_INPUT_MOUSE_UP: app->input_mouse_up_events++; break; - default: break; - } -} - void bongo_cat_app_drain_input(BongoCatApp *app, bool allow_shortcuts) { if (app && app->secondary_pet) allow_shortcuts = false; BongoCatInputEvent event; while (bongo_cat_input_pop(&app->input, &event)) { - record_input_event(app, &event); if (app->smoke_ignore_global_input) continue; - if (app->smoke_input_audit) { - char path[BONGO_CAT_PATH_CAP]; - bongo_cat_path_join(path, sizeof(path), app->state_root, - "input-audit.txt"); - FILE *file = bongo_cat_file_open(path, "ab"); - if (file) { - fprintf(file, "kind=%d name=%s value=%.3f\n", - event.kind, event.name, event.value); - fclose(file); - } - } if (strcmp(event.name, "CapsLock") == 0) bongo_cat_input_schedule_release(&app->input, &event, 100); if (allow_shortcuts && @@ -45,7 +17,6 @@ void bongo_cat_app_drain_input(BongoCatApp *app, bool allow_shortcuts) { } uint64_t now = SDL_GetTicks(); while (bongo_cat_input_take_scheduled_release(&app->input, now, &event)) { - record_input_event(app, &event); if (allow_shortcuts && !bongo_cat_preferences_shortcuts_blocked(app->preferences)) bongo_cat_app_shortcuts(app, &event); diff --git a/src/runtime/lifecycle/app_loop.c b/src/runtime/lifecycle/app_loop.c index 088c07c8..c818170c 100644 --- a/src/runtime/lifecycle/app_loop.c +++ b/src/runtime/lifecycle/app_loop.c @@ -1,4 +1,5 @@ #include "runtime.h" +#include "bongo_cat/log.h" #include "model_cover.h" #include "bongo_cat/overlay.h" #include "bongo_cat/preferences.h" @@ -163,21 +164,18 @@ static bool take_update_shutdown(BongoCatApp *app) { if (!bongo_cat_platform_single_instance_take_update_shutdown()) return false; app->running = false; - SDL_Log("Installed update requested application shutdown"); + SDL_LogInfo(BONGO_CAT_LOG_UPDATE, + "Installed update requested application shutdown"); return true; } void bongo_cat_app_loop(BongoCatApp *app) { - uint64_t iterations = 0, wakes = 0, zero_waits = 0; while (app->running) { - iterations++; int wait_ms = bongo_cat_window_wait_timeout(app, SDL_GetTicksNS()); if (app->secondary_pet && wait_ms > 100) wait_ms = 100; - if (!wait_ms) zero_waits++; bongo_cat_preferences_input_begin(app->preferences); SDL_Event event; if (bongo_cat_wait_event(&event, wait_ms)) { - wakes++; handle_event(app, &event); unsigned queued = 0; while (queued++ < 256 && SDL_PollEvent(&event)) @@ -216,8 +214,4 @@ void bongo_cat_app_loop(BongoCatApp *app) { if (app->smoke_deadline_ns && now >= app->smoke_deadline_ns) app->running = false; } - if (app->smoke) - SDL_Log("Smoke loop: iterations=%llu wakes=%llu zero_waits=%llu", - (unsigned long long)iterations, (unsigned long long)wakes, - (unsigned long long)zero_waits); } diff --git a/src/runtime/lifecycle/shutdown.c b/src/runtime/lifecycle/shutdown.c index 8e102ae5..1a1ed4e0 100644 --- a/src/runtime/lifecycle/shutdown.c +++ b/src/runtime/lifecycle/shutdown.c @@ -1,4 +1,5 @@ #include "runtime.h" +#include "bongo_cat/log.h" #include "model_import_lock.h" #include "bongo_cat/audio.h" #include "bongo_cat/i18n.h" @@ -11,7 +12,8 @@ void bongo_cat_app_shutdown(BongoCatApp *app, const char *stage, int exit_code) { bongo_cat_runtime_stage(app, stage); - SDL_Log("[runtime] Shutdown started: stage=%s exit_code=%d", + SDL_LogInfo(BONGO_CAT_LOG_LIFECYCLE, + "[runtime] Shutdown started: stage=%s exit_code=%d", stage, exit_code); bongo_cat_app_capture_behavior_state(app); bongo_cat_config_store_flush(app); diff --git a/src/runtime/lifecycle/startup.c b/src/runtime/lifecycle/startup.c index 4c8ad105..34548d38 100644 --- a/src/runtime/lifecycle/startup.c +++ b/src/runtime/lifecycle/startup.c @@ -2,6 +2,7 @@ #include "runtime_state.h" #include "bongo_cat/file.h" #include "bongo_cat/i18n.h" +#include "bongo_cat/log.h" #include "bongo_cat/path.h" #include "storage_paths.h" @@ -18,7 +19,6 @@ static char runtime_log_path[BONGO_CAT_PATH_CAP]; static char log_source[BONGO_CAT_ID_CAP + 8] = "unknown"; static bool startup_is_ready; -static bool verbose_logging; static SDL_Mutex *log_mutex; static const char *priority_name(SDL_LogPriority priority) { @@ -60,7 +60,7 @@ static void append_log(const char *path, const char *timestamp, static void SDLCALL log_output(void *userdata, int category, SDL_LogPriority priority, const char *message) { (void)userdata; - if (!verbose_logging && priority < SDL_LOG_PRIORITY_INFO) return; + if (!bongo_cat_log_enabled(category, priority)) return; if (log_mutex) SDL_LockMutex(log_mutex); char timestamp[64]; bongo_cat_runtime_timestamp(timestamp, sizeof(timestamp)); @@ -75,11 +75,11 @@ void bongo_cat_runtime_clean_shutdown(BongoCatApp *app, int exit_code) { bongo_cat_runtime_timestamp(timestamp, sizeof(timestamp)); snprintf(message, sizeof(message), "[runtime] Shutdown complete: exit_code=%d", exit_code); - append_log(runtime_log_path, timestamp, SDL_LOG_CATEGORY_APPLICATION, + append_log(runtime_log_path, timestamp, BONGO_CAT_LOG_LIFECYCLE, SDL_LOG_PRIORITY_INFO, message); fprintf(stderr, "%s [%s] [%s:%d] %s\n", timestamp, log_source, priority_name(SDL_LOG_PRIORITY_INFO), - SDL_LOG_CATEGORY_APPLICATION, message); + BONGO_CAT_LOG_LIFECYCLE, message); bongo_cat_runtime_state_clean(app, timestamp); } @@ -132,14 +132,16 @@ static void begin_log(BongoCatApp *app) { interrupted[ferror(stage) ? 0 : length] = '\0'; fclose(stage); } - verbose_logging = app->smoke; if (!log_mutex) log_mutex = SDL_CreateMutex(); SDL_SetLogOutputFunction(log_output, NULL); - SDL_Log("[runtime] Process started: version=%s platform=%s storage=%s", - BONGO_CAT_VERSION, SDL_GetPlatform(), app->storage_root[0] - ? app->storage_root : app->config_root); + SDL_SetLogPriorities(SDL_LOG_PRIORITY_WARN); + SDL_SetLogPriority(BONGO_CAT_LOG_LIFECYCLE, SDL_LOG_PRIORITY_INFO); + SDL_SetLogPriority(BONGO_CAT_LOG_UPDATE, SDL_LOG_PRIORITY_INFO); + SDL_LogInfo(BONGO_CAT_LOG_LIFECYCLE, + "[runtime] Process started: version=%s platform=%s", + BONGO_CAT_VERSION, SDL_GetPlatform()); #ifdef _WIN32 - SDL_Log("[runtime] Package identity: %s", + SDL_LogInfo(BONGO_CAT_LOG_LIFECYCLE, "[runtime] Package identity: %s", bongo_cat_windows_is_packaged() ? "MSIX" : "unpackaged Win32"); #endif if (interrupted[0]) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, @@ -172,7 +174,7 @@ void bongo_cat_startup_stage(BongoCatApp *app, const char *stage) { char runtime_stage[160]; snprintf(runtime_stage, sizeof(runtime_stage), "startup:%s", stage); bongo_cat_runtime_stage(app, runtime_stage); - SDL_Log("[runtime] Startup stage: %s", stage); + SDL_LogInfo(BONGO_CAT_LOG_LIFECYCLE, "[runtime] Startup stage: %s", stage); } void bongo_cat_startup_ready(BongoCatApp *app) { @@ -184,7 +186,7 @@ void bongo_cat_startup_ready(BongoCatApp *app) { if (bongo_cat_path_join(path, sizeof(path), app->state_root, "startup-stage.tmp")) bongo_cat_file_remove(path); bongo_cat_runtime_stage(app, "running"); - SDL_Log("[runtime] Startup ready"); + SDL_LogInfo(BONGO_CAT_LOG_LIFECYCLE, "[runtime] Startup ready"); } #ifdef _WIN32 diff --git a/src/runtime/lifecycle/startup_arguments.c b/src/runtime/lifecycle/startup_arguments.c index ddfd6f47..1ad562fc 100644 --- a/src/runtime/lifecycle/startup_arguments.c +++ b/src/runtime/lifecycle/startup_arguments.c @@ -60,7 +60,6 @@ bool bongo_cat_startup_arguments(BongoCatApp *app, int argc, char **argv, else if (strcmp(arg, "--ci-remove-imported") == 0) app->smoke_remove_imported = true; else if (strcmp(arg, "--ci-shortcuts") == 0) app->smoke_shortcuts = true; else if (strcmp(arg, "--ci-menu") == 0) app->smoke_menu = true; - else if (strcmp(arg, "--ci-input-audit") == 0) app->smoke_input_audit = true; else if (strcmp(arg, "--ci-ignore-global-input") == 0) app->smoke_ignore_global_input = true; else if (strcmp(arg, "--ci-pass-through") == 0) app->smoke_pass_through = true; else if (strcmp(arg, "--ci-context-menu") == 0) app->smoke_context_menu = true; diff --git a/src/runtime/update/update_channel.c b/src/runtime/update/update_channel.c index cd77f55d..06bee423 100644 --- a/src/runtime/update/update_channel.c +++ b/src/runtime/update/update_channel.c @@ -126,7 +126,5 @@ void bongo_cat_update_show_completion(BongoCatUpdateService *service) { snapshot.error : tr(service, "native.support.updateFailed", "Unable to check for updates")); bongo_cat_preferences_notice_show(service->app, detail, true); - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "Update check failed: %s", snapshot.error); } } diff --git a/src/runtime/update/update_service.c b/src/runtime/update/update_service.c index f405f8ac..896b4ebd 100644 --- a/src/runtime/update/update_service.c +++ b/src/runtime/update/update_service.c @@ -1,6 +1,7 @@ #include "update_internal.h" #include "preferences_notice.h" #include "bongo_cat/i18n.h" +#include "bongo_cat/log.h" #include "bongo_cat/preferences.h" #include @@ -44,6 +45,12 @@ static void complete(BongoCatUpdateService *service, } SDL_UnlockMutex(service->mutex); if (!notify) return; + if (status == BONGO_CAT_UPDATE_ERROR) + SDL_LogError(BONGO_CAT_LOG_UPDATE, "Update check failed: %s", + error && error[0] ? error : "unknown error"); + else SDL_LogInfo(BONGO_CAT_LOG_UPDATE, "Update check: %s version=%s", + status == BONGO_CAT_UPDATE_AVAILABLE ? "available" : "current", + release ? release->version : BONGO_CAT_VERSION); SDL_Event event = {0}; event.type = service->event_type; event.user.data1 = service; @@ -130,11 +137,13 @@ bool bongo_cat_update_check(BongoCatUpdateService *service, bool manual) { SDL_LockMutex(service->http_mutex); service->http_cancelled = false; SDL_UnlockMutex(service->http_mutex); + SDL_LogInfo(BONGO_CAT_LOG_UPDATE, "Update check started"); service->worker = SDL_CreateThread(update_worker, BONGO_CAT_SLUG "-update-check", service); bool started = service->worker != NULL; if (!started) { service->status = BONGO_CAT_UPDATE_ERROR; + SDL_LogError(BONGO_CAT_LOG_UPDATE, "Cannot start the update checker"); snprintf(service->error, sizeof(service->error), "Cannot start the update checker"); } diff --git a/src/ui/preferences/preferences.c b/src/ui/preferences/preferences.c index 3c88555d..f8003e2d 100644 --- a/src/ui/preferences/preferences.c +++ b/src/ui/preferences/preferences.c @@ -223,9 +223,6 @@ bool bongo_cat_preferences_event(BongoCatPreferences *value, const SDL_Event *ev value->import_drop_active = false; bongo_cat_pref_controls_reset(&value->ui.context); } - if (value->app->smoke_input_audit && (event->type == SDL_EVENT_MOUSE_BUTTON_DOWN || - event->type == SDL_EVENT_MOUSE_BUTTON_UP)) SDL_Log("Preferences mouse %s at %.1f,%.1f", - event->button.down ? "down" : "up", event->button.x, event->button.y); if (bongo_cat_preferences_model_rename_event(value, event)) return true; if (bongo_cat_preferences_behavior_rename_event(value, event)) return true; if (bongo_cat_preferences_shortcut_event(value, event)) return true; diff --git a/tests/core/test_log_policy.c b/tests/core/test_log_policy.c new file mode 100644 index 00000000..51cf9ab0 --- /dev/null +++ b/tests/core/test_log_policy.c @@ -0,0 +1,28 @@ +#include "bongo_cat/log.h" + +#include + +int main(void) { + int failures = 0; + const int categories[] = {SDL_LOG_CATEGORY_APPLICATION, + SDL_LOG_CATEGORY_INPUT, SDL_LOG_CATEGORY_VIDEO, + BONGO_CAT_LOG_LIFECYCLE, BONGO_CAT_LOG_UPDATE, + BONGO_CAT_LOG_UPDATE + 1}; + for (unsigned i = 0; i < sizeof(categories) / sizeof(categories[0]); ++i) { + int category = categories[i]; + for (int value = SDL_LOG_PRIORITY_TRACE; + value <= SDL_LOG_PRIORITY_CRITICAL; ++value) { + SDL_LogPriority priority = (SDL_LogPriority)value; + bool expected = priority >= SDL_LOG_PRIORITY_WARN || + (priority == SDL_LOG_PRIORITY_INFO && + (category == BONGO_CAT_LOG_LIFECYCLE || + category == BONGO_CAT_LOG_UPDATE)); + if (bongo_cat_log_enabled(category, priority) != expected) { + fprintf(stderr, "Unexpected log policy: category=%d priority=%d\n", + category, value); + ++failures; + } + } + } + return failures != 0; +} diff --git a/tests/platform/test_windows_input.c b/tests/platform/test_windows_input.c index 0a731003..5735da0c 100644 --- a/tests/platform/test_windows_input.c +++ b/tests/platform/test_windows_input.c @@ -8,6 +8,7 @@ int bongo_cat_test_failures; void test_windows_relative_sources(void); void test_windows_raw_receiver(void); +void test_windows_pointer_detection(void); typedef struct RawFixture { WindowsInputState state; @@ -69,7 +70,7 @@ static void test_key_devices(void) { bongo_cat_windows_input_clear_devices(&test.state); expect(&test, BONGO_CAT_INPUT_KEY_UP, "ControlRight"); CHECK(!bongo_cat_input_control_down(&test.input)); - CHECK(test.state.device_count == 0 && test.state.counters.background == 7); + CHECK(test.state.device_count == 0); } static void test_key_sequences(void) { @@ -144,13 +145,13 @@ static void test_backpressure(void) { initialize(&test); fill_queue(&test, false); key_packet(&test, 1, 'A', 0x1e, 0); - CHECK(test.state.counters.queue_failures == 1); + CHECK(test.state.retry_events); drain(&test); bongo_cat_windows_input_flush(&test.state); expect(&test, BONGO_CAT_INPUT_KEY_DOWN, "KeyA"); fill_queue(&test, true); bongo_cat_windows_input_clear_devices(&test.state); - CHECK(test.state.counters.queue_failures == 2); + CHECK(test.state.retry_events); drain(&test); bongo_cat_windows_input_flush(&test.state); expect(&test, BONGO_CAT_INPUT_KEY_UP, "KeyA"); @@ -170,16 +171,15 @@ static void test_packets(void) { bongo_cat_windows_input_packet(&test.state, &packet, sizeof(packet)); double x, y; CHECK(bongo_cat_windows_input_take_relative(&test.platform, &x, &y, NULL)); - CHECK(x == 8.0 && y == 0.0 && test.state.counters.mouse == 1); - CHECK(test.state.relative_reads == 1); + CHECK(x == 8.0 && y == 0.0); CHECK(!bongo_cat_windows_input_take_relative(&test.platform, &x, &y, NULL)); bongo_cat_windows_input_packet(&test.state, &packet, 1); packet.header.dwSize = sizeof(RAWINPUTHEADER); bongo_cat_windows_input_packet(&test.state, &packet, packet.header.dwSize); - CHECK(test.state.counters.invalid == 2 && test.state.counters.mouse == 1); + CHECK(!bongo_cat_windows_input_take_relative(&test.platform, &x, &y, NULL)); packet.header.dwType = RIM_TYPEHID; bongo_cat_windows_input_packet(&test.state, &packet, packet.header.dwSize); - CHECK(test.state.counters.ignored == 1); + expect_empty(&test); bongo_cat_windows_input_clear_devices(&test.state); test.state.desktop_unavailable = true; key_packet(&test, 1, 'A', 0x1e, 0); @@ -189,6 +189,32 @@ static void test_packets(void) { expect(&test, BONGO_CAT_INPUT_KEY_UP, "KeyA"); } +static void test_motion_keeps_key_and_button_edges(void) { + RawFixture test = {0}; + initialize(&test); + RAWINPUT packet = {0}; + packet.header.dwSize = sizeof(packet); + packet.header.dwType = RIM_TYPEMOUSE; + packet.header.wParam = RIM_INPUTSINK; + packet.data.mouse.lLastX = 1; + for (unsigned i = 0; i < 8000; ++i) { + packet.data.mouse.usButtonFlags = i == 100 ? RI_MOUSE_LEFT_BUTTON_DOWN : + i == 101 ? RI_MOUSE_LEFT_BUTTON_UP : 0; + bongo_cat_windows_input_packet(&test.state, &packet, sizeof(packet)); + if (i == 100) key_packet(&test, 1, 'A', 0x1e, 0); + if (i == 101) key_packet(&test, 1, 'A', 0x1e, RI_KEY_BREAK); + bongo_cat_windows_input_flush(&test.state); + } + expect(&test, BONGO_CAT_INPUT_MOUSE_DOWN, "Left"); + expect(&test, BONGO_CAT_INPUT_KEY_DOWN, "KeyA"); + expect(&test, BONGO_CAT_INPUT_MOUSE_UP, "Left"); + expect(&test, BONGO_CAT_INPUT_KEY_UP, "KeyA"); + expect_empty(&test); + CHECK(test.state.observed_x == 8000); + CHECK(!test.state.retry_events); + bongo_cat_windows_input_clear_devices(&test.state); +} + int main(void) { CHECK(SDL_Init(SDL_INIT_EVENTS)); test_key_devices(); @@ -196,7 +222,9 @@ int main(void) { test_buttons(); test_backpressure(); test_packets(); + test_motion_keeps_key_and_button_edges(); test_windows_relative_sources(); + test_windows_pointer_detection(); test_windows_raw_receiver(); SDL_Quit(); return bongo_cat_test_failures ? 1 : 0; diff --git a/tests/platform/test_windows_pointer_detection.c b/tests/platform/test_windows_pointer_detection.c new file mode 100644 index 00000000..46913d54 --- /dev/null +++ b/tests/platform/test_windows_pointer_detection.c @@ -0,0 +1,190 @@ +#include "test.h" +#include "windows_input_detection.h" + +static WindowsPointerObservation observation(void) { + return (WindowsPointerObservation){.foreground = (HWND)(uintptr_t)1, + .pid = 2, .foreign = true, .position_known = true, .monitor_known = true, + .clip_known = true, .cursor_known = true, .cursor_flags = CURSOR_SHOWING, + .position = {960, 540}, .monitor = {0, 0, 1920, 1080}, + .clip = {0, 0, 1920, 1080}}; +} + +static void test_stalled_and_resume(void) { + WindowsPointerDetection state = {0}; + WindowsPointerObservation sample = observation(); + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, 0)); + sample.raw_x = 12; + sample.motion_packets = 128; + for (unsigned t = 16; t < 128; t += 16) + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, t)); + CHECK(bongo_cat_windows_pointer_detect(&state, &sample, 128)); + CHECK(state.reason == WINDOWS_POINTER_STALLED); + sample.raw_x = 0; + sample.motion_packets = 0; + for (unsigned t = 144; t <= 1024; t += 16) + CHECK(bongo_cat_windows_pointer_detect(&state, &sample, t)); + sample.raw_x = 10; + sample.motion_packets = 1; + for (unsigned t = 1040; t <= 1280; t += 16) { + sample.position.x += 10; + bongo_cat_windows_pointer_detect(&state, &sample, t); + } + CHECK(!state.relative && state.reason == WINDOWS_POINTER_DESKTOP); +} + +static void test_recenter_and_real_travel(void) { + WindowsPointerDetection state = {0}; + WindowsPointerObservation sample = observation(); + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, 0)); + sample.raw_x = 10; + sample.motion_packets = 1; + for (unsigned i = 1; i <= 8; ++i) { + sample.position.x = i % 2 ? 974 : 960; + bongo_cat_windows_pointer_detect(&state, &sample, i * 16); + } + CHECK(state.relative && state.reason == WINDOWS_POINTER_RECENTERED); + state = (WindowsPointerDetection){0}; + sample = observation(); + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, 0)); + for (unsigned i = 1; i <= 64; ++i) { + sample.raw_x = i % 2 ? 14 : -14; + sample.motion_packets = 1; + sample.position.x = i % 2 ? 974 : 960; + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, i * 16)); + } +} + +static void test_desktop_edges_and_slow_motion(void) { + for (unsigned scenario = 0; scenario < 7; ++scenario) { + WindowsPointerDetection state = {0}; + WindowsPointerObservation sample = observation(); + if (scenario == 0) sample.position.x = 1919; + if (scenario == 1) { + sample.monitor = (RECT){-1920, -1080, 0, 0}; + sample.clip = sample.monitor; + sample.position = (POINT){-1920, -500}; + } + if (scenario == 2) sample.clip.right = 961; + if (scenario == 3) sample.monitor_known = false; + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, 0)); + for (unsigned i = 1; i <= 64; ++i) { + sample.motion_packets = 128; + sample.raw_x = 32; + if (scenario == 4) sample.position.x += 1; + if (scenario == 5) sample.raw_x = 1; + if (scenario == 6) sample.raw_x = i % 2 ? 4 : -4; + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, i * 16)); + } + } +} + +static void test_hidden_and_clipped(void) { + for (unsigned scenario = 0; scenario < 3; ++scenario) { + WindowsPointerDetection state = {0}; + WindowsPointerObservation sample = observation(); + if (scenario == 0) sample.cursor_flags = 0; + if (scenario == 1) sample.clip = (RECT){960, 540, 961, 541}; + if (scenario == 2) sample.cursor_known = false; + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, 0)); + /* A hidden cursor without mouse input must not activate tracking. */ + for (unsigned t = 16; t <= 256; t += 16) + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, t)); + sample.raw_y = 5; + sample.motion_packets = 1; + for (unsigned t = 272; t <= 304; t += 16) + bongo_cat_windows_pointer_detect(&state, &sample, t); + CHECK(state.relative == (scenario != 2)); + if (scenario == 2) continue; + CHECK(state.reason == (scenario == 0 ? WINDOWS_POINTER_HIDDEN : + WINDOWS_POINTER_CLIPPED)); + sample.cursor_flags = CURSOR_SHOWING; + sample.clip = sample.monitor; + sample.raw_y = 0; + sample.motion_packets = 0; + for (unsigned t = 320; t <= 640; t += 16) + bongo_cat_windows_pointer_detect(&state, &sample, t); + CHECK(!state.relative); + } +} + +static void test_focus_suspend_and_missing_position(void) { + for (unsigned scenario = 0; scenario < 6; ++scenario) { + WindowsPointerDetection state = {0}; + WindowsPointerObservation sample = observation(); + sample.cursor_flags = 0; + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, 0)); + sample.raw_x = 8; + sample.motion_packets = 1; + for (unsigned t = 16; t <= 64; t += 16) + bongo_cat_windows_pointer_detect(&state, &sample, t); + CHECK(state.relative); + if (scenario == 0) sample.foreground = (HWND)(uintptr_t)3; + if (scenario == 1) sample.pid++; + if (scenario == 2) sample.foreign = false; + if (scenario == 3) sample.position_known = false; + ULONGLONG now = scenario == 4 ? 1000 : scenario == 5 ? 1 : 80; + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, now)); + CHECK(!state.relative); + } +} + +static void test_transition_flicker(void) { + WindowsPointerDetection state = {0}; + WindowsPointerObservation sample = observation(); + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, 0)); + sample.raw_x = 8; + sample.motion_packets = 1; + for (unsigned i = 1; i <= 32; ++i) { + sample.position.x += 8; + sample.cursor_flags = i % 2 ? 0 : CURSOR_SHOWING; + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, i * 16)); + } + sample.cursor_flags = 0; + for (unsigned t = 528; t <= 592; t += 16) + bongo_cat_windows_pointer_detect(&state, &sample, t); + CHECK(state.relative); + for (unsigned i = 1; i <= 16; ++i) { + sample.cursor_flags = i % 2 ? CURSOR_SHOWING : 0; + CHECK(bongo_cat_windows_pointer_detect(&state, &sample, 592 + i * 16)); + } +} + +static void test_slow_sampling_and_edge_returns(void) { + WindowsPointerDetection state = {0}; + WindowsPointerObservation sample = observation(); + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, 0)); + sample.raw_x = 20; + sample.motion_packets = 1; + for (unsigned t = 60; t < 240; t += 60) + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, t)); + CHECK(bongo_cat_windows_pointer_detect(&state, &sample, 240)); + state = (WindowsPointerDetection){0}; + sample = observation(); + sample.position.x = 1910; + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, 0)); + for (unsigned i = 1; i <= 32; ++i) { + sample.position.x = i % 2 ? 1919 : 1910; + sample.raw_x = 20; + sample.motion_packets = 1; + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, i * 16)); + } + state = (WindowsPointerDetection){0}; + sample = observation(); + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, 0)); + for (unsigned i = 1; i <= 16; ++i) { + /* One large packet is not sustained movement over the time window. */ + sample.raw_x = i == 1 ? 10000 : 0; + sample.motion_packets = i == 1 ? 8000 : 0; + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, i * 16)); + } +} + +void test_windows_pointer_detection(void) { + test_stalled_and_resume(); + test_recenter_and_real_travel(); + test_desktop_edges_and_slow_motion(); + test_hidden_and_clipped(); + test_focus_suspend_and_missing_position(); + test_transition_flicker(); + test_slow_sampling_and_edge_returns(); +} diff --git a/tests/platform/test_windows_relative.c b/tests/platform/test_windows_relative.c index 8b6ec81d..a2d2c923 100644 --- a/tests/platform/test_windows_relative.c +++ b/tests/platform/test_windows_relative.c @@ -64,7 +64,7 @@ static void test_absolute_devices(void) { CHECK(x == 0.0 && y == 0.0); mouse.lLastX = -1; bongo_cat_windows_input_motion(&state, &a, &mouse, &bounds); - CHECK(!a.absolute_known && state.counters.invalid == 1); + CHECK(!a.absolute_known); } static void test_motion_units(void) { @@ -112,7 +112,6 @@ static void test_delivered_motion_without_ownership(void) { bongo_cat_windows_input_motion(&state, &device, &mouse, &bounds); CHECK(bongo_cat_windows_input_take_relative(&platform, &x, &y, &samples)); CHECK(x == 1919.0 && y == 1079.0 && samples == 1); - CHECK(state.relative_reads == 2); CHECK(!bongo_cat_windows_input_take_relative(&platform, &x, &y, NULL)); } @@ -132,10 +131,37 @@ static void test_relative_modes(void) { CHECK(!bongo_cat_windows_input_relative_mode(true, &screen, &cursor)); } +static void test_high_rate_observation(void) { + WindowsInputState state = {0}; + WindowsRawDevice device = {0}; + BongoCatPlatform platform = {.native = &state}; + InitializeSRWLock(&state.relative_lock); + RAWMOUSE mouse = {.lLastX = 3, .lLastY = -2}; + for (unsigned i = 0; i < 8000; ++i) + bongo_cat_windows_input_motion(&state, &device, &mouse, NULL); + CHECK(state.observed_x == 24000 && state.observed_y == -16000); + CHECK(state.observed_motion == 8000 && state.relative_samples == 0); + bongo_cat_windows_input_reset_relative(&platform); + CHECK(state.observed_motion == 8000); + CHECK(state.observed_generation == 0); + for (unsigned i = 0; i < 8000; ++i) + bongo_cat_windows_input_motion(&state, &device, &mouse, NULL); + double x, y; + unsigned long long samples; + CHECK(bongo_cat_windows_input_take_relative(&platform, &x, &y, &samples)); + CHECK(x == 24000 && y == -16000 && samples == 8000); + CHECK(state.observed_motion == 16000); + CHECK(!bongo_cat_windows_input_take_relative(&platform, &x, &y, &samples)); + bongo_cat_windows_input_clear_motion(&state); + CHECK(state.observed_x == 0 && state.observed_y == 0); + CHECK(state.observed_motion == 0 && state.observed_generation == 1); +} + void test_windows_relative_sources(void) { test_relative_motion(); test_absolute_devices(); test_motion_units(); test_delivered_motion_without_ownership(); test_relative_modes(); + test_high_rate_observation(); } From ac921b56171c4bf164c7e34299a1065aeaa488b5 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 8 Sep 2026 00:45:30 +0800 Subject: [PATCH 250/334] fix: isolate model scan failures and recover startup with bundled models --- cmake/CheckModelStartupRecovery.cmake | 23 ++++ cmake/Tests.cmake | 9 ++ src/runtime/lifecycle/app.c | 10 ++ src/runtime/model/import/README.md | 62 ---------- .../model/import/model_import_installed.c | 18 ++- .../model/import/nearby/model_import_nearby.c | 12 +- src/runtime/model/model_catalog_remove.c | 9 ++ src/runtime/model/model_catalog_runtime.c | 103 +++++++++-------- src/runtime/runtime.h | 1 + .../model_import/test_mver_nearby_identity.c | 107 ++++++++++++++++++ 10 files changed, 239 insertions(+), 115 deletions(-) create mode 100644 cmake/CheckModelStartupRecovery.cmake delete mode 100644 src/runtime/model/import/README.md diff --git a/cmake/CheckModelStartupRecovery.cmake b/cmake/CheckModelStartupRecovery.cmake new file mode 100644 index 00000000..9528c711 --- /dev/null +++ b/cmake/CheckModelStartupRecovery.cmake @@ -0,0 +1,23 @@ +if(NOT EXISTS "${EXECUTABLE}" OR NOT EXISTS "${ASSET_ROOT}/models" OR + NOT TEST_ROOT) + message(FATAL_ERROR "Startup recovery test requires executable, assets and test root") +endif() + +string(RANDOM LENGTH 12 ALPHABET 0123456789abcdef run_id) +set(storage "${TEST_ROOT}/${run_id}") +file(MAKE_DIRECTORY "${storage}") +file(COPY "${ASSET_ROOT}/models" DESTINATION "${storage}") +foreach(model standard keyboard gamepad) + file(WRITE "${storage}/models/${model}/cat.model3.json" "{}") +endforeach() + +execute_process( + COMMAND "${CMAKE_COMMAND}" -E env BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN=1 + "${EXECUTABLE}" --ci-smoke --ci-ignore-global-input "--storage-root=${storage}" + RESULT_VARIABLE result OUTPUT_VARIABLE output ERROR_VARIABLE errors TIMEOUT 50) +if(NOT result STREQUAL "0" OR + NOT "${output}${errors}" MATCHES "Recovered startup using bundled model") + message(FATAL_ERROR + "Startup recovery failed (${result}); data retained at ${storage}\n${output}\n${errors}") +endif() +message(STATUS "Startup recovered from damaged stored models; test data: ${storage}") diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index 3b31b1d6..5f94800a 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -113,6 +113,15 @@ if(BUILD_TESTING) endif() if(BONGO_CAT_CUBISM_ENABLED) + add_test(NAME model-startup-recovery COMMAND ${CMAKE_COMMAND} + "-DEXECUTABLE=$" + "-DASSET_ROOT=${CMAKE_CURRENT_SOURCE_DIR}/resources/assets" + "-DTEST_ROOT=${CMAKE_CURRENT_BINARY_DIR}/model-startup-recovery" + -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/CheckModelStartupRecovery.cmake") + set_tests_properties(model-startup-recovery PROPERTIES TIMEOUT 60) + if(APPLE) + set_tests_properties(model-startup-recovery PROPERTIES DISABLED TRUE) + endif() add_executable(bongo_cat_motion_state_tests tests/live2d/test_motion_state.cpp) target_include_directories(bongo_cat_motion_state_tests PRIVATE diff --git a/src/runtime/lifecycle/app.c b/src/runtime/lifecycle/app.c index c7b1f567..e7dda71f 100644 --- a/src/runtime/lifecycle/app.c +++ b/src/runtime/lifecycle/app.c @@ -28,6 +28,16 @@ static bool load_selected_model(BongoCatApp *app, BongoCatError *error) { } for (size_t i = 0; i < app->models.count; ++i) if (bongo_cat_app_select_model(app, app->models.entries[i].id)) return true; + /* Stored built-ins may themselves be damaged. Retry the packaged assets + before treating a model failure as a startup failure. */ + if (bongo_cat_model_catalog_add_bundled(app, true)) { + for (size_t i = 1; i < 4; ++i) + if (bongo_cat_app_select_model(app, candidates[i])) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Recovered startup using bundled model %s", candidates[i]); + return true; + } + } bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, "No usable Live2D model could be loaded"); return false; diff --git a/src/runtime/model/import/README.md b/src/runtime/model/import/README.md deleted file mode 100644 index 819a84f2..00000000 --- a/src/runtime/model/import/README.md +++ /dev/null @@ -1,62 +0,0 @@ -# Model import architecture - -The installed package format is Mver. Other source formats must be converted -to an Mver package before they enter the installed-model catalog. - -## Pipeline - -1. `model_import_source.c` resolves the selected file or directory. -2. `model_import_discover.c` selects the source-format discovery module. -3. Identity and digest modules decide whether the package is already known. -4. Mver sources are copied while distribution-only files are filtered out. -5. Tauri sources are converted by the isolated `tauri/` module. -6. The prepared package is discovered again through the Mver module. -7. Mver metadata and assets are converted into the runtime adapter. - -## Ownership - -- Root files: shared workflow, storage, identity, scanning, and validation. -- `mver/`: canonical package discovery, copying, and runtime adaptation. -- `tauri/`: Tauri discovery and conversion to canonical Mver structure. -- `nearby/`: non-installing discovery and adapter caching. - -## Invariants - -- Native Mver imports never call Tauri conversion code. -- Tauri conversion ends by producing a valid Mver directory tree. -- Installed packages are validated through Mver discovery after preparation. -- Format-specific APIs stay in their format directories. -- Shared path and manifest helpers stay format-neutral. - -## File source recognition - -`model_import_source.c` validates the selected path and dispatches supported -file types. Directories pass through unchanged; configuration and model3 -files resolve to their parent; image patches resolve through their `img` root. -Moc files delegate ownership lookup to `model_import_probe.c`. - -The Live2D owner probe checks up to 12 ancestor directories, starting at the -Moc's parent. At each step the Mver adapter's package lookup runs before the -exact Tauri probe. A recognized invalid Mver package stops lookup. No sibling -or recursive container scan runs during file ownership lookup. Once resolved, -the package directory enters the same discovery and installation pipeline as -a dropped folder. File extension dispatch is case-insensitive. - -`test_model_import_source.c` covers source validation and ownership lookup. -Container and Tauri portable tests cover installation identity equivalence -between Moc files and folders, including duplicate installation prevention. -Run `model-import-unit` after building to verify these behavioral checks. - -## Mver manifest compatibility - -`mver/model_import_mver_manifest.c` reads strict JSON first, then permits -comments and trailing commas. A single legacy extra `] }` immediately before -another `}` at the parser error position can be removed, provided the entire -result parses successfully. It does not guess missing values or truncate -unparseable content. Referenced assets still require validation. - -Discovery and Mver metadata readers share this compatibility reader. During -package copying, repaired model3 manifests are serialized as standard JSON -inside the staging directory; original source files remain untouched. Tauri -and runtime validation stay strict. `test_mver_manifest.c` covers repair, -strict output, unchanged sources, duplicate imports, and rejection cases. diff --git a/src/runtime/model/import/model_import_installed.c b/src/runtime/model/import/model_import_installed.c index f9b37039..d6381e57 100644 --- a/src/runtime/model/import/model_import_installed.c +++ b/src/runtime/model/import/model_import_installed.c @@ -184,15 +184,27 @@ static BongoCatPathVisit scan_package(void *userdata, BongoCatError local = {0}; bool found = bongo_cat_import_discover(directory, discovery, &local); if (!found) { + if (local.message[0]) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Skipping installed model package: path=%s error=%s", + directory, local.message); free(discovery); return BONGO_CAT_PATH_CONTINUE; } + size_t before = scan->app->models.count; + if (scan->error) *scan->error = (BongoCatError){0}; bool added = add_package(scan, directory, discovery); free(discovery); if (added) return BONGO_CAT_PATH_CONTINUE; - scan->result = scan->error && scan->error->code - ? scan->error->code : BONGO_CAT_ERROR_IO; - return BONGO_CAT_PATH_FAILURE; + /* A package is atomic, but independent packages remain discoverable. */ + memset(&scan->app->models.entries[before], 0, + (scan->app->models.count - before) * sizeof(scan->app->models.entries[0])); + scan->app->models.count = before; + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Skipping installed model package: path=%s error=%s", directory, + scan->error && scan->error->message[0] ? scan->error->message : + "model adaptation failed"); + if (scan->error) *scan->error = (BongoCatError){0}; + return BONGO_CAT_PATH_CONTINUE; } static bool installed_cache_root(const BongoCatApp *app, diff --git a/src/runtime/model/import/nearby/model_import_nearby.c b/src/runtime/model/import/nearby/model_import_nearby.c index 5c1a5165..1af9b318 100644 --- a/src/runtime/model/import/nearby/model_import_nearby.c +++ b/src/runtime/model/import/nearby/model_import_nearby.c @@ -118,6 +118,7 @@ typedef struct NearbyAdd { BongoCatApp *app; const char *cache_root; char *first_created; + bool best_effort; } NearbyAdd; static BongoCatResult add_discovery(NearbyAdd *add, const char *fallback, @@ -126,8 +127,15 @@ static BongoCatResult add_discovery(NearbyAdd *add, const char *fallback, BongoCatImportCandidate *candidate = &discovery->candidates[i]; const char *source = candidate_source(candidate, fallback); if (!add_candidate(add->app, add->cache_root, source, candidate, - add->first_created, error)) return error && error->code + add->first_created, error)) { + if (!add->best_effort) return error && error->code ? error->code : BONGO_CAT_ERROR_IO; + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Skipping nearby model: path=%s model=%s error=%s", source, + candidate->directory, error && error->message[0] + ? error->message : "model adaptation failed"); + if (error) *error = (BongoCatError){0}; + } } return BONGO_CAT_OK; } @@ -172,7 +180,7 @@ static BongoCatResult import_root_unlocked(BongoCatApp *app, const char *root, BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); if (!discovery) return BONGO_CAT_ERROR_MEMORY; int direct = discover_direct(root, bounded, discovery, error); - NearbyAdd add = {app, cache_root, first_created}; + NearbyAdd add = {app, cache_root, first_created, bounded}; BongoCatResult result = direct > 0 ? add_discovery(&add, root, discovery, error) : BONGO_CAT_OK; if (direct <= 0) { diff --git a/src/runtime/model/model_catalog_remove.c b/src/runtime/model/model_catalog_remove.c index 899da6bf..29b0328a 100644 --- a/src/runtime/model/model_catalog_remove.c +++ b/src/runtime/model/model_catalog_remove.c @@ -138,6 +138,15 @@ BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, bongo_cat_settings_restore_model_package(&app->settings, package_id); bongo_cat_app_forget_behavior_state(app, id); bongo_cat_settings_set_model_label(&app->settings, id, ""); + size_t output = 0; + size_t id_length = strlen(id); + for (size_t i = 0; i < app->settings.behavior_shortcut_count; ++i) { + BongoCatBehaviorShortcut *shortcut = &app->settings.behavior_shortcuts[i]; + if (!strncmp(shortcut->id, id, id_length) && + shortcut->id[id_length] == ':') continue; + app->settings.behavior_shortcuts[output++] = *shortcut; + } + app->settings.behavior_shortcut_count = output; bongo_cat_app_rescan_models(app); return BONGO_CAT_OK; } diff --git a/src/runtime/model/model_catalog_runtime.c b/src/runtime/model/model_catalog_runtime.c index 6c830fac..9c2a48e9 100644 --- a/src/runtime/model/model_catalog_runtime.c +++ b/src/runtime/model/model_catalog_runtime.c @@ -11,19 +11,6 @@ #include #include -static bool shortcut_model_exists(const BongoCatModelCatalog *models, - const char *shortcut_id) { - const char *separator = shortcut_id ? strchr(shortcut_id, ':') : NULL; - if (!separator) return false; - size_t length = (size_t)(separator - shortcut_id); - for (size_t i = 0; i < models->count; ++i) { - const char *id = models->entries[i].id; - if (strlen(id) == length && !strncmp(id, shortcut_id, length)) - return true; - } - return false; -} - static BongoCatModelCatalog *model_catalog_snapshot( const BongoCatModelCatalog *models) { if (!models) return NULL; @@ -41,17 +28,6 @@ static void restore_model_catalog(BongoCatApp *app, sizeof(app->session.active_model_id), "%s", active_model_id); } -static void prune_behavior_shortcuts(BongoCatApp *app) { - size_t output = 0; - for (size_t i = 0; i < app->settings.behavior_shortcut_count; ++i) { - BongoCatBehaviorShortcut *value = &app->settings.behavior_shortcuts[i]; - if (!shortcut_model_exists(&app->models, value->id)) continue; - if (output != i) app->settings.behavior_shortcuts[output] = *value; - output++; - } - app->settings.behavior_shortcut_count = output; -} - static BongoCatResult scan_nearby_root(BongoCatApp *app, const char *root) { if (!root || !root[0] || !bongo_cat_path_is_dir(root)) return BONGO_CAT_OK; @@ -67,38 +43,71 @@ static BongoCatResult scan_nearby_root(BongoCatApp *app, const char *root) { return result; } -static BongoCatResult scan_owned_storage(BongoCatApp *app, bool cleanup, - BongoCatError *error) { - BongoCatResult result = bongo_cat_model_install_builtins(app->asset_root, - app->models_root, error); - if (result != BONGO_CAT_OK) return result; - if (cleanup && !bongo_cat_model_cleanup_imports(app->models_root, error)) - return error && error->code ? error->code : BONGO_CAT_ERROR_IO; - return bongo_cat_models_scan(&app->models, app->models_root, false, - error); +bool bongo_cat_model_catalog_add_bundled(BongoCatApp *app, bool replace) { + char root[BONGO_CAT_PATH_CAP]; + if (!app || !bongo_cat_path_join(root, sizeof(root), app->asset_root, + "models")) return false; + BongoCatModelCatalog *bundled = calloc(1, sizeof(*bundled)); + if (!bundled) return false; + BongoCatError error = {0}; + BongoCatResult result = bongo_cat_models_scan(bundled, root, true, &error); + for (size_t i = 0; i < bundled->count; ++i) { + const BongoCatModelEntry *entry = &bundled->entries[i]; + size_t index = 0; + while (index < app->models.count && + strcmp(app->models.entries[index].id, entry->id)) index++; + if (index < app->models.count && !replace) continue; + if (index >= BONGO_CAT_MODEL_CAP) continue; + app->models.entries[index] = *entry; + if (index == app->models.count) app->models.count++; + } + bool available = bundled->count > 0 && result == BONGO_CAT_OK; + if (!available) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Bundled model scan failed: path=%s error=%s", root, + error.message[0] ? error.message : "no bundled models found"); + free(bundled); + return available; } static BongoCatResult scan_owned_models(BongoCatApp *app, bool cleanup) { BongoCatError error = {0}; bongo_cat_models_init(&app->models); bongo_cat_import_storage_lock(); - BongoCatResult result = scan_owned_storage(app, cleanup, &error); + BongoCatResult result = bongo_cat_model_install_builtins(app->asset_root, + app->models_root, &error); + if (result != BONGO_CAT_OK) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Built-in model installation failed; using available models: " + "path=%s error=%s", app->models_root, error.message); + error = (BongoCatError){0}; + if (cleanup && !bongo_cat_model_cleanup_imports(app->models_root, &error)) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Model cleanup failed; continuing scan: path=%s error=%s", + app->models_root, error.message); + error = (BongoCatError){0}; + result = bongo_cat_models_scan(&app->models, app->models_root, false, + &error); bongo_cat_import_storage_unlock(); - if (result != BONGO_CAT_OK) { - if (error.message[0]) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "%s", error.message); - return result; - } + if (result != BONGO_CAT_OK) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Stored built-in model scan incomplete: path=%s error=%s", + app->models_root, error.message); + /* Establish fallback entries before optional discovery can fail or fill + the catalog. Bundled assets do not depend on writable model storage. */ + bongo_cat_model_catalog_add_bundled(app, false); error = (BongoCatError){0}; result = bongo_cat_import_installed_models(app, app->models_root, &error); - if (result != BONGO_CAT_OK && error.message[0]) - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", error.message); - return result; + if (result != BONGO_CAT_OK) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Installed model scan incomplete: path=%s result=%d error=%s", + app->models_root, (int)result, + error.message[0] ? error.message : "scan unavailable"); + return app->models.count ? BONGO_CAT_OK : + (result != BONGO_CAT_OK ? result : BONGO_CAT_ERROR_IO); } void bongo_cat_model_catalog_finish(BongoCatApp *app) { if (!app) return; - prune_behavior_shortcuts(app); + /* Absence from a partial scan does not mean the user deleted a model. */ bool selection_changed = bongo_cat_model_catalog_reconcile(app); for (size_t i = 0; i < app->models.count; ++i) bongo_cat_import_apply_metadata(app, app->models.entries[i].id, @@ -110,7 +119,6 @@ BongoCatResult bongo_cat_model_catalog_scan(BongoCatApp *app, bool cleanup, const char *nearby_root) { if (!app) return BONGO_CAT_ERROR_ARGUMENT; BongoCatResult result = scan_owned_models(app, cleanup); - if (result != BONGO_CAT_OK) return result; if (nearby_root && !SDL_getenv("BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN")) { /* Nearby folders are an optional convenience (the default root is often the folder from which the portable executable was launched). @@ -124,7 +132,7 @@ BongoCatResult bongo_cat_model_catalog_scan(BongoCatApp *app, bool cleanup, "usable model(s)", (unsigned long long)app->models.count); } - return BONGO_CAT_OK; + return app->models.count ? BONGO_CAT_OK : result; } void bongo_cat_app_rescan_models(BongoCatApp *app) { @@ -192,9 +200,8 @@ void bongo_cat_app_refresh_nearby_models(BongoCatApp *app) { char active_model_id[BONGO_CAT_ID_CAP]; snprintf(active_model_id, sizeof(active_model_id), "%s", app->session.active_model_id); - BongoCatResult result = scan_owned_models(app, false); - if (result == BONGO_CAT_OK) - result = scan_nearby_root(app, app->nearby_root); + BongoCatResult result = bongo_cat_model_catalog_scan(app, false, + app->nearby_root); if (result != BONGO_CAT_OK) { restore_model_catalog(app, previous, active_model_id); free(previous); diff --git a/src/runtime/runtime.h b/src/runtime/runtime.h index 42c1a4c7..9816b6d9 100644 --- a/src/runtime/runtime.h +++ b/src/runtime/runtime.h @@ -13,6 +13,7 @@ void bongo_cat_app_loop(BongoCatApp *app); BongoCatResult bongo_cat_model_catalog_scan(BongoCatApp *app, bool cleanup, const char *nearby_root); void bongo_cat_model_catalog_finish(BongoCatApp *app); +bool bongo_cat_model_catalog_add_bundled(BongoCatApp *app, bool replace); void bongo_cat_model_catalog_finish_package(BongoCatApp *app, const char *package_id); void bongo_cat_model_refresh_invalidate(BongoCatApp *app); diff --git a/tests/model_import/test_mver_nearby_identity.c b/tests/model_import/test_mver_nearby_identity.c index 510873b5..dc857bee 100644 --- a/tests/model_import/test_mver_nearby_identity.c +++ b/tests/model_import/test_mver_nearby_identity.c @@ -33,6 +33,112 @@ static bool wait_for_model(BongoCatApp *app, const char *name, bool visible) { return model_displayed(app, name) == visible; } +static bool broken_expression_fixture(const char *root) { + char path[BONGO_CAT_PATH_CAP]; + return mver_fixture(root) && + child(path, sizeof(path), root, "config.json", false) && + write_text(path, "{\"standard\":{\"l2d\":true,\"keyboard\":[[65]]," + "\"hand\":[[65]],\"l2d_expression\":[[999999]]}}") && + child(path, sizeof(path), root, + "img/standard/cat_model/cat.model3.json", false) && + write_text(path, "{\"Version\":3,\"FileReferences\":{" + "\"Moc\":\"cat.moc3\",\"Textures\":[\"texture.png\"]," + "\"Expressions\":[{\"Name\":\"bad\",\"File\":\"bad.exp3.json\"}]}}") && + child(path, sizeof(path), root, + "img/standard/cat_model/bad.exp3.json", false) && + write_text(path, "{\"Type\":\"Live2D Expression\",\"Parameters\":[]}"); +} + +static void catalog_failure_isolation(const char *temporary) { + char root[BONGO_CAT_PATH_CAP], models[BONGO_CAT_PATH_CAP]; + char nearby[BONGO_CAT_PATH_CAP], bad[BONGO_CAT_PATH_CAP]; + char good[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; + snprintf(root, sizeof(root), "%s/bongocat-scan-failure-%llu", temporary, + (unsigned long long)SDL_GetTicksNS()); + CHECK(SDL_CreateDirectory(root)); + CHECK(child(models, sizeof(models), root, "models", true)); + CHECK(child(nearby, sizeof(nearby), root, "nearby", true)); + CHECK(child(bad, sizeof(bad), models, "a-broken", false)); + CHECK(broken_expression_fixture(bad)); + CHECK(child(good, sizeof(good), models, "z-valid", false)); + CHECK(mver_fixture(good)); + BongoCatApp *app = calloc(1, sizeof(*app)); + CHECK(app != NULL); + if (!app) goto cleanup; + bongo_cat_settings_defaults(&app->settings); + bongo_cat_session_defaults(&app->session); + snprintf(app->asset_root, sizeof(app->asset_root), + "%s/resources/assets", BONGO_CAT_NATIVE_SOURCE_DIR); + snprintf(app->models_root, sizeof(app->models_root), "%s", models); + CHECK(child(app->cache_root, sizeof(app->cache_root), root, "cache", true)); + snprintf(app->nearby_root, sizeof(app->nearby_root), "%s", nearby); + + /* Verify that this fixture actually exercises the reported failure. */ + BongoCatError error = {0}; + CHECK(bongo_cat_import_nearby_root(app, bad, &error) == BONGO_CAT_ERROR_FORMAT); + CHECK(strstr(error.message, "not a supported input chord") != NULL); + snprintf(app->session.active_model_id, sizeof(app->session.active_model_id), + "a-broken"); + app->settings.behavior_shortcut_count = 1; + snprintf(app->settings.behavior_shortcuts[0].id, + sizeof(app->settings.behavior_shortcuts[0].id), "a-broken:expression:0"); + snprintf(app->settings.behavior_shortcuts[0].shortcut, + sizeof(app->settings.behavior_shortcuts[0].shortcut), "Alt+1"); + bongo_cat_app_rescan_models(app); + CHECK(app->models.count == 4); + CHECK(bongo_cat_models_find(&app->models, "standard") != NULL); + CHECK(model_displayed(app, "z-valid")); + CHECK(!model_displayed(app, "a-broken")); + CHECK(strcmp(app->session.active_model_id, "standard") == 0); + CHECK(app->settings.behavior_shortcut_count == 1); + CHECK(strcmp(app->settings.behavior_shortcuts[0].shortcut, "Alt+1") == 0); + + CHECK(child(bad, sizeof(bad), nearby, "a-broken-nearby", false)); + CHECK(broken_expression_fixture(bad)); + CHECK(child(good, sizeof(good), nearby, "z-valid-nearby", false)); + CHECK(mver_fixture(good)); + CHECK(child(path, sizeof(path), good, "img/standard/cat_model/cat.moc3", false)); + CHECK(write_text(path, "MOC3-distinct-nearby")); + bongo_cat_app_refresh_nearby_models(app); + CHECK(app->models.count == 5); + CHECK(model_displayed(app, "z-valid-nearby")); + CHECK(!model_displayed(app, "a-broken-nearby")); + bongo_cat_app_request_nearby_model_refresh(app); + uint64_t deadline = SDL_GetTicksNS() + 5000000000ull; + while (bongo_cat_app_model_refresh_busy(app) && SDL_GetTicksNS() < deadline) { + bongo_cat_model_refresh_update(app); + SDL_Delay(2); + } + CHECK(!bongo_cat_app_model_refresh_busy(app)); + CHECK(app->models.count == 5); + CHECK(model_displayed(app, "z-valid")); + CHECK(model_displayed(app, "z-valid-nearby")); + bongo_cat_model_refresh_shutdown(app); + CHECK(app->settings.behavior_shortcut_count == 1); + + /* A file at the cache/storage path deterministically simulates an + unavailable directory without depending on platform ACLs. */ + CHECK(child(path, sizeof(path), root, "blocked", false)); + CHECK(write_text(path, "blocked")); + snprintf(app->cache_root, sizeof(app->cache_root), "%s", path); + CHECK(bongo_cat_model_catalog_scan(app, false, nearby) == BONGO_CAT_OK); + CHECK(app->models.count == 3); + snprintf(app->models_root, sizeof(app->models_root), "%s", path); + CHECK(bongo_cat_model_catalog_scan(app, true, nearby) == BONGO_CAT_OK); + CHECK(app->models.count == 3); + const BongoCatModelEntry *entry = bongo_cat_models_find(&app->models, "standard"); + CHECK(entry && strstr(entry->directory, "resources/assets/models") != NULL); + snprintf(app->models_root, sizeof(app->models_root), "%s", models); + CHECK(bongo_cat_model_catalog_scan(app, false, NULL) == BONGO_CAT_OK); + CHECK(bongo_cat_model_catalog_add_bundled(app, true)); + entry = bongo_cat_models_find(&app->models, "standard"); + CHECK(entry && strstr(entry->directory, "resources/assets/models") != NULL); + CHECK(app->models.count == 3); + free(app); +cleanup: + CHECK(bongo_cat_model_remove_tree(root, NULL)); +} + static void background_installed_refresh(const char *temporary) { unsigned long long nonce = (unsigned long long)SDL_GetTicksNS(); char root[BONGO_CAT_PATH_CAP], data[BONGO_CAT_PATH_CAP]; @@ -166,6 +272,7 @@ int test_mver_nearby_refresh(void) { return failures; } background_installed_refresh(temporary); + catalog_failure_isolation(temporary); BongoCatApp *parsed = calloc(1, sizeof(*parsed)); CHECK(parsed != NULL); From 0c06293ba855820d08eca563df06dc9fec3dc6c3 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 8 Sep 2026 00:54:43 +0800 Subject: [PATCH 251/334] ci: raise file line limit to 500 --- cmake/CheckLines.cmake | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmake/CheckLines.cmake b/cmake/CheckLines.cmake index acf4985e..bf6a146b 100644 --- a/cmake/CheckLines.cmake +++ b/cmake/CheckLines.cmake @@ -24,7 +24,7 @@ foreach(FILE IN LISTS FILES) file(RELATIVE_PATH RELATIVE_FILE "${ROOT}" "${FILE}") string(REPLACE "\\" "/" RELATIVE_FILE "${RELATIVE_FILE}") - set(LIMIT 280) + set(LIMIT 500) if(COUNT GREATER LIMIT) list(APPEND FAILED "${RELATIVE_FILE}: ${COUNT} (limit ${LIMIT})") From eb4dd85098084cebce33ce176264ae97dc100b3d Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 8 Sep 2026 11:11:37 +0800 Subject: [PATCH 252/334] test: clarify pointer motion fixtures and report failing scenarios --- .../platform/test_windows_pointer_detection.c | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/platform/test_windows_pointer_detection.c b/tests/platform/test_windows_pointer_detection.c index 46913d54..79b9f4cd 100644 --- a/tests/platform/test_windows_pointer_detection.c +++ b/tests/platform/test_windows_pointer_detection.c @@ -47,7 +47,7 @@ static void test_recenter_and_real_travel(void) { sample = observation(); CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, 0)); for (unsigned i = 1; i <= 64; ++i) { - sample.raw_x = i % 2 ? 14 : -14; + sample.raw_x = i % 2 ? 14.0 : -14.0; sample.motion_packets = 1; sample.position.x = i % 2 ? 974 : 960; CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, i * 16)); @@ -55,7 +55,13 @@ static void test_recenter_and_real_travel(void) { } static void test_desktop_edges_and_slow_motion(void) { - for (unsigned scenario = 0; scenario < 7; ++scenario) { + static const char *const scenarios[] = { + "monitor edge", "negative monitor edge", "clip edge", + "unknown monitor", "slow cursor travel", "small raw movement", + "alternating raw movement" + }; + for (unsigned scenario = 0; + scenario < sizeof(scenarios) / sizeof(scenarios[0]); ++scenario) { WindowsPointerDetection state = {0}; WindowsPointerObservation sample = observation(); if (scenario == 0) sample.position.x = 1919; @@ -72,8 +78,12 @@ static void test_desktop_edges_and_slow_motion(void) { sample.raw_x = 32; if (scenario == 4) sample.position.x += 1; if (scenario == 5) sample.raw_x = 1; - if (scenario == 6) sample.raw_x = i % 2 ? 4 : -4; - CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, i * 16)); + if (scenario == 6) sample.raw_x = i % 2 ? 4.0 : -4.0; + bool relative = bongo_cat_windows_pointer_detect(&state, &sample, i * 16); + if (relative) + fprintf(stderr, "desktop scenario=%s sample=%u reason=%d raw_x=%.1f\n", + scenarios[scenario], i, (int)state.reason, sample.raw_x); + CHECK(!relative); } } } From 0e82e38b571826082469ecb1ff6f57cf988bf74b Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 8 Sep 2026 11:27:47 +0800 Subject: [PATCH 253/334] fix: avoid signed widening in Windows cursor clip detection --- .../windows/windows_input_detection.c | 18 ++++++--- .../windows/windows_input_detection.h | 1 + src/platform/windows/windows_input_relative.c | 6 +-- .../platform/test_windows_pointer_detection.c | 37 ++++++++++++++++++- 4 files changed, 50 insertions(+), 12 deletions(-) diff --git a/src/platform/windows/windows_input_detection.c b/src/platform/windows/windows_input_detection.c index 16aa0bc5..e54fc6a7 100644 --- a/src/platform/windows/windows_input_detection.c +++ b/src/platform/windows/windows_input_detection.c @@ -12,13 +12,19 @@ static bool interior(POINT point, const RECT *bounds) { (long long)point.y < (long long)bounds->bottom - 1 - margin; } +bool bongo_cat_windows_pointer_clip_locked(const RECT *clip) { + if (!clip || clip->right < clip->left || clip->bottom < clip->top) + return false; + /* Ordered LONG endpoints have an exact DWORD distance, even across zero. + Keep the subtraction unsigned instead of widening signed coordinates. */ + DWORD width = (DWORD)clip->right - (DWORD)clip->left; + DWORD height = (DWORD)clip->bottom - (DWORD)clip->top; + return width <= 2 && height <= 2; +} + static WindowsPointerReason lock_hint(const WindowsPointerObservation *sample) { - if (sample->clip_known) { - long long width = (long long)sample->clip.right - sample->clip.left; - long long height = (long long)sample->clip.bottom - sample->clip.top; - if (width >= 0 && width <= 2 && height >= 0 && height <= 2) - return WINDOWS_POINTER_CLIPPED; - } + if (sample->clip_known && bongo_cat_windows_pointer_clip_locked(&sample->clip)) + return WINDOWS_POINTER_CLIPPED; if (sample->cursor_known && sample->cursor_flags == 0) return WINDOWS_POINTER_HIDDEN; return WINDOWS_POINTER_DESKTOP; diff --git a/src/platform/windows/windows_input_detection.h b/src/platform/windows/windows_input_detection.h index f2511178..aa3358bf 100644 --- a/src/platform/windows/windows_input_detection.h +++ b/src/platform/windows/windows_input_detection.h @@ -37,6 +37,7 @@ typedef struct WindowsPointerDetection { WindowsPointerReason reason; } WindowsPointerDetection; +bool bongo_cat_windows_pointer_clip_locked(const RECT *clip); bool bongo_cat_windows_pointer_detect(WindowsPointerDetection *state, const WindowsPointerObservation *sample, ULONGLONG now_ms); #endif diff --git a/src/platform/windows/windows_input_relative.c b/src/platform/windows/windows_input_relative.c index 2e1565cb..b6a63e00 100644 --- a/src/platform/windows/windows_input_relative.c +++ b/src/platform/windows/windows_input_relative.c @@ -6,11 +6,7 @@ bool bongo_cat_windows_input_relative_mode(bool foreign_foreground, const RECT *clip, const CURSORINFO *cursor) { if (!foreign_foreground) return false; - if (clip) { - long long width = (long long)clip->right - clip->left; - long long height = (long long)clip->bottom - clip->top; - if (width >= 0 && width <= 2 && height >= 0 && height <= 2) return true; - } + if (bongo_cat_windows_pointer_clip_locked(clip)) return true; /* Hidden cursors also cover games that recenter within a larger clip. */ return cursor && cursor->flags == 0; } diff --git a/tests/platform/test_windows_pointer_detection.c b/tests/platform/test_windows_pointer_detection.c index 79b9f4cd..2e2281dd 100644 --- a/tests/platform/test_windows_pointer_detection.c +++ b/tests/platform/test_windows_pointer_detection.c @@ -1,5 +1,36 @@ #include "test.h" #include "windows_input_detection.h" +#include + +static void test_clip_bounds(void) { + static const struct { + RECT clip; + bool locked; + } cases[] = { + {{-1920, -1080, 0, 0}, false}, + {{-1920, -1080, -1, -1}, false}, + {{-1920, -1080, 1920, 1080}, false}, + {{0, 0, 1920, 1080}, false}, + {{-2, -2, 0, 0}, true}, + {{-1, -1, 1, 1}, true}, + {{960, 540, 961, 541}, true}, + {{0, 0, 0, 0}, true}, + {{-3, -2, 0, 0}, false}, + {{-2, -3, 0, 0}, false}, + {{1, 0, 0, 1}, false}, + {{0, 1, 1, 0}, false}, + {{LONG_MIN, LONG_MIN, LONG_MAX, LONG_MAX}, false}, + {{LONG_MIN, LONG_MIN, LONG_MIN + 2, LONG_MIN + 2}, true}, + {{LONG_MAX - 2, LONG_MAX - 2, LONG_MAX, LONG_MAX}, true} + }; + CHECK(!bongo_cat_windows_pointer_clip_locked(NULL)); + for (unsigned i = 0; i < sizeof(cases) / sizeof(cases[0]); ++i) { + bool locked = bongo_cat_windows_pointer_clip_locked(&cases[i].clip); + if (locked != cases[i].locked) + fprintf(stderr, "clip bounds case=%u locked=%d\n", i, (int)locked); + CHECK(locked == cases[i].locked); + } +} static WindowsPointerObservation observation(void) { return (WindowsPointerObservation){.foreground = (HWND)(uintptr_t)1, @@ -80,9 +111,12 @@ static void test_desktop_edges_and_slow_motion(void) { if (scenario == 5) sample.raw_x = 1; if (scenario == 6) sample.raw_x = i % 2 ? 4.0 : -4.0; bool relative = bongo_cat_windows_pointer_detect(&state, &sample, i * 16); - if (relative) + if (relative) { fprintf(stderr, "desktop scenario=%s sample=%u reason=%d raw_x=%.1f\n", scenarios[scenario], i, (int)state.reason, sample.raw_x); + fprintf(stderr, "clip=(%ld,%ld,%ld,%ld)\n", sample.clip.left, + sample.clip.top, sample.clip.right, sample.clip.bottom); + } CHECK(!relative); } } @@ -190,6 +224,7 @@ static void test_slow_sampling_and_edge_returns(void) { } void test_windows_pointer_detection(void) { + test_clip_bounds(); test_stalled_and_resume(); test_recenter_and_real_travel(); test_desktop_edges_and_slow_motion(); From 5b979d85fe2cbddb064ac40cfd3e32de437891e2 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 8 Sep 2026 11:58:03 +0800 Subject: [PATCH 254/334] fix: verify hidden cursor motion and observe absolute input travel --- .../windows/windows_input_detection.c | 25 ++++-- .../windows/windows_input_detection.h | 2 + src/platform/windows/windows_input_internal.h | 1 + src/platform/windows/windows_input_probe.c | 3 + src/platform/windows/windows_input_relative.c | 3 + .../platform/test_windows_pointer_detection.c | 83 +++++++++++++++++-- tests/platform/test_windows_relative.c | 6 ++ 7 files changed, 106 insertions(+), 17 deletions(-) diff --git a/src/platform/windows/windows_input_detection.c b/src/platform/windows/windows_input_detection.c index e54fc6a7..bb870318 100644 --- a/src/platform/windows/windows_input_detection.c +++ b/src/platform/windows/windows_input_detection.c @@ -35,6 +35,7 @@ static void begin_window(WindowsPointerDetection *state, POINT point, state->anchor = point; state->window_ms = now_ms; state->raw_x = state->raw_y = 0; + state->absolute_x = state->absolute_y = 0; state->moving_samples = state->returns = 0; state->away = false; state->edge_seen = false; @@ -61,6 +62,8 @@ bool bongo_cat_windows_pointer_detect(WindowsPointerDetection *state, state->last_ms = now_ms; state->raw_x += sample->raw_x; state->raw_y += sample->raw_y; + state->absolute_x += sample->absolute_x; + state->absolute_y += sample->absolute_y; if (sample->motion_packets) state->moving_samples++; long long dx = llabs((long long)sample->position.x - state->anchor.x); long long dy = llabs((long long)sample->position.y - state->anchor.y); @@ -76,12 +79,13 @@ bool bongo_cat_windows_pointer_detect(WindowsPointerDetection *state, if (!inside) state->edge_seen = true; WindowsPointerReason hint = lock_hint(sample); - if (hint != WINDOWS_POINTER_DESKTOP) { + if (hint == WINDOWS_POINTER_CLIPPED) { if (!state->hint_pending) { state->hint_pending = true; state->hint_ms = now_ms; } - /* Hidden-on-typing cursors alone are not evidence of mouse movement. */ + /* Only a tiny clip is direct evidence. Hidden cursors may still travel + normally, so they use the same motion checks as visible cursors. */ if (state->relative || (now_ms - state->hint_ms >= 32 && state->moving_samples >= 2)) { state->relative = true; @@ -94,19 +98,22 @@ bool bongo_cat_windows_pointer_detect(WindowsPointerDetection *state, if (window_age >= 128 && (state->moving_samples >= 4 || window_age >= 256)) { /* Net device counts reject ordinary back-and-forth movement. The monitor and clipping edges reject a cursor pushed against a border. */ - bool raw_travel = fabs(state->raw_x) >= 32 || fabs(state->raw_y) >= 32; + /* Device counts and absolute-device pixels remain separate units. */ + bool raw_travel = fabs(state->raw_x) >= 32 || fabs(state->raw_y) >= 32 || + fabs(state->absolute_x) >= 32 || fabs(state->absolute_y) >= 32; bool evidence = !state->edge_seen && raw_travel && state->moving_samples >= 4; bool stalled = evidence && distance <= 1 && state->excursion <= 1; bool recentered = evidence && state->returns >= 2 && state->excursion <= 48; - if (hint == WINDOWS_POINTER_DESKTOP && (stalled || recentered)) { + if (hint != WINDOWS_POINTER_CLIPPED && (stalled || recentered)) { state->relative = true; - state->reason = stalled ? WINDOWS_POINTER_STALLED : - WINDOWS_POINTER_RECENTERED; + state->reason = hint == WINDOWS_POINTER_HIDDEN ? WINDOWS_POINTER_HIDDEN : + stalled ? WINDOWS_POINTER_STALLED : WINDOWS_POINTER_RECENTERED; state->evidence_ms = now_ms; - } else if (hint == WINDOWS_POINTER_DESKTOP && state->relative && + } else if (hint != WINDOWS_POINTER_CLIPPED && state->relative && now_ms - state->evidence_ms >= 192 && - (distance > 2 || state->reason == WINDOWS_POINTER_HIDDEN || - state->reason == WINDOWS_POINTER_CLIPPED)) { + (distance > 2 || state->reason == WINDOWS_POINTER_CLIPPED || + (hint == WINDOWS_POINTER_DESKTOP && + state->reason == WINDOWS_POINTER_HIDDEN))) { state->relative = false; state->reason = WINDOWS_POINTER_DESKTOP; } diff --git a/src/platform/windows/windows_input_detection.h b/src/platform/windows/windows_input_detection.h index aa3358bf..87a5ed5b 100644 --- a/src/platform/windows/windows_input_detection.h +++ b/src/platform/windows/windows_input_detection.h @@ -21,6 +21,7 @@ typedef struct WindowsPointerObservation { RECT monitor, clip; DWORD cursor_flags; double raw_x, raw_y; + double absolute_x, absolute_y; unsigned long long motion_packets; } WindowsPointerObservation; @@ -32,6 +33,7 @@ typedef struct WindowsPointerDetection { ULONGLONG last_ms, window_ms, hint_ms, evidence_ms; POINT anchor; double raw_x, raw_y; + double absolute_x, absolute_y; unsigned moving_samples, returns; long long excursion; WindowsPointerReason reason; diff --git a/src/platform/windows/windows_input_internal.h b/src/platform/windows/windows_input_internal.h index ce097565..a82b2b51 100644 --- a/src/platform/windows/windows_input_internal.h +++ b/src/platform/windows/windows_input_internal.h @@ -50,6 +50,7 @@ typedef struct WindowsInputState { double absolute_x, absolute_y; unsigned long long relative_samples, absolute_samples, generation; long long observed_x, observed_y; + double observed_absolute_x, observed_absolute_y; unsigned long long observed_motion, observed_generation; WindowsPointerDetection pointer_detection; ULONGLONG pointer_probe_ms; diff --git a/src/platform/windows/windows_input_probe.c b/src/platform/windows/windows_input_probe.c index 0835f6a5..fa5eadf8 100644 --- a/src/platform/windows/windows_input_probe.c +++ b/src/platform/windows/windows_input_probe.c @@ -30,8 +30,11 @@ bool bongo_cat_windows_input_pointer_locked(BongoCatPlatform *platform) { AcquireSRWLockExclusive(&state->relative_lock); sample.raw_x = (double)state->observed_x; sample.raw_y = (double)state->observed_y; + sample.absolute_x = state->observed_absolute_x; + sample.absolute_y = state->observed_absolute_y; sample.motion_packets = state->observed_motion; state->observed_x = state->observed_y = 0; + state->observed_absolute_x = state->observed_absolute_y = 0; state->observed_motion = 0; if (state->pointer_generation != state->observed_generation) { state->pointer_detection = (WindowsPointerDetection){0}; diff --git a/src/platform/windows/windows_input_relative.c b/src/platform/windows/windows_input_relative.c index b6a63e00..e4ffde7d 100644 --- a/src/platform/windows/windows_input_relative.c +++ b/src/platform/windows/windows_input_relative.c @@ -43,6 +43,8 @@ void bongo_cat_windows_input_motion(WindowsInputState *state, device->generation == state->generation && memcmp(bounds, &device->absolute_bounds, sizeof(*bounds)) == 0; if (baseline && (x != device->absolute_x || y != device->absolute_y)) { + state->observed_absolute_x += x - device->absolute_x; + state->observed_absolute_y += y - device->absolute_y; state->observed_motion++; if (state->relative_active) { state->absolute_x += x - device->absolute_x; @@ -70,6 +72,7 @@ void bongo_cat_windows_input_clear_motion(WindowsInputState *state) { AcquireSRWLockExclusive(&state->relative_lock); clear_motion(state); state->observed_x = state->observed_y = 0; + state->observed_absolute_x = state->observed_absolute_y = 0; state->observed_motion = 0; state->observed_generation++; ReleaseSRWLockExclusive(&state->relative_lock); diff --git a/tests/platform/test_windows_pointer_detection.c b/tests/platform/test_windows_pointer_detection.c index 2e2281dd..25554870 100644 --- a/tests/platform/test_windows_pointer_detection.c +++ b/tests/platform/test_windows_pointer_detection.c @@ -137,15 +137,18 @@ static void test_hidden_and_clipped(void) { sample.motion_packets = 1; for (unsigned t = 272; t <= 304; t += 16) bongo_cat_windows_pointer_detect(&state, &sample, t); - CHECK(state.relative == (scenario != 2)); - if (scenario == 2) continue; + CHECK(state.relative == (scenario == 1)); + for (unsigned t = 320; t <= 384; t += 16) + bongo_cat_windows_pointer_detect(&state, &sample, t); + CHECK(state.relative); CHECK(state.reason == (scenario == 0 ? WINDOWS_POINTER_HIDDEN : - WINDOWS_POINTER_CLIPPED)); + scenario == 1 ? WINDOWS_POINTER_CLIPPED : WINDOWS_POINTER_STALLED)); + if (scenario == 2) continue; sample.cursor_flags = CURSOR_SHOWING; sample.clip = sample.monitor; sample.raw_y = 0; sample.motion_packets = 0; - for (unsigned t = 320; t <= 640; t += 16) + for (unsigned t = 400; t <= 768; t += 16) bongo_cat_windows_pointer_detect(&state, &sample, t); CHECK(!state.relative); } @@ -159,14 +162,14 @@ static void test_focus_suspend_and_missing_position(void) { CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, 0)); sample.raw_x = 8; sample.motion_packets = 1; - for (unsigned t = 16; t <= 64; t += 16) + for (unsigned t = 16; t <= 128; t += 16) bongo_cat_windows_pointer_detect(&state, &sample, t); CHECK(state.relative); if (scenario == 0) sample.foreground = (HWND)(uintptr_t)3; if (scenario == 1) sample.pid++; if (scenario == 2) sample.foreign = false; if (scenario == 3) sample.position_known = false; - ULONGLONG now = scenario == 4 ? 1000 : scenario == 5 ? 1 : 80; + ULONGLONG now = scenario == 4 ? 1000 : scenario == 5 ? 1 : 144; CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, now)); CHECK(!state.relative); } @@ -184,12 +187,12 @@ static void test_transition_flicker(void) { CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, i * 16)); } sample.cursor_flags = 0; - for (unsigned t = 528; t <= 592; t += 16) + for (unsigned t = 528; t <= 768; t += 16) bongo_cat_windows_pointer_detect(&state, &sample, t); CHECK(state.relative); for (unsigned i = 1; i <= 16; ++i) { sample.cursor_flags = i % 2 ? CURSOR_SHOWING : 0; - CHECK(bongo_cat_windows_pointer_detect(&state, &sample, 592 + i * 16)); + CHECK(bongo_cat_windows_pointer_detect(&state, &sample, 768 + i * 16)); } } @@ -223,7 +226,71 @@ static void test_slow_sampling_and_edge_returns(void) { } } +static void test_hidden_desktop_and_resume(void) { + for (unsigned scenario = 0; scenario < 4; ++scenario) { + WindowsPointerDetection state = {0}; + WindowsPointerObservation sample = observation(); + sample.cursor_flags = 0; + if (scenario == 2) { + sample.monitor = sample.clip = (RECT){-1920, -1080, 0, 0}; + sample.position = (POINT){-1920, -500}; + } + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, 0)); + sample.motion_packets = 1; + for (unsigned i = 1; i <= 64; ++i) { + sample.raw_x = 8; + if (scenario == 0) sample.position.x += 8; + if (scenario == 1) sample.position.x += 1; + if (scenario == 3) { + sample.raw_x = i % 2 ? 14 : -14; + sample.position.x = i % 2 ? 974 : 960; + } + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, i * 16)); + } + } + WindowsPointerDetection state = {0}; + WindowsPointerObservation sample = observation(); + sample.cursor_flags = 0; + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, 0)); + sample.raw_x = 12; + sample.motion_packets = 1; + for (unsigned t = 16; t <= 256; t += 16) { + sample.position.x = t % 32 ? 974 : 960; + bongo_cat_windows_pointer_detect(&state, &sample, t); + } + CHECK(state.relative && state.reason == WINDOWS_POINTER_HIDDEN); + /* Normal coordinate travel must release even while the cursor is hidden. */ + for (unsigned t = 272; t <= 640; t += 16) { + sample.position.x += 12; + bongo_cat_windows_pointer_detect(&state, &sample, t); + } + CHECK(!state.relative); +} + +static void test_absolute_observation(void) { + for (unsigned scenario = 0; scenario < 4; ++scenario) { + WindowsPointerDetection state = {0}; + WindowsPointerObservation sample = observation(); + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, 0)); + sample.motion_packets = 1; + for (unsigned i = 1; i <= 32; ++i) { + sample.absolute_x = 8; + if (scenario == 1) sample.position.x += 8; + if (scenario == 2) sample.absolute_x = i % 2 ? 8 : -8; + if (scenario == 3) { + /* Individually subthreshold signals must not be added. */ + sample.absolute_x = 2; + sample.raw_x = 2; + } + bool relative = bongo_cat_windows_pointer_detect(&state, &sample, i * 16); + CHECK(relative == (scenario == 0 && i >= 8)); + } + } +} + void test_windows_pointer_detection(void) { + test_hidden_desktop_and_resume(); + test_absolute_observation(); test_clip_bounds(); test_stalled_and_resume(); test_recenter_and_real_travel(); diff --git a/tests/platform/test_windows_relative.c b/tests/platform/test_windows_relative.c index a2d2c923..da430428 100644 --- a/tests/platform/test_windows_relative.c +++ b/tests/platform/test_windows_relative.c @@ -49,6 +49,8 @@ static void test_absolute_devices(void) { CHECK(bongo_cat_windows_input_take_relative(&platform, &x, &y, NULL)); CHECK(x == 0.0 && y == 0.0); bongo_cat_windows_input_motion(&state, &a, &mouse, &bounds); + CHECK(state.observed_absolute_x == 5359.0); + CHECK(state.observed_absolute_y == 1639.0); CHECK(bongo_cat_windows_input_take_relative(&platform, &x, &y, NULL)); CHECK(x == 5359.0 && y == 1639.0); CHECK(a.absolute_x == 3439.0 && a.absolute_y == 1439.0); @@ -65,6 +67,10 @@ static void test_absolute_devices(void) { mouse.lLastX = -1; bongo_cat_windows_input_motion(&state, &a, &mouse, &bounds); CHECK(!a.absolute_known); + CHECK(state.observed_absolute_x == 5359.0); + CHECK(state.observed_absolute_y == 1639.0); + bongo_cat_windows_input_clear_motion(&state); + CHECK(state.observed_absolute_x == 0.0 && state.observed_absolute_y == 0.0); } static void test_motion_units(void) { From 2f16affceeceae72cbc07adc12977e6be3629354 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 8 Sep 2026 22:36:09 +0800 Subject: [PATCH 255/334] refactor: streamline mouse input and improve game guidance Share display bounds between relative tracking and model output, separate mouse mapping audits, and encapsulate Raw Input observations. Remove the unused mode rule and skip redundant mouse calculations. Add regression coverage for projection bounds and independent motion consumption. Include localized game troubleshooting hints and wrapped preferences section titles. Validation: static checks passed; build and runtime tests were not run at the user's request. --- cmake/RuntimeSources.cmake | 3 + cmake/Tests.cmake | 2 + resources/assets/locales/de-DE.json | 2 +- resources/assets/locales/en-US.json | 2 +- resources/assets/locales/es-ES.json | 2 +- resources/assets/locales/fr-FR.json | 2 +- resources/assets/locales/ja-JP.json | 2 +- resources/assets/locales/ko-KR.json | 2 +- resources/assets/locales/pt-BR.json | 2 +- resources/assets/locales/ru-RU.json | 2 +- resources/assets/locales/zh-CN.json | 2 +- resources/assets/locales/zh-Hant.json | 2 +- src/platform/windows/windows_input.h | 2 - src/platform/windows/windows_input_internal.h | 5 + src/platform/windows/windows_input_probe.c | 50 ++-- src/platform/windows/windows_input_relative.c | 25 +- .../diagnostics/live2d_mouse_mapping_audit.c | 53 +++++ src/runtime/input/mouse.c | 2 +- src/runtime/input/mouse_bounds.c | 112 +++++++++ src/runtime/input/mouse_internal.h | 23 ++ src/runtime/input/mouse_mapping.c | 216 +++--------------- src/runtime/input/mouse_pipeline.c | 67 +++--- src/runtime/runtime.h | 4 - src/ui/preferences/preferences_pages.c | 3 +- src/ui/preferences/preferences_widgets.c | 33 ++- tests/platform/test_windows_input.c | 2 + tests/platform/test_windows_mouse_mapping.c | 90 ++++++++ tests/platform/test_windows_relative.c | 44 ++-- 28 files changed, 470 insertions(+), 286 deletions(-) create mode 100644 src/runtime/diagnostics/live2d_mouse_mapping_audit.c create mode 100644 src/runtime/input/mouse_bounds.c create mode 100644 src/runtime/input/mouse_internal.h create mode 100644 tests/platform/test_windows_mouse_mapping.c diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 2dfe276e..08ba5160 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -1,5 +1,6 @@ set(BONGO_CAT_RUNTIME_INTERNAL_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/src/runtime" + "${CMAKE_CURRENT_SOURCE_DIR}/src/runtime/input" "${CMAKE_CURRENT_SOURCE_DIR}/src/runtime/lifecycle" "${CMAKE_CURRENT_SOURCE_DIR}/src/runtime/model" "${CMAKE_CURRENT_SOURCE_DIR}/src/runtime/model/import" @@ -56,6 +57,7 @@ set(BONGO_CAT_RUNTIME_DIAGNOSTIC_SOURCES src/runtime/diagnostics/frame_audit.c src/runtime/diagnostics/frame_presentation_audit.c src/runtime/diagnostics/live2d_audit.c + src/runtime/diagnostics/live2d_mouse_mapping_audit.c src/runtime/diagnostics/live2d_pointer_audit.c src/runtime/diagnostics/live2d_audit_scenario.c src/runtime/diagnostics/live2d_viewer_audit.c @@ -65,6 +67,7 @@ set(BONGO_CAT_RUNTIME_DIAGNOSTIC_SOURCES set(BONGO_CAT_RUNTIME_INPUT_SOURCES src/runtime/input/gamepad.c src/runtime/input/mouse.c + src/runtime/input/mouse_bounds.c src/runtime/input/mouse_mapping.c src/runtime/input/mouse_pipeline.c src/runtime/input/shortcuts.c) diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index 5f94800a..6a530c16 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -135,6 +135,7 @@ if(BUILD_TESTING) add_executable(bongo_cat_windows_input_tests tests/platform/test_windows_input.c tests/platform/test_windows_relative.c + tests/platform/test_windows_mouse_mapping.c tests/platform/test_windows_pointer_detection.c tests/platform/test_windows_raw_receiver.c) if(MSVC) @@ -142,6 +143,7 @@ if(BUILD_TESTING) set_property(SOURCE tests/platform/test_windows_input.c tests/platform/test_windows_relative.c + tests/platform/test_windows_mouse_mapping.c tests/platform/test_windows_pointer_detection.c tests/platform/test_windows_raw_receiver.c APPEND PROPERTY COMPILE_OPTIONS "/experimental:c11atomics") diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index b38e68b3..824c6297 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -16,7 +16,7 @@ "mouseMirror": "Maus spiegeln", "mouseCentered": "Maus auf Desktop-Begleiter zentriert", "ignoreMouse": "Mausereignisse ignorieren", - "windowSettings": "Fenster", + "windowSettings": "Fenster - Desktop-Tier reagiert im Spiel nicht? Admin-Modus versuchen oder das Spiel im Fenstermodus starten", "passThrough": "Klicks durchlassen", "alwaysOnTop": "Immer im Vordergrund", "keepInScreen": "Auf dem Bildschirm halten", diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index 188642b6..605c2398 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -16,7 +16,7 @@ "mouseMirror": "Mouse Mirror", "mouseCentered": "Mouse Centered on Desktop Pet", "ignoreMouse": "Ignore Mouse Events", - "windowSettings": "Window", + "windowSettings": "Window - Pet not responding in games? Try admin mode or set the game to windowed mode", "passThrough": "Pass Through", "alwaysOnTop": "Always on Top", "keepInScreen": "Keep on Screen", diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index de1fb5be..4bb21d7e 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -16,7 +16,7 @@ "mouseMirror": "Invertir ratón", "mouseCentered": "Ratón centrado en la mascota", "ignoreMouse": "Ignorar eventos del ratón", - "windowSettings": "Ventana", + "windowSettings": "Ventana - ¿La mascota no responde al jugar? Prueba el modo administrador o pon el juego en modo ventana", "passThrough": "Dejar pasar clics", "alwaysOnTop": "Siempre visible", "keepInScreen": "Mantener en pantalla", diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index 3a9b5753..18f74915 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -16,7 +16,7 @@ "mouseMirror": "Miroir de la souris", "mouseCentered": "Souris centrée sur le compagnon", "ignoreMouse": "Ignorer les événements de souris", - "windowSettings": "Fenêtre", + "windowSettings": "Fenêtre - La mascotte ne réagit pas en jeu ? Essayez le mode administrateur ou passez le jeu en mode fenêtré", "passThrough": "Clics traversants", "alwaysOnTop": "Toujours au premier plan", "keepInScreen": "Maintenir à l'écran", diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index 8af22a1c..8c8a9e7c 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -16,7 +16,7 @@ "mouseMirror": "マウスを反転", "mouseCentered": "マウスをデスクトップペット中心にする", "ignoreMouse": "マウスイベントを無視", - "windowSettings": "ウィンドウ", + "windowSettings": "ウィンドウ - ゲーム中にデスクトップペットが反応しない?管理者モードかゲームのウィンドウモードをお試しください", "passThrough": "クリックを透過", "alwaysOnTop": "常に手前に表示", "keepInScreen": "画面内に保持", diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index 11f188a3..21959889 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -16,7 +16,7 @@ "mouseMirror": "마우스 반전", "mouseCentered": "마우스를 데스크톱 펫 중심으로", "ignoreMouse": "마우스 이벤트 무시", - "windowSettings": "창", + "windowSettings": "창 - 게임 중 데스크톱 펫이 반응하지 않나요? 관리자 모드를 시도하거나 게임을 창 모드로 바꿔 보세요", "passThrough": "클릭 통과", "alwaysOnTop": "항상 위", "keepInScreen": "화면 안에 유지", diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index ebd03350..7d33b141 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -16,7 +16,7 @@ "mouseMirror": "Espelho do Mouse", "mouseCentered": "Mouse centrado no mascote de mesa", "ignoreMouse": "Ignorar Eventos do Mouse", - "windowSettings": "Janela", + "windowSettings": "Janela - O mascote não responde ao jogar? Tente o modo administrador ou coloque o jogo em modo janela", "passThrough": "Janela Transparente", "alwaysOnTop": "Sempre no Topo", "keepInScreen": "Manter na Tela", diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index 50e66b87..0023c1af 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -16,7 +16,7 @@ "mouseMirror": "Отражение мыши", "mouseCentered": "Мышь относительно питомца", "ignoreMouse": "Игнорировать события мыши", - "windowSettings": "Окно", + "windowSettings": "Окно - Питомец не реагирует в игре? Попробуйте режим администратора или переведите игру в оконный режим", "passThrough": "Пропускать щелчки", "alwaysOnTop": "Поверх всех окон", "keepInScreen": "Удерживать на экране", diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index bfab4d55..f4b9950e 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -16,7 +16,7 @@ "mouseMirror": "鼠标镜像", "mouseCentered": "鼠标以桌宠为中心", "ignoreMouse": "忽略鼠标事件", - "windowSettings": "窗口", + "windowSettings": "窗口 - 游戏中桌宠没反应?试试管理员模式或将游戏设为窗口模式", "passThrough": "窗口穿透", "alwaysOnTop": "窗口置顶", "keepInScreen": "保持在屏幕内", diff --git a/resources/assets/locales/zh-Hant.json b/resources/assets/locales/zh-Hant.json index 488fcdff..cc42d76a 100644 --- a/resources/assets/locales/zh-Hant.json +++ b/resources/assets/locales/zh-Hant.json @@ -16,7 +16,7 @@ "mouseMirror": "滑鼠游標鏡像", "mouseCentered": "滑鼠以桌寵為中心", "ignoreMouse": "忽略滑鼠事件", - "windowSettings": "視窗", + "windowSettings": "視窗 - 遊戲中桌寵沒反應?試試管理員模式或將遊戲設為視窗模式", "passThrough": "視窗穿透", "alwaysOnTop": "視窗置頂", "keepInScreen": "保持在螢幕內", diff --git a/src/platform/windows/windows_input.h b/src/platform/windows/windows_input.h index 2ca51096..c50ae60a 100644 --- a/src/platform/windows/windows_input.h +++ b/src/platform/windows/windows_input.h @@ -13,8 +13,6 @@ bool bongo_cat_windows_input_take_relative(BongoCatPlatform *platform, void bongo_cat_windows_input_reset_relative(BongoCatPlatform *platform); void bongo_cat_windows_input_release_relative(BongoCatPlatform *platform); bool bongo_cat_windows_input_pointer_locked(BongoCatPlatform *platform); -bool bongo_cat_windows_input_relative_mode(bool foreign_foreground, - const RECT *clip, const CURSORINFO *cursor); #endif #endif diff --git a/src/platform/windows/windows_input_internal.h b/src/platform/windows/windows_input_internal.h index a82b2b51..4969d8d0 100644 --- a/src/platform/windows/windows_input_internal.h +++ b/src/platform/windows/windows_input_internal.h @@ -45,6 +45,8 @@ typedef struct WindowsInputState { WindowsRawHeld keys[BONGO_CAT_WINDOWS_RAW_HELD_LIMIT]; WindowsRawHeld buttons[BONGO_CAT_WINDOWS_MOUSE_BUTTON_COUNT]; bool desktop_unavailable, wake_pending, retry_events; + /* Shared motion buffers are protected by relative_lock. Detection and + model consumption have separate totals; neither drains the other's data. */ bool relative_active, receiving; long long relative_x, relative_y; double absolute_x, absolute_y; @@ -52,6 +54,7 @@ typedef struct WindowsInputState { long long observed_x, observed_y; double observed_absolute_x, observed_absolute_y; unsigned long long observed_motion, observed_generation; + /* Model-thread-only state. The receiver publishes resets through generation. */ WindowsPointerDetection pointer_detection; ULONGLONG pointer_probe_ms; unsigned long long pointer_generation; @@ -75,6 +78,8 @@ void bongo_cat_windows_input_buttons(WindowsInputState *state, void bongo_cat_windows_input_motion(WindowsInputState *state, WindowsRawDevice *device, const RAWMOUSE *mouse, const RECT *bounds); void bongo_cat_windows_input_clear_motion(WindowsInputState *state); +unsigned long long bongo_cat_windows_input_take_observation( + WindowsInputState *state, WindowsPointerObservation *sample); bool bongo_cat_windows_input_register(WindowsInputState *state); void bongo_cat_windows_input_unregister(WindowsInputState *state); unsigned bongo_cat_windows_input_ownership(WindowsInputState *state); diff --git a/src/platform/windows/windows_input_probe.c b/src/platform/windows/windows_input_probe.c index fa5eadf8..b24980f1 100644 --- a/src/platform/windows/windows_input_probe.c +++ b/src/platform/windows/windows_input_probe.c @@ -1,6 +1,23 @@ #include "windows_input_internal.h" #ifdef _WIN32 +static void observe_cursor(WindowsPointerObservation *sample) { + CURSORINFO cursor = {.cbSize = sizeof(cursor)}; + sample->cursor_known = GetCursorInfo(&cursor) != FALSE; + sample->cursor_flags = cursor.flags; + if (sample->cursor_known) { + sample->position = cursor.ptScreenPos; + sample->position_known = true; + } else sample->position_known = GetCursorPos(&sample->position) != FALSE; + sample->clip_known = GetClipCursor(&sample->clip) != FALSE; + if (sample->position_known) { + MONITORINFO monitor = {.cbSize = sizeof(monitor)}; + sample->monitor_known = GetMonitorInfoW(MonitorFromPoint(sample->position, + MONITOR_DEFAULTTONULL), &monitor) != FALSE; + sample->monitor = monitor.rcMonitor; + } +} + bool bongo_cat_windows_input_pointer_locked(BongoCatPlatform *platform) { WindowsInputState *state = platform ? platform->native : NULL; if (!state) return false; @@ -13,34 +30,15 @@ bool bongo_cat_windows_input_pointer_locked(BongoCatPlatform *platform) { sample.foreground = GetForegroundWindow(); if (sample.foreground) GetWindowThreadProcessId(sample.foreground, &sample.pid); sample.foreign = sample.pid && sample.pid != GetCurrentProcessId(); - CURSORINFO cursor = {.cbSize = sizeof(cursor)}; - sample.cursor_known = GetCursorInfo(&cursor) != FALSE; - sample.cursor_flags = cursor.flags; - if (sample.cursor_known) { - sample.position = cursor.ptScreenPos; - sample.position_known = true; - } else sample.position_known = GetCursorPos(&sample.position) != FALSE; - sample.clip_known = GetClipCursor(&sample.clip) != FALSE; - if (sample.position_known) { - MONITORINFO monitor = {.cbSize = sizeof(monitor)}; - sample.monitor_known = GetMonitorInfoW(MonitorFromPoint(sample.position, - MONITOR_DEFAULTTONULL), &monitor) != FALSE; - sample.monitor = monitor.rcMonitor; - } - AcquireSRWLockExclusive(&state->relative_lock); - sample.raw_x = (double)state->observed_x; - sample.raw_y = (double)state->observed_y; - sample.absolute_x = state->observed_absolute_x; - sample.absolute_y = state->observed_absolute_y; - sample.motion_packets = state->observed_motion; - state->observed_x = state->observed_y = 0; - state->observed_absolute_x = state->observed_absolute_y = 0; - state->observed_motion = 0; - if (state->pointer_generation != state->observed_generation) { + /* Our own windows cannot trigger game mode. Still consume observations so + old desktop movement cannot leak into the next foreground application. */ + if (sample.foreign) observe_cursor(&sample); + unsigned long long generation = + bongo_cat_windows_input_take_observation(state, &sample); + if (state->pointer_generation != generation) { state->pointer_detection = (WindowsPointerDetection){0}; - state->pointer_generation = state->observed_generation; + state->pointer_generation = generation; } - ReleaseSRWLockExclusive(&state->relative_lock); return bongo_cat_windows_pointer_detect(&state->pointer_detection, &sample, now_ms); } diff --git a/src/platform/windows/windows_input_relative.c b/src/platform/windows/windows_input_relative.c index e4ffde7d..5d71ed7a 100644 --- a/src/platform/windows/windows_input_relative.c +++ b/src/platform/windows/windows_input_relative.c @@ -3,14 +3,6 @@ #ifdef _WIN32 #include -bool bongo_cat_windows_input_relative_mode(bool foreign_foreground, - const RECT *clip, const CURSORINFO *cursor) { - if (!foreign_foreground) return false; - if (bongo_cat_windows_pointer_clip_locked(clip)) return true; - /* Hidden cursors also cover games that recenter within a larger clip. */ - return cursor && cursor->flags == 0; -} - void bongo_cat_windows_input_motion(WindowsInputState *state, WindowsRawDevice *device, const RAWMOUSE *mouse, const RECT *bounds) { AcquireSRWLockExclusive(&state->relative_lock); @@ -78,6 +70,23 @@ void bongo_cat_windows_input_clear_motion(WindowsInputState *state) { ReleaseSRWLockExclusive(&state->relative_lock); } +unsigned long long bongo_cat_windows_input_take_observation( + WindowsInputState *state, WindowsPointerObservation *sample) { + if (!state || !sample) return 0; + AcquireSRWLockExclusive(&state->relative_lock); + sample->raw_x = (double)state->observed_x; + sample->raw_y = (double)state->observed_y; + sample->absolute_x = state->observed_absolute_x; + sample->absolute_y = state->observed_absolute_y; + sample->motion_packets = state->observed_motion; + unsigned long long generation = state->observed_generation; + state->observed_x = state->observed_y = 0; + state->observed_absolute_x = state->observed_absolute_y = 0; + state->observed_motion = 0; + ReleaseSRWLockExclusive(&state->relative_lock); + return generation; +} + bool bongo_cat_windows_input_take_relative(BongoCatPlatform *platform, double *x, double *y, unsigned long long *sample_count) { if (!x || !y) return false; diff --git a/src/runtime/diagnostics/live2d_mouse_mapping_audit.c b/src/runtime/diagnostics/live2d_mouse_mapping_audit.c new file mode 100644 index 00000000..9bc24b9b --- /dev/null +++ b/src/runtime/diagnostics/live2d_mouse_mapping_audit.c @@ -0,0 +1,53 @@ +#include "runtime.h" +#include "mouse_internal.h" + +#include + +bool bongo_cat_app_audit_screen_pointer(BongoCatApp *app) { + SDL_Rect bounds; + SDL_DisplayID display = SDL_GetPrimaryDisplay(); + if (!app || !display || !SDL_GetDisplayBounds(display, &bounds)) return false; + bool mouse_centered = app->settings.model.mouse_centered; + app->settings.model.mouse_centered = false; + bongo_cat_app_apply_mouse_position(app, bounds.x + bounds.w * 0.5, + bounds.y + bounds.h * 0.5, 1.0f / 60.0f); + for (int frame = 0; frame < 90; ++frame) + bongo_cat_app_step_live2d(app, 1.0f / 60.0f); + BongoCatParameterRange x, y, z; + bool passed = bongo_cat_live2d_parameter(app->live2d, "ParamAngleX", &x) && + bongo_cat_live2d_parameter(app->live2d, "ParamAngleY", &y) && + fabsf(x.value) < 0.5f && fabsf(y.value) < 0.5f; + bool has_z = bongo_cat_live2d_parameter(app->live2d, "ParamAngleZ", &z); + bongo_cat_app_apply_mouse_position(app, bounds.x + bounds.w * 0.8, + bounds.y + bounds.h * 0.2, 1.0f / 60.0f); + for (int frame = 0; frame < 90; ++frame) + bongo_cat_app_step_live2d(app, 1.0f / 60.0f); + passed = (!has_z || (bongo_cat_live2d_parameter(app->live2d, + "ParamAngleZ", &z) && fabsf(z.value) < 0.25f)) && passed; + app->settings.model.mouse_centered = mouse_centered; + return passed; +} + +bool bongo_cat_app_audit_display_pointer(BongoCatApp *app) { + BongoCatMouseProjection projection; + if (!app) return false; + bool centered = app->settings.model.mouse_centered; + app->settings.model.mouse_centered = true; + if (!bongo_cat_app_mouse_projection(app, 0.0, 0.0, &projection) || + !projection.centered) { + app->settings.model.mouse_centered = centered; + return false; + } + BongoCatMverPointerBounds bounds = projection.bounds; + BongoCatParameterRange tl_x = {0}, tl_y = {0}, br_x = {0}, br_y = {0}; + bongo_cat_app_apply_mouse_position(app, bounds.left, bounds.top, 0.0f); + bool passed = bongo_cat_live2d_parameter(app->live2d, "ParamMouseX", &tl_x) && + bongo_cat_live2d_parameter(app->live2d, "ParamMouseY", &tl_y); + bongo_cat_app_apply_mouse_position(app, bounds.left + bounds.width - 1, + bounds.top + bounds.height - 1, 0.0f); + passed = bongo_cat_live2d_parameter(app->live2d, "ParamMouseX", &br_x) && + bongo_cat_live2d_parameter(app->live2d, "ParamMouseY", &br_y) && passed; + app->settings.model.mouse_centered = centered; + return passed && tl_x.value < -20.0f && tl_y.value > 20.0f && + br_x.value > 20.0f && br_y.value < -20.0f; +} diff --git a/src/runtime/input/mouse.c b/src/runtime/input/mouse.c index bf6216e0..4f16ca3e 100644 --- a/src/runtime/input/mouse.c +++ b/src/runtime/input/mouse.c @@ -3,7 +3,7 @@ #include /* Window-facing pointer state: hover hiding and hit-test scheduling. The - sampling/mapping pipeline lives in mouse_pipeline.c and mouse_mapping.c. */ + sampling pipeline, display bounds and model output have their own modules. */ void bongo_cat_app_track_hover(BongoCatApp *app, double x, double y) { app->pointer_known = true; app->pointer_x = x; diff --git a/src/runtime/input/mouse_bounds.c b/src/runtime/input/mouse_bounds.c new file mode 100644 index 00000000..b8cbb226 --- /dev/null +++ b/src/runtime/input/mouse_bounds.c @@ -0,0 +1,112 @@ +#include "runtime.h" +#include "mouse_internal.h" + +#include +#include + +#ifdef _WIN32 +#include +#endif + +static bool mver_pointer_bounds(const BongoCatApp *app, SDL_Rect *bounds) { + const BongoCatLive2DRenderOptions *options = &app->model_render_options; + if (!options->mver_projection || !bounds) return false; + if (options->custom_pointer_bounds) { + *bounds = (SDL_Rect){options->pointer_left, options->pointer_top, + options->pointer_right - options->pointer_left, + options->pointer_bottom - options->pointer_top}; + return bounds->w > 0 && bounds->h > 0; + } +#ifdef _WIN32 + typedef DPI_AWARENESS_CONTEXT (WINAPI *SetThreadDpiAwarenessContextFn)( + DPI_AWARENESS_CONTEXT); + FARPROC set_thread_dpi_proc = GetProcAddress( + GetModuleHandleW(L"user32.dll"), "SetThreadDpiAwarenessContext"); + SetThreadDpiAwarenessContextFn set_thread_dpi = NULL; + memcpy(&set_thread_dpi, &set_thread_dpi_proc, sizeof(set_thread_dpi)); + DPI_AWARENESS_CONTEXT previous = set_thread_dpi + ? set_thread_dpi(DPI_AWARENESS_CONTEXT_SYSTEM_AWARE) : NULL; + RECT desktop = {0}; + bool available = GetWindowRect(GetDesktopWindow(), &desktop) != FALSE; + if (previous && set_thread_dpi) set_thread_dpi(previous); + if (available && desktop.right > 0 && desktop.bottom > 0) { + /* Mver 0.1.6 uses a zero origin and stores right/bottom as dimensions. */ + *bounds = (SDL_Rect){0, 0, desktop.right, desktop.bottom}; + return true; + } +#endif + SDL_DisplayID primary = SDL_GetPrimaryDisplay(); + SDL_Rect display; + if (!primary || !SDL_GetDisplayBounds(primary, &display)) return false; + *bounds = (SDL_Rect){0, 0, display.w, display.h}; + return bounds->w > 0 && bounds->h > 0; +} + +static float clamp_ratio(float value) { + if (value < 0.0f) return 0.0f; + if (value > 1.0f) return 1.0f; + return value; +} + +static bool model_pointer_center(BongoCatApp *app, double *x, double *y) { + int window_x, window_y, width, height; + if (!app || !app->window || !x || !y || + !SDL_GetWindowPosition(app->window, &window_x, &window_y) || + !SDL_GetWindowSize(app->window, &width, &height) || + width <= 0 || height <= 0) return false; + if (!app->model_pointer_anchor_ready) { + BongoCatLive2DVisualState state = {0}; + if (bongo_cat_live2d_visual_state(app->live2d, &state) && state.visible) { + float center_x = (state.visible_min_x + state.visible_max_x) * 0.5f; + float center_y = (state.visible_min_y + state.visible_max_y) * 0.5f; + if (isfinite(center_x) && isfinite(center_y)) { + app->model_pointer_anchor_x = clamp_ratio(center_x * 0.5f + 0.5f); + app->model_pointer_anchor_y = clamp_ratio(0.5f - center_y * 0.5f); + app->model_pointer_anchor_ready = true; + } + } + } + float anchor_x = app->model_pointer_anchor_ready + ? app->model_pointer_anchor_x : 0.5f; + float anchor_y = app->model_pointer_anchor_ready + ? app->model_pointer_anchor_y : 0.5f; + *x = window_x + (double)width * anchor_x; + *y = window_y + (double)height * anchor_y; + return true; +} + +static bool model_pointer_bounds(BongoCatApp *app, SDL_Rect *bounds, + double *center_x, double *center_y) { + double visible_center_x, visible_center_y; + if (!bounds || !model_pointer_center(app, &visible_center_x, + &visible_center_y)) return false; + if (center_x) *center_x = visible_center_x; + if (center_y) *center_y = visible_center_y; + SDL_Point point = {(int)visible_center_x, (int)visible_center_y}; + SDL_DisplayID display = SDL_GetDisplayForPoint(&point); + return display && SDL_GetDisplayBounds(display, bounds) && + bounds->w > 0 && bounds->h > 0; +} + +bool bongo_cat_app_mouse_projection(BongoCatApp *app, double screen_x, + double screen_y, BongoCatMouseProjection *projection) { + if (!app || !projection || !isfinite(screen_x) || !isfinite(screen_y)) + return false; + *projection = (BongoCatMouseProjection){0}; + SDL_Rect bounds = {0}; + bool centered = app->settings.model.mouse_centered; + projection->centered = centered && model_pointer_bounds(app, &bounds, + &projection->center_x, &projection->center_y); + bool mapped = projection->centered || (!centered && + mver_pointer_bounds(app, &bounds)); + if (!mapped) { + SDL_Point point = {(int)screen_x, (int)screen_y}; + SDL_DisplayID display = SDL_GetDisplayForPoint(&point); + if (!display || !SDL_GetDisplayBounds(display, &bounds)) return false; + } + if (bounds.w <= 0 || bounds.h <= 0) return false; + projection->bounds = (BongoCatMverPointerBounds){ + bounds.x, bounds.y, bounds.w, bounds.h + }; + return true; +} diff --git a/src/runtime/input/mouse_internal.h b/src/runtime/input/mouse_internal.h new file mode 100644 index 00000000..5e6499f8 --- /dev/null +++ b/src/runtime/input/mouse_internal.h @@ -0,0 +1,23 @@ +#ifndef BONGO_CAT_MOUSE_INTERNAL_H +#define BONGO_CAT_MOUSE_INTERNAL_H + +#include "bongo_cat/app.h" + +/* One update uses the same coordinate space for motion limits and model output. + Resolve it from current display geometry; do not cache across window moves. */ +typedef struct BongoCatMouseProjection { + BongoCatMverPointerBounds bounds; + double center_x, center_y; + bool centered; +} BongoCatMouseProjection; + +bool bongo_cat_app_mouse_projection(BongoCatApp *app, double screen_x, + double screen_y, BongoCatMouseProjection *projection); +bool bongo_cat_app_map_pointer(BongoCatApp *app, + const BongoCatMouseProjection *projection, bool relative_requested, + double absolute_x, double absolute_y, double *x, double *y, bool *changed); +void bongo_cat_app_apply_mouse_coordinates(BongoCatApp *app, + const BongoCatMouseProjection *projection, double hand_x, double hand_y, + double gaze_x, double gaze_y); + +#endif diff --git a/src/runtime/input/mouse_mapping.c b/src/runtime/input/mouse_mapping.c index 9760e6bc..bfad4db9 100644 --- a/src/runtime/input/mouse_mapping.c +++ b/src/runtime/input/mouse_mapping.c @@ -1,81 +1,14 @@ #include "runtime.h" -#include "bongo_cat/mver_pointer.h" +#include "mouse_internal.h" #include "bongo_cat/overlay.h" -#include #include -#ifdef _WIN32 -#include -#endif - -static void set_parameter(BongoCatApp *app, const char *id, - float x_ratio, float y_ratio, bool horizontal_mirror) { - BongoCatParameterRange range; - if (!bongo_cat_live2d_parameter(app->live2d, id, &range)) return; - size_t length = strlen(id); - char axis = length ? id[length - 1] : 'X'; - float value = bongo_cat_mouse_parameter_value(range.minimum, range.maximum, - x_ratio, y_ratio, axis, horizontal_mirror); - bongo_cat_live2d_set_parameter(app->live2d, id, value); -} - -static bool mver_pointer_bounds(const BongoCatApp *app, SDL_Rect *bounds) { - const BongoCatLive2DRenderOptions *options = &app->model_render_options; - if (!options->mver_projection || !bounds) return false; - if (options->custom_pointer_bounds) { - *bounds = (SDL_Rect){options->pointer_left, options->pointer_top, - options->pointer_right - options->pointer_left, - options->pointer_bottom - options->pointer_top}; - return bounds->w > 0 && bounds->h > 0; - } -#ifdef _WIN32 - typedef DPI_AWARENESS_CONTEXT (WINAPI *SetThreadDpiAwarenessContextFn)( - DPI_AWARENESS_CONTEXT); - FARPROC set_thread_dpi_proc = GetProcAddress( - GetModuleHandleW(L"user32.dll"), "SetThreadDpiAwarenessContext"); - SetThreadDpiAwarenessContextFn set_thread_dpi = NULL; - memcpy(&set_thread_dpi, &set_thread_dpi_proc, sizeof(set_thread_dpi)); - DPI_AWARENESS_CONTEXT previous = set_thread_dpi - ? set_thread_dpi(DPI_AWARENESS_CONTEXT_SYSTEM_AWARE) : NULL; - RECT desktop = {0}; - bool available = GetWindowRect(GetDesktopWindow(), &desktop) != FALSE; - if (previous && set_thread_dpi) set_thread_dpi(previous); - if (available && desktop.right > 0 && desktop.bottom > 0) { - /* Mver 0.1.6 uses a zero origin and stores right/bottom as dimensions. */ - *bounds = (SDL_Rect){0, 0, desktop.right, desktop.bottom}; - return true; - } -#endif - SDL_DisplayID primary = SDL_GetPrimaryDisplay(); - SDL_Rect display; - if (!primary || !SDL_GetDisplayBounds(primary, &display)) return false; - *bounds = (SDL_Rect){0, 0, display.w, display.h}; - return bounds->w > 0 && bounds->h > 0; -} - -static bool tracked_pointer_bounds(BongoCatApp *app, double absolute_x, - double absolute_y, SDL_Rect *bounds) { - if (!app || !bounds) return false; - if (app->model_render_options.mver_projection) - return mver_pointer_bounds(app, bounds); - SDL_Point point = {(int)absolute_x, (int)absolute_y}; - SDL_DisplayID window_display = app->window - ? SDL_GetDisplayForWindow(app->window) : 0; - SDL_DisplayID display = app->settings.model.mouse_centered - ? window_display : SDL_GetDisplayForPoint(&point); - if (!display) display = window_display; - return display && SDL_GetDisplayBounds(display, bounds) && - bounds->w > 0 && bounds->h > 0; -} - -bool bongo_cat_app_map_pointer(BongoCatApp *app, bool relative_requested, +bool bongo_cat_app_map_pointer(BongoCatApp *app, + const BongoCatMouseProjection *projection, bool relative_requested, double absolute_x, double absolute_y, double *x, double *y, bool *changed) { - SDL_Rect bounds; - if (!tracked_pointer_bounds(app, absolute_x, absolute_y, &bounds)) return false; - BongoCatMverPointerBounds pointer_bounds = { - bounds.x, bounds.y, bounds.w, bounds.h - }; + if (!app || !projection || !x || !y || !changed) return false; + const BongoCatMverPointerBounds *bounds = &projection->bounds; double relative_x = 0.0, relative_y = 0.0; bool use_relative = relative_requested && bongo_cat_platform_relative_pointer(&app->platform, @@ -89,82 +22,36 @@ bool bongo_cat_app_map_pointer(BongoCatApp *app, bool relative_requested, } if (!bongo_cat_mver_pointer_update(&app->mver_pointer, absolute_x, absolute_y, relative_x, relative_y, use_relative, - &pointer_bounds, x, y)) return false; + bounds, x, y)) return false; *changed = !initialized || previous_x != *x || previous_y != *y; return true; } -static float clamp_ratio(float value) { - if (value < 0.0f) return 0.0f; - if (value > 1.0f) return 1.0f; - return value; -} - -static bool model_pointer_center(BongoCatApp *app, double *x, double *y) { - int window_x, window_y, width, height; - if (!app || !app->window || !x || !y || - !SDL_GetWindowPosition(app->window, &window_x, &window_y) || - !SDL_GetWindowSize(app->window, &width, &height) || - width <= 0 || height <= 0) return false; - if (!app->model_pointer_anchor_ready) { - BongoCatLive2DVisualState state = {0}; - if (bongo_cat_live2d_visual_state(app->live2d, &state) && state.visible) { - float center_x = (state.visible_min_x + state.visible_max_x) * 0.5f; - float center_y = (state.visible_min_y + state.visible_max_y) * 0.5f; - if (isfinite(center_x) && isfinite(center_y)) { - app->model_pointer_anchor_x = clamp_ratio(center_x * 0.5f + 0.5f); - app->model_pointer_anchor_y = clamp_ratio(0.5f - center_y * 0.5f); - app->model_pointer_anchor_ready = true; - } - } - } - float anchor_x = app->model_pointer_anchor_ready - ? app->model_pointer_anchor_x : 0.5f; - float anchor_y = app->model_pointer_anchor_ready - ? app->model_pointer_anchor_y : 0.5f; - *x = window_x + (double)width * anchor_x; - *y = window_y + (double)height * anchor_y; - return true; -} - -static bool model_pointer_bounds(BongoCatApp *app, SDL_Rect *bounds, - double *center_x, double *center_y) { - double visible_center_x, visible_center_y; - if (!bounds || !model_pointer_center(app, &visible_center_x, - &visible_center_y)) return false; - if (center_x) *center_x = visible_center_x; - if (center_y) *center_y = visible_center_y; - SDL_Point point = {(int)visible_center_x, (int)visible_center_y}; - SDL_DisplayID display = SDL_GetDisplayForPoint(&point); - return display && SDL_GetDisplayBounds(display, bounds) && - bounds->w > 0 && bounds->h > 0; +static void set_parameter(BongoCatApp *app, const char *id, + float x_ratio, float y_ratio, bool horizontal_mirror) { + BongoCatParameterRange range; + if (!bongo_cat_live2d_parameter(app->live2d, id, &range)) return; + size_t length = strlen(id); + char axis = length ? id[length - 1] : 'X'; + float value = bongo_cat_mouse_parameter_value(range.minimum, range.maximum, + x_ratio, y_ratio, axis, horizontal_mirror); + bongo_cat_live2d_set_parameter(app->live2d, id, value); } -void bongo_cat_app_apply_mouse_coordinates(BongoCatApp *app, double hand_x, - double hand_y, double gaze_x, double gaze_y) { - SDL_Point point = {(int)hand_x, (int)hand_y}; SDL_Rect bounds = {0}; - double model_center_x = 0.0, model_center_y = 0.0; - bool screen_mapped = app->settings.model.mouse_centered; - bool centered_bounds = screen_mapped && model_pointer_bounds(app, &bounds, - &model_center_x, &model_center_y); - bool mapped_bounds = centered_bounds || (!screen_mapped && - mver_pointer_bounds(app, &bounds)); - if (!mapped_bounds) { - SDL_DisplayID display = SDL_GetDisplayForPoint(&point); - if (!display || !SDL_GetDisplayBounds(display, &bounds)) return; - } - BongoCatMverPointerBounds pointer_bounds = { - bounds.x, bounds.y, bounds.w, bounds.h - }; +void bongo_cat_app_apply_mouse_coordinates(BongoCatApp *app, + const BongoCatMouseProjection *projection, double hand_x, double hand_y, + double gaze_x, double gaze_y) { + if (!app || !projection) return; + const BongoCatMverPointerBounds *bounds = &projection->bounds; float hand_x_ratio, hand_y_ratio; - if (!bongo_cat_mver_pointer_ratios(hand_x, hand_y, &pointer_bounds, + if (!bongo_cat_mver_pointer_ratios(hand_x, hand_y, bounds, &hand_x_ratio, &hand_y_ratio)) return; float gaze_x_ratio = hand_x_ratio, gaze_y_ratio = hand_y_ratio; - if (centered_bounds) { - gaze_x_ratio = bongo_cat_mouse_centered_ratio(gaze_x, model_center_x, - bounds.x, bounds.x + bounds.w); - gaze_y_ratio = bongo_cat_mouse_centered_ratio(gaze_y, model_center_y, - bounds.y, bounds.y + bounds.h); + if (projection->centered) { + gaze_x_ratio = bongo_cat_mouse_centered_ratio(gaze_x, projection->center_x, + bounds->left, bounds->left + bounds->width); + gaze_y_ratio = bongo_cat_mouse_centered_ratio(gaze_y, projection->center_y, + bounds->top, bounds->top + bounds->height); } bool exact_pointer = bongo_cat_overlay_mver_pointer_enabled(app->overlay); bool overlay_left_handed = exact_pointer && @@ -212,52 +99,7 @@ void bongo_cat_app_apply_mouse_position(BongoCatApp *app, double x, double y, float elapsed_seconds) { if (!app || app->settings.model.ignore_mouse) return; (void)elapsed_seconds; - bongo_cat_app_apply_mouse_coordinates(app, x, y, x, y); -} - -bool bongo_cat_app_audit_screen_pointer(BongoCatApp *app) { - SDL_Rect bounds; - SDL_DisplayID display = SDL_GetPrimaryDisplay(); - if (!app || !display || !SDL_GetDisplayBounds(display, &bounds)) return false; - bool mouse_centered = app->settings.model.mouse_centered; - app->settings.model.mouse_centered = false; - bongo_cat_app_apply_mouse_position(app, bounds.x + bounds.w * 0.5, - bounds.y + bounds.h * 0.5, 1.0f / 60.0f); - for (int frame = 0; frame < 90; ++frame) - bongo_cat_app_step_live2d(app, 1.0f / 60.0f); - BongoCatParameterRange x, y, z; - bool passed = bongo_cat_live2d_parameter(app->live2d, "ParamAngleX", &x) && - bongo_cat_live2d_parameter(app->live2d, "ParamAngleY", &y) && - fabsf(x.value) < 0.5f && fabsf(y.value) < 0.5f; - bool has_z = bongo_cat_live2d_parameter(app->live2d, "ParamAngleZ", &z); - bongo_cat_app_apply_mouse_position(app, bounds.x + bounds.w * 0.8, - bounds.y + bounds.h * 0.2, 1.0f / 60.0f); - for (int frame = 0; frame < 90; ++frame) - bongo_cat_app_step_live2d(app, 1.0f / 60.0f); - passed = (!has_z || (bongo_cat_live2d_parameter(app->live2d, - "ParamAngleZ", &z) && fabsf(z.value) < 0.25f)) && passed; - app->settings.model.mouse_centered = mouse_centered; - return passed; -} - -bool bongo_cat_app_audit_display_pointer(BongoCatApp *app) { - SDL_Rect bounds; - if (!app) return false; - bool centered = app->settings.model.mouse_centered; - app->settings.model.mouse_centered = true; - if (!model_pointer_bounds(app, &bounds, NULL, NULL)) { - app->settings.model.mouse_centered = centered; - return false; - } - BongoCatParameterRange tl_x = {0}, tl_y = {0}, br_x = {0}, br_y = {0}; - bongo_cat_app_apply_mouse_position(app, bounds.x, bounds.y, 0.0f); - bool passed = bongo_cat_live2d_parameter(app->live2d, "ParamMouseX", &tl_x) && - bongo_cat_live2d_parameter(app->live2d, "ParamMouseY", &tl_y); - bongo_cat_app_apply_mouse_position(app, bounds.x + bounds.w - 1, - bounds.y + bounds.h - 1, 0.0f); - passed = bongo_cat_live2d_parameter(app->live2d, "ParamMouseX", &br_x) && - bongo_cat_live2d_parameter(app->live2d, "ParamMouseY", &br_y) && passed; - app->settings.model.mouse_centered = centered; - return passed && tl_x.value < -20.0f && tl_y.value > 20.0f && - br_x.value > 20.0f && br_y.value < -20.0f; + BongoCatMouseProjection projection; + if (bongo_cat_app_mouse_projection(app, x, y, &projection)) + bongo_cat_app_apply_mouse_coordinates(app, &projection, x, y, x, y); } diff --git a/src/runtime/input/mouse_pipeline.c b/src/runtime/input/mouse_pipeline.c index d8e9f896..695b2bda 100644 --- a/src/runtime/input/mouse_pipeline.c +++ b/src/runtime/input/mouse_pipeline.c @@ -1,4 +1,5 @@ #include "runtime.h" +#include "mouse_internal.h" #include @@ -16,6 +17,21 @@ static bool reconcile_button(BongoCatApp *app, bool *current, bool pressed, } #endif +static bool update_pointer_mode(BongoCatApp *app, bool cursor_locked) { + bool relative = !app->settings.model.ignore_mouse && cursor_locked; + bool changed = relative != app->pointer_relative_active || + (relative && cursor_locked != app->pointer_cursor_locked); + if (changed) { + if (relative != app->pointer_relative_active) + app->mver_pointer = (BongoCatMverPointerState){0}; + if (relative) bongo_cat_platform_relative_pointer_reset(&app->platform); + else bongo_cat_platform_relative_pointer_release(&app->platform); + app->pointer_relative_active = relative; + } + app->pointer_cursor_locked = cursor_locked; + return changed; +} + void bongo_cat_app_apply_mouse(BongoCatApp *app) { if (!app) return; double target_x, target_y; @@ -64,7 +80,6 @@ void bongo_cat_app_apply_mouse(BongoCatApp *app) { if (cursor_locked && cursor_lock_changed) { target_x = global_x; target_y = global_y; - native_selected = false; } bool moved = !app->pointer_known || app->pointer_x != target_x || app->pointer_y != target_y; @@ -77,40 +92,30 @@ void bongo_cat_app_apply_mouse(BongoCatApp *app) { bongo_cat_window_capture_pointer_hit(app); } bongo_cat_window_sync_click_through(app); + bool pointer_mode_changed = update_pointer_mode(app, cursor_locked); + app->mouse_button_event_pending = false; + if (app->settings.model.ignore_mouse) return; + bool relative_requested = app->pointer_relative_active; + if (!relative_requested && !moved && !button_changed && + !pointer_mode_changed && !button_event_pending) return; + + /* Resolve once: tracking limits and model output share the same display, + custom workarea and centered anchor for this update. */ + BongoCatMouseProjection projection; + if (!bongo_cat_app_mouse_projection(app, target_x, target_y, &projection)) + return; double model_x = target_x, model_y = target_y; bool model_moved = moved; bool profile_relative = app->model_render_options.mver_projection && app->model_render_options.mouse_force_move; - /* Model metadata alone does not imply that a game locked the cursor. */ - bool relative_requested = !app->settings.model.ignore_mouse && - cursor_locked; - bool pointer_mode_changed = false; - if (relative_requested != app->pointer_relative_active || - (relative_requested && cursor_lock_changed)) { - pointer_mode_changed = true; - if (relative_requested != app->pointer_relative_active) - app->mver_pointer = (BongoCatMverPointerState){0}; - if (relative_requested) - bongo_cat_platform_relative_pointer_reset(&app->platform); - else bongo_cat_platform_relative_pointer_release(&app->platform); - app->pointer_relative_active = relative_requested; + bool map_pointer = cursor_locked || (app->model_render_options.mver_projection && + (!app->settings.model.mouse_centered || profile_relative)); + if (map_pointer && !bongo_cat_app_map_pointer(app, &projection, + relative_requested, target_x, target_y, &model_x, &model_y, + &model_moved)) { + model_x = target_x; model_y = target_y; model_moved = moved; } - app->pointer_cursor_locked = cursor_locked; - bool map_pointer = !app->settings.model.ignore_mouse && (cursor_locked || - (app->model_render_options.mver_projection && - (!app->settings.model.mouse_centered || profile_relative))); - bool map_ok = !map_pointer; - if (map_pointer) { - map_ok = bongo_cat_app_map_pointer(app, relative_requested, target_x, - target_y, &model_x, &model_y, &model_moved); - if (!map_ok) { - model_x = target_x; model_y = target_y; model_moved = moved; - } - } - if (!app->settings.model.ignore_mouse && - (model_moved || button_changed || pointer_mode_changed || button_event_pending)) { - bongo_cat_app_apply_mouse_coordinates(app, model_x, model_y, + if (model_moved || button_changed || pointer_mode_changed || button_event_pending) + bongo_cat_app_apply_mouse_coordinates(app, &projection, model_x, model_y, cursor_locked ? model_x : target_x, cursor_locked ? model_y : target_y); - } - app->mouse_button_event_pending = false; } diff --git a/src/runtime/runtime.h b/src/runtime/runtime.h index 9816b6d9..deaaa9a0 100644 --- a/src/runtime/runtime.h +++ b/src/runtime/runtime.h @@ -87,10 +87,6 @@ void bongo_cat_gamepads_set_enabled(BongoCatApp *app, bool enabled); void bongo_cat_app_reset_gamepad(BongoCatApp *app); void bongo_cat_app_apply_mouse(BongoCatApp *app); void bongo_cat_app_reset_pointer_tracking(BongoCatApp *app); -bool bongo_cat_app_map_pointer(BongoCatApp *app, bool relative_requested, - double absolute_x, double absolute_y, double *x, double *y, bool *changed); -void bongo_cat_app_apply_mouse_coordinates(BongoCatApp *app, double hand_x, - double hand_y, double gaze_x, double gaze_y); void bongo_cat_app_drain_input(BongoCatApp *app, bool allow_shortcuts); void bongo_cat_app_apply_mouse_position(BongoCatApp *app, double x, double y, float elapsed_seconds); diff --git a/src/ui/preferences/preferences_pages.c b/src/ui/preferences/preferences_pages.c index 357ffb64..b99b3084 100644 --- a/src/ui/preferences/preferences_pages.c +++ b/src/ui/preferences/preferences_pages.c @@ -24,7 +24,8 @@ static void page_display(BongoCatApp *app, struct nk_context *context) { BongoCatWindowPreferences *window = &app->settings.window; BongoCatWindowState *window_state = &app->session.window; bongo_cat_pref_section_icon(context, tr(app, - "pages.preference.cat.labels.windowSettings", "Window"), + "pages.preference.cat.labels.windowSettings", + "Window - Pet not responding in games? Try admin mode or set the game to windowed mode"), BONGO_CAT_PREF_ICON_SECTION_WINDOW); bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_PASS_THROUGH); if (bongo_cat_pref_toggle(context, "pass-through", tr(app, diff --git a/src/ui/preferences/preferences_widgets.c b/src/ui/preferences/preferences_widgets.c index 6a8fd90f..c8e4f7ad 100644 --- a/src/ui/preferences/preferences_widgets.c +++ b/src/ui/preferences/preferences_widgets.c @@ -158,6 +158,38 @@ void bongo_cat_pref_section(struct nk_context *context, const char *title) { void bongo_cat_pref_section_icon(struct nk_context *context, const char *title, BongoCatPrefIcon icon) { section_context = context; section_first = true; + const struct nk_user_font *font = bongo_cat_ui_label_font(context); + float width = nk_window_get_content_region(context).w - 42; + float measured = font->width(font->userdata, font->height, + title, nk_strlen(title)); + if (width > 0 && measured > width) { + /* Leave room for word wrapping in longer translations. */ + float height = (float)((int)(measured / width) + 2) * + NK_MAX(22.0f, font->height + 2.0f); + struct nk_vec2 spacing = context->style.window.spacing; + struct nk_vec2 padding = context->style.text.padding; + context->style.window.spacing.x = 0; + context->style.text.padding = nk_vec2(0, 0); + nk_style_push_font(context, font); + nk_layout_row_begin(context, NK_STATIC, height, 2); + nk_layout_row_push(context, 42); + struct nk_rect bounds; + if (nk_widget(&bounds, context) != NK_WIDGET_INVALID) { + BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); + struct nk_command_buffer *canvas = nk_window_get_canvas(context); + nk_fill_rect(canvas, nk_rect(bounds.x, bounds.y + 2, 4, 18), 2, p.pink); + bongo_cat_pref_icon_draw(canvas, + nk_rect(bounds.x + 14, bounds.y + 2, 18, 18), icon, p.accent); + } + nk_layout_row_push(context, width); + nk_text_wrap_colored(context, title, nk_strlen(title), + bongo_cat_ui_palette(bongo_cat_ui_dark(context)).accent); + nk_layout_row_end(context); + nk_style_pop_font(context); + context->style.window.spacing = spacing; + context->style.text.padding = padding; + return; + } struct nk_rect bounds; nk_layout_row_dynamic(context, 22, 1); if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return; @@ -167,7 +199,6 @@ void bongo_cat_pref_section_icon(struct nk_context *context, struct nk_rect icon_bounds = nk_rect(bounds.x + 14, bounds.y + 2, 18, 18); bongo_cat_pref_icon_draw(canvas, icon_bounds, icon, p.accent); - const struct nk_user_font *font = bongo_cat_ui_label_font(context); struct nk_rect text = nk_rect(bounds.x + 42, bounds.y + (bounds.h - font->height) * .5f, bounds.w - 42, font->height); diff --git a/tests/platform/test_windows_input.c b/tests/platform/test_windows_input.c index 5735da0c..6ec700df 100644 --- a/tests/platform/test_windows_input.c +++ b/tests/platform/test_windows_input.c @@ -9,6 +9,7 @@ int bongo_cat_test_failures; void test_windows_relative_sources(void); void test_windows_raw_receiver(void); void test_windows_pointer_detection(void); +void test_windows_mouse_mapping(void); typedef struct RawFixture { WindowsInputState state; @@ -225,6 +226,7 @@ int main(void) { test_motion_keeps_key_and_button_edges(); test_windows_relative_sources(); test_windows_pointer_detection(); + test_windows_mouse_mapping(); test_windows_raw_receiver(); SDL_Quit(); return bongo_cat_test_failures ? 1 : 0; diff --git a/tests/platform/test_windows_mouse_mapping.c b/tests/platform/test_windows_mouse_mapping.c new file mode 100644 index 00000000..ac86628e --- /dev/null +++ b/tests/platform/test_windows_mouse_mapping.c @@ -0,0 +1,90 @@ +#include "test.h" +#include "mouse_internal.h" +#include "windows_input_internal.h" + +#include +#include + +static void expect_position(BongoCatApp *app, const BongoCatMouseProjection *projection, + bool relative, double seed_x, double seed_y, float expected_x, float expected_y, + bool expected_change) { + double x, y; + bool changed; + bool mapped = bongo_cat_app_map_pointer(app, projection, relative, + seed_x, seed_y, &x, &y, &changed); + CHECK(mapped); + if (!mapped) return; + float ratio_x, ratio_y; + bool normalized = bongo_cat_mver_pointer_ratios(x, y, &projection->bounds, + &ratio_x, &ratio_y); + CHECK(normalized); + if (normalized) { + CHECK(fabsf(ratio_x - expected_x) < 0.0001f); + CHECK(fabsf(ratio_y - expected_y) < 0.0001f); + } + CHECK(changed == expected_change); +} + +static void test_display_projection(BongoCatApp *app, + BongoCatMverPointerBounds bounds) { + WindowsInputState input = {0}; + WindowsRawDevice device = {0}; + InitializeSRWLock(&input.relative_lock); + app->platform.native = &input; + app->mver_pointer = (BongoCatMverPointerState){0}; + bongo_cat_windows_input_reset_relative(&app->platform); + BongoCatMouseProjection projection = {.bounds = bounds, .centered = true, + .center_x = bounds.left + bounds.width * 0.5, + .center_y = bounds.top + bounds.height * 0.5}; + /* The pet display differs from the model's authored workarea. Tracking + must use the same per-update projection as model output. */ + app->settings.model.mouse_centered = true; + RAWMOUSE mouse = {.lLastX = (LONG)(bounds.width * 0.1), + .lLastY = (LONG)(bounds.height * 0.1)}; + bongo_cat_windows_input_motion(&input, &device, &mouse, NULL); + expect_position(app, &projection, true, projection.center_x, projection.center_y, + 0.6f, 0.6f, true); + /* An unavailable source cannot replace the virtual point with a cursor + position from another monitor. A delivered packet remains usable. */ + expect_position(app, &projection, true, 9000, 9000, 0.6f, 0.6f, false); + mouse.lLastX = -mouse.lLastX; + mouse.lLastY = -mouse.lLastY; + bongo_cat_windows_input_motion(&input, &device, &mouse, NULL); + expect_position(app, &projection, true, 9000, 9000, 0.5f, 0.5f, true); + expect_position(app, &projection, true, 9000, 9000, 0.5f, 0.5f, false); + mouse.lLastX = mouse.lLastY = 10000; + bongo_cat_windows_input_motion(&input, &device, &mouse, NULL); + expect_position(app, &projection, true, 9000, 9000, 1.0f, 1.0f, true); + mouse.lLastX = (LONG)(-bounds.width * 0.1); + mouse.lLastY = (LONG)(-bounds.height * 0.1); + bongo_cat_windows_input_motion(&input, &device, &mouse, NULL); + expect_position(app, &projection, true, 9000, 9000, 0.9f, 0.9f, true); + expect_position(app, &projection, false, bounds.left + bounds.width * 0.25, + bounds.top + bounds.height * 0.25, 0.25f, 0.25f, true); + app->platform.native = NULL; +} + +void test_windows_mouse_mapping(void) { + BongoCatApp *app = calloc(1, sizeof(*app)); + CHECK(app != NULL); + if (!app) return; + app->model_render_options.mver_projection = true; + app->model_render_options.custom_pointer_bounds = true; + app->model_render_options.pointer_left = 100; + app->model_render_options.pointer_top = 200; + app->model_render_options.pointer_right = 900; + app->model_render_options.pointer_bottom = 800; + BongoCatMouseProjection custom; + bool resolved = bongo_cat_app_mouse_projection(app, 500, 500, &custom); + CHECK(resolved); + if (resolved) { + CHECK(!custom.centered); + CHECK(custom.bounds.left == 100 && custom.bounds.top == 200); + CHECK(custom.bounds.width == 800 && custom.bounds.height == 600); + expect_position(app, &custom, false, 500, 500, 0.5f, 0.5f, true); + } + test_display_projection(app, (BongoCatMverPointerBounds){0, 0, 1920, 1080}); + test_display_projection(app, (BongoCatMverPointerBounds){-1920, -200, 1920, 1080}); + test_display_projection(app, (BongoCatMverPointerBounds){2560, 0, 1080, 1920}); + free(app); +} diff --git a/tests/platform/test_windows_relative.c b/tests/platform/test_windows_relative.c index da430428..7b6fed91 100644 --- a/tests/platform/test_windows_relative.c +++ b/tests/platform/test_windows_relative.c @@ -121,20 +121,34 @@ static void test_delivered_motion_without_ownership(void) { CHECK(!bongo_cat_windows_input_take_relative(&platform, &x, &y, NULL)); } -static void test_relative_modes(void) { - RECT point = {1279, 799, 1279, 799}, screen = {0, 0, 2560, 1600}; - CURSORINFO cursor = {.cbSize = sizeof(cursor), .flags = CURSOR_SHOWING}; - CHECK(bongo_cat_windows_input_relative_mode(true, &point, &cursor)); - CHECK(!bongo_cat_windows_input_relative_mode(false, &point, &cursor)); - CHECK(!bongo_cat_windows_input_relative_mode(true, &screen, &cursor)); - cursor.flags = 0; - CHECK(bongo_cat_windows_input_relative_mode(true, &screen, &cursor)); - CHECK(bongo_cat_windows_input_relative_mode(true, NULL, &cursor)); - CHECK(!bongo_cat_windows_input_relative_mode(false, &screen, &cursor)); - CHECK(!bongo_cat_windows_input_relative_mode(true, &screen, NULL)); - CHECK(!bongo_cat_windows_input_relative_mode(true, NULL, NULL)); - cursor.flags = CURSOR_SUPPRESSED; - CHECK(!bongo_cat_windows_input_relative_mode(true, &screen, &cursor)); +static void test_observation_does_not_consume_motion(void) { + WindowsInputState state = {0}; + WindowsRawDevice relative = {0}, absolute = {0}; + BongoCatPlatform platform = {.native = &state}; + InitializeSRWLock(&state.relative_lock); + bongo_cat_windows_input_reset_relative(&platform); + RAWMOUSE mouse = {.lLastX = 12, .lLastY = -3}; + bongo_cat_windows_input_motion(&state, &relative, &mouse, NULL); + RECT bounds = {0, 0, 1920, 1080}; + mouse = (RAWMOUSE){.usFlags = MOUSE_MOVE_ABSOLUTE}; + bongo_cat_windows_input_motion(&state, &absolute, &mouse, &bounds); + mouse.lLastX = mouse.lLastY = 65535; + bongo_cat_windows_input_motion(&state, &absolute, &mouse, &bounds); + WindowsPointerObservation sample = {.position_known = true, .position = {42, 24}}; + CHECK(bongo_cat_windows_input_take_observation(&state, &sample) == 0); + CHECK(sample.raw_x == 12 && sample.raw_y == -3); + CHECK(sample.absolute_x == 1919 && sample.absolute_y == 1079); + CHECK(sample.motion_packets == 2); + CHECK(sample.position_known && sample.position.x == 42 && sample.position.y == 24); + double x, y; + unsigned long long count; + CHECK(bongo_cat_windows_input_take_relative(&platform, &x, &y, &count)); + CHECK(x == 12 && y == -3 && count == 1); + CHECK(bongo_cat_windows_input_take_observation(&state, &sample) == 0); + CHECK(sample.raw_x == 0 && sample.absolute_x == 0 && sample.motion_packets == 0); + bongo_cat_windows_input_clear_motion(&state); + CHECK(bongo_cat_windows_input_take_observation(&state, &sample) == 1); + CHECK(sample.raw_y == 0 && sample.absolute_y == 0 && sample.motion_packets == 0); } static void test_high_rate_observation(void) { @@ -168,6 +182,6 @@ void test_windows_relative_sources(void) { test_absolute_devices(); test_motion_units(); test_delivered_motion_without_ownership(); - test_relative_modes(); + test_observation_does_not_consume_motion(); test_high_rate_observation(); } From a2002a108daa3aa4f208f6245b2bba4d2196c7a6 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 9 Sep 2026 01:23:57 +0800 Subject: [PATCH 256/334] build: align release size optimization across local and CI builds --- .github/workflows/ci.yml | 6 ++++++ .github/workflows/release.yml | 2 ++ cmake/ReleaseOptimize.cmake | 35 +++++++++++++++++++++-------------- scripts/build-windows.ps1 | 9 +++++++-- 4 files changed, 36 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22fcaa2b..f22bf9ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,8 @@ jobs: run: | cmake -S . -B build-quality -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_OPTIMIZE_RELEASE_SIZE=ON \ + -DBONGO_CAT_OPTIMIZE_RELEASE_IPO=ON \ -DBONGO_CAT_WARNINGS_AS_ERRORS=ON - name: Register GCC diagnostics @@ -134,6 +136,8 @@ jobs: run: | cmake -S . -B build -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_OPTIMIZE_RELEASE_SIZE=ON \ + -DBONGO_CAT_OPTIMIZE_RELEASE_IPO=ON \ -DCMAKE_OSX_ARCHITECTURES=${{ matrix.cmake_arch }} \ -DBONGO_CAT_WARNINGS_AS_ERRORS=ON @@ -232,6 +236,8 @@ jobs: run: | cmake -S . -B build-package -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_OPTIMIZE_RELEASE_SIZE=ON \ + -DBONGO_CAT_OPTIMIZE_RELEASE_IPO=ON \ -DCMAKE_OSX_ARCHITECTURES=${{ matrix.cmake_arch }} \ -DBONGO_CAT_REQUIRE_CUBISM=ON \ -DBONGO_CAT_WARNINGS_AS_ERRORS=ON diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 52f9118d..aef57319 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,6 +79,8 @@ jobs: run: | cmake -S . -B build-release -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_OPTIMIZE_RELEASE_SIZE=ON \ + -DBONGO_CAT_OPTIMIZE_RELEASE_IPO=ON \ -DCMAKE_OSX_ARCHITECTURES=${{ matrix.cmake_arch }} \ -DBONGO_CAT_REQUIRE_CUBISM=ON \ -DBONGO_CAT_WARNINGS_AS_ERRORS=ON diff --git a/cmake/ReleaseOptimize.cmake b/cmake/ReleaseOptimize.cmake index 4e7f95ed..57985643 100644 --- a/cmake/ReleaseOptimize.cmake +++ b/cmake/ReleaseOptimize.cmake @@ -1,5 +1,5 @@ option(BONGO_CAT_OPTIMIZE_RELEASE_SIZE - "Enable conservative size and dead-code optimization for Release builds" ON) + "Enable conservative size and dead-code optimization for Release and MinSizeRel builds" ON) option(BONGO_CAT_OPTIMIZE_RELEASE_IPO "Enable compiler link-time optimization for native project targets" ON) @@ -21,6 +21,8 @@ function(bongo_cat_enable_release_ipo) NOT MINGW AND TARGET "${target}") set_property(TARGET "${target}" PROPERTY INTERPROCEDURAL_OPTIMIZATION_RELEASE TRUE) + set_property(TARGET "${target}" PROPERTY + INTERPROCEDURAL_OPTIMIZATION_MINSIZEREL TRUE) endif() endforeach() endfunction() @@ -32,27 +34,32 @@ endif() # Keep the Windows loader's control-flow enforcement enabled in release # binaries as well as the compiler-side indirect-call instrumentation. if(MSVC OR CMAKE_C_SIMULATE_ID STREQUAL "MSVC") - add_compile_options($<$:/guard:cf>) - add_link_options($<$:/guard:cf>) + add_compile_options($<$:/guard:cf>) + add_link_options($<$:/guard:cf>) endif() # Apply before FetchContent so static dependencies use the same settings. LTO -# stays disabled because not every supported third-party archive is LTO-safe. +# is enabled only for the explicitly selected targets above; prebuilt and +# other third-party archives keep their existing LTO settings. if(BONGO_CAT_OPTIMIZE_RELEASE_SIZE) if(MSVC OR CMAKE_C_SIMULATE_ID STREQUAL "MSVC") - add_compile_options($<$:/O1> - $<$:/Gy> $<$:/Gw>) - add_link_options($<$:/OPT:REF> - $<$:/OPT:ICF>) + # Omit unused inline COMDATs and incremental-link padding. Keep the + # existing runtime, exception handling and floating-point semantics. + add_compile_options($<$:/O1> + $<$:/Gy> $<$:/Gw> + $<$:/Zc:inline>) + add_link_options($<$:/OPT:REF> + $<$:/OPT:ICF> + $<$:/INCREMENTAL:NO>) elseif(CMAKE_C_COMPILER_ID MATCHES "^(GNU|Clang|AppleClang)$") - add_compile_options($<$:-Os> - $<$:-ffunction-sections> - $<$:-fdata-sections>) + add_compile_options($<$:-Os> + $<$:-ffunction-sections> + $<$:-fdata-sections>) if(APPLE) - add_link_options($<$:-Wl,-dead_strip>) + add_link_options($<$:-Wl,-dead_strip>) else() - add_link_options($<$:-Wl,--gc-sections> - $<$:-s>) + add_link_options($<$:-Wl,--gc-sections> + $<$:-s>) endif() endif() endif() diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 index 4c2aa572..59d9ee6a 100644 --- a/scripts/build-windows.ps1 +++ b/scripts/build-windows.ps1 @@ -10,7 +10,10 @@ param( [string[]]$Target = @('bongo_cat'), [switch]$RequireCubism, [switch]$Package, - [switch]$Clean + [switch]$Clean, + # Reapply defaults to existing caches; -SkipConfigure reuses cached values. + [bool]$OptimizeReleaseSize = $true, + [bool]$OptimizeReleaseIpo = $true ) $ErrorActionPreference = 'Continue' @@ -205,7 +208,9 @@ if ($SkipConfigure) { $configureArgs = @( '-S', $root, '-B', $BuildDir, '-G', 'Visual Studio 17 2022', '-A', $Architecture, - '-DBONGO_CAT_WARNINGS_AS_ERRORS=ON' + '-DBONGO_CAT_WARNINGS_AS_ERRORS=ON', + "-DBONGO_CAT_OPTIMIZE_RELEASE_SIZE=$($OptimizeReleaseSize.ToString().ToUpperInvariant())", + "-DBONGO_CAT_OPTIMIZE_RELEASE_IPO=$($OptimizeReleaseIpo.ToString().ToUpperInvariant())" ) if ($RequireCubism) { $configureArgs += '-DBONGO_CAT_REQUIRE_CUBISM=ON' } $configureWriter = New-Object IO.StreamWriter( From c1cbe934409dbbeaa9f46d94dd86a4a3d440b16e Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 9 Sep 2026 09:38:56 +0800 Subject: [PATCH 257/334] fix: show localized game input help in a hover tooltip --- resources/assets/locales/de-DE.json | 4 +++- resources/assets/locales/en-US.json | 4 +++- resources/assets/locales/es-ES.json | 4 +++- resources/assets/locales/fr-FR.json | 4 +++- resources/assets/locales/ja-JP.json | 4 +++- resources/assets/locales/ko-KR.json | 4 +++- resources/assets/locales/pt-BR.json | 4 +++- resources/assets/locales/ru-RU.json | 4 +++- resources/assets/locales/zh-CN.json | 4 +++- resources/assets/locales/zh-Hant.json | 4 +++- src/ui/preferences/preferences_pages.c | 7 ++++++- 11 files changed, 36 insertions(+), 11 deletions(-) diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index 824c6297..05ac5151 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -16,7 +16,7 @@ "mouseMirror": "Maus spiegeln", "mouseCentered": "Maus auf Desktop-Begleiter zentriert", "ignoreMouse": "Mausereignisse ignorieren", - "windowSettings": "Fenster - Desktop-Tier reagiert im Spiel nicht? Admin-Modus versuchen oder das Spiel im Fenstermodus starten", + "windowSettings": "Fenster", "passThrough": "Klicks durchlassen", "alwaysOnTop": "Immer im Vordergrund", "keepInScreen": "Auf dem Bildschirm halten", @@ -29,6 +29,8 @@ "maxFPS": "Maximale Bildrate" }, "hints": { + "gameInput": "Desktop-Tier reagiert im Spiel nicht?", + "gameInputHelp": "Versuche, BongoCat als Administrator auszuführen und das Spiel in den Fenstermodus zu versetzen.", "passThrough": "Wenn aktiviert, gehen Klicks durch das Fenster hindurch, ohne es zu beeinflussen.", "alwaysOnTop": "Wenn aktiviert, bleibt das Fenster über allen anderen Fenstern.", "keepInScreen": "Wenn aktiviert, bleibt das Fenster automatisch innerhalb der Bildschirmgrenzen.", diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index 605c2398..ab6a2303 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -16,7 +16,7 @@ "mouseMirror": "Mouse Mirror", "mouseCentered": "Mouse Centered on Desktop Pet", "ignoreMouse": "Ignore Mouse Events", - "windowSettings": "Window - Pet not responding in games? Try admin mode or set the game to windowed mode", + "windowSettings": "Window", "passThrough": "Pass Through", "alwaysOnTop": "Always on Top", "keepInScreen": "Keep on Screen", @@ -29,6 +29,8 @@ "maxFPS": "Max Frame Rate" }, "hints": { + "gameInput": "Pet not responding in games?", + "gameInputHelp": "Try running BongoCat as administrator and setting the game to windowed mode.", "passThrough": "When enabled, clicks pass through the window without affecting it.", "alwaysOnTop": "When enabled, the window stays above all other windows.", "keepInScreen": "When enabled, the window automatically stays within the screen boundaries.", diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index 4bb21d7e..a404e9ae 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -16,7 +16,7 @@ "mouseMirror": "Invertir ratón", "mouseCentered": "Ratón centrado en la mascota", "ignoreMouse": "Ignorar eventos del ratón", - "windowSettings": "Ventana - ¿La mascota no responde al jugar? Prueba el modo administrador o pon el juego en modo ventana", + "windowSettings": "Ventana", "passThrough": "Dejar pasar clics", "alwaysOnTop": "Siempre visible", "keepInScreen": "Mantener en pantalla", @@ -29,6 +29,8 @@ "maxFPS": "Frecuencia máxima de fotogramas" }, "hints": { + "gameInput": "¿La mascota no responde al jugar?", + "gameInputHelp": "Prueba a ejecutar BongoCat como administrador y a poner el juego en modo ventana.", "passThrough": "Al activarlo, los clics atraviesan la ventana sin afectarla.", "alwaysOnTop": "Al activarlo, la ventana permanece por encima de las demás.", "keepInScreen": "Al activarlo, la ventana se mantiene automáticamente dentro de los límites de la pantalla.", diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index 18f74915..f323b64c 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -16,7 +16,7 @@ "mouseMirror": "Miroir de la souris", "mouseCentered": "Souris centrée sur le compagnon", "ignoreMouse": "Ignorer les événements de souris", - "windowSettings": "Fenêtre - La mascotte ne réagit pas en jeu ? Essayez le mode administrateur ou passez le jeu en mode fenêtré", + "windowSettings": "Fenêtre", "passThrough": "Clics traversants", "alwaysOnTop": "Toujours au premier plan", "keepInScreen": "Maintenir à l'écran", @@ -29,6 +29,8 @@ "maxFPS": "Fréquence d'images maximale" }, "hints": { + "gameInput": "La mascotte ne réagit pas en jeu ?", + "gameInputHelp": "Essayez de lancer BongoCat en tant qu’administrateur et de passer le jeu en mode fenêtré.", "passThrough": "Lorsque cette option est activée, les clics traversent la fenêtre sans l'affecter.", "alwaysOnTop": "Lorsque cette option est activée, la fenêtre reste au-dessus des autres fenêtres.", "keepInScreen": "Lorsque cette option est activée, la fenêtre reste automatiquement dans les limites de l'écran.", diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index 8c8a9e7c..92718e74 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -16,7 +16,7 @@ "mouseMirror": "マウスを反転", "mouseCentered": "マウスをデスクトップペット中心にする", "ignoreMouse": "マウスイベントを無視", - "windowSettings": "ウィンドウ - ゲーム中にデスクトップペットが反応しない?管理者モードかゲームのウィンドウモードをお試しください", + "windowSettings": "ウィンドウ", "passThrough": "クリックを透過", "alwaysOnTop": "常に手前に表示", "keepInScreen": "画面内に保持", @@ -29,6 +29,8 @@ "maxFPS": "最大フレームレート" }, "hints": { + "gameInput": "ゲーム中にデスクトップペットが反応しない?", + "gameInputHelp": "BongoCat を管理者として起動し、ゲームをウィンドウモードに設定してみてください。", "passThrough": "有効にすると、クリックはウィンドウに影響せず背後へ通過します。", "alwaysOnTop": "有効にすると、ウィンドウがほかのすべてのウィンドウより手前に表示されます。", "keepInScreen": "有効にすると、ウィンドウが自動的に画面の範囲内に収まります。", diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index 21959889..ff93093e 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -16,7 +16,7 @@ "mouseMirror": "마우스 반전", "mouseCentered": "마우스를 데스크톱 펫 중심으로", "ignoreMouse": "마우스 이벤트 무시", - "windowSettings": "창 - 게임 중 데스크톱 펫이 반응하지 않나요? 관리자 모드를 시도하거나 게임을 창 모드로 바꿔 보세요", + "windowSettings": "창", "passThrough": "클릭 통과", "alwaysOnTop": "항상 위", "keepInScreen": "화면 안에 유지", @@ -29,6 +29,8 @@ "maxFPS": "최대 프레임 속도" }, "hints": { + "gameInput": "게임 중 데스크톱 펫이 반응하지 않나요?", + "gameInputHelp": "BongoCat을 관리자 권한으로 실행하고 게임을 창 모드로 설정해 보세요.", "passThrough": "활성화하면 클릭이 창에 영향을 주지 않고 뒤로 통과합니다.", "alwaysOnTop": "활성화하면 창이 다른 모든 창 위에 유지됩니다.", "keepInScreen": "활성화하면 창이 자동으로 화면 경계 안에 유지됩니다.", diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index 7d33b141..d11b395f 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -16,7 +16,7 @@ "mouseMirror": "Espelho do Mouse", "mouseCentered": "Mouse centrado no mascote de mesa", "ignoreMouse": "Ignorar Eventos do Mouse", - "windowSettings": "Janela - O mascote não responde ao jogar? Tente o modo administrador ou coloque o jogo em modo janela", + "windowSettings": "Janela", "passThrough": "Janela Transparente", "alwaysOnTop": "Sempre no Topo", "keepInScreen": "Manter na Tela", @@ -29,6 +29,8 @@ "maxFPS": "Taxa Máxima de Quadros" }, "hints": { + "gameInput": "O mascote não responde ao jogar?", + "gameInputHelp": "Tente executar o BongoCat como administrador e colocar o jogo em modo janela.", "passThrough": "Quando ativado, a janela não afetará operações em outros aplicativos.", "alwaysOnTop": "Quando ativado, a janela sempre ficará acima de outros aplicativos.", "keepInScreen": "Quando ativado, a janela permanece automaticamente dentro dos limites da tela.", diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index 0023c1af..edcc38ab 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -16,7 +16,7 @@ "mouseMirror": "Отражение мыши", "mouseCentered": "Мышь относительно питомца", "ignoreMouse": "Игнорировать события мыши", - "windowSettings": "Окно - Питомец не реагирует в игре? Попробуйте режим администратора или переведите игру в оконный режим", + "windowSettings": "Окно", "passThrough": "Пропускать щелчки", "alwaysOnTop": "Поверх всех окон", "keepInScreen": "Удерживать на экране", @@ -29,6 +29,8 @@ "maxFPS": "Максимальная частота кадров" }, "hints": { + "gameInput": "Питомец не реагирует в игре?", + "gameInputHelp": "Попробуйте запустить BongoCat от имени администратора и перевести игру в оконный режим.", "passThrough": "Если включено, щелчки проходят сквозь окно, не воздействуя на него.", "alwaysOnTop": "Если включено, окно остаётся поверх всех остальных окон.", "keepInScreen": "Если включено, окно автоматически остаётся в границах экрана.", diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index f4b9950e..3787093c 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -16,7 +16,7 @@ "mouseMirror": "鼠标镜像", "mouseCentered": "鼠标以桌宠为中心", "ignoreMouse": "忽略鼠标事件", - "windowSettings": "窗口 - 游戏中桌宠没反应?试试管理员模式或将游戏设为窗口模式", + "windowSettings": "窗口", "passThrough": "窗口穿透", "alwaysOnTop": "窗口置顶", "keepInScreen": "保持在屏幕内", @@ -29,6 +29,8 @@ "maxFPS": "最大帧率" }, "hints": { + "gameInput": "游戏中桌宠没反应?", + "gameInputHelp": "试试以管理员身份启动 BongoCat,并将游戏设为窗口模式。", "passThrough": "启用后,窗口不影响对其他应用程序的操作。", "alwaysOnTop": "启用后,窗口始终显示在其他应用程序上方。", "keepInScreen": "启用后,窗口会自动调整位置,防止超出屏幕边界。", diff --git a/resources/assets/locales/zh-Hant.json b/resources/assets/locales/zh-Hant.json index cc42d76a..cac12e8e 100644 --- a/resources/assets/locales/zh-Hant.json +++ b/resources/assets/locales/zh-Hant.json @@ -16,7 +16,7 @@ "mouseMirror": "滑鼠游標鏡像", "mouseCentered": "滑鼠以桌寵為中心", "ignoreMouse": "忽略滑鼠事件", - "windowSettings": "視窗 - 遊戲中桌寵沒反應?試試管理員模式或將遊戲設為視窗模式", + "windowSettings": "視窗", "passThrough": "視窗穿透", "alwaysOnTop": "視窗置頂", "keepInScreen": "保持在螢幕內", @@ -29,6 +29,8 @@ "maxFPS": "最大幀率" }, "hints": { + "gameInput": "遊戲中桌寵沒反應?", + "gameInputHelp": "試試以系統管理員身分啟動 BongoCat,並將遊戲設為視窗模式。", "passThrough": "啟用後,視窗不影響對其他應用程式的操作。", "alwaysOnTop": "啟用後,視窗始終顯示在其他應用程式上方。", "keepInScreen": "啟用後,視窗會自動調整位置,防止超出螢幕邊界。", diff --git a/src/ui/preferences/preferences_pages.c b/src/ui/preferences/preferences_pages.c index b99b3084..834dd182 100644 --- a/src/ui/preferences/preferences_pages.c +++ b/src/ui/preferences/preferences_pages.c @@ -2,6 +2,7 @@ #include "preferences_theme.h" #include "preferences_widgets.h" #include "preferences_notice.h" +#include "ui_tooltip.h" #include "bongo_cat/audio.h" #include "bongo_cat/i18n.h" #include "bongo_cat/preferences.h" @@ -25,8 +26,12 @@ static void page_display(BongoCatApp *app, struct nk_context *context) { BongoCatWindowState *window_state = &app->session.window; bongo_cat_pref_section_icon(context, tr(app, "pages.preference.cat.labels.windowSettings", - "Window - Pet not responding in games? Try admin mode or set the game to windowed mode"), + "Window"), BONGO_CAT_PREF_ICON_SECTION_WINDOW); + bongo_cat_ui_question_tooltip(context, tr(app, + "pages.preference.cat.hints.gameInput", "Pet not responding in games?"), + tr(app, "pages.preference.cat.hints.gameInputHelp", + "Try running BongoCat as administrator and setting the game to windowed mode.")); bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_PASS_THROUGH); if (bongo_cat_pref_toggle(context, "pass-through", tr(app, "composables.useAppMenu.labels.passThrough", "Pass Through"), "", From 18269fc3e9af3144527e8d6aee9e5991ee522d94 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 9 Sep 2026 10:36:11 +0800 Subject: [PATCH 258/334] fix: simplify NSIS legacy upgrade shutdown --- cmake/Packaging.cmake | 43 +++++-------------------------------------- 1 file changed, 5 insertions(+), 38 deletions(-) diff --git a/cmake/Packaging.cmake b/cmake/Packaging.cmake index 0a255ca9..3941761c 100644 --- a/cmake/Packaging.cmake +++ b/cmake/Packaging.cmake @@ -188,7 +188,7 @@ bongo_cat_user_data_done: string(REPLACE [=[@CPACK_NSIS_EXTRA_PREINSTALL_COMMANDS@]=] [=[ StrCmp "$BONGO_CAT_UPGRADE_DIR" "" bongo_cat_upgrade_ready StrCmp "$BONGO_CAT_UPDATE_SHUTDOWN" "1" \ - bongo_cat_signal_update_shutdown bongo_cat_find_legacy_tray + bongo_cat_signal_update_shutdown bongo_cat_restart_manager_shutdown bongo_cat_signal_update_shutdown: ClearErrors @@ -197,44 +197,11 @@ bongo_cat_signal_update_shutdown: StrCmp "$3" "0" bongo_cat_uninstall_old \ bongo_cat_restart_manager_shutdown - ; Versions before update shutdown support interpret WM_CLOSE as "hide to - ; tray". Find a legacy SDL tray window owned by the exact installed - ; executable and trigger its Exit entry so state is flushed normally. -bongo_cat_find_legacy_tray: - StrCpy $0 0 - System::Call 'user32::FindWindowExW(p -3, p r0, w "Message", p 0) p .r0' - StrCmp "$0" "0" bongo_cat_restart_manager_shutdown - System::Call 'user32::GetWindowThreadProcessId(p r0, *i .r6)' - System::Call 'kernel32::OpenProcess(i 0x00101000, i 0, i r6) p .r8' - StrCmp "$8" "0" bongo_cat_find_legacy_tray - StrCpy $5 1024 - System::Call 'kernel32::QueryFullProcessImageNameW(p r8, i 0, \ - w .r7, *i r5) i .r9' - StrCmp "$9" "0" bongo_cat_close_unmatched_process - StrCmp "$7" "$BONGO_CAT_UPGRADE_DIR\BongoCat.exe" 0 \ - bongo_cat_close_unmatched_process - ; Each legacy tray creation consumes eight SDL command identifiers. Try - ; the Exit slot for several shell-restoration generations. - StrCpy $3 8 -bongo_cat_signal_legacy_tray_exit: - System::Call 'user32::PostMessageW(p r0, i 0x0111, p r3, p 0)' - IntOp $3 $3 + 8 - IntCmp $3 136 bongo_cat_wait_for_legacy_shutdown \ - bongo_cat_signal_legacy_tray_exit bongo_cat_wait_for_legacy_shutdown - -bongo_cat_wait_for_legacy_shutdown: - System::Call 'kernel32::WaitForSingleObject(p r8, i 10000) i .r9' - System::Call 'kernel32::CloseHandle(p r8)' - StrCmp "$9" "0" bongo_cat_uninstall_old \ - bongo_cat_restart_manager_shutdown - -bongo_cat_close_unmatched_process: - System::Call 'kernel32::CloseHandle(p r8)' - Goto bongo_cat_find_legacy_tray - bongo_cat_restart_manager_shutdown: - ; Tray-disabled legacy versions have no graceful remote-exit API. Windows - ; Restart Manager shuts down only processes using this exact executable. + ; Older versions lack the update shutdown command. Use Windows Restart + ; Manager for this exact executable instead of probing tray windows and + ; sending guessed menu commands to legacy processes. If shutdown fails, + ; leave the installation intact and ask the user to close the application. !insertmacro RestartManager_ShutdownFile \ "$BONGO_CAT_UPGRADE_DIR\BongoCat.exe" $9 StrCmp "$9" "0" bongo_cat_uninstall_old bongo_cat_upgrade_failed From 2ff7927d0a40f12d295bdd0476a077f54677b95e Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 9 Sep 2026 10:55:28 +0800 Subject: [PATCH 259/334] Add Linux AppImage release packaging --- .github/scripts/check-appimage.sh | 32 +++++++++++++++++++++++ .github/workflows/release.yml | 17 +++++++++++- CMakeLists.txt | 2 +- cmake/Packaging.cmake | 1 + cmake/PackagingAppImage.cmake | 9 +++++++ packaging/linux/bongocat.desktop | 8 ++++++ packaging/linux/build-appimage.sh | 43 +++++++++++++++++++++++++++++++ 7 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/check-appimage.sh create mode 100644 cmake/PackagingAppImage.cmake create mode 100644 packaging/linux/bongocat.desktop create mode 100644 packaging/linux/build-appimage.sh diff --git a/.github/scripts/check-appimage.sh b/.github/scripts/check-appimage.sh new file mode 100644 index 00000000..b7bfcc1a --- /dev/null +++ b/.github/scripts/check-appimage.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +appimage=$(realpath "${1:?Usage: check-appimage.sh APPIMAGE}") +test_runner=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/test-unix.sh +work=$(mktemp -d) +trap 'rm -rf -- "$work"' EXIT +cd "$work" +chmod +x "$appimage" +"$appimage" --appimage-extract > /dev/null +root="$work/squashfs-root" +for file in AppRun bongocat.desktop bongocat.png usr/bin/BongoCat \ + usr/bin/assets/bongocat.png usr/bin/assets/locales/en-US.json \ + usr/bin/assets/models/standard/cat.model3.json \ + usr/bin/assets/models/standard/demomodel.moc3 \ + usr/bin/assets/models/standard/demomodel.1024/texture_00.png \ + usr/bin/assets/FrameworkShaders/VertShaderSrc.vert \ + usr/bin/assets/FrameworkShaders/FragShaderSrc.frag \ + usr/bin/assets/FrameworkShaders/VertShaderSrcBlend.vert \ + usr/bin/assets/FrameworkShaders/FragShaderSrcBlend.frag; do + test -s "$root/$file" || { echo "Missing AppImage resource: $file" >&2; exit 1; } +done +# Exercise the actual AppImage entry point without requiring a FUSE mount. +bash "$test_runner" env APPIMAGE_EXTRACT_AND_RUN=1 \ + BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN=1 "$appimage" \ + --ci-smoke --ci-ignore-global-input \ + --ci-live2d-scenario=visual-consistency "--storage-root=$work/smoke-data" +mapfile -d '' audits < <(find "$work/smoke-data" -name live2d-audit.txt -print0) +[[ ${#audits[@]} == 1 ]] +grep -qx 'renderer=cubism-native' "${audits[0]}" +grep -qx 'assertions=passed' "${audits[0]}" +echo 'AppImage layout and native Live2D smoke test passed.' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aef57319..967aacc9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,7 +31,7 @@ jobs: - os: windows-2022 platform: windows-x86 cmake_arch: Win32 - - os: ubuntu-latest + - os: ubuntu-22.04 platform: linux-x64 cmake_arch: '' - os: macos-15-intel @@ -73,6 +73,7 @@ jobs: sudo apt-get install -y --no-install-recommends \ cmake ninja-build libgl1-mesa-dev libx11-dev libxi-dev \ libxfixes-dev libcurl4-openssl-dev xvfb xauth libgl1-mesa-dri + curl file desktop-file-utils - name: Configure Unix release if: runner.os != 'Windows' @@ -246,6 +247,16 @@ jobs: Copy-Item -LiteralPath $archive.FullName, $checksum ` -Destination release-output + if ('${{ matrix.platform }}' -eq 'linux-x64') { + cmake --build build-release --target package-appimage + if ($LASTEXITCODE -ne 0) { throw 'AppImage packaging failed' } + $appimage = Get-Item build-release/dist/*.AppImage + bash ./.github/scripts/check-appimage.sh $appimage.FullName + $appimageHash = (Get-FileHash $appimage -Algorithm SHA256).Hash.ToLowerInvariant() + "$appimageHash $($appimage.Name)" | Set-Content "$($appimage.FullName).sha256" -Encoding ascii + Copy-Item -LiteralPath $appimage.FullName, "$($appimage.FullName).sha256" -Destination release-output + } + - name: Upload packaged release uses: actions/upload-artifact@v7 with: @@ -357,6 +368,8 @@ jobs: $files += $archive $files += "$archive.sha256" } + $files += "release-output/BongoCat-$version-linux-x64.AppImage" + $files += "release-output/BongoCat-$version-linux-x64.AppImage.sha256" foreach ($file in $files) { if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { throw "Release is incomplete: $file" @@ -402,6 +415,7 @@ jobs: - [macOS Apple Silicon](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/BongoCat-${{ steps.version.outputs.version }}-macos-arm64.zip) - [macOS Intel](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/BongoCat-${{ steps.version.outputs.version }}-macos-x64.zip) - [Linux x64](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/BongoCat-${{ steps.version.outputs.version }}-linux-x64.tar.gz) + - [Linux x64 AppImage](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/BongoCat-${{ steps.version.outputs.version }}-linux-x64.AppImage) generate_release_notes: true fail_on_unmatched_files: true @@ -411,3 +425,4 @@ jobs: release-output/*-setup.exe release-output/*.zip release-output/*.tar.gz + release-output/*.AppImage diff --git a/CMakeLists.txt b/CMakeLists.txt index d7e3872a..eac66096 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -set(BONGO_CAT_VERSION "1.6.0") +set(BONGO_CAT_VERSION "1.7.0") project(BongoCat VERSION ${BONGO_CAT_VERSION} DESCRIPTION "BongoCat" HOMEPAGE_URL "https://github.com/vladelaina/BongoCat" LANGUAGES C CXX) include(cmake/Version.cmake) include(FetchContent) diff --git a/cmake/Packaging.cmake b/cmake/Packaging.cmake index 3941761c..9514a9d6 100644 --- a/cmake/Packaging.cmake +++ b/cmake/Packaging.cmake @@ -227,6 +227,7 @@ bongo_cat_upgrade_ready: endif() include(CPack) +include(cmake/PackagingAppImage.cmake) if(WIN32) add_custom_target(package-installer diff --git a/cmake/PackagingAppImage.cmake b/cmake/PackagingAppImage.cmake new file mode 100644 index 00000000..abb30670 --- /dev/null +++ b/cmake/PackagingAppImage.cmake @@ -0,0 +1,9 @@ +if(CMAKE_SYSTEM_NAME STREQUAL "Linux" AND + BONGO_CAT_PACKAGE_PLATFORM STREQUAL "linux-x64") + add_custom_target(package-appimage + COMMAND bash "${CMAKE_SOURCE_DIR}/packaging/linux/build-appimage.sh" + "${CMAKE_BINARY_DIR}" + DEPENDS bongo_cat + COMMENT "Building the BongoCat Linux AppImage" + VERBATIM) +endif() diff --git a/packaging/linux/bongocat.desktop b/packaging/linux/bongocat.desktop new file mode 100644 index 00000000..ea8969ec --- /dev/null +++ b/packaging/linux/bongocat.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Application +Name=BongoCat +Comment=An animated desktop cat that reacts to your input +Exec=BongoCat +Icon=bongocat +Terminal=false +Categories=Utility; diff --git a/packaging/linux/build-appimage.sh b/packaging/linux/build-appimage.sh new file mode 100644 index 00000000..9fa163ec --- /dev/null +++ b/packaging/linux/build-appimage.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $(uname -s) != Linux || $(uname -m) != x86_64 ]]; then + echo 'AppImage packaging requires Linux x86_64.' >&2 + exit 1 +fi +build_dir=$(cd "${1:?Usage: build-appimage.sh BUILD_DIRECTORY}" && pwd) +source_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +name=$(tr -d '\r\n' < "$build_dir/bongocat-package-name.txt") +[[ $name =~ ^BongoCat(-Diagnostic)?-[0-9][A-Za-z0-9.+-]*-linux-x64$ ]] || { + echo "Unexpected package name: $name" >&2 + exit 1 +} +work=$(mktemp -d "$build_dir/appimage.XXXXXXXX") +trap 'rm -rf -- "$work"' EXIT +mkdir -p "$build_dir/dist" + +# A private staging tree keeps repeated builds free of stale dependencies. +# Keep assets next to the executable, as expected by the runtime. +appdir="$work/BongoCat.AppDir" +cmake --install "$build_dir" --component Runtime --prefix "$appdir/usr/bin" + +tool="$work/linuxdeploy.AppImage" +curl --fail --location --retry 3 --output "$tool" \ + https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20250213-2/linuxdeploy-x86_64.AppImage +chmod +x "$tool" +export ARCH=x86_64 +export VERSION="${name#BongoCat-}" +export OUTPUT="$build_dir/dist/$name.AppImage" +# Build runners do not need FUSE, including subprocess AppImage tools. +export APPIMAGE_EXTRACT_AND_RUN=1 +pushd "$build_dir" >/dev/null +"$tool" --appdir "$appdir" \ + --executable "$appdir/usr/bin/BongoCat" \ + --desktop-file "$source_dir/packaging/linux/bongocat.desktop" \ + --icon-file "$source_dir/resources/assets/bongocat.png" \ + --output appimage +popd >/dev/null +test -s "$OUTPUT" +chmod +x "$OUTPUT" +(cd "$build_dir/dist" && sha256sum "$name.AppImage" > "$name.AppImage.sha256") +echo "AppImage ready: $OUTPUT" From 6b12b160533424431dde1ab60697defcf90fcdce Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 9 Sep 2026 15:17:39 +0800 Subject: [PATCH 260/334] =?UTF-8?q?=F0=9F=8C=9F=20chore:=20update=20projec?= =?UTF-8?q?t=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 967aacc9..405d2189 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -72,7 +72,7 @@ jobs: sudo apt-get update sudo apt-get install -y --no-install-recommends \ cmake ninja-build libgl1-mesa-dev libx11-dev libxi-dev \ - libxfixes-dev libcurl4-openssl-dev xvfb xauth libgl1-mesa-dri + libxfixes-dev libcurl4-openssl-dev xvfb xauth libgl1-mesa-dri \ curl file desktop-file-utils - name: Configure Unix release From ab5f12e0fc4a14b0940c40f8d98d02654521ab2f Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 9 Sep 2026 15:32:47 +0800 Subject: [PATCH 261/334] fix: disable MIT-SHM in headless Linux tests --- .github/scripts/test-unix.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/scripts/test-unix.sh b/.github/scripts/test-unix.sh index 75d3452e..4486713b 100644 --- a/.github/scripts/test-unix.sh +++ b/.github/scripts/test-unix.sh @@ -6,7 +6,9 @@ if [[ "$(uname -s)" == "Linux" ]]; then # SDL window lifecycle tests need an X server and an OpenGL context. export SDL_VIDEODRIVER=x11 export LIBGL_ALWAYS_SOFTWARE=1 - exec xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" "$@" + # Avoid X_ShmPutImage BadMatch failures in the headless SDL surface path. + exec xvfb-run --auto-servernum \ + --server-args="-screen 0 1280x1024x24 -extension MIT-SHM" "$@" fi exec "$@" From e701e094baaad2b61ffb9cfe8885d50c92ae7570 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 9 Sep 2026 16:26:12 +0800 Subject: [PATCH 262/334] fix: share pinned NSIS installation from official Chocolatey source --- .github/scripts/install-nsis.ps1 | 38 ++++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 29 ++++-------------------- .github/workflows/release.yml | 30 ++++--------------------- 3 files changed, 46 insertions(+), 51 deletions(-) create mode 100644 .github/scripts/install-nsis.ps1 diff --git a/.github/scripts/install-nsis.ps1 b/.github/scripts/install-nsis.ps1 new file mode 100644 index 00000000..5fc47d48 --- /dev/null +++ b/.github/scripts/install-nsis.ps1 @@ -0,0 +1,38 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$PackageVersion, + [Parameter(Mandatory = $true)][string]$CompilerVersion +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# Install the concrete package from the public feed, even on preloaded runners. +choco upgrade nsis.install --version $PackageVersion ` + --source https://community.chocolatey.org/api/v2/ ` + --force --allow-downgrade --fail-on-unfound --no-progress --yes +if ($LASTEXITCODE -ne 0) { + throw "Pinned NSIS installation failed (exit $LASTEXITCODE)" +} + +$candidates = @( + (Join-Path ${env:ProgramFiles(x86)} 'NSIS/makensis.exe'), + (Join-Path $env:ProgramFiles 'NSIS/makensis.exe') +) +$command = Get-Command makensis.exe -ErrorAction SilentlyContinue +if ($command) { $candidates += $command.Source } +$found = @() +foreach ($candidate in ($candidates | Select-Object -Unique)) { + if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue } + $version = (& $candidate /VERSION | Out-String).Trim() + $status = $LASTEXITCODE + $found += "$candidate ($version, exit $status)" + if ($status -eq 0 -and $version -eq $CompilerVersion) { + $root = Split-Path $candidate + $env:PATH = "$root;$env:PATH" + $root | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 + Write-Host "NSIS compiler: $candidate ($version)" + return + } +} +throw "NSIS $CompilerVersion was not installed. Candidates: $($found -join '; ')" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f22bf9ee..17f402f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -352,31 +352,10 @@ jobs: - name: Install NSIS shell: pwsh - run: | - choco upgrade nsis --version $env:BONGO_CAT_NSIS_PACKAGE_VERSION ` - --allow-downgrade --no-progress --yes - if ($LASTEXITCODE -ne 0) { - throw 'Pinned NSIS installation failed' - } - $nsisCommand = Get-Command makensis.exe -ErrorAction SilentlyContinue - $nsisRoots = @( - (Join-Path ${env:ProgramFiles(x86)} 'NSIS'), - (Join-Path $env:ProgramFiles 'NSIS') - ) - if ($nsisCommand) { $nsisRoots += Split-Path $nsisCommand.Source } - $nsisRoot = $nsisRoots | Where-Object { - Test-Path -LiteralPath (Join-Path $_ 'makensis.exe') - } | Select-Object -First 1 - if (-not $nsisRoot) { - throw 'NSIS was not installed (makensis.exe was not found)' - } - $makensis = Join-Path $nsisRoot 'makensis.exe' - $nsisVersion = (& $makensis /VERSION).Trim() - if ($nsisVersion -ne $env:BONGO_CAT_NSIS_COMPILER_VERSION) { - throw "Unexpected NSIS compiler version: $nsisVersion" - } - Write-Host "NSIS compiler: $makensis ($nsisVersion)" - $nsisRoot | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 + run: >- + & ./.github/scripts/install-nsis.ps1 + -PackageVersion $env:BONGO_CAT_NSIS_PACKAGE_VERSION + -CompilerVersion $env:BONGO_CAT_NSIS_COMPILER_VERSION - name: Package Windows desktop release id: desktop-package diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 405d2189..b171aa49 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -123,32 +123,10 @@ jobs: - name: Install NSIS if: runner.os == 'Windows' shell: pwsh - run: | - choco upgrade nsis --version $env:BONGO_CAT_NSIS_PACKAGE_VERSION ` - --allow-downgrade --no-progress --yes - if ($LASTEXITCODE -ne 0) { - throw 'Pinned NSIS installation failed' - } - $nsisCommand = Get-Command makensis.exe -ErrorAction SilentlyContinue - $nsisRoots = @( - (Join-Path ${env:ProgramFiles(x86)} 'NSIS'), - (Join-Path $env:ProgramFiles 'NSIS') - ) - if ($nsisCommand) { $nsisRoots += Split-Path $nsisCommand.Source } - $nsisRoot = $nsisRoots | Where-Object { - Test-Path -LiteralPath (Join-Path $_ 'makensis.exe') - } | Select-Object -First 1 - if (-not $nsisRoot) { - throw 'NSIS was not installed (makensis.exe was not found)' - } - $makensis = Join-Path $nsisRoot 'makensis.exe' - $nsisVersion = (& $makensis /VERSION).Trim() - if ($nsisVersion -ne $env:BONGO_CAT_NSIS_COMPILER_VERSION) { - throw "Unexpected NSIS compiler version: $nsisVersion" - } - Write-Host "NSIS compiler: $makensis ($nsisVersion)" - $nsisRoot | Out-File -FilePath $env:GITHUB_PATH ` - -Append -Encoding utf8 + run: >- + & ./.github/scripts/install-nsis.ps1 + -PackageVersion $env:BONGO_CAT_NSIS_PACKAGE_VERSION + -CompilerVersion $env:BONGO_CAT_NSIS_COMPILER_VERSION - name: Package Windows release if: runner.os == 'Windows' From 6922e9aac989b763c30d5054a168493c4a87ce73 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 9 Sep 2026 18:55:54 +0800 Subject: [PATCH 263/334] fix: use EGL for headless Linux tests --- .github/scripts/test-unix.sh | 7 ++++--- .github/workflows/ci.yml | 6 +++--- .github/workflows/release.yml | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/scripts/test-unix.sh b/.github/scripts/test-unix.sh index 4486713b..469337e4 100644 --- a/.github/scripts/test-unix.sh +++ b/.github/scripts/test-unix.sh @@ -6,9 +6,10 @@ if [[ "$(uname -s)" == "Linux" ]]; then # SDL window lifecycle tests need an X server and an OpenGL context. export SDL_VIDEODRIVER=x11 export LIBGL_ALWAYS_SOFTWARE=1 - # Avoid X_ShmPutImage BadMatch failures in the headless SDL surface path. - exec xvfb-run --auto-servernum \ - --server-args="-screen 0 1280x1024x24 -extension MIT-SHM" "$@" + # Mesa GLX fails to present transparent windows under Xvfb (BadMatch). + # EGL still exercises desktop OpenGL, including shared preferences contexts. + export SDL_VIDEO_FORCE_EGL=1 + exec xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" "$@" fi exec "$@" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17f402f8..bc5c3e3d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: sudo apt-get update sudo apt-get install -y --no-install-recommends \ cmake ninja-build cppcheck xvfb xauth libgl1-mesa-dri \ - libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev \ + libgl1-mesa-dev libegl1-mesa-dev libx11-dev libxi-dev libxfixes-dev \ libcurl4-openssl-dev - name: Reject legacy product-name references @@ -128,7 +128,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - cmake ninja-build libgl1-mesa-dev libx11-dev libxi-dev \ + cmake ninja-build libgl1-mesa-dev libegl1-mesa-dev libx11-dev libxi-dev \ libxfixes-dev libcurl4-openssl-dev xvfb xauth libgl1-mesa-dri - name: Configure Unix release @@ -229,7 +229,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - cmake ninja-build libgl1-mesa-dev libx11-dev libxi-dev \ + cmake ninja-build libgl1-mesa-dev libegl1-mesa-dev libx11-dev libxi-dev \ libxfixes-dev libcurl4-openssl-dev xvfb xauth libgl1-mesa-dri - name: Configure release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b171aa49..98f9369c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,7 +71,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - cmake ninja-build libgl1-mesa-dev libx11-dev libxi-dev \ + cmake ninja-build libgl1-mesa-dev libegl1-mesa-dev libx11-dev libxi-dev \ libxfixes-dev libcurl4-openssl-dev xvfb xauth libgl1-mesa-dri \ curl file desktop-file-utils From 169df7dffbf41952f63deea49e2e1f06ecc40070 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 9 Sep 2026 19:04:48 +0800 Subject: [PATCH 264/334] fix: keep GLX for headless GLEW tests --- .github/scripts/test-unix.sh | 5 ++--- src/ui/preferences/preferences_scale.c | 2 ++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/scripts/test-unix.sh b/.github/scripts/test-unix.sh index 469337e4..3624d8ae 100644 --- a/.github/scripts/test-unix.sh +++ b/.github/scripts/test-unix.sh @@ -6,9 +6,8 @@ if [[ "$(uname -s)" == "Linux" ]]; then # SDL window lifecycle tests need an X server and an OpenGL context. export SDL_VIDEODRIVER=x11 export LIBGL_ALWAYS_SOFTWARE=1 - # Mesa GLX fails to present transparent windows under Xvfb (BadMatch). - # EGL still exercises desktop OpenGL, including shared preferences contexts. - export SDL_VIDEO_FORCE_EGL=1 + # Keep GLX enabled: the Cubism/GLEW path requires GLX initialization. + export BONGO_CAT_TEST_DISABLE_PREFERENCES_TRANSPARENCY=1 exec xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" "$@" fi diff --git a/src/ui/preferences/preferences_scale.c b/src/ui/preferences/preferences_scale.c index 68ef5279..0c5ee396 100644 --- a/src/ui/preferences/preferences_scale.c +++ b/src/ui/preferences/preferences_scale.c @@ -94,6 +94,8 @@ static void discard_window(BongoCatPreferences *value) { static bool create_window(BongoCatPreferences *value, int width, int height, SDL_WindowFlags flags, bool transparent) { + if (SDL_getenv("BONGO_CAT_TEST_DISABLE_PREFERENCES_TRANSPARENCY")) + transparent = false; const char *title = bongo_cat_i18n_get(value->app->i18n, "native.preferencesWindowTitle", "BongoCat - Settings"); value->window = SDL_CreateWindow(title, From 9a5d5f4eb6d70788b7ab04c3558caf60b0184bc8 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 9 Sep 2026 19:25:14 +0800 Subject: [PATCH 265/334] fix: disable transparent main window in headless smoke tests --- src/runtime/shell/window.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/runtime/shell/window.c b/src/runtime/shell/window.c index 94f918c8..851af73d 100644 --- a/src/runtime/shell/window.c +++ b/src/runtime/shell/window.c @@ -23,6 +23,8 @@ static bool set_gl_attributes(int samples) { static bool try_window(BongoCatApp *app, bool transparent, int samples, char *failure, size_t capacity) { + if (SDL_getenv("BONGO_CAT_TEST_DISABLE_PREFERENCES_TRANSPARENCY")) + transparent = false; if (!set_gl_attributes(samples)) { snprintf(failure, capacity, "OpenGL attributes: %s", SDL_GetError()); return false; } From 469476d85f94dac6f019f211bcdb8237620f2d3c Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 9 Sep 2026 21:35:19 +0800 Subject: [PATCH 266/334] Update README.md --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index 1d25adaa..7755e222 100644 --- a/README.md +++ b/README.md @@ -348,6 +348,24 @@ we need. ## Project Status ![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") +## Sponsors + +
+ + + + + +
+ SignPath + + Free code signing on Windows provided by + SignPath.io, certificate by + SignPath Foundation +
+
+ + ## 🙏 Special Thanks > [!TIP] From cecbadfd3e718b852aaf8b2844e5eb219c758a76 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 11 Sep 2026 12:01:17 +0800 Subject: [PATCH 267/334] feat: add skin audio playback and controls for 1.8.0 --- CMakeLists.txt | 4 +- cmake/AudioDecoder.cmake | 34 +++++ cmake/Dependencies.cmake | 13 +- cmake/RuntimeSources.cmake | 8 +- cmake/Tests.cmake | 9 ++ include/bongo_cat/app.h | 3 + include/bongo_cat/audio.h | 8 ++ include/bongo_cat/config.h | 1 + include/bongo_cat/model.h | 2 + include/bongo_cat/platform.h | 7 +- include/bongo_cat/sound_shortcut.h | 18 +++ resources/assets/locales/de-DE.json | 4 +- resources/assets/locales/en-US.json | 4 +- resources/assets/locales/es-ES.json | 4 +- resources/assets/locales/fr-FR.json | 4 +- resources/assets/locales/ja-JP.json | 4 +- resources/assets/locales/ko-KR.json | 4 +- resources/assets/locales/pt-BR.json | 4 +- resources/assets/locales/ru-RU.json | 4 +- resources/assets/locales/zh-CN.json | 4 +- resources/assets/locales/zh-Hant.json | 4 +- src/core/app_state.c | 10 ++ src/core/config_defaults.c | 30 +++- src/core/config_io.c | 1 + src/core/config_settings_save.c | 2 + src/core/model_behavior.c | 8 +- src/core/sound_shortcut.c | 84 +++++++++++ src/media/audio.c | 71 ---------- src/media/audio/audio.c | 105 ++++++++++++++ src/media/audio/audio_internal.h | 33 +++++ src/media/audio/audio_playback.c | 83 +++++++++++ src/media/audio/miniaudio_impl.c | 10 ++ src/platform/linux/linux_menu.c | 15 +- src/platform/macos/macos_menu.m | 3 + src/platform/windows/windows_menu.c | 14 +- src/runtime/input/shortcuts.c | 51 ++----- src/runtime/input/sound_shortcuts.c | 26 ++++ src/runtime/lifecycle/app_input.c | 4 + src/runtime/lifecycle/app_loop.c | 2 + .../import/mver/model_import_mver_audio.c | 69 +++++++++ .../import/mver/model_import_mver_internal.h | 4 + .../import/mver/model_import_mver_metadata.c | 64 +-------- .../import/mver/model_import_mver_shortcut.c | 35 +++++ src/runtime/model/model_behavior.c | 40 ++++++ src/runtime/model/model_files.c | 4 +- src/runtime/runtime.h | 2 + src/runtime/shell/window_menu.h | 2 + src/runtime/shell/window_menu_actions.c | 8 +- src/runtime/shell/window_menu_behavior.c | 28 ++++ src/ui/preferences/preferences.c | 13 ++ .../preferences/preferences_behavior_dialog.c | 31 ++++- src/ui/preferences/preferences_behavior_row.c | 34 +++-- src/ui/preferences/preferences_shortcuts.c | 10 ++ src/ui/preferences/preferences_state.h | 3 +- tests/core/test_config_validation.c | 27 ++++ tests/core/test_main.c | 1 + tests/core/test_sound_shortcut.c | 37 +++++ tests/media/test_audio.c | 131 ++++++++++++++++++ tests/model_import/test_mver_audio.c | 102 ++++++++++++++ tests/model_import/test_mver_import.c | 1 + .../model_import/test_mver_import_internal.h | 1 + tests/support/test.h | 1 + 62 files changed, 1122 insertions(+), 225 deletions(-) create mode 100644 cmake/AudioDecoder.cmake create mode 100644 include/bongo_cat/sound_shortcut.h create mode 100644 src/core/sound_shortcut.c delete mode 100644 src/media/audio.c create mode 100644 src/media/audio/audio.c create mode 100644 src/media/audio/audio_internal.h create mode 100644 src/media/audio/audio_playback.c create mode 100644 src/media/audio/miniaudio_impl.c create mode 100644 src/runtime/input/sound_shortcuts.c create mode 100644 src/runtime/model/import/mver/model_import_mver_audio.c create mode 100644 src/runtime/model/model_behavior.c create mode 100644 tests/core/test_sound_shortcut.c create mode 100644 tests/media/test_audio.c create mode 100644 tests/model_import/test_mver_audio.c diff --git a/CMakeLists.txt b/CMakeLists.txt index d7e3872a..2f45b2cd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -set(BONGO_CAT_VERSION "1.6.0") +set(BONGO_CAT_VERSION "1.8.0") project(BongoCat VERSION ${BONGO_CAT_VERSION} DESCRIPTION "BongoCat" HOMEPAGE_URL "https://github.com/vladelaina/BongoCat" LANGUAGES C CXX) include(cmake/Version.cmake) include(FetchContent) @@ -31,6 +31,7 @@ else() endif() endif() include(cmake/Dependencies.cmake) +include(cmake/AudioDecoder.cmake) find_package(OpenGL REQUIRED) set(BONGO_CAT_LIVE2D_SOURCES src/live2d/live2d_stub.c) set(BONGO_CAT_CUBISM_ENABLED OFF) @@ -76,6 +77,7 @@ add_library(bongo_cat_core STATIC src/core/model_catalog.c src/core/model_behavior.c src/core/path.c src/core/path_io.c src/core/json.c src/core/shortcut.c + src/core/sound_shortcut.c src/core/update_release.c src/core/update_version.c) target_include_directories(bongo_cat_core PUBLIC "${BONGO_CAT_GENERATED_INCLUDE_DIR}" include) target_link_libraries(bongo_cat_core PUBLIC yyjson PRIVATE bongo_cat_warnings) diff --git a/cmake/AudioDecoder.cmake b/cmake/AudioDecoder.cmake new file mode 100644 index 00000000..0d9fc23a --- /dev/null +++ b/cmake/AudioDecoder.cmake @@ -0,0 +1,34 @@ +# Own the decoder translation unit so WAV, FLAC and Ogg/Vorbis work equally +# with downloaded dependencies and system packages. No extra codec DLLs. +if(EXISTS "${BONGO_CAT_STB_INCLUDE_DIR}/stb_vorbis.c") + set(BONGO_CAT_VORBIS_HEADER "stb_vorbis.c") +elseif(EXISTS "${BONGO_CAT_STB_INCLUDE_DIR}/stb_vorbis.h") + set(BONGO_CAT_VORBIS_HEADER "stb_vorbis.h") +else() + message(FATAL_ERROR "Install the complete stb headers, including stb_vorbis.c or stb_vorbis.h") +endif() +if(BONGO_CAT_FETCH_DEPS) + set_property(TARGET miniaudio PROPERTY SOURCES + "${CMAKE_CURRENT_SOURCE_DIR}/src/media/audio/miniaudio_impl.c") + target_include_directories(miniaudio SYSTEM PRIVATE "${BONGO_CAT_STB_INCLUDE_DIR}") +else() + add_library(bongo_cat_audio_decoder STATIC src/media/audio/miniaudio_impl.c) + if(BONGO_CAT_MINIAUDIO_INCLUDE_DIR) + target_include_directories(bongo_cat_audio_decoder SYSTEM PUBLIC + "${BONGO_CAT_MINIAUDIO_INCLUDE_DIR}") + else() + target_include_directories(bongo_cat_audio_decoder SYSTEM PUBLIC + "$") + endif() + target_include_directories(bongo_cat_audio_decoder SYSTEM PRIVATE + "${BONGO_CAT_STB_INCLUDE_DIR}") + target_compile_definitions(bongo_cat_audio_decoder PRIVATE MA_NO_ENCODING MA_NO_GENERATION) + find_package(Threads REQUIRED) + target_link_libraries(bongo_cat_audio_decoder PRIVATE Threads::Threads ${CMAKE_DL_LIBS}) + if(UNIX) + target_link_libraries(bongo_cat_audio_decoder PRIVATE m) + endif() + set(BONGO_CAT_MINIAUDIO_TARGET bongo_cat_audio_decoder) +endif() +target_compile_definitions(${BONGO_CAT_MINIAUDIO_TARGET} PRIVATE + BONGO_CAT_VORBIS_HEADER="${BONGO_CAT_VORBIS_HEADER}") diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake index ef2a0539..81888efe 100644 --- a/cmake/Dependencies.cmake +++ b/cmake/Dependencies.cmake @@ -44,6 +44,7 @@ if(BONGO_CAT_FETCH_DEPS) # Motion metadata may reference WAV/MP3 files, so keep both decoders enabled. # FORCE also repairs build trees created by older size-only configurations. set(MINIAUDIO_NO_WAV OFF CACHE BOOL "" FORCE) + set(MINIAUDIO_NO_FLAC OFF CACHE BOOL "" FORCE) set(MINIAUDIO_NO_MP3 OFF CACHE BOOL "" FORCE) set(MINIAUDIO_NO_GENERATION ON CACHE BOOL "" FORCE) FetchContent_Declare(SDL3 URL https://github.com/libsdl-org/SDL/archive/402fc52af4e731184ad6a704068b5ccd27d8f1b8.tar.gz @@ -103,16 +104,10 @@ else() bongo_cat_require_dependency_header(BONGO_CAT_MINIAUDIO_INCLUDE_DIR miniaudio.h "Install miniaudio headers or a miniaudio CMake package") - set(miniaudio_impl "${CMAKE_CURRENT_BINARY_DIR}/generated/miniaudio_impl.c") - file(CONFIGURE OUTPUT "${miniaudio_impl}" CONTENT [=[ -#define MINIAUDIO_IMPLEMENTATION -#include -]=] @ONLY) - add_library(bongo_cat_miniaudio_system STATIC "${miniaudio_impl}") - target_include_directories(bongo_cat_miniaudio_system SYSTEM PUBLIC + # AudioDecoder.cmake supplies the implementation and the Vorbis backend. + add_library(bongo_cat_miniaudio_system INTERFACE) + target_include_directories(bongo_cat_miniaudio_system SYSTEM INTERFACE "${BONGO_CAT_MINIAUDIO_INCLUDE_DIR}") - target_compile_definitions(bongo_cat_miniaudio_system PRIVATE - MA_NO_ENCODING MA_NO_GENERATION) set(BONGO_CAT_MINIAUDIO_TARGET bongo_cat_miniaudio_system) endif() endif() diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 08ba5160..57faf392 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -14,7 +14,8 @@ set(BONGO_CAT_RUNTIME_INTERNAL_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/src/ui/theme") set(BONGO_CAT_MEDIA_SOURCES - src/media/audio.c + src/media/audio/audio.c + src/media/audio/audio_playback.c src/media/image.c src/media/image_decode.c src/media/image_info.c @@ -70,9 +71,11 @@ set(BONGO_CAT_RUNTIME_INPUT_SOURCES src/runtime/input/mouse_bounds.c src/runtime/input/mouse_mapping.c src/runtime/input/mouse_pipeline.c - src/runtime/input/shortcuts.c) + src/runtime/input/shortcuts.c + src/runtime/input/sound_shortcuts.c) set(BONGO_CAT_RUNTIME_MODEL_SOURCES + src/runtime/model/model_behavior.c src/runtime/model/model_behavior_cache.c src/runtime/model/model_behavior_state.c src/runtime/model/model_catalog_runtime.c @@ -126,6 +129,7 @@ set(BONGO_CAT_RUNTIME_IMPORT_SOURCES src/runtime/model/import/mver/model_import_mver_image.c src/runtime/model/import/mver/model_import_mver_labels.c src/runtime/model/import/mver/model_import_mver_metadata.c + src/runtime/model/import/mver/model_import_mver_audio.c src/runtime/model/import/mver/model_import_mver_motion.c src/runtime/model/import/mver/model_import_mver_patch.c src/runtime/model/import/mver/model_import_mver_policy.c diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index 6a530c16..dd5a38da 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -1,4 +1,11 @@ if(BUILD_TESTING) + add_executable(bongo_cat_audio_tests tests/media/test_audio.c) + target_include_directories(bongo_cat_audio_tests PRIVATE src/media/audio tests/support) + target_link_libraries(bongo_cat_audio_tests PRIVATE bongo_cat_runtime bongo_cat_warnings) + target_compile_definitions(bongo_cat_audio_tests PRIVATE + BONGO_CAT_NATIVE_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") + add_test(NAME audio COMMAND bongo_cat_audio_tests) + add_executable(bongo_cat_log_policy_tests tests/core/test_log_policy.c) target_link_libraries(bongo_cat_log_policy_tests PRIVATE bongo_cat_runtime bongo_cat_warnings) @@ -13,6 +20,7 @@ if(BUILD_TESTING) tests/core/test_models.c tests/core/test_mver_pointer.c tests/core/test_shortcut.c + tests/core/test_sound_shortcut.c tests/core/test_update.c src/platform/windows/windows_keys.c) target_link_libraries(bongo_cat_core_tests PRIVATE @@ -51,6 +59,7 @@ if(BUILD_TESTING) add_test(NAME app-state COMMAND bongo_cat_app_state_tests) set(BONGO_CAT_MVER_IMPORT_TEST_SOURCES + tests/model_import/test_mver_audio.c tests/model_import/test_mver_import.c tests/model_import/test_model_import_source.c tests/model_import/test_mver_manifest.c diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index 7e31f30e..100af5b6 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -8,6 +8,7 @@ #include "bongo_cat/mouse.h" #include "bongo_cat/platform.h" #include "bongo_cat/shortcut.h" +#include "bongo_cat/sound_shortcut.h" typedef struct BongoCatAudio BongoCatAudio; typedef struct BongoCatTray BongoCatTray; @@ -25,6 +26,8 @@ typedef struct BongoCatApp { BongoCatSessionState session; BongoCatInputState input; BongoCatShortcutState shortcut_state; + BongoCatSoundShortcutState sound_shortcut_state; + bool sound_shortcut_active[BONGO_CAT_BEHAVIOR_BINDING_CAP]; BongoCatModelCatalog models; BongoCatBehaviorCatalog behaviors; /* One immutable installed package's behavior catalog can be reused when diff --git a/include/bongo_cat/audio.h b/include/bongo_cat/audio.h index d442b9fc..6720f3a7 100644 --- a/include/bongo_cat/audio.h +++ b/include/bongo_cat/audio.h @@ -8,7 +8,15 @@ typedef struct BongoCatAudio BongoCatAudio; BongoCatAudio *bongo_cat_audio_create(BongoCatError *error); void bongo_cat_audio_destroy(BongoCatAudio *audio); BongoCatResult bongo_cat_audio_play(BongoCatAudio *audio, const char *path, BongoCatError *error); +/* With overlap disabled, only the same clip restarts; other clips continue. */ +BongoCatResult bongo_cat_audio_play_sound(BongoCatAudio *audio, const char *path, + bool overlap, BongoCatError *error); +void bongo_cat_audio_update(BongoCatAudio *audio); +void bongo_cat_audio_reset(BongoCatAudio *audio); void bongo_cat_audio_stop(BongoCatAudio *audio); +bool bongo_cat_audio_any_playing(const BongoCatAudio *audio); +bool bongo_cat_audio_is_playing(const BongoCatAudio *audio, const char *path); +void bongo_cat_audio_stop_path(BongoCatAudio *audio, const char *path); void bongo_cat_audio_set_enabled(BongoCatAudio *audio, bool enabled); #endif diff --git a/include/bongo_cat/config.h b/include/bongo_cat/config.h index c04a29ca..f299ca29 100644 --- a/include/bongo_cat/config.h +++ b/include/bongo_cat/config.h @@ -93,6 +93,7 @@ typedef struct BongoCatBehaviorShortcut { char id[BONGO_CAT_BEHAVIOR_ID_CAP]; char shortcut[BONGO_CAT_SHORTCUT_CAP]; char label[BONGO_CAT_ID_CAP]; + bool shortcut_disabled; } BongoCatBehaviorShortcut; typedef struct BongoCatModelLabel { diff --git a/include/bongo_cat/model.h b/include/bongo_cat/model.h index 6bede629..b57b7109 100644 --- a/include/bongo_cat/model.h +++ b/include/bongo_cat/model.h @@ -66,6 +66,8 @@ typedef struct BongoCatBehaviorEntry { int index; BongoCatBehaviorKind kind; bool momentary; + bool sound_overlap; + bool sound_clear; } BongoCatBehaviorEntry; typedef struct BongoCatBehaviorCatalog { diff --git a/include/bongo_cat/platform.h b/include/bongo_cat/platform.h index 3fb29435..eae3db73 100644 --- a/include/bongo_cat/platform.h +++ b/include/bongo_cat/platform.h @@ -54,7 +54,8 @@ typedef enum BongoCatMenuAction { BONGO_CAT_MENU_REMOVE_PET, BONGO_CAT_MENU_MODEL_FIRST = 1000, BONGO_CAT_MENU_MOTION_FIRST = 2000, - BONGO_CAT_MENU_EXPRESSION_FIRST = 3000 + BONGO_CAT_MENU_EXPRESSION_FIRST = 3000, + BONGO_CAT_MENU_AUDIO_FIRST = 4000 } BongoCatMenuAction; typedef void (*BongoCatMenuPreview)(void *userdata, BongoCatMenuAction action); @@ -76,6 +77,10 @@ typedef struct BongoCatMenuLabels { void *preview_userdata; const char *remove_pet; bool remove_pet_visible; + const char *audio; + const char (*audio_names)[BONGO_CAT_MENU_LABEL_CAP]; + const bool *audio_checked; + size_t audio_count; } BongoCatMenuLabels; typedef void (*BongoCatTrayClick)(void *userdata); diff --git a/include/bongo_cat/sound_shortcut.h b/include/bongo_cat/sound_shortcut.h new file mode 100644 index 00000000..64b93eef --- /dev/null +++ b/include/bongo_cat/sound_shortcut.h @@ -0,0 +1,18 @@ +#ifndef BONGO_CAT_SOUND_SHORTCUT_H +#define BONGO_CAT_SOUND_SHORTCUT_H + +#include "bongo_cat/input.h" + +/* Stores physical keys separately so releasing one Ctrl does not release + the other. Only changed input edges require reevaluating bindings. */ +typedef struct BongoCatSoundShortcutState { + char held[BONGO_CAT_INPUT_KEY_STATE_CAP][BONGO_CAT_ID_CAP]; + size_t count; +} BongoCatSoundShortcutState; + +bool bongo_cat_sound_shortcut_update(BongoCatSoundShortcutState *state, + const BongoCatInputEvent *event); +bool bongo_cat_sound_shortcut_down(const BongoCatSoundShortcutState *state, + const char *shortcut); + +#endif diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index 05ac5151..79affe62 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -110,10 +110,12 @@ "moreModels": "Weitere Modelle" }, "behaviorModal": { - "title": "Bewegungen und Ausdrücke", + "title": "Bewegungen, Ausdrücke und Audio", "labels": { "motion": "Bewegung", "expression": "Ausdruck", + "audio": "Audio", + "stopAllAudio": "Alle Audios stoppen", "motionIndex": "Bewegung {index}", "motionGroupIndex": "Bewegungsgruppe {index}", "expressionIndex": "Ausdruck {index}" diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index ab6a2303..d1970ff3 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -110,10 +110,12 @@ "moreModels": "More Models" }, "behaviorModal": { - "title": "Motions and Expressions", + "title": "Motions, Expressions and Audio", "labels": { "motion": "Motion", "expression": "Expression", + "audio": "Audio", + "stopAllAudio": "Stop all audio", "motionIndex": "Motion {index}", "motionGroupIndex": "Motion Group {index}", "expressionIndex": "Expression {index}" diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index a404e9ae..0e96be60 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -110,10 +110,12 @@ "moreModels": "Más modelos" }, "behaviorModal": { - "title": "Movimientos y expresiones", + "title": "Movimientos, expresiones y audio", "labels": { "motion": "Movimiento", "expression": "Expresión", + "audio": "Audio", + "stopAllAudio": "Detener todo el audio", "motionIndex": "Movimiento {index}", "motionGroupIndex": "Grupo de movimientos {index}", "expressionIndex": "Expresión {index}" diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index f323b64c..b2296ed2 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -110,10 +110,12 @@ "moreModels": "Plus de modèles" }, "behaviorModal": { - "title": "Mouvements et expressions", + "title": "Mouvements, expressions et audio", "labels": { "motion": "Mouvement", "expression": "Expression", + "audio": "Audio", + "stopAllAudio": "Arrêter tous les sons", "motionIndex": "Mouvement {index}", "motionGroupIndex": "Groupe de mouvements {index}", "expressionIndex": "Expression {index}" diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index 92718e74..d1586986 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -110,10 +110,12 @@ "moreModels": "その他のモデル" }, "behaviorModal": { - "title": "モーションと表情", + "title": "モーション・表情・音声", "labels": { "motion": "モーション", "expression": "表情", + "audio": "音声", + "stopAllAudio": "すべての音声を停止", "motionIndex": "モーション {index}", "motionGroupIndex": "モーショングループ {index}", "expressionIndex": "表情 {index}" diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index ff93093e..d99019c7 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -110,10 +110,12 @@ "moreModels": "더 많은 모델" }, "behaviorModal": { - "title": "모션 및 표정", + "title": "모션, 표정 및 오디오", "labels": { "motion": "모션", "expression": "표정", + "audio": "오디오", + "stopAllAudio": "모든 오디오 중지", "motionIndex": "모션 {index}", "motionGroupIndex": "모션 그룹 {index}", "expressionIndex": "표정 {index}" diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index d11b395f..add0777c 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -110,10 +110,12 @@ "moreModels": "Mais modelos" }, "behaviorModal": { - "title": "Movimentos e Expressões", + "title": "Movimentos, expressões e áudio", "labels": { "motion": "Movimento", "expression": "Expressão", + "audio": "Áudio", + "stopAllAudio": "Parar todos os áudios", "motionIndex": "Movimento {index}", "motionGroupIndex": "Grupo de Movimento {index}", "expressionIndex": "Expressão {index}" diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index edcc38ab..1cc82c1c 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -110,10 +110,12 @@ "moreModels": "Другие модели" }, "behaviorModal": { - "title": "Движения и выражения", + "title": "Движения, выражения и аудио", "labels": { "motion": "Движение", "expression": "Выражение", + "audio": "Аудио", + "stopAllAudio": "Остановить все звуки", "motionIndex": "Движение {index}", "motionGroupIndex": "Группа движений {index}", "expressionIndex": "Выражение {index}" diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index 3787093c..97b9c63c 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -110,10 +110,12 @@ "moreModels": "模型库" }, "behaviorModal": { - "title": "动作与表情", + "title": "动作、表情与音频", "labels": { "motion": "动作", "expression": "表情", + "audio": "音频", + "stopAllAudio": "停止所有音频", "motionIndex": "动作{index}", "motionGroupIndex": "动作组{index}", "expressionIndex": "表情{index}" diff --git a/resources/assets/locales/zh-Hant.json b/resources/assets/locales/zh-Hant.json index cac12e8e..0f7ddfa6 100644 --- a/resources/assets/locales/zh-Hant.json +++ b/resources/assets/locales/zh-Hant.json @@ -110,10 +110,12 @@ "moreModels": "模型庫" }, "behaviorModal": { - "title": "動作與表情", + "title": "動作、表情與音訊", "labels": { "motion": "動作", "expression": "表情", + "audio": "音訊", + "stopAllAudio": "停止所有音訊", "motionIndex": "動作{index}", "motionGroupIndex": "動作組{index}", "expressionIndex": "表情{index}" diff --git a/src/core/app_state.c b/src/core/app_state.c index ffccf210..6a5f4143 100644 --- a/src/core/app_state.c +++ b/src/core/app_state.c @@ -109,6 +109,16 @@ static void apply_gamepad(BongoCatApp *app, const BongoCatInputEvent *event) { void bongo_cat_app_reset_gamepad(BongoCatApp *app) { if (!app || !app->live2d) return; + for (size_t i = app->sound_shortcut_state.count; i > 0; --i) { + if (strncmp(app->sound_shortcut_state.held[i - 1], "Gamepad:", 8)) continue; + --app->sound_shortcut_state.count; + if (i - 1 != app->sound_shortcut_state.count) + memcpy(app->sound_shortcut_state.held[i - 1], + app->sound_shortcut_state.held[app->sound_shortcut_state.count], BONGO_CAT_ID_CAP); + } + for (size_t i = 0; i < app->settings.behavior_shortcut_count; ++i) + if (strstr(app->settings.behavior_shortcuts[i].shortcut, "Gamepad:")) + app->sound_shortcut_active[i] = false; app->left_stick_x = app->left_stick_y = 0.0f; app->right_stick_x = app->right_stick_y = 0.0f; app->left_stick_pressed = app->right_stick_pressed = false; diff --git a/src/core/config_defaults.c b/src/core/config_defaults.c index 5a09842e..c39ecd57 100644 --- a/src/core/config_defaults.c +++ b/src/core/config_defaults.c @@ -31,6 +31,8 @@ static bool normalize_text(char *text, size_t capacity) { return true; } +static bool audio_binding(const BongoCatBehaviorShortcut *binding); + bool bongo_cat_settings_shortcut_conflicts(const BongoCatSettings *config, const char *shortcut, const char *exclude) { if (!config || !shortcut || !shortcut[0]) return false; @@ -43,13 +45,27 @@ bool bongo_cat_settings_shortcut_conflicts(const BongoCatSettings *config, size_t behavior_count = config->behavior_shortcut_count; if (behavior_count > BONGO_CAT_BEHAVIOR_BINDING_CAP) behavior_count = BONGO_CAT_BEHAVIOR_BINDING_CAP; + bool editing_audio = false, editing_behavior = false; + for (size_t i = 0; i < behavior_count; ++i) + if (config->behavior_shortcuts[i].shortcut == exclude) { + editing_behavior = true; + editing_audio = audio_binding(&config->behavior_shortcuts[i]); + } for (size_t i = 0; i < behavior_count; ++i) { const char *bound = config->behavior_shortcuts[i].shortcut; - if (bound != exclude && shortcut_equal(bound, shortcut)) return true; + if (bound != exclude && shortcut_equal(bound, shortcut) && + (!editing_behavior || (!editing_audio && + !audio_binding(&config->behavior_shortcuts[i])))) return true; } return false; } +static bool audio_binding(const BongoCatBehaviorShortcut *binding) { + const char *index = strrchr(binding->id, ':'); + return index && index - binding->id >= 6 && + strncmp(index - 6, ":sound", 6) == 0; +} + static void validate_shortcuts(BongoCatSettings *config) { char *global[] = {config->shortcuts.toggle_pet_visibility, config->shortcuts.visible_preferences, config->shortcuts.mirror, @@ -66,8 +82,9 @@ static void validate_shortcuts(BongoCatSettings *config) { for (size_t j = 0; j < sizeof(global) / sizeof(global[0]); ++j) duplicate = duplicate || shortcut_equal(shortcut, global[j]); for (size_t j = 0; j < i; ++j) - duplicate = duplicate || shortcut_equal(shortcut, - config->behavior_shortcuts[j].shortcut); + duplicate = duplicate || (!audio_binding(&config->behavior_shortcuts[i]) && + !audio_binding(&config->behavior_shortcuts[j]) && shortcut_equal(shortcut, + config->behavior_shortcuts[j].shortcut)); if (duplicate) shortcut[0] = '\0'; } } @@ -85,8 +102,10 @@ static void compact_behavior_overrides(BongoCatSettings *config) { if (!bongo_cat_utf8_valid(entry.id) || !bongo_cat_utf8_valid(entry.shortcut) || !bongo_cat_utf8_valid(entry.label)) continue; - if (!entry.id[0] || (!entry.shortcut[0] && !entry.label[0])) continue; + if (entry.shortcut[0]) entry.shortcut_disabled = false; + if (!entry.id[0] || (!entry.shortcut[0] && !entry.label[0] && !entry.shortcut_disabled)) continue; BongoCatBehaviorShortcut canonical = {0}; + canonical.shortcut_disabled = entry.shortcut_disabled; snprintf(canonical.id, sizeof(canonical.id), "%s", entry.id); snprintf(canonical.shortcut, sizeof(canonical.shortcut), "%s", entry.shortcut); @@ -98,7 +117,8 @@ static void compact_behavior_overrides(BongoCatSettings *config) { break; } if (existing < output_count) { - if (canonical.shortcut[0]) { + if (canonical.shortcut[0] || canonical.shortcut_disabled) { + config->behavior_shortcuts[existing].shortcut_disabled = canonical.shortcut_disabled; memset(config->behavior_shortcuts[existing].shortcut, 0, sizeof(config->behavior_shortcuts[existing].shortcut)); snprintf( diff --git a/src/core/config_io.c b/src/core/config_io.c index 5c3d68ce..59631b1a 100644 --- a/src/core/config_io.c +++ b/src/core/config_io.c @@ -142,6 +142,7 @@ static bool read_behaviors(yyjson_val *array, BongoCatSettings *settings, BongoCatBehaviorShortcut *entry = &settings->behavior_shortcuts[ settings->behavior_shortcut_count++]; memset(entry, 0, sizeof(*entry)); + if (!read_bool(item, "shortcutDisabled", &entry->shortcut_disabled, error)) return false; if (!copy_text(entry->id, sizeof(entry->id), id, id_length, "behaviorId", error) || !copy_text(entry->shortcut, sizeof(entry->shortcut), shortcut, diff --git a/src/core/config_settings_save.c b/src/core/config_settings_save.c index 765576b4..5da24b9b 100644 --- a/src/core/config_settings_save.c +++ b/src/core/config_settings_save.c @@ -87,6 +87,8 @@ static bool write_behaviors(yyjson_mut_doc *doc, yyjson_mut_val *root, yyjson_mut_val *item = yyjson_mut_obj(doc); if (!item || !yyjson_mut_obj_add_strcpy( doc, item, "behaviorId", value->id) || + (value->shortcut_disabled && !yyjson_mut_obj_add_bool( + doc, item, "shortcutDisabled", true)) || (value->shortcut[0] && !yyjson_mut_obj_add_strcpy( doc, item, "shortcut", value->shortcut)) || (value->label[0] && !yyjson_mut_obj_add_strcpy( diff --git a/src/core/model_behavior.c b/src/core/model_behavior.c index 6eb9f9aa..bd6a69c2 100644 --- a/src/core/model_behavior.c +++ b/src/core/model_behavior.c @@ -97,6 +97,7 @@ static bool read_adapter_assets(BongoCatBehaviorCatalog *catalog, model->adapter_directory)) { ok = false; break; } + catalog->entries[catalog->count - 1].sound_clear = sound; continue; } const char *asset = yyjson_get_str(yyjson_obj_get(item, effect ? "effect" : "sound")); @@ -109,8 +110,11 @@ static bool read_adapter_assets(BongoCatBehaviorCatalog *catalog, if (!add_behavior(catalog, model, effect ? BONGO_CAT_BEHAVIOR_EFFECT : BONGO_CAT_BEHAVIOR_SOUND, NULL, current, label, asset, model->adapter_directory)) { ok = false; break; } - catalog->entries[catalog->count - 1].momentary = - yyjson_get_bool(yyjson_obj_get(item, "momentary")); + BongoCatBehaviorEntry *entry = &catalog->entries[catalog->count - 1]; + bool momentary = yyjson_get_bool(yyjson_obj_get(item, "momentary")); + entry->momentary = effect && momentary; + yyjson_val *overlap = yyjson_obj_get(item, "overlap"); + entry->sound_overlap = sound && (overlap ? yyjson_get_bool(overlap) : true); } yyjson_doc_free(document); return ok; diff --git a/src/core/sound_shortcut.c b/src/core/sound_shortcut.c new file mode 100644 index 00000000..108b3239 --- /dev/null +++ b/src/core/sound_shortcut.c @@ -0,0 +1,84 @@ +#include "bongo_cat/sound_shortcut.h" + +#include +#include +#include + +static bool equal(const char *a, const char *b) { + while (*a && *b) { + if (tolower((unsigned char)*a++) != tolower((unsigned char)*b++)) return false; + } + return *a == *b; +} + +static const char *canonical(const char *key) { + if ((strncmp(key, "Key", 3) == 0 || strncmp(key, "Num", 3) == 0) && + key[3] && !key[4]) return key + 3; + if (strcmp(key, "UpArrow") == 0) return "ArrowUp"; + if (strcmp(key, "DownArrow") == 0) return "ArrowDown"; + if (strcmp(key, "LeftArrow") == 0) return "ArrowLeft"; + if (strcmp(key, "RightArrow") == 0) return "ArrowRight"; + if (strcmp(key, "Return") == 0) return "Enter"; + return key; +} + +static bool token_matches(const char *token, const char *held) { + if (equal(token, canonical(held))) return true; + if (equal(token, "Control") || equal(token, "Ctrl")) + return equal(held, "ControlLeft") || equal(held, "ControlRight"); + if (equal(token, "Shift")) + return equal(held, "ShiftLeft") || equal(held, "ShiftRight"); + if (equal(token, "Alt")) return equal(held, "Alt") || equal(held, "AltGr"); + if (equal(token, "Super") || equal(token, "Command")) return equal(held, "Meta"); + if (equal(token, "-")) return equal(held, "Minus"); + if (equal(token, "=")) return equal(held, "Equal"); + return false; +} + +bool bongo_cat_sound_shortcut_update(BongoCatSoundShortcutState *state, + const BongoCatInputEvent *event) { + if (!state || !event || !event->name[0]) return false; + bool down; + char name[BONGO_CAT_ID_CAP]; + if (event->kind == BONGO_CAT_INPUT_GAMEPAD_BUTTON) { + down = event->value > 0.5f; + if (strlen(event->name) + 8 >= sizeof(name)) return false; + snprintf(name, sizeof(name), "Gamepad:%s", event->name); + } else { + if (event->kind != BONGO_CAT_INPUT_KEY_DOWN && event->kind != BONGO_CAT_INPUT_KEY_UP && + event->kind != BONGO_CAT_INPUT_MOUSE_DOWN && event->kind != BONGO_CAT_INPUT_MOUSE_UP) + return false; + down = event->kind == BONGO_CAT_INPUT_KEY_DOWN || event->kind == BONGO_CAT_INPUT_MOUSE_DOWN; + snprintf(name, sizeof(name), "%s", event->name); + } + for (size_t i = 0; i < state->count; ++i) { + if (strcmp(state->held[i], name) != 0) continue; + if (down) return false; + --state->count; + if (i != state->count) memcpy(state->held[i], state->held[state->count], sizeof(name)); + return true; + } + if (!down || state->count >= BONGO_CAT_INPUT_KEY_STATE_CAP) return false; + snprintf(state->held[state->count++], sizeof(name), "%s", name); + return true; +} + +bool bongo_cat_sound_shortcut_down(const BongoCatSoundShortcutState *state, + const char *shortcut) { + if (!state || !shortcut || !*shortcut) return false; + const char *cursor = shortcut; + while (*cursor) { + const char *plus = strchr(cursor, '+'); + size_t length = plus ? (size_t)(plus - cursor) : strlen(cursor); + char token[BONGO_CAT_ID_CAP]; + if (!length || length >= sizeof(token)) return false; + memcpy(token, cursor, length); token[length] = '\0'; + bool found = false; + for (size_t i = 0; i < state->count && !found; ++i) + found = token_matches(token, state->held[i]); + if (!found) return false; + if (!plus) return true; + cursor = plus + 1; + } + return false; +} diff --git a/src/media/audio.c b/src/media/audio.c deleted file mode 100644 index 105f8450..00000000 --- a/src/media/audio.c +++ /dev/null @@ -1,71 +0,0 @@ -#include "bongo_cat/audio.h" - -#include -#include -#include - -struct BongoCatAudio { - ma_engine engine; - ma_sound sound; - bool initialized; - bool sound_ready; - bool enabled; -}; - -BongoCatAudio *bongo_cat_audio_create(BongoCatError *error) { - (void)error; - BongoCatAudio *audio = calloc(1, sizeof(*audio)); - if (!audio) return NULL; - audio->enabled = true; - return audio; -} - -void bongo_cat_audio_stop(BongoCatAudio *audio) { - if (!audio || !audio->sound_ready) return; - ma_sound_stop(&audio->sound); - ma_sound_uninit(&audio->sound); - audio->sound_ready = false; -} - -BongoCatResult bongo_cat_audio_play(BongoCatAudio *audio, const char *path, BongoCatError *error) { - if (!audio || !path) return BONGO_CAT_ERROR_ARGUMENT; - if (!audio->enabled) return BONGO_CAT_OK; - if (!audio->initialized) { - ma_result initialized = ma_engine_init(NULL, &audio->engine); - if (initialized != MA_SUCCESS) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, - "Audio initialization failed: %d", initialized); - return BONGO_CAT_ERROR_PLATFORM; - } - audio->initialized = true; - } - bongo_cat_audio_stop(audio); - ma_result result = ma_sound_init_from_file(&audio->engine, path, - MA_SOUND_FLAG_DECODE | MA_SOUND_FLAG_ASYNC, NULL, NULL, &audio->sound); - if (result != MA_SUCCESS) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, "Cannot load motion sound: %s", path); - return BONGO_CAT_ERROR_IO; - } - audio->sound_ready = true; - result = ma_sound_start(&audio->sound); - if (result != MA_SUCCESS) { - bongo_cat_audio_stop(audio); - bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, - "Cannot start motion sound: %s", path); - return BONGO_CAT_ERROR_PLATFORM; - } - return BONGO_CAT_OK; -} - -void bongo_cat_audio_set_enabled(BongoCatAudio *audio, bool enabled) { - if (!audio) return; - audio->enabled = enabled; - if (!enabled) bongo_cat_audio_stop(audio); -} - -void bongo_cat_audio_destroy(BongoCatAudio *audio) { - if (!audio) return; - bongo_cat_audio_stop(audio); - if (audio->initialized) ma_engine_uninit(&audio->engine); - free(audio); -} diff --git a/src/media/audio/audio.c b/src/media/audio/audio.c new file mode 100644 index 00000000..0e815b45 --- /dev/null +++ b/src/media/audio/audio.c @@ -0,0 +1,105 @@ +#include "audio_internal.h" + +#include +#include +#include + +/* App-thread owned. Streaming bounds decoded memory independently of clip + duration; the pool bounds decoder/file handles during rapid key presses. */ +void bongo_cat_audio_voice_release(AudioVoice *voice) { + if (!voice->ready) return; + ma_sound_stop(&voice->sound); + /* uninit waits for pending decoding jobs before releasing their data. */ + ma_sound_uninit(&voice->sound); + memset(voice, 0, sizeof(*voice)); +} + +BongoCatAudio *bongo_cat_audio_create(BongoCatError *error) { + BongoCatAudio *audio = calloc(1, sizeof(*audio)); + if (!audio) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, "Cannot allocate audio player"); + return NULL; + } + audio->enabled = true; + return audio; +} + +void bongo_cat_audio_stop(BongoCatAudio *audio) { + if (!audio) return; + for (size_t i = 0; i < AUDIO_VOICES; ++i) bongo_cat_audio_voice_release(&audio->voices[i]); +} + +void bongo_cat_audio_reset(BongoCatAudio *audio) { + if (!audio) return; + bongo_cat_audio_stop(audio); + if (audio->initialized) ma_engine_uninit(&audio->engine); + audio->initialized = false; + audio->next_collect = 0; +} + +void bongo_cat_audio_collect(BongoCatAudio *audio, uint64_t now) { + if (!audio || !audio->initialized || now < audio->next_collect) return; + audio->next_collect = now + 1000; + bool active = false; + for (size_t i = 0; i < AUDIO_VOICES; ++i) { + AudioVoice *voice = &audio->voices[i]; + if (!voice->ready) continue; + ma_result result = ma_resource_manager_data_source_result( + voice->sound.pResourceManagerDataSource); + if (result != MA_SUCCESS && result != MA_BUSY) { + SDL_LogWarn(SDL_LOG_CATEGORY_AUDIO, "Cannot decode audio (%d): %s", result, voice->path); + bongo_cat_audio_voice_release(voice); + } else if (ma_sound_at_end(&voice->sound) || !ma_sound_is_playing(&voice->sound)) { + if (now - voice->used >= AUDIO_IDLE_MS) bongo_cat_audio_voice_release(voice); + } else active = true; + } + /* Idle players retain neither device callbacks nor decoder workers. */ + if (!active && now - audio->last_play >= AUDIO_IDLE_MS) bongo_cat_audio_reset(audio); +} + +void bongo_cat_audio_update(BongoCatAudio *audio) { + bongo_cat_audio_collect(audio, SDL_GetTicks()); +} + +void bongo_cat_audio_set_enabled(BongoCatAudio *audio, bool enabled) { + if (!audio) return; + audio->enabled = enabled; + if (!enabled) bongo_cat_audio_reset(audio); +} + +void bongo_cat_audio_destroy(BongoCatAudio *audio) { + if (!audio) return; + bongo_cat_audio_reset(audio); + free(audio); +} + +bool bongo_cat_audio_is_playing(const BongoCatAudio *audio, const char *path) { + if (!audio || !path || !path[0]) return false; + for (size_t i = 0; i < AUDIO_VOICES; ++i) { + const AudioVoice *voice = &audio->voices[i]; + if (!voice->ready || strcmp(voice->path, path)) continue; + ma_result result = ma_resource_manager_data_source_result(voice->sound.pResourceManagerDataSource); + if ((result == MA_SUCCESS || result == MA_BUSY) && + ma_sound_is_playing(&voice->sound) && !ma_sound_at_end(&voice->sound)) return true; + } + return false; +} + +void bongo_cat_audio_stop_path(BongoCatAudio *audio, const char *path) { + if (!audio || !path || !path[0]) return; + for (size_t i = 0; i < AUDIO_VOICES; ++i) + if (audio->voices[i].ready && !strcmp(audio->voices[i].path, path)) + bongo_cat_audio_voice_release(&audio->voices[i]); +} + +bool bongo_cat_audio_any_playing(const BongoCatAudio *audio) { + if (!audio) return false; + for (size_t i = 0; i < AUDIO_VOICES; ++i) { + const AudioVoice *voice = &audio->voices[i]; + if (!voice->ready) continue; + ma_result result = ma_resource_manager_data_source_result(voice->sound.pResourceManagerDataSource); + if ((result == MA_SUCCESS || result == MA_BUSY) && + ma_sound_is_playing(&voice->sound) && !ma_sound_at_end(&voice->sound)) return true; + } + return false; +} diff --git a/src/media/audio/audio_internal.h b/src/media/audio/audio_internal.h new file mode 100644 index 00000000..5d2c3d93 --- /dev/null +++ b/src/media/audio/audio_internal.h @@ -0,0 +1,33 @@ +#ifndef BONGO_CAT_AUDIO_INTERNAL_H +#define BONGO_CAT_AUDIO_INTERNAL_H + +#include "bongo_cat/audio.h" +#include + +#define AUDIO_VOICES 50 +#define AUDIO_IDLE_MS 5000 + +typedef struct AudioVoice { + ma_sound sound; + char path[BONGO_CAT_PATH_CAP]; + uint64_t used; + uint64_t order; + bool ready; +} AudioVoice; + +struct BongoCatAudio { + ma_engine engine; + AudioVoice voices[AUDIO_VOICES]; + uint64_t sequence; + uint64_t last_play; + uint64_t next_collect; + bool initialized; + bool enabled; +}; + +void bongo_cat_audio_voice_release(AudioVoice *voice); + +/* Explicit time keeps idle reclamation deterministic in offline tests. */ +void bongo_cat_audio_collect(BongoCatAudio *audio, uint64_t now_ms); + +#endif diff --git a/src/media/audio/audio_playback.c b/src/media/audio/audio_playback.c new file mode 100644 index 00000000..a5ec74e2 --- /dev/null +++ b/src/media/audio/audio_playback.c @@ -0,0 +1,83 @@ +#include "audio_internal.h" + +#include +#include +#ifdef _WIN32 +#include +#endif + +static ma_result load_voice(BongoCatAudio *audio, AudioVoice *voice, const char *path) { + ma_uint32 flags = MA_SOUND_FLAG_STREAM | MA_SOUND_FLAG_ASYNC | + MA_SOUND_FLAG_NO_SPATIALIZATION; +#ifdef _WIN32 + wchar_t wide[BONGO_CAT_PATH_CAP]; + if (!MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, path, -1, + wide, BONGO_CAT_PATH_CAP)) return MA_INVALID_ARGS; + return ma_sound_init_from_file_w(&audio->engine, wide, flags, NULL, NULL, &voice->sound); +#else + return ma_sound_init_from_file(&audio->engine, path, flags, NULL, NULL, &voice->sound); +#endif +} + +BongoCatResult bongo_cat_audio_play_sound(BongoCatAudio *audio, const char *path, + bool overlap, BongoCatError *error) { + if (!audio || !path || !path[0] || strlen(path) >= BONGO_CAT_PATH_CAP) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, "Invalid audio path or player"); + return BONGO_CAT_ERROR_ARGUMENT; + } + if (!audio->enabled) return BONGO_CAT_OK; + if (!audio->initialized) { + ma_result result = ma_engine_init(NULL, &audio->engine); + if (result != MA_SUCCESS) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, + "Audio initialization failed: %d", result); + return BONGO_CAT_ERROR_PLATFORM; + } + audio->initialized = true; + } + AudioVoice *selected = NULL, *idle = NULL, *oldest = &audio->voices[0]; + for (size_t i = 0; i < AUDIO_VOICES; ++i) { + AudioVoice *voice = &audio->voices[i]; + bool playing = voice->ready && ma_sound_is_playing(&voice->sound) && + !ma_sound_at_end(&voice->sound); + if (voice->ready && strcmp(voice->path, path) == 0 && (!overlap || !playing)) { + selected = voice; + break; + } + if (!playing && (!idle || !voice->ready)) idle = voice; + if (voice->order < oldest->order) oldest = voice; + } + if (!selected) { + selected = idle ? idle : oldest; + bongo_cat_audio_voice_release(selected); + ma_result result = load_voice(audio, selected, path); + if (result != MA_SUCCESS) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot load audio (%d): %s", result, path); + return BONGO_CAT_ERROR_IO; + } + selected->ready = true; + memcpy(selected->path, path, strlen(path) + 1); + } else { + ma_sound_stop(&selected->sound); + ma_result result = ma_sound_seek_to_pcm_frame(&selected->sound, 0); + if (result != MA_SUCCESS) { + bongo_cat_audio_voice_release(selected); + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, "Cannot rewind audio: %s", path); + return BONGO_CAT_ERROR_IO; + } + } + selected->used = audio->last_play = SDL_GetTicks(); + selected->order = ++audio->sequence; + if (ma_sound_start(&selected->sound) != MA_SUCCESS) { + bongo_cat_audio_voice_release(selected); + bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, "Cannot start audio: %s", path); + return BONGO_CAT_ERROR_PLATFORM; + } + return BONGO_CAT_OK; +} + +BongoCatResult bongo_cat_audio_play(BongoCatAudio *audio, const char *path, BongoCatError *error) { + return bongo_cat_audio_play_sound(audio, path, false, error); +} + diff --git a/src/media/audio/miniaudio_impl.c b/src/media/audio/miniaudio_impl.c new file mode 100644 index 00000000..2091ce2d --- /dev/null +++ b/src/media/audio/miniaudio_impl.c @@ -0,0 +1,10 @@ +/* stb_vorbis must be declared before miniaudio to enable its Ogg backend. */ +#ifndef BONGO_CAT_VORBIS_HEADER +#define BONGO_CAT_VORBIS_HEADER +#endif +#define STB_VORBIS_HEADER_ONLY +#include BONGO_CAT_VORBIS_HEADER +#define MINIAUDIO_IMPLEMENTATION +#include +#undef STB_VORBIS_HEADER_ONLY +#include BONGO_CAT_VORBIS_HEADER diff --git a/src/platform/linux/linux_menu.c b/src/platform/linux/linux_menu.c index 14ef1c26..b51894e1 100644 --- a/src/platform/linux/linux_menu.c +++ b/src/platform/linux/linux_menu.c @@ -16,6 +16,7 @@ typedef struct LinuxMenuRow { #define LINUX_MENU_HINT ((BongoCatMenuAction)-4) #define LINUX_MENU_MOTIONS ((BongoCatMenuAction)-5) #define LINUX_MENU_EXPRESSIONS ((BongoCatMenuAction)-6) +#define LINUX_MENU_AUDIO ((BongoCatMenuAction)-8) #define LINUX_MENU_SEPARATOR ((BongoCatMenuAction)-7) typedef struct LinuxMenuPalette { @@ -190,7 +191,7 @@ BongoCatMenuAction bongo_cat_linux_context_menu(BongoCatPlatform *platform, labels->pass_through); snprintf(top, sizeof(top), "%s%s", labels->always_on_top_checked ? "[x] " : "", labels->always_on_top); - LinuxMenuRow main_rows[13]; int main_count = 0; + LinuxMenuRow main_rows[14]; int main_count = 0; main_rows[main_count++] = (LinuxMenuRow){labels->preferences, BONGO_CAT_MENU_PREFERENCES}; main_rows[main_count++] = (LinuxMenuRow){labels->hide, BONGO_CAT_MENU_HIDE}; main_rows[main_count++] = (LinuxMenuRow){pass, BONGO_CAT_MENU_PASS_THROUGH}; @@ -201,6 +202,8 @@ BongoCatMenuAction bongo_cat_linux_context_menu(BongoCatPlatform *platform, (LinuxMenuRow){labels->motion, LINUX_MENU_MOTIONS}; if (labels->expression_count) main_rows[main_count++] = (LinuxMenuRow){labels->expression, LINUX_MENU_EXPRESSIONS}; + if (labels->audio_count) main_rows[main_count++] = + (LinuxMenuRow){labels->audio, LINUX_MENU_AUDIO}; main_rows[main_count++] = (LinuxMenuRow){labels->model, -3}; main_rows[main_count++] = (LinuxMenuRow){NULL, LINUX_MENU_SEPARATOR}; main_rows[main_count++] = (LinuxMenuRow){labels->exit, BONGO_CAT_MENU_EXIT}; @@ -246,6 +249,16 @@ BongoCatMenuAction bongo_cat_linux_context_menu(BongoCatPlatform *platform, BONGO_CAT_MENU_MOTION_FIRST + (int)i}; } action = popup_rows(display, owner, rows, (int)labels->motion_count, labels); + } else if (action == LINUX_MENU_AUDIO) { + LinuxMenuRow rows[BONGO_CAT_BEHAVIOR_CAP]; + char text[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP + 4]; + for (size_t i = 0; i < labels->audio_count; ++i) { + snprintf(text[i], sizeof(text[i]), "%s%s", + labels->audio_checked && labels->audio_checked[i] ? "[x] " : "", + labels->audio_names[i]); + rows[i] = (LinuxMenuRow){text[i], BONGO_CAT_MENU_AUDIO_FIRST + (int)i}; + } + action = popup_rows(display, owner, rows, (int)labels->audio_count, labels); } else if (action == LINUX_MENU_EXPRESSIONS) { LinuxMenuRow rows[BONGO_CAT_BEHAVIOR_CAP]; char text[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_ID_CAP]; diff --git a/src/platform/macos/macos_menu.m b/src/platform/macos/macos_menu.m index 633aef48..2c364516 100644 --- a/src/platform/macos/macos_menu.m +++ b/src/platform/macos/macos_menu.m @@ -102,6 +102,9 @@ BongoCatMenuAction bongo_cat_macos_context_menu(BongoCatPlatform *platform, add_named_menu(menu, target, labels->expression, labels->expression_names, labels->expression_count, BONGO_CAT_MENU_EXPRESSION_FIRST, NULL, labels->current_expression); + add_named_menu(menu, target, labels->audio, labels->audio_names, + labels->audio_count, BONGO_CAT_MENU_AUDIO_FIRST, + labels->audio_checked, labels->audio_count); NSMenuItem *modelRoot = [[NSMenuItem alloc] initWithTitle:text(labels->model) action:nil keyEquivalent:@""]; NSMenu *models = [[NSMenu alloc] initWithTitle:text(labels->model)]; diff --git a/src/platform/windows/windows_menu.c b/src/platform/windows/windows_menu.c index 8df81936..495bc5cb 100644 --- a/src/platform/windows/windows_menu.c +++ b/src/platform/windows/windows_menu.c @@ -47,9 +47,11 @@ BongoCatMenuAction bongo_cat_platform_context_menu(BongoCatPlatform *platform, HMENU models = CreatePopupMenu(); HMENU motions = labels->motion_count ? CreatePopupMenu() : NULL; HMENU expressions = labels->expression_count ? CreatePopupMenu() : NULL; + HMENU audio = labels->audio_count ? CreatePopupMenu() : NULL; if (!sizes || !opacity || !models || (labels->motion_count && !motions) || - (labels->expression_count && !expressions)) { + (labels->expression_count && !expressions) || (labels->audio_count && !audio)) { + if (audio) DestroyMenu(audio); destroy_unattached(menu, sizes, opacity, models, motions, expressions); return BONGO_CAT_MENU_NONE; } @@ -84,6 +86,10 @@ BongoCatMenuAction bongo_cat_platform_context_menu(BongoCatPlatform *platform, for (size_t i = 0; i < labels->expression_count; ++i) menu_text(expressions, MF_STRING, BONGO_CAT_MENU_EXPRESSION_FIRST + i, labels->expression_names[i]); + for (size_t i = 0; i < labels->audio_count; ++i) + menu_text(audio, MF_STRING | + (labels->audio_checked && labels->audio_checked[i] ? MF_CHECKED : 0), + BONGO_CAT_MENU_AUDIO_FIRST + i, labels->audio_names[i]); if (labels->current_expression < labels->expression_count) CheckMenuItem(expressions, (UINT)(BONGO_CAT_MENU_EXPRESSION_FIRST + labels->current_expression), @@ -102,6 +108,12 @@ BongoCatMenuAction bongo_cat_platform_context_menu(BongoCatPlatform *platform, motion_label ? motion_label : L""); if (labels->expression_count) AppendMenuW(menu, MF_POPUP, (UINT_PTR)expressions, expression_label ? expression_label : L""); + if (audio) { + wchar_t *audio_label = wide(labels->audio); + if (!AppendMenuW(menu, MF_POPUP, (UINT_PTR)audio, audio_label ? audio_label : L"")) + DestroyMenu(audio); + free(audio_label); + } AppendMenuW(menu, MF_POPUP, (UINT_PTR)models, model_label ? model_label : L""); free(size_label); free(opacity_label); free(model_label); free(motion_label); free(expression_label); diff --git a/src/runtime/input/shortcuts.c b/src/runtime/input/shortcuts.c index 08615d66..8197d605 100644 --- a/src/runtime/input/shortcuts.c +++ b/src/runtime/input/shortcuts.c @@ -1,5 +1,4 @@ #include "runtime.h" -#include "bongo_cat/audio.h" #include "bongo_cat/overlay.h" #include "bongo_cat/preferences.h" #include "bongo_cat/shortcut.h" @@ -11,36 +10,6 @@ static void toggle_pet_visibility(BongoCatApp *app) { bongo_cat_window_set_visible(app, !app->session.window.visible); } -bool bongo_cat_app_run_behavior(BongoCatApp *app, - const BongoCatBehaviorEntry *behavior) { - if (!app || !behavior) return false; - if (behavior->kind == BONGO_CAT_BEHAVIOR_EFFECT) { - if (!bongo_cat_overlay_effect(app->overlay, behavior->effect)) return false; - } else if (behavior->kind == BONGO_CAT_BEHAVIOR_SOUND) { - if (!behavior->sound[0]) { - bongo_cat_audio_stop(app->audio); - return true; - } - BongoCatError error = {0}; - bongo_cat_audio_play(app->audio, behavior->sound, &error); - } else if (behavior->kind == BONGO_CAT_BEHAVIOR_MOTION) { - bool started = bongo_cat_live2d_start_motion(app->live2d, - behavior->group, behavior->index); - if (!started) return false; - if (behavior->sound[0]) { - BongoCatError error = {0}; - bongo_cat_audio_play(app->audio, behavior->sound, &error); - } - } else { - int expression = bongo_cat_live2d_expression(app->live2d) == - behavior->index ? -1 : behavior->index; - if (!bongo_cat_live2d_set_expression(app->live2d, expression)) return false; - } - bongo_cat_app_capture_behavior_state(app); - app->dirty = true; - return true; -} - static bool hidden_toggle_has_visible_binding(BongoCatApp *app, const BongoCatBehaviorEntry *behavior, const char *shortcut) { if (!app || !behavior || !shortcut || !shortcut[0] || @@ -66,21 +35,18 @@ static bool hidden_toggle_has_visible_binding(BongoCatApp *app, return false; } -static bool behavior_shortcut(BongoCatApp *app, const BongoCatInputEvent *event) { - bool handled = false; +static bool behavior_shortcut(BongoCatApp *app, const BongoCatInputEvent *event, + bool handled, bool sound_edge) { for (size_t i = 0; i < app->settings.behavior_shortcut_count; ++i) { BongoCatBehaviorShortcut *shortcut = &app->settings.behavior_shortcuts[i]; for (size_t j = 0; j < app->behaviors.count; ++j) { BongoCatBehaviorEntry *behavior = &app->behaviors.entries[j]; + if (behavior->kind == BONGO_CAT_BEHAVIOR_SOUND) continue; if (strcmp(shortcut->id, behavior->id) != 0) continue; if (behavior->momentary && bongo_cat_shortcut_release_matches(event, shortcut->shortcut)) { if (behavior->kind == BONGO_CAT_BEHAVIOR_EFFECT) handled = bongo_cat_overlay_effect(app->overlay, NULL) || handled; - else if (behavior->kind == BONGO_CAT_BEHAVIOR_SOUND) { - bongo_cat_audio_stop(app->audio); - handled = true; - } } else if (bongo_cat_shortcut_matches(&app->shortcut_state, event, shortcut->shortcut) && !hidden_toggle_has_visible_binding(app, behavior, @@ -91,6 +57,7 @@ static bool behavior_shortcut(BongoCatApp *app, const BongoCatInputEvent *event) if (handled) return true; size_t limit = app->behaviors.count < 10 ? app->behaviors.count : 10; for (size_t i = 0; i < limit; ++i) { + if (app->behaviors.entries[i].kind == BONGO_CAT_BEHAVIOR_SOUND && !sound_edge) continue; char alias[8]; snprintf(alias, sizeof(alias), "Alt+%c", i == 9 ? '0' : (char)('1' + i)); if (bongo_cat_shortcut_matches(&app->shortcut_state, event, alias)) @@ -100,14 +67,16 @@ static bool behavior_shortcut(BongoCatApp *app, const BongoCatInputEvent *event) } void bongo_cat_app_shortcuts(BongoCatApp *app, const BongoCatInputEvent *event) { - if (!app) return; + if (!app || !event) return; + bool sound_edge = false; + bool sound_handled = bongo_cat_app_sound_shortcuts(app, event, &sound_edge); if (event->kind == BONGO_CAT_INPUT_GAMEPAD_BUTTON) { - behavior_shortcut(app, event); + behavior_shortcut(app, event, sound_handled, sound_edge); return; } bool primary = bongo_cat_shortcut_update(&app->shortcut_state, event); if (!primary) { - behavior_shortcut(app, event); + behavior_shortcut(app, event, sound_handled, sound_edge); return; } BongoCatShortcutPreferences *shortcuts = &app->settings.shortcuts; @@ -135,7 +104,7 @@ void bongo_cat_app_shortcuts(BongoCatApp *app, const BongoCatInputEvent *event) bongo_cat_window_mark_hit_dirty(app); bongo_cat_window_sync_click_through(app); bongo_cat_preferences_invalidate(app->preferences); - } else behavior_shortcut(app, event); + } else behavior_shortcut(app, event, sound_handled, sound_edge); } static void test_key(BongoCatApp *app, BongoCatInputKind kind, const char *name) { diff --git a/src/runtime/input/sound_shortcuts.c b/src/runtime/input/sound_shortcuts.c new file mode 100644 index 00000000..67a524ec --- /dev/null +++ b/src/runtime/input/sound_shortcuts.c @@ -0,0 +1,26 @@ +#include "runtime.h" + +#include + +bool bongo_cat_app_sound_shortcuts(BongoCatApp *app, const BongoCatInputEvent *event, + bool *changed) { + *changed = bongo_cat_sound_shortcut_update(&app->sound_shortcut_state, event); + if (!*changed) return false; + bool handled = false; + const BongoCatBehaviorEntry *pending[BONGO_CAT_BEHAVIOR_BINDING_CAP]; + size_t pending_count = 0; + for (size_t i = 0; i < app->settings.behavior_shortcut_count; ++i) { + const BongoCatBehaviorShortcut *binding = &app->settings.behavior_shortcuts[i]; + for (size_t j = 0; j < app->behaviors.count; ++j) { + const BongoCatBehaviorEntry *entry = &app->behaviors.entries[j]; + if (entry->kind != BONGO_CAT_BEHAVIOR_SOUND || strcmp(binding->id, entry->id)) continue; + bool down = bongo_cat_sound_shortcut_down(&app->sound_shortcut_state, binding->shortcut); + handled = handled || down; + if (down && !app->sound_shortcut_active[i] && + pending_count < BONGO_CAT_BEHAVIOR_BINDING_CAP) pending[pending_count++] = entry; + app->sound_shortcut_active[i] = down; + } + } + for (size_t i = 0; i < pending_count; ++i) bongo_cat_app_run_behavior(app, pending[i]); + return handled; +} diff --git a/src/runtime/lifecycle/app_input.c b/src/runtime/lifecycle/app_input.c index ad879f37..3fa4243b 100644 --- a/src/runtime/lifecycle/app_input.c +++ b/src/runtime/lifecycle/app_input.c @@ -13,6 +13,10 @@ void bongo_cat_app_drain_input(BongoCatApp *app, bool allow_shortcuts) { if (allow_shortcuts && !bongo_cat_preferences_shortcuts_blocked(app->preferences)) bongo_cat_app_shortcuts(app, &event); + else if (app->sound_shortcut_state.count) { + app->sound_shortcut_state.count = 0; + memset(app->sound_shortcut_active, 0, sizeof(app->sound_shortcut_active)); + } bongo_cat_app_apply_input(app, &event); } uint64_t now = SDL_GetTicks(); diff --git a/src/runtime/lifecycle/app_loop.c b/src/runtime/lifecycle/app_loop.c index c818170c..b846a81f 100644 --- a/src/runtime/lifecycle/app_loop.c +++ b/src/runtime/lifecycle/app_loop.c @@ -1,4 +1,5 @@ #include "runtime.h" +#include "bongo_cat/audio.h" #include "bongo_cat/log.h" #include "model_cover.h" #include "bongo_cat/overlay.h" @@ -191,6 +192,7 @@ void bongo_cat_app_loop(BongoCatApp *app) { bongo_cat_window_update_wheel_animation(app, now); bongo_cat_multi_pet_update(app, now); bongo_cat_random_expression_update(app, now); + bongo_cat_audio_update(app->audio); bongo_cat_window_update_display_recovery(app, now); bongo_cat_runtime_flow_update(app, now); bongo_cat_window_apply_pending_resize(app); diff --git a/src/runtime/model/import/mver/model_import_mver_audio.c b/src/runtime/model/import/mver/model_import_mver_audio.c new file mode 100644 index 00000000..64e73376 --- /dev/null +++ b/src/runtime/model/import/mver/model_import_mver_audio.c @@ -0,0 +1,69 @@ +#include "model_import_mver_internal.h" +#include "bongo_cat/path.h" + +#include +#include + +static bool sound_source(const BongoCatImportCandidate *candidate, size_t index, + char *source, size_t capacity, char *relative, size_t relative_capacity) { + static const char *extensions[] = {"wav", "ogg", "flac"}; + for (size_t i = 0; i < sizeof(extensions) / sizeof(extensions[0]); ++i) { + char name[40], sounds[BONGO_CAT_PATH_CAP]; + snprintf(name, sizeof(name), "%zu.%s", index, extensions[i]); + if (!bongo_cat_path_join(sounds, sizeof(sounds), candidate->assets, "sounds") || + !bongo_cat_path_join(source, capacity, sounds, name) || + !bongo_cat_path_is_file(source)) continue; + snprintf(relative, relative_capacity, "resources/sounds/%s", name); + return true; + } + return false; +} + +static bool add_sound_clear(yyjson_mut_doc *output, yyjson_mut_val *items, + yyjson_val *config) { + yyjson_val *decoration = yyjson_obj_get(config, "decoration"); + yyjson_val *row = yyjson_obj_get(decoration, "soundClear"); + if (!row || (yyjson_is_arr(row) && yyjson_arr_size(row) == 0)) return true; + yyjson_mut_val *item = yyjson_mut_arr_add_obj(output, items); + /* Stopping all audio is an explicit command and has no default shortcut. */ + return item && yyjson_mut_obj_add_str(output, item, "kind", "sound-clear"); +} + +bool bongo_cat_mver_add_audio(void *raw_output, void *raw_items, + void *raw_config, void *raw_rows, + const BongoCatImportCandidate *candidate, const BongoCatMverLabels *labels, + const char *target) { + yyjson_mut_doc *output = raw_output; + yyjson_mut_val *items = raw_items; + yyjson_val *config = raw_config, *rows = raw_rows; + if (!rows) return true; + if (!yyjson_is_arr(rows)) return false; + char target_resources[BONGO_CAT_PATH_CAP], target_sounds[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(target_resources, sizeof(target_resources), target, "resources") || + !bongo_cat_path_join(target_sounds, sizeof(target_sounds), target_resources, "sounds") || + !bongo_cat_path_create_directory(target_sounds)) return false; + yyjson_val *decoration = yyjson_obj_get(config, "decoration"); + yyjson_val *keep_value = yyjson_obj_get(decoration, "soundKeep"); + bool keep = !keep_value || yyjson_get_bool(keep_value); + size_t emitted = 0, index, count; yyjson_val *row; + yyjson_arr_foreach(rows, index, count, row) { + char shortcut[BONGO_CAT_SHORTCUT_CAP], source[BONGO_CAT_PATH_CAP]; + char relative[BONGO_CAT_PATH_CAP], destination[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_mver_sound_chord(row, shortcut, sizeof(shortcut))) return false; + if (!sound_source(candidate, index, source, sizeof(source), relative, + sizeof(relative))) continue; + const char *name = bongo_cat_path_name(source); + if (!bongo_cat_path_join(destination, sizeof(destination), target_sounds, name) || + !bongo_cat_path_copy_file(source, destination)) return false; + yyjson_mut_val *item = yyjson_mut_arr_add_obj(output, items); + const char *label = bongo_cat_mver_label(labels, "sounds", index); + if (!item || !yyjson_mut_obj_add_str(output, item, "kind", "sound") || + !yyjson_mut_obj_add_strcpy(output, item, "shortcut", shortcut) || + !yyjson_mut_obj_add_strcpy(output, item, "sound", relative) || + (label && !yyjson_mut_obj_add_strcpy(output, item, "label", label)) || + !yyjson_mut_obj_add_bool(output, item, "overlap", keep)) return false; + emitted++; + } + return !emitted || add_sound_clear(output, items, config); +} + diff --git a/src/runtime/model/import/mver/model_import_mver_internal.h b/src/runtime/model/import/mver/model_import_mver_internal.h index 547f70c8..8c3b7597 100644 --- a/src/runtime/model/import/mver/model_import_mver_internal.h +++ b/src/runtime/model/import/mver/model_import_mver_internal.h @@ -28,6 +28,10 @@ BongoCatMverKeyNames bongo_cat_mver_gamepad_names(int code); int bongo_cat_mver_modifier_index(int code); bool bongo_cat_mver_chord(const BongoCatImportCandidate *candidate, void *row, char *output, size_t capacity); +bool bongo_cat_mver_sound_chord(void *row, char *output, size_t capacity); +bool bongo_cat_mver_add_audio(void *output, void *items, void *config, void *rows, + const BongoCatImportCandidate *candidate, const BongoCatMverLabels *labels, + const char *target); bool bongo_cat_mver_effects(void *output, void *items, void *root, void *mode, const BongoCatImportCandidate *candidate, const char *target); bool bongo_cat_mver_add_behaviors(void *output, void *items, void *mode, diff --git a/src/runtime/model/import/mver/model_import_mver_metadata.c b/src/runtime/model/import/mver/model_import_mver_metadata.c index 1c0b2e12..f8dbfc08 100644 --- a/src/runtime/model/import/mver/model_import_mver_metadata.c +++ b/src/runtime/model/import/mver/model_import_mver_metadata.c @@ -150,68 +150,6 @@ static bool add_native_render(yyjson_mut_doc *output, yyjson_mut_val *root) { return render && yyjson_mut_obj_add_str(output, render, "profile", "native"); } -static bool sound_source(const BongoCatImportCandidate *candidate, size_t index, - char *source, size_t capacity, char *relative, size_t relative_capacity) { - static const char *extensions[] = {"wav", "ogg", "flac"}; - for (size_t i = 0; i < sizeof(extensions) / sizeof(extensions[0]); ++i) { - char name[40], sounds[BONGO_CAT_PATH_CAP]; - snprintf(name, sizeof(name), "%zu.%s", index, extensions[i]); - if (!bongo_cat_path_join(sounds, sizeof(sounds), candidate->assets, "sounds") || - !bongo_cat_path_join(source, capacity, sounds, name) || - !bongo_cat_path_is_file(source)) continue; - snprintf(relative, relative_capacity, "resources/sounds/%s", name); - return true; - } - return false; -} - -static bool add_sound_clear(yyjson_mut_doc *output, yyjson_mut_val *items, - yyjson_val *config, const BongoCatImportCandidate *candidate) { - yyjson_val *decoration = yyjson_obj_get(config, "decoration"); - yyjson_val *row = yyjson_obj_get(decoration, "soundClear"); - if (!row) return true; - char shortcut[BONGO_CAT_SHORTCUT_CAP]; - if (!bongo_cat_mver_chord(candidate, row, shortcut, sizeof(shortcut))) return false; - yyjson_mut_val *item = yyjson_mut_arr_add_obj(output, items); - return item && yyjson_mut_obj_add_str(output, item, "kind", "sound-clear") && - yyjson_mut_obj_add_strcpy(output, item, "shortcut", shortcut); -} - -static bool add_sounds(yyjson_mut_doc *output, yyjson_mut_val *items, - yyjson_val *config, yyjson_val *rows, - const BongoCatImportCandidate *candidate, const BongoCatMverLabels *labels, - const char *target) { - if (!rows) return true; - if (!yyjson_is_arr(rows)) return false; - char target_resources[BONGO_CAT_PATH_CAP], target_sounds[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(target_resources, sizeof(target_resources), target, "resources") || - !bongo_cat_path_join(target_sounds, sizeof(target_sounds), target_resources, "sounds") || - !bongo_cat_path_create_directory(target_sounds)) return false; - yyjson_val *decoration = yyjson_obj_get(config, "decoration"); - yyjson_val *keep_value = yyjson_obj_get(decoration, "soundKeep"); - bool keep = !keep_value || yyjson_get_bool(keep_value); - size_t emitted = 0, index, count; yyjson_val *row; - yyjson_arr_foreach(rows, index, count, row) { - char shortcut[BONGO_CAT_SHORTCUT_CAP], source[BONGO_CAT_PATH_CAP]; - char relative[BONGO_CAT_PATH_CAP], destination[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_mver_chord(candidate, row, shortcut, sizeof(shortcut))) return false; - if (!sound_source(candidate, index, source, sizeof(source), relative, - sizeof(relative))) continue; - const char *name = bongo_cat_path_name(source); - if (!bongo_cat_path_join(destination, sizeof(destination), target_sounds, name) || - !bongo_cat_path_copy_file(source, destination)) return false; - yyjson_mut_val *item = yyjson_mut_arr_add_obj(output, items); - const char *label = bongo_cat_mver_label(labels, "sounds", index); - if (!item || !yyjson_mut_obj_add_str(output, item, "kind", "sound") || - !yyjson_mut_obj_add_strcpy(output, item, "shortcut", shortcut) || - !yyjson_mut_obj_add_strcpy(output, item, "sound", relative) || - (label && !yyjson_mut_obj_add_strcpy(output, item, "label", label)) || - (!keep && !yyjson_mut_obj_add_bool(output, item, "momentary", true))) return false; - emitted++; - } - return !emitted || !keep || add_sound_clear(output, items, config, candidate); -} - bool bongo_cat_import_adapter_metadata(const BongoCatImportCandidate *candidate, const char *target, BongoCatError *error) { bool mver = candidate->format != BONGO_CAT_IMPORT_TAURI; @@ -235,7 +173,7 @@ bool bongo_cat_import_adapter_metadata(const BongoCatImportCandidate *candidate, if (ok && mver) ok = yyjson_is_obj(mode) && add_render_profile(output, root, config, candidate) && bongo_cat_mver_add_behaviors(output, items, mode, candidate, &labels, error) && - add_sounds(output, items, config, yyjson_obj_get(mode, "sounds"), + bongo_cat_mver_add_audio(output, items, config, yyjson_obj_get(mode, "sounds"), candidate, &labels, target) && bongo_cat_mver_effects(output, items, config, mode, candidate, target); else if (ok) ok = add_native_render(output, root); diff --git a/src/runtime/model/import/mver/model_import_mver_shortcut.c b/src/runtime/model/import/mver/model_import_mver_shortcut.c index 51ffd9b6..652ed1d6 100644 --- a/src/runtime/model/import/mver/model_import_mver_shortcut.c +++ b/src/runtime/model/import/mver/model_import_mver_shortcut.c @@ -73,3 +73,38 @@ bool bongo_cat_mver_chord(const BongoCatImportCandidate *candidate, return candidate && candidate->gamepad_buttons && code >= 0 && code <= 15 ? gamepad_chord(row, output, capacity) : keyboard_chord(row, output, capacity); } + +/* Mver's audio uses Windows keys even when its visual mode uses a gamepad. + Preserve arbitrary chords, modifier-only keys, and mouse buttons. */ +bool bongo_cat_mver_sound_chord(void *raw, char *output, size_t capacity) { + yyjson_val *row = raw; + if (!output || !capacity || !yyjson_is_arr(row) || !yyjson_arr_size(row)) return false; + output[0] = '\0'; + size_t index, count; yyjson_val *key; + yyjson_arr_foreach(row, index, count, key) { + if (!yyjson_is_int(key) && !yyjson_is_uint(key)) return false; + int64_t raw_code = yyjson_get_sint(key); + if (raw_code <= 0 || raw_code > 255) return false; + int code = (int)raw_code; + const char *name = NULL; + if (code == 1) name = "Left"; + else if (code == 2) name = "Right"; + else if (code == 4) name = "Middle"; + else if (code == 5) name = "Back"; + else if (code == 6) name = "Forward"; + else if (code == 16) name = "Shift"; + else if (code == 17) name = "Control"; + else if (code == 18) name = "Alt"; + else if (code == 160) name = "ShiftLeft"; + else if (code == 161) name = "ShiftRight"; + else if (code == 162) name = "ControlLeft"; + else if (code == 163) name = "ControlRight"; + else if (code == 164) name = "Alt"; + else if (code == 165) name = "AltGr"; + char generated[16]; + if (!name) name = shortcut_key(code, generated); + if (!name || (index && !append(output, capacity, "+")) || + !append(output, capacity, name)) return false; + } + return true; +} diff --git a/src/runtime/model/model_behavior.c b/src/runtime/model/model_behavior.c new file mode 100644 index 00000000..90b4dab7 --- /dev/null +++ b/src/runtime/model/model_behavior.c @@ -0,0 +1,40 @@ +#include "runtime.h" +#include "bongo_cat/audio.h" +#include "bongo_cat/overlay.h" + +bool bongo_cat_app_run_behavior(BongoCatApp *app, + const BongoCatBehaviorEntry *behavior) { + if (!app || !behavior) return false; + if (behavior->kind == BONGO_CAT_BEHAVIOR_EFFECT) { + if (!bongo_cat_overlay_effect(app->overlay, behavior->effect)) return false; + } else if (behavior->kind == BONGO_CAT_BEHAVIOR_SOUND) { + if (behavior->sound_clear) { + bongo_cat_audio_stop(app->audio); + return true; + } + if (!behavior->sound[0]) return false; + BongoCatError error = {0}; + if (bongo_cat_audio_play_sound(app->audio, behavior->sound, + behavior->sound_overlap, &error) != BONGO_CAT_OK) { + SDL_LogWarn(SDL_LOG_CATEGORY_AUDIO, "%s", error.message); + return false; + } + return true; + } else if (behavior->kind == BONGO_CAT_BEHAVIOR_MOTION) { + bool started = bongo_cat_live2d_start_motion(app->live2d, + behavior->group, behavior->index); + if (!started) return false; + if (behavior->sound[0]) { + BongoCatError error = {0}; + bongo_cat_audio_play(app->audio, behavior->sound, &error); + } + } else { + int expression = bongo_cat_live2d_expression(app->live2d) == + behavior->index ? -1 : behavior->index; + if (!bongo_cat_live2d_set_expression(app->live2d, expression)) return false; + } + bongo_cat_app_capture_behavior_state(app); + app->dirty = true; + return true; +} + diff --git a/src/runtime/model/model_files.c b/src/runtime/model/model_files.c index 30759fdc..3322aa0f 100644 --- a/src/runtime/model/model_files.c +++ b/src/runtime/model/model_files.c @@ -245,7 +245,9 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, SDL_GetWindowSizeInPixels(app->window, &pixel_width, &pixel_height); } bongo_cat_live2d_resize(app->live2d, pixel_width, pixel_height); - bongo_cat_audio_stop(app->audio); + bongo_cat_audio_reset(app->audio); + memset(&app->sound_shortcut_state, 0, sizeof(app->sound_shortcut_state)); + memset(app->sound_shortcut_active, 0, sizeof(app->sound_shortcut_active)); commit_model(app, entry, true, replacing_model); bongo_cat_app_reapply_input(app); bongo_cat_app_apply_mouse(app); diff --git a/src/runtime/runtime.h b/src/runtime/runtime.h index deaaa9a0..4bf4776b 100644 --- a/src/runtime/runtime.h +++ b/src/runtime/runtime.h @@ -95,6 +95,8 @@ bool bongo_cat_app_audit_display_pointer(BongoCatApp *app); void bongo_cat_app_track_hover(BongoCatApp *app, double x, double y); void bongo_cat_app_update_hover(BongoCatApp *app, uint64_t now); bool bongo_cat_app_shortcuts_self_test(BongoCatApp *app); +bool bongo_cat_app_sound_shortcuts(BongoCatApp *app, const BongoCatInputEvent *event, + bool *changed); void bongo_cat_window_menu_action(BongoCatApp *app, BongoCatMenuAction action); bool bongo_cat_window_menu_self_test(BongoCatApp *app); bool bongo_cat_window_geometry_self_test(BongoCatApp *app); diff --git a/src/runtime/shell/window_menu.h b/src/runtime/shell/window_menu.h index a0219384..8ff20822 100644 --- a/src/runtime/shell/window_menu.h +++ b/src/runtime/shell/window_menu.h @@ -18,6 +18,8 @@ void bongo_cat_window_menu_preview_tick(void *userdata); void bongo_cat_window_menu_restore(void *userdata, BongoCatMenuAction selected); bool bongo_cat_window_menu_preview_applied( const BongoCatWindowMenuPreview *state, BongoCatMenuAction selected); +void bongo_cat_window_audio_labels(BongoCatApp *app, + char names[][BONGO_CAT_MENU_LABEL_CAP], bool *checked, size_t *count); void bongo_cat_window_behavior_labels(BongoCatApp *app, char motions[][BONGO_CAT_MENU_LABEL_CAP], bool *motion_checked, size_t *motion_count, char expressions[][BONGO_CAT_MENU_LABEL_CAP], diff --git a/src/runtime/shell/window_menu_actions.c b/src/runtime/shell/window_menu_actions.c index c614f401..8aca3e31 100644 --- a/src/runtime/shell/window_menu_actions.c +++ b/src/runtime/shell/window_menu_actions.c @@ -70,7 +70,13 @@ void bongo_cat_window_show_context_menu(BongoCatApp *app) { bongo_cat_window_menu_restore, &preview, tr(app, "native.removeDesktopPet", "Close this desktop pet"), app->secondary_pet || (app->settings.model.multiple_pets && - app->session.additional_model_count > 0)}; + app->session.additional_model_count > 0), NULL, NULL, NULL, 0}; + char audio_names[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; + bool audio_checked[BONGO_CAT_BEHAVIOR_CAP] = {false}; + labels.audio = tr(app, "pages.preference.model.behaviorModal.labels.audio", "Audio"); + labels.audio_names = audio_names; + labels.audio_checked = audio_checked; + bongo_cat_window_audio_labels(app, audio_names, audio_checked, &labels.audio_count); BongoCatMenuAction action = bongo_cat_platform_context_menu( &app->platform, &labels); if (bongo_cat_window_menu_preview_applied(&preview, action)) diff --git a/src/runtime/shell/window_menu_behavior.c b/src/runtime/shell/window_menu_behavior.c index f195936d..cd4b3569 100644 --- a/src/runtime/shell/window_menu_behavior.c +++ b/src/runtime/shell/window_menu_behavior.c @@ -1,5 +1,6 @@ #include "window_menu.h" #include "bongo_cat/audio.h" +#include "bongo_cat/i18n.h" #include #include @@ -10,6 +11,7 @@ static const BongoCatBehaviorEntry *nth_behavior(BongoCatApp *app, for (size_t i = 0; i < app->behaviors.count; ++i) { const BongoCatBehaviorEntry *entry = &app->behaviors.entries[i]; if (entry->kind != kind) continue; + if (kind == BONGO_CAT_BEHAVIOR_SOUND && !entry->sound[0] && !entry->sound_clear) continue; if (kind == BONGO_CAT_BEHAVIOR_MOTION && !bongo_cat_live2d_motion_visible(app->live2d, entry->group, entry->index)) continue; @@ -57,6 +59,8 @@ static const char *behavior_label(const BongoCatApp *app, if (strcmp(binding->id, entry->id) == 0 && binding->label[0]) return binding->label; } + if (entry->sound_clear) return bongo_cat_i18n_get(app->i18n, + "pages.preference.model.behaviorModal.labels.stopAllAudio", "Stop all audio"); return entry->label; } @@ -124,6 +128,15 @@ bool bongo_cat_window_behavior_action(BongoCatApp *app, action < BONGO_CAT_MENU_EXPRESSION_FIRST + BONGO_CAT_BEHAVIOR_CAP) { kind = BONGO_CAT_BEHAVIOR_EXPRESSION; position = (size_t)(action - BONGO_CAT_MENU_EXPRESSION_FIRST); + } else if (action >= BONGO_CAT_MENU_AUDIO_FIRST && + action < BONGO_CAT_MENU_AUDIO_FIRST + BONGO_CAT_BEHAVIOR_CAP) { + const BongoCatBehaviorEntry *sound = nth_behavior(app, BONGO_CAT_BEHAVIOR_SOUND, + (size_t)(action - BONGO_CAT_MENU_AUDIO_FIRST)); + if (!sound) return false; + if (bongo_cat_audio_is_playing(app->audio, sound->sound)) + bongo_cat_audio_stop_path(app->audio, sound->sound); + else return bongo_cat_app_run_behavior(app, sound); + return true; } else return false; const BongoCatBehaviorEntry *entry = nth_behavior(app, kind, position); return entry && run_binding(app, entry); @@ -186,3 +199,18 @@ bool bongo_cat_window_behavior_menu_action(BongoCatMenuAction action) { (action >= BONGO_CAT_MENU_EXPRESSION_FIRST && action < BONGO_CAT_MENU_EXPRESSION_FIRST + BONGO_CAT_BEHAVIOR_CAP); } + +void bongo_cat_window_audio_labels(BongoCatApp *app, + char names[][BONGO_CAT_MENU_LABEL_CAP], bool *checked, size_t *count) { + *count = 0; + bool available = false; + for (size_t i = 0; i < app->behaviors.count; ++i) { + const BongoCatBehaviorEntry *entry = &app->behaviors.entries[i]; + if (entry->kind != BONGO_CAT_BEHAVIOR_SOUND || (!entry->sound[0] && !entry->sound_clear)) continue; + available = available || entry->sound[0]; + menu_label(names[*count], app, entry); + checked[*count] = bongo_cat_audio_is_playing(app->audio, entry->sound); + ++*count; + } + if (!available) *count = 0; +} diff --git a/src/ui/preferences/preferences.c b/src/ui/preferences/preferences.c index f8003e2d..732b5290 100644 --- a/src/ui/preferences/preferences.c +++ b/src/ui/preferences/preferences.c @@ -1,3 +1,4 @@ +#include "bongo_cat/audio.h" #include "bongo_cat/preferences.h" #include "bongo_cat/app.h" #include "bongo_cat/memory.h" @@ -73,6 +74,18 @@ bool bongo_cat_preferences_needs_frame(BongoCatPreferences *value) { value->render_dirty = true; } if (!value->window || !value->visible) return false; + if (value->behavior_dialog && value->app) { + for (size_t i = 0; i < value->app->behaviors.count; ++i) { + const BongoCatBehaviorEntry *entry = &value->app->behaviors.entries[i]; + bool playing = entry->kind == BONGO_CAT_BEHAVIOR_SOUND && + (entry->sound_clear ? bongo_cat_audio_any_playing(value->app->audio) : + bongo_cat_audio_is_playing(value->app->audio, entry->sound)); + if (value->behavior_audio_playing[i] != playing) { + value->behavior_audio_playing[i] = playing; + value->render_dirty = true; + } + } + } if (value->render_retry_ns > now) return false; bool raster_due = value->pending_raster_scale > 0.0f && value->raster_retry_ns <= now; diff --git a/src/ui/preferences/preferences_behavior_dialog.c b/src/ui/preferences/preferences_behavior_dialog.c index 00ed6aca..3ac47744 100644 --- a/src/ui/preferences/preferences_behavior_dialog.c +++ b/src/ui/preferences/preferences_behavior_dialog.c @@ -39,10 +39,20 @@ static bool matches(const BongoCatPreferences *value, if (tab == 0) return entry->kind == BONGO_CAT_BEHAVIOR_MOTION && bongo_cat_live2d_motion_visible(value->app->live2d, entry->group, entry->index); - return entry->kind == BONGO_CAT_BEHAVIOR_EXPRESSION; + if (tab == 1) return entry->kind == BONGO_CAT_BEHAVIOR_EXPRESSION; + return entry->kind == BONGO_CAT_BEHAVIOR_SOUND && (entry->sound[0] || entry->sound_clear); +} + +static bool tab_available(const BongoCatPreferences *value, int tab) { + for (size_t i = 0; i < value->app->behaviors.count; ++i) { + const BongoCatBehaviorEntry *entry = &value->app->behaviors.entries[i]; + if (matches(value, entry, tab) && (tab != 2 || entry->sound[0])) return true; + } + return false; } static size_t row_count(const BongoCatPreferences *value) { + if (!tab_available(value, value->behavior_tab)) return 0; size_t count = 0; for (size_t i = 0; i < value->app->behaviors.count; ++i) if (matches(value, &value->app->behaviors.entries[i], @@ -84,7 +94,7 @@ static bool draw_header(BongoCatPreferences *value, struct nk_context *context, BongoCatUIPalette p, float opacity, bool enabled) { text(canvas, nk_rect(panel.x + 20, panel.y + 21, panel.w - 74, 24), tr(value, "pages.preference.model.behaviorModal.title", - "Motions and expressions"), value->ui.label_font, alpha(p.text, opacity)); + "Motions, expressions and audio"), value->ui.label_font, alpha(p.text, opacity)); struct nk_rect close = nk_rect(panel.x + panel.w - 52, panel.y + 17, 32, 32); return bongo_cat_ui_close_button(context, canvas, close, alpha(p.muted, opacity), alpha(p.accent, opacity), enabled); @@ -98,10 +108,15 @@ static void draw_segments(BongoCatPreferences *value, nk_fill_rect(canvas, wrapper, 10, alpha(p.field, opacity)); const char *labels[] = {tr(value, "pages.preference.model.behaviorModal.labels.motion", "Motions"), tr(value, - "pages.preference.model.behaviorModal.labels.expression", "Expressions")}; - float width = (wrapper.w - 6) * .5f; - for (int i = 0; i < 2; ++i) { - struct nk_rect button = nk_rect(wrapper.x + 3 + width * i, + "pages.preference.model.behaviorModal.labels.expression", "Expressions"), tr(value, + "pages.preference.model.behaviorModal.labels.audio", "Audio")}; + int count = 0, position = 0; + for (int i = 0; i < 3; ++i) count += tab_available(value, i); + if (!count) return; + float width = (wrapper.w - 6) / count; + for (int i = 0; i < 3; ++i) { + if (!tab_available(value, i)) continue; + struct nk_rect button = nk_rect(wrapper.x + 3 + width * position++, wrapper.y + 3, width, 37); bool hover = enabled && nk_input_is_mouse_hovering_rect( &context->input, button); @@ -183,6 +198,10 @@ static void draw_rows(BongoCatPreferences *value, struct nk_context *context, void bongo_cat_preferences_behavior_dialog_draw( BongoCatPreferences *value, struct nk_context *context) { if (!bongo_cat_preferences_behavior_dialog_active(value)) return; + if (!tab_available(value, value->behavior_tab)) + for (int i = 0; i < 3; ++i) if (tab_available(value, i)) { + value->behavior_tab = i; break; + } bongo_cat_ui_cursor_reset(context); struct nk_rect region = nk_window_get_bounds(context); size_t count = row_count(value); diff --git a/src/ui/preferences/preferences_behavior_row.c b/src/ui/preferences/preferences_behavior_row.c index b721a17b..0d210b31 100644 --- a/src/ui/preferences/preferences_behavior_row.c +++ b/src/ui/preferences/preferences_behavior_row.c @@ -1,4 +1,5 @@ #include "preferences_state.h" +#include "bongo_cat/audio.h" #include "preferences_overlay.h" #include "preferences_shortcut_clear.h" #include "ui_animation.h" @@ -39,9 +40,13 @@ static BongoCatBehaviorShortcut *binding_for(BongoCatSettings *config, return binding; } -static const char *display_label(const BongoCatBehaviorEntry *entry, +static const char *display_label(BongoCatPreferences *value, const BongoCatBehaviorEntry *entry, const BongoCatBehaviorShortcut *binding) { - return binding && binding->label[0] ? binding->label : entry->label; + if (binding && binding->label[0]) + return binding->label; + if (entry->sound_clear) return bongo_cat_i18n_get(value->app->i18n, + "pages.preference.model.behaviorModal.labels.stopAllAudio", "Stop all audio"); + return entry->label; } static bool shortcut_editor(BongoCatPreferences *value, @@ -82,7 +87,9 @@ static bool shortcut_editor(BongoCatPreferences *value, bounds.y + 8, 17, 20); if (bongo_cat_pref_shortcut_clear(context, canvas, id, clear, p, opacity, enabled && !active && shortcut->shortcut[0])) { - shortcut->shortcut[0] = '\0'; value->render_dirty = true; return false; + shortcut->shortcut[0] = '\0'; + shortcut->shortcut_disabled = true; + value->render_dirty = true; return false; } if (hover) bongo_cat_ui_cursor_hover_rect(context, bounds, BONGO_CAT_UI_CURSOR_POINTER); @@ -93,7 +100,7 @@ static void draw_name(BongoCatPreferences *value, struct nk_context *context, struct nk_command_buffer *canvas, struct nk_rect bounds, BongoCatBehaviorEntry *entry, BongoCatBehaviorShortcut *binding, BongoCatUIPalette p, float opacity, bool enabled) { - const char *label = display_label(entry, binding); + const char *label = display_label(value, entry, binding); BongoCatPreferencesTextSession *session = &value->behavior_rename; bool renaming = !strcmp(session->id, entry->id); bool hover = enabled && nk_input_is_mouse_hovering_rect( @@ -141,18 +148,27 @@ void bongo_cat_preferences_behavior_row_draw(BongoCatPreferences *value, BongoCatBehaviorShortcut *binding = binding_for(&value->app->settings, entry->id); draw_name(value, context, canvas, name, entry, binding, p, opacity, enabled); - bool play_hover = enabled && nk_input_is_mouse_hovering_rect( + bool play_enabled = enabled; + bool play_hover = play_enabled && nk_input_is_mouse_hovering_rect( &context->input, play); nk_fill_rect(canvas, play, 10, alpha(play_hover ? p.hover : p.field, opacity)); nk_stroke_rect(canvas, play, 10, 1, alpha(play_hover ? p.accent : p.border_subtle, opacity)); - bongo_cat_preferences_icon_draw(value, canvas, BONGO_CAT_UI_ICON_PLAY, + bool playing = entry->kind == BONGO_CAT_BEHAVIOR_SOUND && + bongo_cat_audio_is_playing(value->app->audio, entry->sound); + if (playing) + nk_fill_rect(canvas, nk_rect(play.x + 12, play.y + 12, 12, 12), 2, + alpha(!play_enabled ? p.muted : play_hover ? p.accent : p.text, opacity)); + else bongo_cat_preferences_icon_draw(value, canvas, BONGO_CAT_UI_ICON_PLAY, nk_rect(play.x + 10, play.y + 10, 16, 16), - alpha(play_hover ? p.accent : p.text, opacity)); + alpha(!play_enabled ? p.muted : play_hover ? p.accent : p.text, opacity)); if (play_hover) bongo_cat_ui_cursor_hover_rect(context, play, BONGO_CAT_UI_CURSOR_POINTER); - if (hit(context, play, enabled)) - bongo_cat_app_run_behavior(value->app, entry); + if (hit(context, play, play_enabled)) { + if (playing) bongo_cat_audio_stop_path(value->app->audio, entry->sound); + else bongo_cat_app_run_behavior(value->app, entry); + value->render_dirty = true; + } if (!binding) return; char id[BONGO_CAT_ID_CAP + 16]; snprintf(id, sizeof(id), "behavior-%.*s", (int)sizeof(id) - 10, entry->id); diff --git a/src/ui/preferences/preferences_shortcuts.c b/src/ui/preferences/preferences_shortcuts.c index 07acabbd..f342ed87 100644 --- a/src/ui/preferences/preferences_shortcuts.c +++ b/src/ui/preferences/preferences_shortcuts.c @@ -20,6 +20,12 @@ bool bongo_cat_preferences_shortcut_active(const BongoCatPreferences *value, } static void finish(BongoCatPreferences *value) { + if (value->shortcut_target && strcmp(value->shortcut_target, value->shortcut_original)) + for (size_t i = 0; i < value->app->settings.behavior_shortcut_count; ++i) { + BongoCatBehaviorShortcut *binding = &value->app->settings.behavior_shortcuts[i]; + if (binding->shortcut == value->shortcut_target) + binding->shortcut_disabled = !binding->shortcut[0]; + } value->shortcut_recording = false; value->shortcut_id[0] = '\0'; value->shortcut_target = NULL; @@ -27,6 +33,8 @@ static void finish(BongoCatPreferences *value) { value->shortcut_key = SDLK_UNKNOWN; value->shortcut_suppress_until_ns = SDL_GetTicksNS() + 250000000ULL; bongo_cat_shortcut_init(&value->app->shortcut_state); + memset(&value->app->sound_shortcut_state, 0, sizeof(value->app->sound_shortcut_state)); + memset(value->app->sound_shortcut_active, 0, sizeof(value->app->sound_shortcut_active)); value->render_dirty = true; } @@ -50,6 +58,8 @@ void bongo_cat_preferences_shortcut_begin(BongoCatPreferences *value, value->shortcut_key = SDLK_UNKNOWN; value->shortcut_recording = true; bongo_cat_shortcut_init(&value->app->shortcut_state); + memset(&value->app->sound_shortcut_state, 0, sizeof(value->app->sound_shortcut_state)); + memset(value->app->sound_shortcut_active, 0, sizeof(value->app->sound_shortcut_active)); value->render_dirty = true; } diff --git a/src/ui/preferences/preferences_state.h b/src/ui/preferences/preferences_state.h index 26023e3f..dce31914 100644 --- a/src/ui/preferences/preferences_state.h +++ b/src/ui/preferences/preferences_state.h @@ -98,7 +98,8 @@ struct BongoCatPreferences { uint64_t behavior_dialog_opened_ns; uint64_t behavior_dialog_closing_ns; uint64_t behavior_tab_transition_ns; - float behavior_scroll[2]; + float behavior_scroll[3]; + bool behavior_audio_playing[BONGO_CAT_BEHAVIOR_CAP]; BongoCatPreferencesScrollbar behavior_scrollbar; BongoCatPreferencesTextSession behavior_rename; BongoCatPreferencesTextSession model_rename; diff --git a/tests/core/test_config_validation.c b/tests/core/test_config_validation.c index 1415189c..f333d3ea 100644 --- a/tests/core/test_config_validation.c +++ b/tests/core/test_config_validation.c @@ -1,6 +1,7 @@ #include "test.h" #include "test_config_validation.h" #include "bongo_cat/config.h" +#include "bongo_cat/file.h" #include "bongo_cat/model.h" #include @@ -115,8 +116,34 @@ static void check_shortcuts(void) { settings.shortcuts.toggle_pet_visibility)); } +static void check_audio_companions(void) { + static BongoCatSettings settings, loaded; + bongo_cat_settings_defaults(&settings); + settings.behavior_shortcut_count = 3; + const char *ids[] = {"lucia:motion:CAT_motion_lock:14", "lucia:sound:0", "lucia:sound:3"}; + for (size_t i = 0; i < 3; ++i) + snprintf(settings.behavior_shortcuts[i].id, sizeof(settings.behavior_shortcuts[i].id), "%s", ids[i]); + snprintf(settings.behavior_shortcuts[0].shortcut, BONGO_CAT_SHORTCUT_CAP, "Alt+O"); + snprintf(settings.behavior_shortcuts[1].shortcut, BONGO_CAT_SHORTCUT_CAP, "Alt+O"); + settings.behavior_shortcuts[2].shortcut_disabled = true; + bongo_cat_settings_validate(&settings); + CHECK(settings.behavior_shortcut_count == 3); + CHECK(!strcmp(settings.behavior_shortcuts[1].shortcut, "Alt+O")); + CHECK(!bongo_cat_settings_shortcut_conflicts(&settings, "Alt+O", settings.behavior_shortcuts[1].shortcut)); + CHECK(!bongo_cat_settings_shortcut_conflicts(&settings, "Alt+O", settings.behavior_shortcuts[0].shortcut)); + CHECK(bongo_cat_settings_shortcut_conflicts(&settings, "Alt+O", settings.shortcuts.mirror)); + CHECK(bongo_cat_settings_save("bongocat-audio-config-test.json", &settings, NULL) == BONGO_CAT_OK); + bongo_cat_settings_defaults(&loaded); + CHECK(bongo_cat_settings_load("bongocat-audio-config-test.json", &loaded, NULL) == BONGO_CAT_OK); + CHECK(loaded.behavior_shortcut_count == 3); + CHECK(!strcmp(loaded.behavior_shortcuts[1].shortcut, "Alt+O")); + CHECK(loaded.behavior_shortcuts[2].shortcut_disabled); + CHECK(bongo_cat_file_remove("bongocat-audio-config-test.json")); +} + void test_config_validation(void) { check_defaults_and_validation(); + check_audio_companions(); check_shortcuts(); check_override_canonicalization(); } diff --git a/tests/core/test_main.c b/tests/core/test_main.c index 51ee8ff7..24fd9dbb 100644 --- a/tests/core/test_main.c +++ b/tests/core/test_main.c @@ -9,6 +9,7 @@ int main(void) { test_models(); test_mver_pointer(); test_shortcut(); + test_sound_shortcut(); test_update(); if (bongo_cat_test_failures) { fprintf(stderr, "%d checks failed\n", bongo_cat_test_failures); diff --git a/tests/core/test_sound_shortcut.c b/tests/core/test_sound_shortcut.c new file mode 100644 index 00000000..510d2543 --- /dev/null +++ b/tests/core/test_sound_shortcut.c @@ -0,0 +1,37 @@ +#include "bongo_cat/sound_shortcut.h" +#include "test.h" + +#include + +static bool edge(BongoCatSoundShortcutState *state, BongoCatInputKind kind, + const char *name) { + BongoCatInputEvent event = {.kind = kind}; + snprintf(event.name, sizeof(event.name), "%s", name); + return bongo_cat_sound_shortcut_update(state, &event); +} + +void test_sound_shortcut(void) { + BongoCatSoundShortcutState state = {0}; + CHECK(!bongo_cat_sound_shortcut_down(&state, "A")); + CHECK(edge(&state, BONGO_CAT_INPUT_KEY_DOWN, "KeyA")); + CHECK(!edge(&state, BONGO_CAT_INPUT_KEY_DOWN, "KeyA")); + CHECK(bongo_cat_sound_shortcut_down(&state, "A")); + CHECK(!bongo_cat_sound_shortcut_down(&state, "Control+A")); + CHECK(edge(&state, BONGO_CAT_INPUT_KEY_DOWN, "ControlLeft")); + CHECK(bongo_cat_sound_shortcut_down(&state, "Control+A")); + CHECK(bongo_cat_sound_shortcut_down(&state, "A")); + CHECK(edge(&state, BONGO_CAT_INPUT_KEY_DOWN, "ControlRight")); + CHECK(edge(&state, BONGO_CAT_INPUT_KEY_UP, "ControlLeft")); + CHECK(bongo_cat_sound_shortcut_down(&state, "Ctrl+A")); + CHECK(edge(&state, BONGO_CAT_INPUT_KEY_DOWN, "KeyB")); + CHECK(bongo_cat_sound_shortcut_down(&state, "A+B+Control")); + CHECK(edge(&state, BONGO_CAT_INPUT_KEY_UP, "KeyA")); + CHECK(!bongo_cat_sound_shortcut_down(&state, "A+B+Control")); + CHECK(edge(&state, BONGO_CAT_INPUT_MOUSE_DOWN, "Left")); + CHECK(bongo_cat_sound_shortcut_down(&state, "Control+Left")); + CHECK(edge(&state, BONGO_CAT_INPUT_MOUSE_UP, "Left")); + CHECK(!bongo_cat_sound_shortcut_down(&state, "Control+Left")); + CHECK(!bongo_cat_sound_shortcut_down(&state, "")); + CHECK(!bongo_cat_sound_shortcut_down(&state, "Control+")); + CHECK(!bongo_cat_sound_shortcut_down(&state, "+Control")); +} diff --git a/tests/media/test_audio.c b/tests/media/test_audio.c new file mode 100644 index 00000000..3b6d74a5 --- /dev/null +++ b/tests/media/test_audio.c @@ -0,0 +1,131 @@ +#include "audio_internal.h" +#include "bongo_cat/file.h" +#include "test.h" + +#include +#include +#include +#include + +int bongo_cat_test_failures; + +static void little(FILE *file, unsigned value, unsigned bytes) { + for (unsigned i = 0; i < bytes; ++i) fputc((int)((value >> (i * 8)) & 255), file); +} + +static bool wave(const char *path) { + FILE *file = bongo_cat_file_open(path, "wb"); + if (!file) return false; + unsigned frames = 96000; + fwrite("RIFF", 1, 4, file); little(file, 36 + frames * 2, 4); + fwrite("WAVEfmt ", 1, 8, file); little(file, 16, 4); + little(file, 1, 2); little(file, 1, 2); little(file, 48000, 4); + little(file, 96000, 4); little(file, 2, 2); little(file, 16, 2); + fwrite("data", 1, 4, file); little(file, frames * 2, 4); + for (unsigned i = 0; i < frames; ++i) little(file, (i / 100) % 2 ? 4000 : 61536, 2); + bool ok = !ferror(file); + return fclose(file) == 0 && ok; +} + +static bool offline(BongoCatAudio *audio) { + ma_engine_config config = ma_engine_config_init(); + config.noDevice = MA_TRUE; + config.noAutoStart = MA_TRUE; + config.channels = 2; + config.sampleRate = 48000; + audio->initialized = ma_engine_init(&config, &audio->engine) == MA_SUCCESS; + return audio->initialized; +} + +static size_t count(const BongoCatAudio *audio) { + size_t total = 0; + for (size_t i = 0; i < AUDIO_VOICES; ++i) total += audio->voices[i].ready; + return total; +} + +static bool audible(BongoCatAudio *audio) { + uint64_t deadline = SDL_GetTicks() + 3000; + do { + float samples[512] = {0}; + if (ma_engine_read_pcm_frames(&audio->engine, samples, 256, NULL) != MA_SUCCESS) return false; + for (size_t i = 0; i < 512; ++i) if (fabsf(samples[i]) > 0.001f) return true; + SDL_Delay(1); + } while (SDL_GetTicks() < deadline); + return false; +} + +int main(void) { + char first[BONGO_CAT_PATH_CAP], second[BONGO_CAT_PATH_CAP]; + snprintf(first, sizeof(first), "bongocat-audio-%llu-\xE9\x9F\xB3.wav", + (unsigned long long)SDL_GetTicksNS()); + snprintf(second, sizeof(second), "bongocat-audio-%llu.wav", + (unsigned long long)SDL_GetTicksNS()); + CHECK(wave(first)); CHECK(wave(second)); + BongoCatError error = {0}; + BongoCatAudio *audio = bongo_cat_audio_create(&error); + CHECK(audio != NULL); + if (!audio) return 1; + CHECK(!audio->initialized); + CHECK(!bongo_cat_audio_any_playing(audio)); + CHECK(offline(audio)); + if (!audio->initialized) { bongo_cat_audio_destroy(audio); return 1; } + CHECK(bongo_cat_audio_play_sound(audio, first, false, &error) == BONGO_CAT_OK); + CHECK(audible(audio)); + CHECK(bongo_cat_audio_play_sound(audio, second, false, &error) == BONGO_CAT_OK); + CHECK(count(audio) == 2); + CHECK(bongo_cat_audio_is_playing(audio, first)); + bongo_cat_audio_stop_path(audio, first); + CHECK(!bongo_cat_audio_is_playing(audio, first)); + CHECK(bongo_cat_audio_is_playing(audio, second)); + CHECK(bongo_cat_audio_any_playing(audio)); + CHECK(bongo_cat_audio_play_sound(audio, first, false, &error) == BONGO_CAT_OK); + CHECK(count(audio) == 2); /* Restarting first must not discard second. */ + CHECK(audible(audio)); + for (size_t i = 0; i < AUDIO_VOICES + 10; ++i) + CHECK(bongo_cat_audio_play_sound(audio, first, true, &error) == BONGO_CAT_OK); + CHECK(count(audio) == AUDIO_VOICES); + /* Stop/reset while asynchronous file loads are still pending. */ + bongo_cat_audio_stop(audio); CHECK(count(audio) == 0); + CHECK(!bongo_cat_audio_any_playing(audio)); + CHECK(bongo_cat_audio_play(audio, + BONGO_CAT_NATIVE_SOURCE_DIR "/resources/assets/models/standard/live2d_motion1.flac", + &error) == BONGO_CAT_OK); + CHECK(audible(audio)); + bongo_cat_audio_set_enabled(audio, false); + CHECK(!audio->initialized && count(audio) == 0); + CHECK(!bongo_cat_audio_is_playing(audio, first)); + CHECK(bongo_cat_audio_play(audio, first, &error) == BONGO_CAT_OK); + CHECK(!audio->initialized); + CHECK(!bongo_cat_audio_any_playing(audio)); + bongo_cat_audio_set_enabled(audio, true); + CHECK(offline(audio)); + CHECK(bongo_cat_audio_play(audio, first, &error) == BONGO_CAT_OK); + uint64_t end_deadline = SDL_GetTicks() + 4000; + while (bongo_cat_audio_is_playing(audio, first) && SDL_GetTicks() < end_deadline) { + float samples[2048]; + CHECK(ma_engine_read_pcm_frames(&audio->engine, samples, 1024, NULL) == MA_SUCCESS); + SDL_Delay(1); + } + CHECK(!bongo_cat_audio_is_playing(audio, first)); + CHECK(bongo_cat_audio_play(audio, first, &error) == BONGO_CAT_OK); + CHECK(audible(audio)); + for (size_t i = 0; i < AUDIO_VOICES; ++i) + if (audio->voices[i].ready) ma_sound_stop(&audio->voices[i].sound); + bongo_cat_audio_collect(audio, audio->last_play + AUDIO_IDLE_MS + 1); + CHECK(!audio->initialized && count(audio) == 0); + CHECK(!bongo_cat_audio_is_playing(audio, first)); + CHECK(offline(audio)); + BongoCatResult invalid = bongo_cat_audio_play(audio, "missing-audio-test-file.wav", &error); + CHECK(invalid == BONGO_CAT_OK || invalid == BONGO_CAT_ERROR_IO); + uint64_t deadline = SDL_GetTicks() + 3000; + while (count(audio) && SDL_GetTicks() < deadline) { + audio->next_collect = 0; + bongo_cat_audio_collect(audio, SDL_GetTicks()); + SDL_Delay(1); + } + CHECK(count(audio) == 0); + bongo_cat_audio_reset(audio); bongo_cat_audio_reset(audio); + bongo_cat_audio_destroy(audio); + CHECK(SDL_RemovePath(first)); CHECK(SDL_RemovePath(second)); + return bongo_cat_test_failures ? 1 : 0; +} diff --git a/tests/model_import/test_mver_audio.c b/tests/model_import/test_mver_audio.c new file mode 100644 index 00000000..4cf468e4 --- /dev/null +++ b/tests/model_import/test_mver_audio.c @@ -0,0 +1,102 @@ +#include "model_import_mver_internal.h" +#include "model_storage.h" +#include "runtime.h" +#include "window_menu.h" +#include "test_mver_support.h" +#include "test_mver_import_internal.h" +#include "bongo_cat/json.h" +#include "bongo_cat/path.h" + +#include +#include +#include +#include + +static void sound_chord(const char *text, const char *expected) { + yyjson_doc *doc = yyjson_read(text, strlen(text), 0); + char value[BONGO_CAT_SHORTCUT_CAP]; + bool ok = bongo_cat_mver_sound_chord(yyjson_doc_get_root(doc), value, sizeof(value)); + CHECK(expected ? ok && strcmp(value, expected) == 0 : !ok); + yyjson_doc_free(doc); +} + +void test_mver_audio(void) { + sound_chord("[90]", "Z"); + sound_chord("[17,65,66]", "Control+A+B"); + sound_chord("[17]", "Control"); + sound_chord("[17,1]", "Control+Left"); + sound_chord("[]", NULL); + sound_chord("[256]", NULL); + char root[BONGO_CAT_PATH_CAP], adapter[BONGO_CAT_PATH_CAP]; + char sound_dir[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; + snprintf(root, sizeof(root), "bongocat-sound-import-%llu", (unsigned long long)SDL_GetTicksNS()); + CHECK(mver_fixture(root)); + BongoCatImportCandidate candidate = {.format = BONGO_CAT_IMPORT_MVER, + .mode = BONGO_CAT_MODE_STANDARD}; + CHECK(child(candidate.directory, sizeof(candidate.directory), root, "img/standard/cat_model", false)); + snprintf(candidate.setting, sizeof(candidate.setting), "cat.model3.json"); + CHECK(child(candidate.assets, sizeof(candidate.assets), root, "img/standard", false)); + CHECK(child(candidate.config, sizeof(candidate.config), root, "config.json", false)); + CHECK(child(adapter, sizeof(adapter), root, "adapter", true)); + CHECK(child(sound_dir, sizeof(sound_dir), candidate.assets, "sounds", true)); + CHECK(child(path, sizeof(path), sound_dir, "0.flac", false)); + CHECK(bongo_cat_path_copy_file(BONGO_CAT_NATIVE_SOURCE_DIR + "/resources/assets/models/standard/live2d_motion1.flac", path)); + BongoCatModelEntry *model = calloc(1, sizeof(*model)); + BongoCatBehaviorCatalog *catalog = calloc(1, sizeof(*catalog)); + CHECK(model && catalog); + if (!model || !catalog) { free(model); free(catalog); return; } + snprintf(model->id, sizeof(model->id), "test-audio"); + snprintf(model->directory, sizeof(model->directory), "%s", candidate.directory); + snprintf(model->setting_file, sizeof(model->setting_file), "%s", candidate.setting); + snprintf(model->adapter_directory, sizeof(model->adapter_directory), "%s", adapter); + for (int keep = 0; keep <= 1; ++keep) { + char config[256]; + snprintf(config, sizeof(config), "{\"decoration\":{\"soundKeep\":%s," + "\"soundClear\":[17,222]},\"standard\":{\"sounds\":[[90],[88]]}}", + keep ? "true" : "false"); + CHECK(write_text(candidate.config, config)); + BongoCatError error = {0}; + CHECK(bongo_cat_import_adapter_metadata(&candidate, adapter, &error)); + CHECK(bongo_cat_behaviors_load(catalog, model, &error) == BONGO_CAT_OK); + CHECK(catalog->count == 2); /* Missing 1.flac skipped; clear always imported. */ + CHECK(catalog->entries[0].sound[0] && !catalog->entries[0].momentary); + CHECK(catalog->entries[0].sound_overlap == (keep != 0)); + CHECK(!catalog->entries[0].sound_clear && catalog->entries[1].sound_clear); + } + BongoCatApp *app = calloc(1, sizeof(*app)); + CHECK(app != NULL); + if (app) { + bongo_cat_import_apply_metadata(app, "test-audio", adapter); + CHECK(app->settings.behavior_shortcut_count == 1); + CHECK(!strcmp(app->settings.behavior_shortcuts[0].id, "test-audio:sound:0")); + CHECK(!strcmp(app->settings.behavior_shortcuts[0].shortcut, "Z")); + app->settings.behavior_shortcuts[0].shortcut[0] = '\0'; + bongo_cat_import_apply_metadata(app, "test-audio", adapter); + CHECK(!app->settings.behavior_shortcuts[0].shortcut[0]); + char names[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; + bool checked[BONGO_CAT_BEHAVIOR_CAP] = {false}; + size_t count = 99; + bongo_cat_window_audio_labels(app, names, checked, &count); + CHECK(count == 0); + app->behaviors = *catalog; + bongo_cat_window_audio_labels(app, names, checked, &count); + CHECK(count == 2 && !checked[0] && !checked[1]); + app->behaviors.entries[0].sound[0] = '\0'; + bongo_cat_window_audio_labels(app, names, checked, &count); + CHECK(count == 0); /* A clear command alone does not expose Audio. */ + free(app); + } + CHECK(child(path, sizeof(path), adapter, BONGO_CAT_MODEL_ADAPTER_FILE, false)); + CHECK(write_text(path, "{\"bindings\":[" + "{\"kind\":\"sound\",\"sound\":\"resources/sounds/0.flac\",\"overlap\":false}," + "{\"kind\":\"sound\",\"sound\":\"missing.flac\"}," + "{\"kind\":\"sound-clear\"}]}")); + CHECK(bongo_cat_behaviors_load(catalog, model, NULL) == BONGO_CAT_OK); + CHECK(catalog->count == 3); + CHECK(!catalog->entries[0].momentary && !catalog->entries[0].sound_overlap); + CHECK(!catalog->entries[1].sound[0] && !catalog->entries[1].sound_clear); + CHECK(catalog->entries[2].sound_clear); + free(catalog); free(model); + CHECK(bongo_cat_model_remove_tree(root, NULL)); +} diff --git a/tests/model_import/test_mver_import.c b/tests/model_import/test_mver_import.c index 7e48fb64..e1c7d849 100644 --- a/tests/model_import/test_mver_import.c +++ b/tests/model_import/test_mver_import.c @@ -199,6 +199,7 @@ static void model_visual_curve(void) { } int main(void) { + test_mver_audio(); failures += test_preferences_text(); CHECK(chord("[17,65]", true, "Control+A")); CHECK(chord("[0]", true, "Gamepad:South")); diff --git a/tests/model_import/test_mver_import_internal.h b/tests/model_import/test_mver_import_internal.h index b1fe8c6e..0f15bf67 100644 --- a/tests/model_import/test_mver_import_internal.h +++ b/tests/model_import/test_mver_import_internal.h @@ -12,6 +12,7 @@ extern int failures; } } while (0) void test_mver_container_discovery(void); +void test_mver_audio(void); void test_model_import_source(void); void test_mver_manifest(void); void test_tauri_portable(void); diff --git a/tests/support/test.h b/tests/support/test.h index 9b4023f7..72a807e0 100644 --- a/tests/support/test.h +++ b/tests/support/test.h @@ -15,6 +15,7 @@ void test_input(void); void test_models(void); void test_mver_pointer(void); void test_shortcut(void); +void test_sound_shortcut(void); void test_update(void); #endif From 62c1a1353bee36e0e15904ad5b993ef3a4ea606f Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 11 Sep 2026 17:59:58 +0800 Subject: [PATCH 268/334] feat: replace Windows context menu with a native radial dial Add themed sectors, model cover thumbnails, contextual center labels, and focused submenu navigation using pure C and GDI+. Cache popup resources and thumbnails and skip idle redraws. Validation: source whitespace, line policy, platform safety checks, and icon data checks passed. Compilation was not run as requested. --- cmake/PlatformRuntime.cmake | 8 +- include/bongo_cat/platform.h | 1 + scripts/generate-dial-icons.py | 121 +++++++++++ src/platform/windows/dial/dial_covers.c | 84 ++++++++ src/platform/windows/dial/dial_gdip.h | 74 +++++++ src/platform/windows/dial/dial_geometry.c | 91 ++++++++ src/platform/windows/dial/dial_icons.c | 216 +++++++++++++++++++ src/platform/windows/dial/dial_internal.h | 65 ++++++ src/platform/windows/dial/dial_items.c | 107 ++++++++++ src/platform/windows/dial/dial_paint.c | 238 +++++++++++++++++++++ src/platform/windows/dial/dial_window.c | 239 ++++++++++++++++++++++ src/platform/windows/dial/windows_dial.h | 9 + src/platform/windows/windows_layered.c | 10 + src/platform/windows/windows_layered.h | 3 + src/platform/windows/windows_menu.c | 135 +----------- src/runtime/shell/window_menu_actions.c | 9 +- 16 files changed, 1278 insertions(+), 132 deletions(-) create mode 100644 scripts/generate-dial-icons.py create mode 100644 src/platform/windows/dial/dial_covers.c create mode 100644 src/platform/windows/dial/dial_gdip.h create mode 100644 src/platform/windows/dial/dial_geometry.c create mode 100644 src/platform/windows/dial/dial_icons.c create mode 100644 src/platform/windows/dial/dial_internal.h create mode 100644 src/platform/windows/dial/dial_items.c create mode 100644 src/platform/windows/dial/dial_paint.c create mode 100644 src/platform/windows/dial/dial_window.c create mode 100644 src/platform/windows/dial/windows_dial.h diff --git a/cmake/PlatformRuntime.cmake b/cmake/PlatformRuntime.cmake index 10224360..6b928e6b 100644 --- a/cmake/PlatformRuntime.cmake +++ b/cmake/PlatformRuntime.cmake @@ -3,6 +3,12 @@ if(WIN32) target_sources(bongo_cat_runtime PRIVATE src/platform/windows/windows.c src/platform/windows/windows_menu.c + src/platform/windows/dial/dial_window.c + src/platform/windows/dial/dial_items.c + src/platform/windows/dial/dial_geometry.c + src/platform/windows/dial/dial_paint.c + src/platform/windows/dial/dial_icons.c + src/platform/windows/dial/dial_covers.c src/platform/windows/windows_directory.c src/platform/windows/windows_package.c src/platform/windows/windows_startup.c @@ -31,7 +37,7 @@ if(WIN32) src/platform/windows) target_link_libraries(bongo_cat_runtime PRIVATE dwmapi ole32 shell32 user32 uuid windowscodecs advapi32 - winhttp) + winhttp gdiplus gdi32) elseif(APPLE) find_package(CURL REQUIRED) enable_language(OBJC) diff --git a/include/bongo_cat/platform.h b/include/bongo_cat/platform.h index eae3db73..ed15b5f6 100644 --- a/include/bongo_cat/platform.h +++ b/include/bongo_cat/platform.h @@ -81,6 +81,7 @@ typedef struct BongoCatMenuLabels { const char (*audio_names)[BONGO_CAT_MENU_LABEL_CAP]; const bool *audio_checked; size_t audio_count; + const char *const *model_cover_directories; } BongoCatMenuLabels; typedef void (*BongoCatTrayClick)(void *userdata); diff --git a/scripts/generate-dial-icons.py b/scripts/generate-dial-icons.py new file mode 100644 index 00000000..54196bf8 --- /dev/null +++ b/scripts/generate-dial-icons.py @@ -0,0 +1,121 @@ +"""Convert the reference's SVG icons into static C polylines (no runtime SVG).""" +from pathlib import Path +import argparse, re, math, xml.etree.ElementTree as ET +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('reference', type=Path, help='Path to the reference index.html') +parser.add_argument('--output', type=Path, default=Path(__file__).resolve().parents[1] / + 'src/platform/windows/dial/dial_icons.c') +args = parser.parse_args() +html=args.reference.read_text(encoding='utf-8') +icon_block = re.search(r'const\s+icons\s*=\s*\{(.*?)\n\s*\};', html, re.S) +if not icon_block: + parser.error('Reference does not contain the SVG icons object') +icons=dict(re.findall(r"^ (\w+): '([^']+)'",icon_block.group(1),re.M)) +order=['settings','hide','pass','pin','size','opacity','motion','expression','audio','model','exit','close','passOn','pinOn'] +def arc(x,y,rx,ry,rotation,large,sweep,nx,ny): + rx,ry=abs(rx),abs(ry) + if not rx or not ry or (x==nx and y==ny): return [(nx,ny)] + phi=math.radians(rotation); co,si=math.cos(phi),math.sin(phi) + dx,dy=(x-nx)/2,(y-ny)/2 + xp,yp=co*dx+si*dy,-si*dx+co*dy + k=xp*xp/(rx*rx)+yp*yp/(ry*ry) + if k>1: rx*=math.sqrt(k); ry*=math.sqrt(k) + q=max(0,(rx*rx*ry*ry-rx*rx*yp*yp-ry*ry*xp*xp)/(rx*rx*yp*yp+ry*ry*xp*xp)) + factor=(-1 if bool(large)==bool(sweep) else 1)*math.sqrt(q) + cxp,cyp=factor*rx*yp/ry,-factor*ry*xp/rx + cx,cy=co*cxp-si*cyp+(x+nx)/2,si*cxp+co*cyp+(y+ny)/2 + a=math.atan2((yp-cyp)/ry,(xp-cxp)/rx) + end=math.atan2((-yp-cyp)/ry,(-xp-cxp)/rx) + delta=(end-a)%(2*math.pi) + if not sweep: delta-=2*math.pi + steps=max(2,math.ceil(abs(delta)*max(rx,ry)/0.5)) + return [(cx+co*rx*math.cos(a+delta*j/steps)-si*ry*math.sin(a+delta*j/steps),cy+si*rx*math.cos(a+delta*j/steps)+co*ry*math.sin(a+delta*j/steps)) for j in range(1,steps+1)] +def path(data): + tokens=re.findall(r'[A-Za-z]|[-+]?(?:\d*\.\d+|\d+)(?:[eE][-+]?\d+)?',data) + contours=[]; points=[]; x=y=0; start=(0,0); i=0; cmd=None; last_control=(0,0); previous=None + while i'+icons[name]+''): + a=el.attrib + if el.tag=='path': paths=path(a['d']) + elif el.tag=='circle': + cx,cy,r=map(float,[a['cx'],a['cy'],a['r']]); paths=[[(cx+r*math.cos(j*math.pi/24),cy+r*math.sin(j*math.pi/24)) for j in range(49)]] + elif el.tag=='line': paths=[[(float(a['x1']),float(a['y1'])),(float(a['x2']),float(a['y2']))]] + else: + v=list(map(float,re.findall(r'[-+]?[\d.]+',a['points']))); pts=list(zip(v[::2],v[1::2])); paths=[pts+[pts[0]] if el.tag=='polygon' else pts] + for pts in paths: + contours.append((len(allpoints),len(pts))); allpoints+=pts + if len(contours) == first: + raise ValueError(f'Icon {name!r} has no drawable contours') + ranges.append((first,len(contours)-first)) +lines.append('static const DialPoint points[] = {') +for j in range(0,len(allpoints),4): lines.append(' '+', '.join('{%.4ff, %.4ff}'%p for p in allpoints[j:j+4])+',') +lines+=['};','static const IconContour contours[] = {'] +for j in range(0,len(contours),6): lines.append(' '+', '.join('{%d, %d}'%p for p in contours[j:j+6])+',') +lines+=['};','static const IconContour icons[] = {',' '+', '.join('{%d, %d}'%p for p in ranges),'};', ''' +void dial_icon(GpGraphics *g, int icon, float x, float y, float size, DWORD color) { + if (icon < 0 || icon >= (int)(sizeof(icons) / sizeof(icons[0]))) return; + GpPen *pen = NULL; + if (GdipCreatePen1(color, 1.95f * size / 24, 2, &pen)) return; + GdipSetPenStartCap(pen, 2); + GdipSetPenEndCap(pen, 2); + GdipSetPenLineJoin(pen, 2); + IconContour range = icons[icon]; + for (int i = 0; i < range.count; ++i) { + IconContour contour = contours[range.offset + i]; + DialPoint transformed[512]; + if (contour.count > 512) continue; + for (int j = 0; j < contour.count; ++j) { + DialPoint p = points[contour.offset + j]; + transformed[j] = (DialPoint){x + (p.x - 12) * size / 24, + y + (p.y - 12) * size / 24}; + } + GdipDrawLines(g, pen, transformed, contour.count); + } + GdipDeletePen(pen); +} +'''] +assert max(n for _, n in contours) <= 512 +args.output.write_text('\n'.join(lines),encoding='utf-8') +print('Generated',len(allpoints),'icon points;',max(n for _,n in contours),'max contour points') diff --git a/src/platform/windows/dial/dial_covers.c b/src/platform/windows/dial/dial_covers.c new file mode 100644 index 00000000..a2f617e9 --- /dev/null +++ b/src/platform/windows/dial/dial_covers.c @@ -0,0 +1,84 @@ +#include "dial_internal.h" +#include "bongo_cat/path.h" + +/* Fits inside every child sector, including the narrower paginated layout. */ +#define COVER_WIDTH 44.0f +#define COVER_HEIGHT 40.0f + +static void load_cover(Dial *d, size_t index) { + DialCover *cover = &d->covers[index]; + cover->attempted = true; + if (!d->labels->model_cover_directories || + !d->labels->model_cover_directories[index]) return; + char path[BONGO_CAT_PATH_CAP]; + WCHAR wide[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), + d->labels->model_cover_directories[index], "resources/cover.png") || + !MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, path, -1, + wide, BONGO_CAT_PATH_CAP)) return; + GpBitmap *source = NULL, *thumbnail = NULL; + GpGraphics *graphics = NULL; + UINT width = 0, height = 0; + if (GdipCreateBitmapFromFile(wide, &source) || + GdipGetImageWidth(source, &width) || GdipGetImageHeight(source, &height) || + !width || !height || (uint64_t)width * height > 64000000) goto done; + float fit = fminf(COVER_WIDTH / (float)width, COVER_HEIGHT / (float)height); + cover->width = (float)width * fit; + cover->height = (float)height * fit; + /* Retain only a DPI-sized thumbnail, never the full decoded cover. */ + float pixels = fminf(160.0f, ceilf(COVER_WIDTH * d->scale * 1.25f)); + int thumb_width = (int)fmaxf(1, ceilf(cover->width * pixels / COVER_WIDTH)); + int thumb_height = (int)fmaxf(1, ceilf(cover->height * pixels / COVER_WIDTH)); + if (GdipCreateBitmapFromScan0(thumb_width, thumb_height, 0, + 0x000e200b, NULL, &thumbnail) || + GdipGetImageGraphicsContext(thumbnail, &graphics)) goto done; + GdipGraphicsClear(graphics, 0); + GdipSetInterpolationMode(graphics, 7); + if (!GdipDrawImageRect(graphics, source, 0, 0, + (float)thumb_width, (float)thumb_height)) { + GdipFlush(graphics, 1); + cover->image = thumbnail; + thumbnail = NULL; + } +done: + if (graphics) GdipDeleteGraphics(graphics); + if (thumbnail) GdipDisposeImage(thumbnail); + if (source) GdipDisposeImage(source); +} + +void dial_covers_tick(Dial *d) { + if (d->active != 9) return; + /* Same one-image-per-tick budget as the preferences cover loader. + Failed loads are remembered too, avoiding repeated disk access. */ + for (int i = 0; i < dial_child_count(d); ++i) { + size_t index = (size_t)(d->page * DIAL_PAGE + i); + if (index >= d->labels->model_count || index >= BONGO_CAT_MODEL_CAP) continue; + if (!d->covers[index].attempted) { + load_cover(d, index); + d->dirty = true; + return; + } + } +} + +bool dial_cover_draw(Dial *d, int child, float x, float y) { + if (d->active != 9 || child < 0) return false; + size_t index = (size_t)(d->page * DIAL_PAGE + child); + if (index >= d->labels->model_count || index >= BONGO_CAT_MODEL_CAP) return false; + DialCover *cover = &d->covers[index]; + if (cover->image) { + GdipDrawImageRect(d->paint.graphics, cover->image, + x - cover->width / 2, y - cover->height / 2, cover->width, cover->height); + } else { + /* Missing/loading covers keep a recognizable model glyph. */ + dial_icon(d->paint.graphics, 9, x, y, 28, 0xffa78bfa); + } + return true; +} + +void dial_covers_free(Dial *d) { + for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) { + if (d->covers[i].image) GdipDisposeImage(d->covers[i].image); + d->covers[i] = (DialCover){0}; + } +} diff --git a/src/platform/windows/dial/dial_gdip.h b/src/platform/windows/dial/dial_gdip.h new file mode 100644 index 00000000..db2ccd89 --- /dev/null +++ b/src/platform/windows/dial/dial_gdip.h @@ -0,0 +1,74 @@ +#ifndef BONGO_CAT_DIAL_GDIP_H +#define BONGO_CAT_DIAL_GDIP_H +/* GDI+ flat ABI: no C++ headers, wrappers or browser runtime. */ +#include +typedef struct GpBitmap GpBitmap; +typedef struct GpGraphics GpGraphics; +typedef struct GpPath GpPath; +typedef struct GpBrush GpBrush; +typedef struct GpPen GpPen; +typedef struct GpFontFamily GpFontFamily; +typedef struct GpFont GpFont; +typedef struct GpStringFormat GpStringFormat; +typedef struct DialPoint { float x, y; } DialPoint; +typedef struct DialRect { float x, y, w, h; } DialRect; +typedef struct DialStartup { + UINT32 version; + void *callback; + BOOL no_thread, no_codecs; +} DialStartup; +int WINAPI GdiplusStartup(ULONG_PTR *, const DialStartup *, void *); +void WINAPI GdiplusShutdown(ULONG_PTR); +int WINAPI GdipCreateBitmapFromScan0(INT, INT, INT, INT, BYTE *, GpBitmap **); +int WINAPI GdipGetImageGraphicsContext(void *, GpGraphics **); +int WINAPI GdipDisposeImage(void *); +int WINAPI GdipCreateBitmapFromFile(const WCHAR *, GpBitmap **); +int WINAPI GdipGetImageWidth(void *, UINT *); +int WINAPI GdipGetImageHeight(void *, UINT *); +int WINAPI GdipSetInterpolationMode(GpGraphics *, int); +int WINAPI GdipDrawImageRect(GpGraphics *, void *, float, float, float, float); +int WINAPI GdipDeleteGraphics(GpGraphics *); +int WINAPI GdipGraphicsClear(GpGraphics *, DWORD); +int WINAPI GdipSetSmoothingMode(GpGraphics *, int); +int WINAPI GdipSetTextRenderingHint(GpGraphics *, int); +int WINAPI GdipSetWorldTransform(GpGraphics *, void *); +int WINAPI GdipCreateMatrix2(float, float, float, float, float, float, void **); +int WINAPI GdipDeleteMatrix(void *); +int WINAPI GdipCreatePath(int, GpPath **); +int WINAPI GdipDeletePath(GpPath *); +int WINAPI GdipAddPathLine(GpPath *, float, float, float, float); +int WINAPI GdipAddPathArc(GpPath *, float, float, float, float, float, float); +int WINAPI GdipAddPathBezier(GpPath *, float, float, float, float, + float, float, float, float); +int WINAPI GdipClosePathFigure(GpPath *); +int WINAPI GdipStartPathFigure(GpPath *); +int WINAPI GdipFillPath(GpGraphics *, GpBrush *, GpPath *); +int WINAPI GdipDrawPath(GpGraphics *, GpPen *, GpPath *); +int WINAPI GdipGetPathWorldBounds(GpPath *, DialRect *, const void *, const GpPen *); +int WINAPI GdipFlush(GpGraphics *, int); +int WINAPI GdipCreateSolidFill(DWORD, GpBrush **); +int WINAPI GdipCreateLineBrush(const DialPoint *, const DialPoint *, + DWORD, DWORD, int, GpBrush **); +int WINAPI GdipSetLinePresetBlend(GpBrush *, const DWORD *, const float *, int); +int WINAPI GdipDeleteBrush(GpBrush *); +int WINAPI GdipCreatePen1(DWORD, float, int, GpPen **); +int WINAPI GdipSetPenStartCap(GpPen *, int); +int WINAPI GdipSetPenEndCap(GpPen *, int); +int WINAPI GdipSetPenLineJoin(GpPen *, int); +int WINAPI GdipDeletePen(GpPen *); +int WINAPI GdipDrawLines(GpGraphics *, GpPen *, const DialPoint *, int); +int WINAPI GdipFillEllipse(GpGraphics *, GpBrush *, float, float, float, float); +int WINAPI GdipDrawEllipse(GpGraphics *, GpPen *, float, float, float, float); +int WINAPI GdipCreateFontFamilyFromName(const WCHAR *, void *, GpFontFamily **); +int WINAPI GdipGetGenericFontFamilySansSerif(GpFontFamily **); +int WINAPI GdipDeleteFontFamily(GpFontFamily *); +int WINAPI GdipCreateFont(const GpFontFamily *, float, int, int, GpFont **); +int WINAPI GdipDeleteFont(GpFont *); +int WINAPI GdipCreateStringFormat(int, WORD, GpStringFormat **); +int WINAPI GdipSetStringFormatAlign(GpStringFormat *, int); +int WINAPI GdipSetStringFormatLineAlign(GpStringFormat *, int); +int WINAPI GdipSetStringFormatTrimming(GpStringFormat *, int); +int WINAPI GdipDeleteStringFormat(GpStringFormat *); +int WINAPI GdipDrawString(GpGraphics *, const WCHAR *, int, const GpFont *, + const DialRect *, const GpStringFormat *, const GpBrush *); +#endif diff --git a/src/platform/windows/dial/dial_geometry.c b/src/platform/windows/dial/dial_geometry.c new file mode 100644 index 00000000..0231b305 --- /dev/null +++ b/src/platform/windows/dial/dial_geometry.c @@ -0,0 +1,91 @@ +#include "dial_internal.h" + +static DialPoint polar(float r, float a) { + return (DialPoint){r * cosf(a), r * sinf(a)}; +} + +static void line(GpPath *p, DialPoint a, DialPoint b) { + GdipAddPathLine(p, a.x, a.y, b.x, b.y); +} + +static void quad(GpPath *p, DialPoint a, DialPoint c, DialPoint b) { + GdipAddPathBezier(p, a.x, a.y, a.x + (c.x - a.x) * 2 / 3, + a.y + (c.y - a.y) * 2 / 3, b.x + (c.x - b.x) * 2 / 3, + b.y + (c.y - b.y) * 2 / 3, b.x, b.y); +} + +static void arc(GpPath *p, float r, float a, float b) { + GdipAddPathArc(p, -r, -r, r * 2, r * 2, + a * 180 / DIAL_PI, (b - a) * 180 / DIAL_PI); +} + +GpPath *dial_sector(float inner, float outer, float start, float end, bool rim) { + /* Exact quadratic corners and circular arcs from index.html's sector(). */ + const float corner = 12, a = start + .024f, b = end - .024f; + const float ci = corner / inner, co = corner / outer; + GpPath *p = NULL; + if (GdipCreatePath(0, &p)) return NULL; + if (!rim) { + line(p, polar(outer - corner, b), polar(inner + corner, b)); + quad(p, polar(inner + corner, b), polar(inner, b), polar(inner, b - ci)); + arc(p, inner, b - ci, a + ci); + quad(p, polar(inner, a + ci), polar(inner, a), polar(inner + corner, a)); + line(p, polar(inner + corner, a), polar(outer - corner, a)); + } + quad(p, polar(outer - corner, a), polar(outer, a), polar(outer, a + co)); + arc(p, outer, a + co, b - co); + quad(p, polar(outer, b - co), polar(outer, b), polar(outer - corner, b)); + if (!rim) GdipClosePathFigure(p); + return p; +} + +void dial_child_paths(Dial *d) { + for (int i = 0; i < DIAL_PAGE; ++i) { + if (d->paint.children[i]) GdipDeletePath(d->paint.children[i]); + if (d->paint.child_rims[i]) GdipDeletePath(d->paint.child_rims[i]); + d->paint.children[i] = d->paint.child_rims[i] = NULL; + if (i < dial_child_count(d)) { + float angle = dial_child_angle(d, i), half = dial_child_step(d) / 2; + d->paint.children[i] = dial_sector(198, 262, angle - half, angle + half, false); + d->paint.child_rims[i] = dial_sector(198, 262, angle - half, angle + half, true); + } + } +} + +static float angle_distance(float a, float b) { + return atan2f(sinf(a - b), cosf(a - b)); +} + +void dial_hit(Dial *d, float x, float y, int *root, int *child) { + float radius = hypotf(x, y), angle = atan2f(y, x); + *root = -1; *child = -1; + if (d->active >= 0 && radius >= 190 && radius <= 277) { + for (int i = 0; i < dial_child_count(d); ++i) { + if (fabsf(angle_distance(angle, dial_child_angle(d, i))) < + dial_child_step(d) / 2) { + *root = d->active; *child = i; return; + } + } + } + if (radius >= 73 && radius <= 198) { + for (int i = 0; i < d->count; ++i) { + if (d->child_focus && i != d->active) continue; + float center = -DIAL_PI / 2 + i * 2 * DIAL_PI / d->count; + if (fabsf(angle_distance(angle, center)) <= DIAL_PI / d->count) { + *root = i; return; + } + } + } + /* Keep the iris open while crossing its radial gap or the center hub. */ + if (radius <= 280) *root = d->active; +} + +void dial_transform(Dial *d, float zoom, float x, float y) { + void *matrix = NULL; + float scale = d->scale * d->opening; + if (!GdipCreateMatrix2(scale * zoom, 0, 0, scale * zoom, + d->pixels / 2.0f + x * scale, d->pixels / 2.0f + y * scale, &matrix)) { + GdipSetWorldTransform(d->paint.graphics, matrix); + GdipDeleteMatrix(matrix); + } +} diff --git a/src/platform/windows/dial/dial_icons.c b/src/platform/windows/dial/dial_icons.c new file mode 100644 index 00000000..124c5aa1 --- /dev/null +++ b/src/platform/windows/dial/dial_icons.c @@ -0,0 +1,216 @@ +/* Static vector contours transcribed from the supplied index.html. */ +#include "dial_internal.h" +typedef struct IconContour { unsigned short offset, count; } IconContour; +static const DialPoint points[] = { + {12.2200f, 2.0000f}, {11.7800f, 2.0000f}, {11.3350f, 2.0501f}, {10.9122f, 2.1981f}, + {10.5330f, 2.4363f}, {10.2163f, 2.7530f}, {9.9781f, 3.1322f}, {9.8301f, 3.5550f}, + {9.7800f, 4.0000f}, {9.7800f, 4.1800f}, {9.7360f, 4.5954f}, {9.6066f, 4.9925f}, + {9.3976f, 5.3542f}, {9.1180f, 5.6645f}, {8.7800f, 5.9100f}, {8.3500f, 6.1600f}, + {7.9680f, 6.3301f}, {7.5591f, 6.4170f}, {7.1409f, 6.4170f}, {6.7320f, 6.3301f}, + {6.3500f, 6.1600f}, {6.2000f, 6.0800f}, {5.7899f, 5.9012f}, {5.3504f, 5.8180f}, + {4.9033f, 5.8346f}, {4.4712f, 5.9502f}, {4.0755f, 6.1589f}, {3.7361f, 6.4504f}, + {3.4700f, 6.8100f}, {3.2500f, 7.1900f}, {3.0712f, 7.6001f}, {2.9880f, 8.0396f}, + {3.0046f, 8.4867f}, {3.1202f, 8.9188f}, {3.3289f, 9.3145f}, {3.6204f, 9.6539f}, + {3.9800f, 9.9200f}, {4.1300f, 10.0200f}, {4.4665f, 10.2641f}, {4.7452f, 10.5726f}, + {4.9542f, 10.9320f}, {5.0843f, 11.3268f}, {5.1300f, 11.7400f}, {5.1300f, 12.2500f}, + {5.0876f, 12.6676f}, {4.9590f, 13.0671f}, {4.7499f, 13.4309f}, {4.4694f, 13.7432f}, + {4.1300f, 13.9900f}, {3.9800f, 14.0800f}, {3.6204f, 14.3461f}, {3.3289f, 14.6855f}, + {3.1202f, 15.0812f}, {3.0046f, 15.5133f}, {2.9880f, 15.9604f}, {3.0712f, 16.3999f}, + {3.2500f, 16.8100f}, {3.4700f, 17.1900f}, {3.7361f, 17.5496f}, {4.0755f, 17.8411f}, + {4.4712f, 18.0498f}, {4.9033f, 18.1654f}, {5.3504f, 18.1820f}, {5.7899f, 18.0988f}, + {6.2000f, 17.9200f}, {6.3500f, 17.8400f}, {6.7320f, 17.6699f}, {7.1409f, 17.5830f}, + {7.5591f, 17.5830f}, {7.9680f, 17.6699f}, {8.3500f, 17.8400f}, {8.7800f, 18.0900f}, + {9.1180f, 18.3355f}, {9.3976f, 18.6458f}, {9.6066f, 19.0075f}, {9.7360f, 19.4046f}, + {9.7800f, 19.8200f}, {9.7800f, 20.0000f}, {9.8301f, 20.4450f}, {9.9781f, 20.8678f}, + {10.2163f, 21.2470f}, {10.5330f, 21.5637f}, {10.9122f, 21.8019f}, {11.3350f, 21.9499f}, + {11.7800f, 22.0000f}, {12.2200f, 22.0000f}, {12.6650f, 21.9499f}, {13.0878f, 21.8019f}, + {13.4670f, 21.5637f}, {13.7837f, 21.2470f}, {14.0219f, 20.8678f}, {14.1699f, 20.4450f}, + {14.2200f, 20.0000f}, {14.2200f, 19.8200f}, {14.2640f, 19.4046f}, {14.3934f, 19.0075f}, + {14.6024f, 18.6458f}, {14.8820f, 18.3355f}, {15.2200f, 18.0900f}, {15.6500f, 17.8400f}, + {16.0320f, 17.6699f}, {16.4409f, 17.5830f}, {16.8591f, 17.5830f}, {17.2680f, 17.6699f}, + {17.6500f, 17.8400f}, {17.8000f, 17.9200f}, {18.2101f, 18.0988f}, {18.6496f, 18.1820f}, + {19.0967f, 18.1654f}, {19.5288f, 18.0498f}, {19.9245f, 17.8411f}, {20.2639f, 17.5496f}, + {20.5300f, 17.1900f}, {20.7500f, 16.8000f}, {20.9288f, 16.3899f}, {21.0120f, 15.9504f}, + {20.9954f, 15.5033f}, {20.8798f, 15.0712f}, {20.6711f, 14.6755f}, {20.3796f, 14.3361f}, + {20.0200f, 14.0700f}, {19.8700f, 13.9900f}, {19.5306f, 13.7432f}, {19.2501f, 13.4309f}, + {19.0410f, 13.0671f}, {18.9124f, 12.6676f}, {18.8700f, 12.2500f}, {18.8700f, 11.7500f}, + {18.9124f, 11.3324f}, {19.0410f, 10.9329f}, {19.2501f, 10.5691f}, {19.5306f, 10.2568f}, + {19.8700f, 10.0100f}, {20.0200f, 9.9200f}, {20.3796f, 9.6539f}, {20.6711f, 9.3145f}, + {20.8798f, 8.9188f}, {20.9954f, 8.4867f}, {21.0120f, 8.0396f}, {20.9288f, 7.6001f}, + {20.7500f, 7.1900f}, {20.5300f, 6.8100f}, {20.2639f, 6.4504f}, {19.9245f, 6.1589f}, + {19.5288f, 5.9502f}, {19.0967f, 5.8346f}, {18.6496f, 5.8180f}, {18.2101f, 5.9012f}, + {17.8000f, 6.0800f}, {17.6500f, 6.1600f}, {17.2680f, 6.3301f}, {16.8591f, 6.4170f}, + {16.4409f, 6.4170f}, {16.0320f, 6.3301f}, {15.6500f, 6.1600f}, {15.2200f, 5.9100f}, + {14.8820f, 5.6645f}, {14.6024f, 5.3542f}, {14.3934f, 4.9925f}, {14.2640f, 4.5954f}, + {14.2200f, 4.1800f}, {14.2200f, 4.0000f}, {14.1699f, 3.5550f}, {14.0219f, 3.1322f}, + {13.7837f, 2.7530f}, {13.4670f, 2.4363f}, {13.0878f, 2.1981f}, {12.6650f, 2.0501f}, + {12.2200f, 2.0000f}, {12.2200f, 2.0000f}, {15.0000f, 12.0000f}, {14.9743f, 12.3916f}, + {14.8978f, 12.7765f}, {14.7716f, 13.1481f}, {14.5981f, 13.5000f}, {14.3801f, 13.8263f}, + {14.1213f, 14.1213f}, {13.8263f, 14.3801f}, {13.5000f, 14.5981f}, {13.1481f, 14.7716f}, + {12.7765f, 14.8978f}, {12.3916f, 14.9743f}, {12.0000f, 15.0000f}, {11.6084f, 14.9743f}, + {11.2235f, 14.8978f}, {10.8519f, 14.7716f}, {10.5000f, 14.5981f}, {10.1737f, 14.3801f}, + {9.8787f, 14.1213f}, {9.6199f, 13.8263f}, {9.4019f, 13.5000f}, {9.2284f, 13.1481f}, + {9.1022f, 12.7765f}, {9.0257f, 12.3916f}, {9.0000f, 12.0000f}, {9.0257f, 11.6084f}, + {9.1022f, 11.2235f}, {9.2284f, 10.8519f}, {9.4019f, 10.5000f}, {9.6199f, 10.1737f}, + {9.8787f, 9.8787f}, {10.1737f, 9.6199f}, {10.5000f, 9.4019f}, {10.8519f, 9.2284f}, + {11.2235f, 9.1022f}, {11.6084f, 9.0257f}, {12.0000f, 9.0000f}, {12.3916f, 9.0257f}, + {12.7765f, 9.1022f}, {13.1481f, 9.2284f}, {13.5000f, 9.4019f}, {13.8263f, 9.6199f}, + {14.1213f, 9.8787f}, {14.3801f, 10.1737f}, {14.5981f, 10.5000f}, {14.7716f, 10.8519f}, + {14.8978f, 11.2235f}, {14.9743f, 11.6084f}, {15.0000f, 12.0000f}, {9.8800f, 9.8800f}, + {9.5553f, 10.2353f}, {9.2917f, 10.6380f}, {9.0958f, 11.0776f}, {8.9727f, 11.5429f}, + {8.9256f, 12.0219f}, {8.9558f, 12.5023f}, {9.0623f, 12.9716f}, {9.2426f, 13.4179f}, + {9.4919f, 13.8296f}, {9.8038f, 14.1962f}, {10.1704f, 14.5081f}, {10.5821f, 14.7574f}, + {11.0284f, 14.9377f}, {11.4977f, 15.0442f}, {11.9781f, 15.0744f}, {12.4571f, 15.0273f}, + {12.9224f, 14.9042f}, {13.3620f, 14.7083f}, {13.7647f, 14.4447f}, {14.1200f, 14.1200f}, + {10.7300f, 5.0800f}, {11.1521f, 5.0361f}, {11.5757f, 5.0094f}, {12.0000f, 5.0000f}, + {13.6672f, 5.1377f}, {15.1713f, 5.5185f}, {16.5156f, 6.0938f}, {17.7037f, 6.8148f}, + {18.7390f, 7.6331f}, {19.6250f, 8.5000f}, {20.3652f, 9.3669f}, {20.9630f, 10.1852f}, + {21.4219f, 10.9062f}, {21.7454f, 11.4815f}, {21.9369f, 11.8623f}, {22.0000f, 12.0000f}, + {21.8016f, 12.4063f}, {21.5894f, 12.8056f}, {21.3636f, 13.1974f}, {21.1245f, 13.5811f}, + {20.8723f, 13.9564f}, {20.6074f, 14.3229f}, {20.3300f, 14.6800f}, {6.6100f, 6.6100f}, + {6.2194f, 6.8862f}, {5.8388f, 7.1761f}, {5.4687f, 7.4793f}, {5.1096f, 7.7953f}, + {4.7619f, 8.1239f}, {4.4260f, 8.4646f}, {4.1023f, 8.8169f}, {3.7914f, 9.1805f}, + {3.4935f, 9.5548f}, {3.2090f, 9.9395f}, {2.9383f, 10.3339f}, {2.6818f, 10.7377f}, + {2.4397f, 11.1504f}, {2.2123f, 11.5713f}, {2.0000f, 12.0000f}, {2.0631f, 12.1377f}, + {2.2546f, 12.5185f}, {2.5781f, 13.0938f}, {3.0370f, 13.8148f}, {3.6348f, 14.6331f}, + {4.3750f, 15.5000f}, {5.2610f, 16.3669f}, {6.2963f, 17.1852f}, {7.4844f, 17.9062f}, + {8.8287f, 18.4815f}, {10.3328f, 18.8623f}, {12.0000f, 19.0000f}, {12.4754f, 18.9897f}, + {12.9497f, 18.9561f}, {13.4218f, 18.8995f}, {13.8906f, 18.8199f}, {14.3550f, 18.7175f}, + {14.8138f, 18.5926f}, {15.2659f, 18.4454f}, {15.7103f, 18.2764f}, {16.1460f, 18.0858f}, + {16.5718f, 17.8742f}, {16.9868f, 17.6421f}, {17.3900f, 17.3900f}, {2.0000f, 2.0000f}, + {22.0000f, 22.0000f}, {5.0000f, 3.0000f}, {18.0000f, 12.0000f}, {12.0000f, 13.0000f}, + {9.0000f, 19.0000f}, {5.0000f, 3.0000f}, {17.0000f, 3.0000f}, {17.0000f, 7.0000f}, + {15.0000f, 5.0000f}, {19.0000f, 5.0000f}, {17.0000f, 17.0000f}, {20.0000f, 20.0000f}, + {12.0000f, 17.0000f}, {12.0000f, 22.0000f}, {5.0000f, 17.0000f}, {19.0000f, 17.0000f}, + {17.3000f, 10.2000f}, {17.1473f, 9.7954f}, {16.9112f, 9.4330f}, {16.6027f, 9.1299f}, + {16.2363f, 8.9002f}, {15.8290f, 8.7546f}, {15.4000f, 8.7000f}, {8.6000f, 8.7000f}, + {8.1710f, 8.7546f}, {7.7637f, 8.9002f}, {7.3973f, 9.1299f}, {7.0888f, 9.4330f}, + {6.8527f, 9.7954f}, {6.7000f, 10.2000f}, {5.0000f, 17.0000f}, {5.0000f, 17.0000f}, + {9.0000f, 8.0000f}, {9.0000f, 4.0000f}, {9.0761f, 3.6173f}, {9.2929f, 3.2929f}, + {9.6173f, 3.0761f}, {10.0000f, 3.0000f}, {14.0000f, 3.0000f}, {14.3827f, 3.0761f}, + {14.7071f, 3.2929f}, {14.9239f, 3.6173f}, {15.0000f, 4.0000f}, {15.0000f, 8.0000f}, + {15.0000f, 3.0000f}, {21.0000f, 3.0000f}, {21.0000f, 9.0000f}, {9.0000f, 21.0000f}, + {3.0000f, 21.0000f}, {3.0000f, 15.0000f}, {21.0000f, 3.0000f}, {14.0000f, 10.0000f}, + {3.0000f, 21.0000f}, {10.0000f, 14.0000f}, {12.0000f, 2.0000f}, {11.8623f, 2.1597f}, + {11.4815f, 2.6111f}, {10.9062f, 3.3125f}, {10.1852f, 4.2222f}, {9.3669f, 5.2986f}, + {8.5000f, 6.5000f}, {7.6331f, 7.7847f}, {6.8148f, 9.1111f}, {6.0938f, 10.4375f}, + {5.5185f, 11.7222f}, {5.1377f, 12.9236f}, {5.0000f, 14.0000f}, {5.0178f, 14.4994f}, + {5.0712f, 14.9962f}, {5.1600f, 15.4880f}, {5.2835f, 15.9721f}, {5.4414f, 16.4462f}, + {5.6326f, 16.9079f}, {5.8562f, 17.3547f}, {6.1112f, 17.7845f}, {6.3962f, 18.1949f}, + {6.7098f, 18.5840f}, {7.0503f, 18.9497f}, {7.4160f, 19.2902f}, {7.8051f, 19.6038f}, + {8.2155f, 19.8888f}, {8.6453f, 20.1438f}, {9.0921f, 20.3674f}, {9.5538f, 20.5586f}, + {10.0279f, 20.7165f}, {10.5120f, 20.8400f}, {11.0038f, 20.9288f}, {11.5006f, 20.9822f}, + {12.0000f, 21.0000f}, {12.4994f, 20.9822f}, {12.9962f, 20.9288f}, {13.4880f, 20.8400f}, + {13.9721f, 20.7165f}, {14.4462f, 20.5586f}, {14.9079f, 20.3674f}, {15.3547f, 20.1438f}, + {15.7845f, 19.8888f}, {16.1949f, 19.6038f}, {16.5840f, 19.2902f}, {16.9497f, 18.9497f}, + {17.2902f, 18.5840f}, {17.6038f, 18.1949f}, {17.8888f, 17.7845f}, {18.1438f, 17.3547f}, + {18.3674f, 16.9079f}, {18.5586f, 16.4462f}, {18.7165f, 15.9721f}, {18.8400f, 15.4880f}, + {18.9288f, 14.9962f}, {18.9822f, 14.4994f}, {19.0000f, 14.0000f}, {18.8623f, 12.9236f}, + {18.4815f, 11.7222f}, {17.9062f, 10.4375f}, {17.1852f, 9.1111f}, {16.3669f, 7.7847f}, + {15.5000f, 6.5000f}, {14.6331f, 5.2986f}, {13.8148f, 4.2222f}, {13.0938f, 3.3125f}, + {12.5185f, 2.6111f}, {12.1377f, 2.1597f}, {12.0000f, 2.0000f}, {12.0000f, 2.0000f}, + {12.0000f, 8.0000f}, {12.0000f, 20.0000f}, {13.0000f, 2.0000f}, {3.0000f, 14.0000f}, + {12.0000f, 14.0000f}, {11.0000f, 22.0000f}, {21.0000f, 10.0000f}, {12.0000f, 10.0000f}, + {13.0000f, 2.0000f}, {13.0000f, 2.0000f}, {22.0000f, 12.0000f}, {21.9144f, 13.3053f}, + {21.6593f, 14.5882f}, {21.2388f, 15.8268f}, {20.6603f, 17.0000f}, {19.9335f, 18.0876f}, + {19.0711f, 19.0711f}, {18.0876f, 19.9335f}, {17.0000f, 20.6603f}, {15.8268f, 21.2388f}, + {14.5882f, 21.6593f}, {13.3053f, 21.9144f}, {12.0000f, 22.0000f}, {10.6947f, 21.9144f}, + {9.4118f, 21.6593f}, {8.1732f, 21.2388f}, {7.0000f, 20.6603f}, {5.9124f, 19.9335f}, + {4.9289f, 19.0711f}, {4.0665f, 18.0876f}, {3.3397f, 17.0000f}, {2.7612f, 15.8268f}, + {2.3407f, 14.5882f}, {2.0856f, 13.3053f}, {2.0000f, 12.0000f}, {2.0856f, 10.6947f}, + {2.3407f, 9.4118f}, {2.7612f, 8.1732f}, {3.3397f, 7.0000f}, {4.0665f, 5.9124f}, + {4.9289f, 4.9289f}, {5.9124f, 4.0665f}, {7.0000f, 3.3397f}, {8.1732f, 2.7612f}, + {9.4118f, 2.3407f}, {10.6947f, 2.0856f}, {12.0000f, 2.0000f}, {13.3053f, 2.0856f}, + {14.5882f, 2.3407f}, {15.8268f, 2.7612f}, {17.0000f, 3.3397f}, {18.0876f, 4.0665f}, + {19.0711f, 4.9289f}, {19.9335f, 5.9124f}, {20.6603f, 7.0000f}, {21.2388f, 8.1732f}, + {21.6593f, 9.4118f}, {21.9144f, 10.6947f}, {22.0000f, 12.0000f}, {8.0000f, 14.0000f}, + {8.0310f, 14.0394f}, {8.1227f, 14.1481f}, {8.2734f, 14.3125f}, {8.4815f, 14.5185f}, + {8.7451f, 14.7523f}, {9.0625f, 15.0000f}, {9.4320f, 15.2477f}, {9.8519f, 15.4815f}, + {10.3203f, 15.6875f}, {10.8356f, 15.8519f}, {11.3961f, 15.9606f}, {12.0000f, 16.0000f}, + {12.6039f, 15.9606f}, {13.1644f, 15.8519f}, {13.6797f, 15.6875f}, {14.1481f, 15.4815f}, + {14.5680f, 15.2477f}, {14.9375f, 15.0000f}, {15.2549f, 14.7523f}, {15.5185f, 14.5185f}, + {15.7266f, 14.3125f}, {15.8773f, 14.1481f}, {15.9690f, 14.0394f}, {16.0000f, 14.0000f}, + {9.0000f, 9.0000f}, {9.0100f, 9.0000f}, {15.0000f, 9.0000f}, {15.0100f, 9.0000f}, + {9.0000f, 18.0000f}, {9.0000f, 5.0000f}, {21.0000f, 3.0000f}, {21.0000f, 16.0000f}, + {9.0000f, 18.0000f}, {8.9743f, 18.3916f}, {8.8978f, 18.7765f}, {8.7716f, 19.1481f}, + {8.5981f, 19.5000f}, {8.3801f, 19.8263f}, {8.1213f, 20.1213f}, {7.8263f, 20.3801f}, + {7.5000f, 20.5981f}, {7.1481f, 20.7716f}, {6.7765f, 20.8978f}, {6.3916f, 20.9743f}, + {6.0000f, 21.0000f}, {5.6084f, 20.9743f}, {5.2235f, 20.8978f}, {4.8519f, 20.7716f}, + {4.5000f, 20.5981f}, {4.1737f, 20.3801f}, {3.8787f, 20.1213f}, {3.6199f, 19.8263f}, + {3.4019f, 19.5000f}, {3.2284f, 19.1481f}, {3.1022f, 18.7765f}, {3.0257f, 18.3916f}, + {3.0000f, 18.0000f}, {3.0257f, 17.6084f}, {3.1022f, 17.2235f}, {3.2284f, 16.8519f}, + {3.4019f, 16.5000f}, {3.6199f, 16.1737f}, {3.8787f, 15.8787f}, {4.1737f, 15.6199f}, + {4.5000f, 15.4019f}, {4.8519f, 15.2284f}, {5.2235f, 15.1022f}, {5.6084f, 15.0257f}, + {6.0000f, 15.0000f}, {6.3916f, 15.0257f}, {6.7765f, 15.1022f}, {7.1481f, 15.2284f}, + {7.5000f, 15.4019f}, {7.8263f, 15.6199f}, {8.1213f, 15.8787f}, {8.3801f, 16.1737f}, + {8.5981f, 16.5000f}, {8.7716f, 16.8519f}, {8.8978f, 17.2235f}, {8.9743f, 17.6084f}, + {9.0000f, 18.0000f}, {21.0000f, 16.0000f}, {20.9743f, 16.3916f}, {20.8978f, 16.7765f}, + {20.7716f, 17.1481f}, {20.5981f, 17.5000f}, {20.3801f, 17.8263f}, {20.1213f, 18.1213f}, + {19.8263f, 18.3801f}, {19.5000f, 18.5981f}, {19.1481f, 18.7716f}, {18.7765f, 18.8978f}, + {18.3916f, 18.9743f}, {18.0000f, 19.0000f}, {17.6084f, 18.9743f}, {17.2235f, 18.8978f}, + {16.8519f, 18.7716f}, {16.5000f, 18.5981f}, {16.1737f, 18.3801f}, {15.8787f, 18.1213f}, + {15.6199f, 17.8263f}, {15.4019f, 17.5000f}, {15.2284f, 17.1481f}, {15.1022f, 16.7765f}, + {15.0257f, 16.3916f}, {15.0000f, 16.0000f}, {15.0257f, 15.6084f}, {15.1022f, 15.2235f}, + {15.2284f, 14.8519f}, {15.4019f, 14.5000f}, {15.6199f, 14.1737f}, {15.8787f, 13.8787f}, + {16.1737f, 13.6199f}, {16.5000f, 13.4019f}, {16.8519f, 13.2284f}, {17.2235f, 13.1022f}, + {17.6084f, 13.0257f}, {18.0000f, 13.0000f}, {18.3916f, 13.0257f}, {18.7765f, 13.1022f}, + {19.1481f, 13.2284f}, {19.5000f, 13.4019f}, {19.8263f, 13.6199f}, {20.1213f, 13.8787f}, + {20.3801f, 14.1737f}, {20.5981f, 14.5000f}, {20.7716f, 14.8519f}, {20.8978f, 15.2235f}, + {20.9743f, 15.6084f}, {21.0000f, 16.0000f}, {21.0000f, 16.0000f}, {21.0000f, 8.0000f}, + {20.9560f, 7.5846f}, {20.8266f, 7.1875f}, {20.6176f, 6.8258f}, {20.3380f, 6.5155f}, + {20.0000f, 6.2700f}, {13.0000f, 2.2700f}, {12.6180f, 2.0999f}, {12.2091f, 2.0130f}, + {11.7909f, 2.0130f}, {11.3820f, 2.0999f}, {11.0000f, 2.2700f}, {4.0000f, 6.2700f}, + {3.6620f, 6.5155f}, {3.3824f, 6.8258f}, {3.1734f, 7.1875f}, {3.0440f, 7.5846f}, + {3.0000f, 8.0000f}, {3.0000f, 16.0000f}, {3.0440f, 16.4154f}, {3.1734f, 16.8125f}, + {3.3824f, 17.1742f}, {3.6620f, 17.4845f}, {4.0000f, 17.7300f}, {11.0000f, 21.7300f}, + {11.3820f, 21.9001f}, {11.7909f, 21.9870f}, {12.2091f, 21.9870f}, {12.6180f, 21.9001f}, + {13.0000f, 21.7300f}, {20.0000f, 17.7300f}, {20.3380f, 17.4845f}, {20.6176f, 17.1742f}, + {20.8266f, 16.8125f}, {20.9560f, 16.4154f}, {21.0000f, 16.0000f}, {21.0000f, 16.0000f}, + {9.0000f, 21.0000f}, {5.0000f, 21.0000f}, {4.5550f, 20.9499f}, {4.1322f, 20.8019f}, + {3.7530f, 20.5637f}, {3.4363f, 20.2470f}, {3.1981f, 19.8678f}, {3.0501f, 19.4450f}, + {3.0000f, 19.0000f}, {3.0000f, 5.0000f}, {3.0501f, 4.5550f}, {3.1981f, 4.1322f}, + {3.4363f, 3.7530f}, {3.7530f, 3.4363f}, {4.1322f, 3.1981f}, {4.5550f, 3.0501f}, + {5.0000f, 3.0000f}, {9.0000f, 3.0000f}, {16.0000f, 17.0000f}, {21.0000f, 12.0000f}, + {16.0000f, 7.0000f}, {21.0000f, 12.0000f}, {9.0000f, 12.0000f}, {18.0000f, 6.0000f}, + {6.0000f, 18.0000f}, {6.0000f, 6.0000f}, {18.0000f, 18.0000f}, {5.0000f, 3.0000f}, + {18.0000f, 12.0000f}, {12.0000f, 13.0000f}, {9.0000f, 19.0000f}, {5.0000f, 3.0000f}, + {4.0000f, 4.0000f}, {20.0000f, 20.0000f}, {2.0000f, 2.0000f}, {22.0000f, 22.0000f}, + {12.0000f, 17.0000f}, {12.0000f, 22.0000f}, {9.0000f, 8.0000f}, {9.0000f, 4.0000f}, + {9.0761f, 3.6173f}, {9.2929f, 3.2929f}, {9.6173f, 3.0761f}, {10.0000f, 3.0000f}, + {14.0000f, 3.0000f}, {14.3827f, 3.0761f}, {14.7071f, 3.2929f}, {14.9239f, 3.6173f}, + {15.0000f, 4.0000f}, {15.0000f, 8.0000f}, +}; +static const IconContour contours[] = { + {0, 170}, {170, 49}, {219, 21}, {240, 23}, {263, 40}, {303, 2}, + {305, 5}, {310, 2}, {312, 2}, {314, 2}, {316, 2}, {318, 18}, + {336, 12}, {348, 3}, {351, 3}, {354, 2}, {356, 2}, {358, 70}, + {428, 2}, {430, 8}, {438, 49}, {487, 25}, {512, 2}, {514, 2}, + {516, 4}, {520, 49}, {569, 49}, {618, 38}, {656, 18}, {674, 3}, + {677, 2}, {679, 2}, {681, 2}, {683, 5}, {688, 2}, {690, 2}, + {692, 2}, {694, 12}, +}; +static const IconContour icons[] = { + {0, 2}, {2, 4}, {6, 4}, {10, 3}, {13, 4}, {17, 2}, {19, 1}, {20, 4}, {24, 3}, {27, 1}, {28, 3}, {31, 2}, {33, 2}, {35, 3} +}; + +void dial_icon(GpGraphics *g, int icon, float x, float y, float size, DWORD color) { + if (icon < 0 || icon >= (int)(sizeof(icons) / sizeof(icons[0]))) return; + GpPen *pen = NULL; + if (GdipCreatePen1(color, 1.95f * size / 24, 2, &pen)) return; + GdipSetPenStartCap(pen, 2); + GdipSetPenEndCap(pen, 2); + GdipSetPenLineJoin(pen, 2); + IconContour range = icons[icon]; + for (int i = 0; i < range.count; ++i) { + IconContour contour = contours[range.offset + i]; + DialPoint transformed[512]; + if (contour.count > 512) continue; + for (int j = 0; j < contour.count; ++j) { + DialPoint p = points[contour.offset + j]; + transformed[j] = (DialPoint){x + (p.x - 12) * size / 24, + y + (p.y - 12) * size / 24}; + } + GdipDrawLines(g, pen, transformed, contour.count); + } + GdipDeletePen(pen); +} diff --git a/src/platform/windows/dial/dial_internal.h b/src/platform/windows/dial/dial_internal.h new file mode 100644 index 00000000..206da4ac --- /dev/null +++ b/src/platform/windows/dial/dial_internal.h @@ -0,0 +1,65 @@ +#ifndef BONGO_CAT_DIAL_INTERNAL_H +#define BONGO_CAT_DIAL_INTERNAL_H +#include "windows_dial.h" +#include "dial_gdip.h" +#include +#define DIAL_PI 3.14159265358979323846f +#define DIAL_PAGE 16 +#define DIAL_ROOTS 12 +typedef struct DialItem { + const char *label; + BongoCatMenuAction command; + DWORD color; + int icon; + bool checked; + size_t children; +} DialItem; +typedef struct DialPaint { + ULONG_PTR token; + HDC dc; + HBITMAP dib; + HGDIOBJ old_bitmap; + GpBitmap *bitmap; + GpGraphics *graphics; + GpFontFamily *family; + GpFont *title_font, *value_font; + GpStringFormat *format; + GpPath *roots[DIAL_ROOTS], *root_rims[DIAL_ROOTS]; + GpPath *children[DIAL_PAGE], *child_rims[DIAL_PAGE]; +} DialPaint; +typedef struct DialCover { + GpBitmap *image; + float width, height; + bool attempted; +} DialCover; +typedef struct Dial { + HWND window, owner; + const BongoCatMenuLabels *labels; + DialItem items[DIAL_ROOTS]; + int count, active, child, page, pressed; + bool done, dark, reduced_motion, dirty, child_focus; + BongoCatMenuAction result, preview; + int pixels; + float scale, opening, lift[DIAL_ROOTS]; + ULONGLONG opened_at, changed_at; + DialPaint paint; + DialCover covers[BONGO_CAT_MODEL_CAP]; +} Dial; +void dial_covers_tick(Dial *dial); +void dial_covers_free(Dial *dial); +bool dial_cover_draw(Dial *dial, int child, float x, float y); +void dial_items(Dial *dial); +int dial_child_count(const Dial *dial); +float dial_child_angle(const Dial *dial, int index); +float dial_child_step(const Dial *dial); +DialItem dial_child_item(const Dial *dial, int index, char *text, size_t capacity); +void dial_select(Dial *dial, int root, int child); +void dial_hit(Dial *dial, float x, float y, int *root, int *child); +bool dial_paint_init(Dial *dial); +void dial_paint_free(Dial *dial); +bool dial_paint_frame(Dial *dial); +void dial_child_paths(Dial *dial); +GpPath *dial_sector(float inner, float outer, float start, float end, bool rim); +void dial_icon(GpGraphics *g, int icon, float x, float y, float size, DWORD color); +void dial_transform(Dial *dial, float zoom, float x, float y); +#endif diff --git a/src/platform/windows/dial/dial_items.c b/src/platform/windows/dial/dial_items.c new file mode 100644 index 00000000..39466846 --- /dev/null +++ b/src/platform/windows/dial/dial_items.c @@ -0,0 +1,107 @@ +#include "dial_internal.h" +#include + +void dial_items(Dial *d) { + const BongoCatMenuLabels *l = d->labels; + const DialItem items[] = { + {l->preferences, BONGO_CAT_MENU_PREFERENCES, 0xff54aeff, 0, false, 0}, + {l->hide, BONGO_CAT_MENU_HIDE, 0xff60a5fa, 1, false, 0}, + {l->pass_through, BONGO_CAT_MENU_PASS_THROUGH, 0xff38bdf8, 2, + l->pass_through_checked, 0}, + {l->always_on_top, BONGO_CAT_MENU_ALWAYS_ON_TOP, 0xfff77daa, 3, + l->always_on_top_checked, 0}, + {l->window_size, BONGO_CAT_MENU_NONE, 0xff34d399, 4, false, 16}, + {l->opacity, BONGO_CAT_MENU_NONE, 0xff818cf8, 5, false, 10}, + {l->motion, BONGO_CAT_MENU_NONE, 0xfffbbf24, 6, false, l->motion_count}, + {l->expression, BONGO_CAT_MENU_NONE, 0xfff472b6, 7, false, l->expression_count}, + {l->audio, BONGO_CAT_MENU_NONE, 0xff4ade80, 8, false, l->audio_count}, + {l->model, BONGO_CAT_MENU_NONE, 0xffa78bfa, 9, false, l->model_count + 1}, + {l->exit, BONGO_CAT_MENU_EXIT, 0xffff453a, 10, false, 0}, + {l->remove_pet, BONGO_CAT_MENU_REMOVE_PET, 0xffff453a, 11, false, 0} + }; + d->count = l->remove_pet_visible ? 12 : 11; + for (int i = 0; i < d->count; ++i) d->items[i] = items[i]; +} + +int dial_child_count(const Dial *d) { + if (d->active < 0) return 0; + int remaining = (int)d->items[d->active].children - d->page * DIAL_PAGE; + return remaining < 0 ? 0 : (remaining > DIAL_PAGE ? DIAL_PAGE : remaining); +} + +float dial_child_step(const Dial *d) { + /* Preserve HTML spacing; large real catalogs use pages to avoid overlap. */ + return d->active == 4 || d->active == 5 || dial_child_count(d) > 11 ? .32f : .50f; +} + +float dial_child_angle(const Dial *d, int index) { + return -DIAL_PI / 2 + d->active * 2 * DIAL_PI / d->count + + (index - (dial_child_count(d) - 1) / 2.0f) * dial_child_step(d); +} + +DialItem dial_child_item(const Dial *d, int child, char *text, size_t capacity) { + DialItem item = {0}; + if (child < 0 || child >= dial_child_count(d)) return item; + const BongoCatMenuLabels *l = d->labels; + size_t i = (size_t)(d->page * DIAL_PAGE + child); + item = d->items[d->active]; + item.children = 0; + switch (d->active) { + case 4: + snprintf(text, capacity, "%d%%", 50 + (int)i * 10); + item.label = text; + item.command = (BongoCatMenuAction)(BONGO_CAT_MENU_SCALE_50 + i); + item.checked = fabsf(l->scale_percent - (50 + (int)i * 10)) < .5f; + break; + case 5: + snprintf(text, capacity, "%d%%", 10 + (int)i * 10); + item.label = text; + item.command = (BongoCatMenuAction)(BONGO_CAT_MENU_OPACITY_10 + i); + item.checked = fabsf(l->opacity_percent - (10 + (int)i * 10)) < .5f; + break; + case 6: + item.label = l->motion_names[i]; + item.command = (BongoCatMenuAction)(BONGO_CAT_MENU_MOTION_FIRST + i); + item.checked = l->motion_checked && l->motion_checked[i]; + break; + case 7: + item.label = l->expression_names[i]; + item.command = (BongoCatMenuAction)(BONGO_CAT_MENU_EXPRESSION_FIRST + i); + item.checked = i == l->current_expression; + break; + case 8: + item.label = l->audio_names[i]; + item.command = (BongoCatMenuAction)(BONGO_CAT_MENU_AUDIO_FIRST + i); + item.checked = l->audio_checked && l->audio_checked[i]; + break; + case 9: + item.label = i == l->model_count ? l->add_model : l->model_names[i]; + item.command = i == l->model_count ? BONGO_CAT_MENU_MODEL_ADD : + (BongoCatMenuAction)(BONGO_CAT_MENU_MODEL_FIRST + i); + item.checked = i < l->model_count && i == l->current_model; + break; + } + return item; +} + +void dial_select(Dial *d, int root, int child) { + if (d->active == root && d->child == child) return; + bool changed = d->active != root; + d->active = root; + d->child = child; + if (changed) d->child_focus = false; + if (child >= 0) d->child_focus = true; + if (changed) { + d->page = 0; + d->changed_at = GetTickCount64(); + dial_child_paths(d); + } + char text[32]; + BongoCatMenuAction action = child >= 0 ? + dial_child_item(d, child, text, sizeof(text)).command : BONGO_CAT_MENU_NONE; + if (action != d->preview) { + d->preview = action; + if (d->labels->preview) d->labels->preview(d->labels->preview_userdata, action); + } + d->dirty = true; +} diff --git a/src/platform/windows/dial/dial_paint.c b/src/platform/windows/dial/dial_paint.c new file mode 100644 index 00000000..a60917db --- /dev/null +++ b/src/platform/windows/dial/dial_paint.c @@ -0,0 +1,238 @@ +#include "dial_internal.h" +#include "../windows_layered.h" +#include +#include + +static DWORD alpha(DWORD color, float opacity) { + return (color & 0xffffff) | ((DWORD)((color >> 24) * opacity) << 24); +} + +static void stroke(GpGraphics *g, GpPath *path, DWORD color, float width) { + GpPen *pen = NULL; + if (!path || GdipCreatePen1(color, width, 2, &pen)) return; + GdipSetPenStartCap(pen, 2); + GdipSetPenEndCap(pen, 2); + GdipSetPenLineJoin(pen, 2); + GdipDrawPath(g, pen, path); + GdipDeletePen(pen); +} + +static void fill(GpGraphics *g, GpPath *path, DWORD color) { + GpBrush *brush = NULL; + if (!path || GdipCreateSolidFill(color, &brush)) return; + GdipFillPath(g, brush, path); + GdipDeleteBrush(brush); +} + +static void dot(GpGraphics *g, float x, float y, float r, DWORD color) { + GpBrush *brush = NULL; + if (GdipCreateSolidFill(color, &brush)) return; + GdipFillEllipse(g, brush, x - r, y - r, 2 * r, 2 * r); + GdipDeleteBrush(brush); +} + +static void surface(Dial *d, GpPath *path, GpPath *rim, + int tint, float opacity) { + if (!path) return; + GpGraphics *g = d->paint.graphics; + DWORD colors[3]; + if (tint == 1) { + colors[0] = 0xff6ec2ff; colors[1] = 0xff54aeff; colors[2] = 0xff1e82f0; + } else if (tint == 2) { + colors[0] = 0xffff97be; colors[1] = 0xfff77daa; colors[2] = 0xffe34882; + } else if (tint == 3) { + colors[0] = 0xffff6960; colors[1] = 0xffff453a; colors[2] = 0xffd10014; + } else if (d->dark) { + colors[0] = 0x2effffff; colors[1] = 0x14ffffff; colors[2] = 0x08ffffff; + } else { + colors[0] = 0xffffffff; colors[1] = 0xf0f6f9ff; colors[2] = 0xe0eef3fc; + } + /* Concentric translucent strokes approximate the SVG's soft glow without + allocating blur buffers or capturing the desktop on every frame. */ + DWORD glow = tint ? (colors[1] & 0xffffff) : (d->dark ? 0 : 0x325082); + for (int i = 6; i > 0; --i) + stroke(g, path, alpha(glow | ((DWORD)(tint ? 6 : 3) << 24), opacity), + (float)i * (tint ? 5 : 6)); + /* Solid backing keeps the desktop from showing through settled sectors. + Opacity is used only for the child entrance animation. */ + fill(g, path, alpha(d->dark ? 0xff161c2a : 0xffffffff, opacity)); + DialRect bounds; + GpBrush *brush = NULL; + if (!GdipGetPathWorldBounds(path, &bounds, NULL, NULL)) { + DialPoint from = {bounds.x, bounds.y}, to = {bounds.x + bounds.w, bounds.y + bounds.h}; + for (int i = 0; i < 3; ++i) colors[i] = alpha(colors[i], opacity); + if (!GdipCreateLineBrush(&from, &to, colors[0], colors[2], 0, &brush)) { + const float stops[] = {0, .5f, 1}; + GdipSetLinePresetBlend(brush, colors, stops, 3); + GdipFillPath(g, brush, path); + GdipDeleteBrush(brush); + } + } + stroke(g, path, alpha(tint || !d->dark ? 0xfaffffff : 0x38ffffff, opacity), 1.1f); + stroke(g, rim, alpha(d->dark ? 0xd3ffffff : 0xebffffff, opacity), 1.4f); +} + +static void text(Dial *d, const char *value, float x, float y, float width, + bool title, DWORD color) { + if (!value) return; + WCHAR wide[BONGO_CAT_MENU_LABEL_CAP + 64]; + int n = MultiByteToWideChar(CP_UTF8, 0, value, -1, wide, + (int)(sizeof(wide) / sizeof(wide[0]))); + if (!n) return; + if (!strncmp(value, "live2d_", 7)) wide[6] = L'\n'; + DialRect box = {x - width / 2, y - 20, width, 40}; + GpBrush *brush = NULL; + if (GdipCreateSolidFill(color, &brush)) return; + GdipDrawString(d->paint.graphics, wide, -1, + title ? d->paint.title_font : d->paint.value_font, + &box, d->paint.format, brush); + GdipDeleteBrush(brush); +} + +static int item_icon(DialItem item) { + return item.checked && item.icon == 2 ? 12 : + (item.checked && item.icon == 3 ? 13 : item.icon); +} + +static void root(Dial *d, int index) { + DialItem item = d->items[index]; + float angle = -DIAL_PI / 2 + index * 2 * DIAL_PI / d->count; + float x = 132 * cosf(angle), y = 132 * sinf(angle); + float lift = d->lift[index], zoom = 1 + .055f * lift; + float pull = 7.5f * lift; + if (d->pressed == index) { zoom = .96f; pull *= .4f; } + dial_transform(d, zoom, x * (1 - zoom) + cosf(angle) * pull, + y * (1 - zoom) + sinf(angle) * pull); + bool active = index == d->active; + float opacity = 1.0f; + int tint = active ? (index >= 10 ? 3 : 1) : 0; + surface(d, d->paint.roots[index], d->paint.root_rims[index], tint, opacity); + DWORD color = active ? 0xffffffff : (item.checked ? 0xfff77daa : item.color); + if (index >= 6 && index <= 8 && !item.children) opacity *= .4f; + dial_icon(d->paint.graphics, item_icon(item), x, y, 28 * (1 + .15f * lift), alpha(color, opacity)); + if (item.checked) { + for (int i = 4; i > 0; --i) + dot(d->paint.graphics, x + 17, y - 17, 3.2f + i * 1.5f, 0x0af77daa); + dot(d->paint.graphics, x + 17, y - 17, 3.2f, 0xfff77daa); + } +} + +static void center(Dial *d) { + if (d->active < 0) return; + dial_transform(d, 1, 0, 0); + GpGraphics *g = d->paint.graphics; + DialItem item = d->items[d->active]; + char buffer[32]; + bool child_hovered = d->child >= 0; + DWORD color = child_hovered ? 0xfff77daa : 0xff52a9f8; + const char *label = child_hovered ? + dial_child_item(d, d->child, buffer, sizeof(buffer)).label : item.label; + if (!child_hovered) + dial_icon(g, item_icon(item), 0, -5, 38, item.checked ? 0xfff77daa : item.color); + text(d, label, 0, child_hovered ? 0.0f : 24.0f, 116, true, color); + if (item.children > DIAL_PAGE) { + snprintf(buffer, sizeof(buffer), "%d / %d", d->page + 1, + ((int)item.children + DIAL_PAGE - 1) / DIAL_PAGE); + text(d, buffer, 0, 48, 80, false, color); + } +} + +static void children(Dial *d, ULONGLONG now) { + for (int i = 0; i < dial_child_count(d); ++i) { + float elapsed = (float)(now - d->changed_at) - i * 22; + float t = d->reduced_motion ? 1 : fmaxf(0, fminf(1, elapsed / 360)); + float ease = 1 - powf(1 - t, 3); + float angle = dial_child_angle(d, i), x = 230 * cosf(angle), y = 230 * sinf(angle); + bool hover = i == d->child; + float zoom = .72f + .28f * ease + (hover ? .06f : 0); + float pull = -26 * (1 - ease) + (hover ? 6 : 0); + dial_transform(d, zoom, x * (1 - zoom) + cosf(angle) * pull, + y * (1 - zoom) + sinf(angle) * pull); + char buffer[32]; + DialItem item = dial_child_item(d, i, buffer, sizeof(buffer)); + surface(d, d->paint.children[i], d->paint.child_rims[i], + item.checked ? 2 : (hover ? 1 : 0), ease); + DWORD color = hover || item.checked || d->dark ? 0xffffffff : 0xff181c28; + if (!dial_cover_draw(d, i, x, y)) + text(d, item.label, x, y, dial_child_step(d) > .4f ? 92.0f : 62.0f, + false, alpha(color, ease)); + } +} + +bool dial_paint_frame(Dial *d) { + if (GdipGraphicsClear(d->paint.graphics, 0)) return false; + for (int i = 0; i < d->count; ++i) { + if (!d->child_focus || i == d->active) root(d, i); + } + center(d); + children(d, GetTickCount64()); + GdipFlush(d->paint.graphics, 1); + float fade = d->reduced_motion ? 1 : fminf(1, + (float)(GetTickCount64() - d->opened_at) / 180); + return bongo_cat_windows_layered_present_popup(d->window, d->paint.dc, + d->pixels, d->pixels, (BYTE)(255 * fade)); +} + +bool dial_paint_init(Dial *d) { + DialPaint *p = &d->paint; + const DialStartup startup = {1, NULL, FALSE, FALSE}; + if (GdiplusStartup(&p->token, &startup, NULL)) return false; + p->dc = CreateCompatibleDC(NULL); + BITMAPINFO info = {0}; + info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + info.bmiHeader.biWidth = d->pixels; + info.bmiHeader.biHeight = -d->pixels; + info.bmiHeader.biPlanes = 1; + info.bmiHeader.biBitCount = 32; + info.bmiHeader.biCompression = BI_RGB; + void *pixels = NULL; + p->dib = CreateDIBSection(p->dc, &info, DIB_RGB_COLORS, &pixels, NULL, 0); + if (!p->dc || !p->dib || !pixels) return false; + p->old_bitmap = SelectObject(p->dc, p->dib); + /* PARGB matches the layered presenter; no full-frame alpha conversion. */ + if (GdipCreateBitmapFromScan0(d->pixels, d->pixels, d->pixels * 4, + 0x000e200b, pixels, &p->bitmap) || + GdipGetImageGraphicsContext(p->bitmap, &p->graphics)) return false; + GdipSetSmoothingMode(p->graphics, 4); + GdipSetInterpolationMode(p->graphics, 7); + GdipSetTextRenderingHint(p->graphics, 4); + if (GdipCreateFontFamilyFromName(L"Microsoft YaHei UI", NULL, &p->family) && + GdipGetGenericFontFamilySansSerif(&p->family)) return false; + if (GdipCreateFont(p->family, 15, 1, 2, &p->title_font) || + GdipCreateFont(p->family, 10.5f, 1, 2, &p->value_font) || + GdipCreateStringFormat(0, 0, &p->format)) return false; + GdipSetStringFormatAlign(p->format, 1); + GdipSetStringFormatLineAlign(p->format, 1); + GdipSetStringFormatTrimming(p->format, 3); + for (int i = 0; i < d->count; ++i) { + float angle = -DIAL_PI / 2 + i * 2 * DIAL_PI / d->count; + float half = DIAL_PI / d->count; + p->roots[i] = dial_sector(82, 184, angle - half, angle + half, false); + p->root_rims[i] = dial_sector(82, 184, angle - half, angle + half, true); + if (!p->roots[i] || !p->root_rims[i]) return false; + } + return true; +} + +void dial_paint_free(Dial *d) { + DialPaint *p = &d->paint; + dial_covers_free(d); + for (int i = 0; i < DIAL_ROOTS; ++i) { + if (p->roots[i]) GdipDeletePath(p->roots[i]); + if (p->root_rims[i]) GdipDeletePath(p->root_rims[i]); + } + for (int i = 0; i < DIAL_PAGE; ++i) { + if (p->children[i]) GdipDeletePath(p->children[i]); + if (p->child_rims[i]) GdipDeletePath(p->child_rims[i]); + } + if (p->format) GdipDeleteStringFormat(p->format); + if (p->title_font) GdipDeleteFont(p->title_font); + if (p->value_font) GdipDeleteFont(p->value_font); + if (p->family) GdipDeleteFontFamily(p->family); + if (p->graphics) GdipDeleteGraphics(p->graphics); + if (p->bitmap) GdipDisposeImage(p->bitmap); + if (p->old_bitmap && p->old_bitmap != HGDI_ERROR) SelectObject(p->dc, p->old_bitmap); + if (p->dib) DeleteObject(p->dib); + if (p->dc) DeleteDC(p->dc); + if (p->token) GdiplusShutdown(p->token); +} diff --git a/src/platform/windows/dial/dial_window.c b/src/platform/windows/dial/dial_window.c new file mode 100644 index 00000000..1aa8faca --- /dev/null +++ b/src/platform/windows/dial/dial_window.c @@ -0,0 +1,239 @@ +#include "dial_internal.h" +#include "../windows_popup.h" +#include + +#define DIAL_TIMER 1 +static const WCHAR dial_class[] = L"BongoCat.LuminousSpatialDial"; + +static UINT window_dpi(HWND owner) { + typedef UINT (WINAPI *GetWindowDpi)(HWND); + HMODULE user32 = GetModuleHandleW(L"user32.dll"); + GetWindowDpi get_dpi = user32 ? + (GetWindowDpi)(void *)GetProcAddress(user32, "GetDpiForWindow") : NULL; + UINT dpi = get_dpi ? get_dpi(owner) : 96; + return dpi ? dpi : 96; +} + +static void animate(Dial *d) { + ULONGLONG now = GetTickCount64(); + float t = fminf(1, (float)(now - d->opened_at) / 700); + float opening = d->reduced_motion ? 1 : .85f + .15f * (1 - powf(1 - t, 4)); + if (opening != d->opening) { d->opening = opening; d->dirty = true; } + for (int i = 0; i < d->count; ++i) { + if (d->child_focus && i != d->active) { + d->lift[i] = 0; + continue; + } + float target = d->active == i ? 1.0f : 0.0f; + float next = d->reduced_motion ? target : d->lift[i] + (target - d->lift[i]) * .24f; + if (fabsf(next - target) < .002f) next = target; + if (next != d->lift[i]) { d->lift[i] = next; d->dirty = true; } + } + if (!d->reduced_motion && dial_child_count(d) && + now - d->changed_at <= (ULONGLONG)(360 + (dial_child_count(d) - 1) * 22 + 16)) + d->dirty = true; + if (d->dirty) { + d->dirty = false; + if (!dial_paint_frame(d)) d->done = true; + } +} + +static void pointer(Dial *d, LPARAM position, bool click, int *root, int *child) { + float scale = d->scale * d->opening; + float x = (GET_X_LPARAM(position) - d->pixels / 2.0f) / scale; + float y = (GET_Y_LPARAM(position) - d->pixels / 2.0f) / scale; + float radius = hypotf(x, y); + /* Keep siblings hidden across child gaps; returning inward restores them. */ + if (d->child_focus && radius < 190) { + d->child_focus = false; + d->dirty = true; + } + dial_hit(d, x, y, root, child); + if (click && *child < 0 && *root >= 0) { + float angle = atan2f(y, x) - (-DIAL_PI / 2 + *root * 2 * DIAL_PI / d->count); + if (radius < 73 || radius > 198 || + fabsf(atan2f(sinf(angle), cosf(angle))) > DIAL_PI / d->count) + *root = -1; + } +} + +static void activate(Dial *d) { + if (d->active < 0) return; + char text[32]; + DialItem item = d->child < 0 ? d->items[d->active] : + dial_child_item(d, d->child, text, sizeof(text)); + if (item.children) { dial_select(d, d->active, 0); return; } + if (item.command == BONGO_CAT_MENU_NONE) return; + d->result = item.command; + d->done = true; +} + +static void page(Dial *d, int direction) { + if (d->active < 0 || d->items[d->active].children <= DIAL_PAGE) return; + int pages = ((int)d->items[d->active].children + DIAL_PAGE - 1) / DIAL_PAGE; + d->page = (d->page + direction + pages) % pages; + /* Clear the previous preview before rebinding the page's item indices. */ + d->child = -1; + if (d->preview != BONGO_CAT_MENU_NONE && d->labels->preview) + d->labels->preview(d->labels->preview_userdata, BONGO_CAT_MENU_NONE); + d->preview = BONGO_CAT_MENU_NONE; + d->changed_at = GetTickCount64(); + dial_child_paths(d); + d->dirty = true; +} + +static void key(Dial *d, WPARAM value) { + if (value == VK_ESCAPE) { + if (d->active >= 0 && dial_child_count(d)) dial_select(d, -1, -1); + else d->done = true; + } else if (value == VK_RETURN || value == VK_SPACE) { + activate(d); + } else if (value == VK_PRIOR || value == VK_NEXT) { + page(d, value == VK_NEXT ? 1 : -1); + } else if (value == VK_LEFT || value == VK_UP || value == VK_RIGHT || + value == VK_DOWN || value == VK_TAB) { + int direction = value == VK_LEFT || value == VK_UP || + (value == VK_TAB && (GetKeyState(VK_SHIFT) & 0x8000)) ? -1 : 1; + int count = dial_child_count(d); + if (d->child >= 0 && count) + dial_select(d, d->active, (d->child + direction + count) % count); + else dial_select(d, d->active < 0 ? 0 : + (d->active + direction + d->count) % d->count, -1); + } else if (value == VK_BACK) { + d->child_focus = false; + d->dirty = true; + dial_select(d, d->active, -1); + } +} + +static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM w, LPARAM l) { + Dial *d = (Dial *)GetWindowLongPtrW(window, GWLP_USERDATA); + if (message == WM_NCCREATE) { + d = ((CREATESTRUCTW *)l)->lpCreateParams; + d->window = window; + SetWindowLongPtrW(window, GWLP_USERDATA, (LONG_PTR)d); + } + if (!d) return DefWindowProcW(window, message, w, l); + switch (message) { + case WM_ERASEBKGND: return 1; + case WM_PAINT: { + PAINTSTRUCT paint; + BeginPaint(window, &paint); EndPaint(window, &paint); + return 0; + } + case WM_MOUSEMOVE: { + int root, child; + pointer(d, l, false, &root, &child); + dial_select(d, root, child); + SetCursor(LoadCursorW(NULL, MAKEINTRESOURCEW(root >= 0 ? 32649 : 32512))); + return 0; + } + case WM_LBUTTONDOWN: { + int root, child; + pointer(d, l, true, &root, &child); + if (root < 0) { d->done = true; return 0; } + dial_select(d, root, child); + d->pressed = child >= 0 ? DIAL_ROOTS + child : root; + d->dirty = true; + return 0; + } + case WM_LBUTTONUP: { + int root, child, pressed = d->pressed; + d->pressed = -1; d->dirty = true; + pointer(d, l, true, &root, &child); + if (root >= 0 && pressed == + (child >= 0 ? DIAL_ROOTS + child : root)) { + dial_select(d, root, child); activate(d); + } + return 0; + } + case WM_MOUSEWHEEL: page(d, GET_WHEEL_DELTA_WPARAM(w) < 0 ? 1 : -1); return 0; + case WM_KEYDOWN: key(d, w); return 0; + case WM_TIMER: + if (w == DIAL_TIMER && !d->done) { + if (d->labels->preview_tick) d->labels->preview_tick(d->labels->preview_userdata); + if (!IsWindow(d->owner)) d->done = true; + if (!d->done) dial_covers_tick(d); + if (!d->done) animate(d); + } + return 0; + case WM_ACTIVATE: + if (LOWORD(w) == WA_INACTIVE) d->done = true; + return 0; + case WM_CAPTURECHANGED: + if ((HWND)l != window) d->done = true; + return 0; + case WM_CANCELMODE: case WM_CLOSE: case WM_RBUTTONDOWN: case WM_MBUTTONDOWN: + case WM_DISPLAYCHANGE: case WM_DPICHANGED: + d->done = true; return 0; + case WM_NCDESTROY: + d->done = true; + SetWindowLongPtrW(window, GWLP_USERDATA, 0); + break; + } + return DefWindowProcW(window, message, w, l); +} + +BongoCatMenuAction bongo_cat_windows_dial_track(HWND owner, const BongoCatMenuLabels *labels) { + if (!owner || !labels) return BONGO_CAT_MENU_NONE; + Dial d = {0}; + d.owner = owner; d.labels = labels; d.dark = labels->dark_theme; + d.active = d.child = d.pressed = -1; + d.opening = 1; + d.dirty = true; + BOOL animations = TRUE; + SystemParametersInfoW(SPI_GETCLIENTAREAANIMATION, 0, &animations, 0); + d.reduced_motion = !animations; + dial_items(&d); + POINT cursor; + if (!GetCursorPos(&cursor)) return BONGO_CAT_MENU_NONE; + MONITORINFO monitor = {0}; + monitor.cbSize = sizeof(monitor); + if (!GetMonitorInfoW(MonitorFromPoint(cursor, MONITOR_DEFAULTTONEAREST), &monitor)) + return BONGO_CAT_MENU_NONE; + RECT work = monitor.rcWork; + d.scale = 740.0f / 560 * window_dpi(owner) / 96; + d.scale = fminf(d.scale, .96f * (float)min(work.right - work.left, work.bottom - work.top) / 608); + d.pixels = (int)ceilf(608 * d.scale); + if (d.pixels < 1) return BONGO_CAT_MENU_NONE; + WNDCLASSEXW cls = {0}; + cls.cbSize = sizeof(cls); cls.lpfnWndProc = window_proc; + cls.hInstance = GetModuleHandleW(NULL); cls.lpszClassName = dial_class; + cls.hCursor = LoadCursorW(NULL, MAKEINTRESOURCEW(32512)); + if (!RegisterClassExW(&cls) && GetLastError() != ERROR_CLASS_ALREADY_EXISTS) + return BONGO_CAT_MENU_NONE; + if (!dial_paint_init(&d)) { dial_paint_free(&d); return BONGO_CAT_MENU_NONE; } + int x = max(work.left, min(cursor.x - d.pixels / 2, work.right - d.pixels)); + int y = max(work.top, min(cursor.y - d.pixels / 2, work.bottom - d.pixels)); + HWND window = CreateWindowExW(WS_EX_LAYERED | WS_EX_TOOLWINDOW | WS_EX_TOPMOST, + dial_class, L"BongoCat", WS_POPUP, x, y, d.pixels, d.pixels, + owner, NULL, cls.hInstance, &d); + if (!window) { dial_paint_free(&d); return BONGO_CAT_MENU_NONE; } + d.opened_at = d.changed_at = GetTickCount64(); + animate(&d); + if (!d.done) { + ShowWindow(window, SW_SHOW); + SetForegroundWindow(window); + SetFocus(window); + SetCapture(window); + if (!SetTimer(window, DIAL_TIMER, 16, NULL)) d.done = true; + } + MSG msg; + while (!d.done) { + int status = (int)GetMessageW(&msg, NULL, 0, 0); + if (status <= 0) { + if (!status) PostQuitMessage((int)msg.wParam); + break; + } + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + d.done = true; + KillTimer(window, DIAL_TIMER); + if (GetCapture() == window) ReleaseCapture(); + if (IsWindow(window)) DestroyWindow(window); + dial_paint_free(&d); + if (labels->restore) labels->restore(labels->preview_userdata, d.result); + bongo_cat_windows_popup_complete(owner); + return d.result; +} diff --git a/src/platform/windows/dial/windows_dial.h b/src/platform/windows/dial/windows_dial.h new file mode 100644 index 00000000..d7937772 --- /dev/null +++ b/src/platform/windows/dial/windows_dial.h @@ -0,0 +1,9 @@ +#ifndef BONGO_CAT_WINDOWS_DIAL_H +#define BONGO_CAT_WINDOWS_DIAL_H +#include "bongo_cat/platform.h" +#ifdef _WIN32 +#include +BongoCatMenuAction bongo_cat_windows_dial_track(HWND owner, + const BongoCatMenuLabels *labels); +#endif +#endif diff --git a/src/platform/windows/windows_layered.c b/src/platform/windows/windows_layered.c index 7841ca47..fbef6609 100644 --- a/src/platform/windows/windows_layered.c +++ b/src/platform/windows/windows_layered.c @@ -221,6 +221,16 @@ static bool read_frame(BongoCatWindowsLayered *value, int width, int height, STBIR_BGRA_PM) != NULL; } +bool bongo_cat_windows_layered_present_popup(HWND window, HDC dc, + int width, int height, BYTE opacity) { + if (!window || !dc || width <= 0 || height <= 0) return false; + POINT source = {0, 0}; + SIZE size = {width, height}; + BLENDFUNCTION blend = {AC_SRC_OVER, 0, opacity, AC_SRC_ALPHA}; + return UpdateLayeredWindow(window, NULL, NULL, &size, dc, + &source, 0, &blend, ULW_ALPHA) != FALSE; +} + bool bongo_cat_platform_present(BongoCatPlatform *platform, int width, int height) { if (!platform || !platform->window) return false; BongoCatWindowsLayered *value = platform->presenter; diff --git a/src/platform/windows/windows_layered.h b/src/platform/windows/windows_layered.h index a951a24e..d5bae367 100644 --- a/src/platform/windows/windows_layered.h +++ b/src/platform/windows/windows_layered.h @@ -12,6 +12,9 @@ void bongo_cat_windows_layered_set_click_through( void bongo_cat_windows_layered_set_always_on_top( BongoCatPlatform *platform, bool enabled); HWND bongo_cat_windows_layered_proxy(HWND source); +/* Present an owned popup's premultiplied BGRA surface without pet proxy state. */ +bool bongo_cat_windows_layered_present_popup(HWND window, HDC dc, + int width, int height, BYTE opacity); #endif #endif diff --git a/src/platform/windows/windows_menu.c b/src/platform/windows/windows_menu.c index 495bc5cb..209edf89 100644 --- a/src/platform/windows/windows_menu.c +++ b/src/platform/windows/windows_menu.c @@ -1,139 +1,16 @@ #include "bongo_cat/platform.h" -#include "windows_borderless.h" -#include "windows_popup.h" -#include "ui_native_theme.h" +#include "dial/windows_dial.h" #ifdef _WIN32 #include #include -#include -#include - -static HWND native_window(BongoCatPlatform *platform) { - return (HWND)SDL_GetPointerProperty(SDL_GetWindowProperties(platform->window), - SDL_PROP_WINDOW_WIN32_HWND_POINTER, NULL); -} - -static wchar_t *wide(const char *text) { - if (!text) return NULL; - int length = MultiByteToWideChar(CP_UTF8, 0, text, -1, NULL, 0); - wchar_t *value = length > 0 ? calloc((size_t)length, sizeof(*value)) : NULL; - if (value) MultiByteToWideChar(CP_UTF8, 0, text, -1, value, length); - return value; -} - -static void menu_text(HMENU menu, UINT flags, UINT_PTR id, const char *text) { - wchar_t *label = wide(text); - AppendMenuW(menu, flags, id, label ? label : L""); - free(label); -} - -static void destroy_unattached(HMENU menu, HMENU sizes, HMENU opacity, - HMENU models, HMENU motions, HMENU expressions) { - if (menu) DestroyMenu(menu); - if (sizes) DestroyMenu(sizes); - if (opacity) DestroyMenu(opacity); - if (models) DestroyMenu(models); - if (motions) DestroyMenu(motions); - if (expressions) DestroyMenu(expressions); -} BongoCatMenuAction bongo_cat_platform_context_menu(BongoCatPlatform *platform, const BongoCatMenuLabels *labels) { - if (!platform || !labels) return BONGO_CAT_MENU_NONE; - HMENU menu = CreatePopupMenu(); - if (!menu) return BONGO_CAT_MENU_NONE; - HMENU sizes = CreatePopupMenu(), opacity = CreatePopupMenu(); - HMENU models = CreatePopupMenu(); - HMENU motions = labels->motion_count ? CreatePopupMenu() : NULL; - HMENU expressions = labels->expression_count ? CreatePopupMenu() : NULL; - HMENU audio = labels->audio_count ? CreatePopupMenu() : NULL; - if (!sizes || !opacity || !models || - (labels->motion_count && !motions) || - (labels->expression_count && !expressions) || (labels->audio_count && !audio)) { - if (audio) DestroyMenu(audio); - destroy_unattached(menu, sizes, opacity, models, motions, expressions); - return BONGO_CAT_MENU_NONE; - } - menu_text(menu, MF_STRING, BONGO_CAT_MENU_PREFERENCES, labels->preferences); - menu_text(menu, MF_STRING, BONGO_CAT_MENU_HIDE, labels->hide); - AppendMenuW(menu, MF_SEPARATOR, 0, NULL); - menu_text(menu, MF_STRING | (labels->pass_through_checked ? MF_CHECKED : 0), - BONGO_CAT_MENU_PASS_THROUGH, labels->pass_through); - menu_text(menu, MF_STRING | (labels->always_on_top_checked ? MF_CHECKED : 0), - BONGO_CAT_MENU_ALWAYS_ON_TOP, labels->always_on_top); - for (int i = 0; i < 16; ++i) { - int scale = 50 + i * 10; - wchar_t label[16]; swprintf(label, 16, L"%d%%", scale); - UINT flags = MF_STRING | (SDL_fabsf(labels->scale_percent - scale) < .5f ? MF_CHECKED : 0); - AppendMenuW(sizes, flags, BONGO_CAT_MENU_SCALE_50 + i, label); - } - menu_text(sizes, MF_STRING | MF_DISABLED | MF_GRAYED, 0, - labels->wheel_size_hint); - const int opacities[] = {10,20,30,40,50,60,70,80,90,100}; - for (int i = 0; i < 10; ++i) { - wchar_t label[16]; swprintf(label, 16, L"%d%%", opacities[i]); - UINT flags = MF_STRING | (SDL_fabsf(labels->opacity_percent - opacities[i]) < .5f ? MF_CHECKED : 0); - AppendMenuW(opacity, flags, BONGO_CAT_MENU_OPACITY_10 + i, label); - } - menu_text(opacity, MF_STRING | MF_DISABLED | MF_GRAYED, 0, - labels->wheel_opacity_hint); - for (size_t i = 0; i < labels->motion_count; ++i) - menu_text(motions, MF_STRING | - (labels->motion_checked && labels->motion_checked[i] ? MF_CHECKED : 0), - BONGO_CAT_MENU_MOTION_FIRST + i, - labels->motion_names[i]); - for (size_t i = 0; i < labels->expression_count; ++i) - menu_text(expressions, MF_STRING, BONGO_CAT_MENU_EXPRESSION_FIRST + i, - labels->expression_names[i]); - for (size_t i = 0; i < labels->audio_count; ++i) - menu_text(audio, MF_STRING | - (labels->audio_checked && labels->audio_checked[i] ? MF_CHECKED : 0), - BONGO_CAT_MENU_AUDIO_FIRST + i, labels->audio_names[i]); - if (labels->current_expression < labels->expression_count) - CheckMenuItem(expressions, - (UINT)(BONGO_CAT_MENU_EXPRESSION_FIRST + labels->current_expression), - MF_BYCOMMAND | MF_CHECKED); - for (size_t i = 0; i < labels->model_count; ++i) - menu_text(models, MF_STRING | (i == labels->current_model ? MF_CHECKED : 0), - BONGO_CAT_MENU_MODEL_FIRST + i, labels->model_names[i]); - if (labels->model_count) AppendMenuW(models, MF_SEPARATOR, 0, NULL); - menu_text(models, MF_STRING, BONGO_CAT_MENU_MODEL_ADD, labels->add_model); - wchar_t *size_label = wide(labels->window_size), *opacity_label = wide(labels->opacity); - wchar_t *model_label = wide(labels->model), *motion_label = wide(labels->motion); - wchar_t *expression_label = wide(labels->expression); - AppendMenuW(menu, MF_POPUP, (UINT_PTR)sizes, size_label ? size_label : L""); - AppendMenuW(menu, MF_POPUP, (UINT_PTR)opacity, opacity_label ? opacity_label : L""); - if (labels->motion_count) AppendMenuW(menu, MF_POPUP, (UINT_PTR)motions, - motion_label ? motion_label : L""); - if (labels->expression_count) AppendMenuW(menu, MF_POPUP, (UINT_PTR)expressions, - expression_label ? expression_label : L""); - if (audio) { - wchar_t *audio_label = wide(labels->audio); - if (!AppendMenuW(menu, MF_POPUP, (UINT_PTR)audio, audio_label ? audio_label : L"")) - DestroyMenu(audio); - free(audio_label); - } - AppendMenuW(menu, MF_POPUP, (UINT_PTR)models, model_label ? model_label : L""); - free(size_label); free(opacity_label); free(model_label); free(motion_label); - free(expression_label); - AppendMenuW(menu, MF_SEPARATOR, 0, NULL); - menu_text(menu, MF_STRING, BONGO_CAT_MENU_EXIT, labels->exit); - if (labels->remove_pet_visible) { - AppendMenuW(menu, MF_SEPARATOR, 0, NULL); - menu_text(menu, MF_STRING, BONGO_CAT_MENU_REMOVE_PET, - labels->remove_pet); - } - POINT point; GetCursorPos(&point); - HWND window = native_window(platform); - bongo_cat_ui_native_menu_prepare(platform->window, labels->dark_theme); - bongo_cat_windows_menu_preview(window, labels->preview, - labels->preview_tick, labels->preview_userdata); - UINT command = bongo_cat_windows_popup_track(window, menu, - TPM_RETURNCMD | TPM_RIGHTBUTTON, point.x, point.y); - bongo_cat_windows_menu_preview(window, NULL, NULL, NULL); - if (labels->restore) labels->restore(labels->preview_userdata, (BongoCatMenuAction)command); - DestroyMenu(menu); - return (BongoCatMenuAction)command; + if (!platform || !platform->window || !labels) return BONGO_CAT_MENU_NONE; + HWND owner = (HWND)SDL_GetPointerProperty( + SDL_GetWindowProperties(platform->window), + SDL_PROP_WINDOW_WIN32_HWND_POINTER, NULL); + return bongo_cat_windows_dial_track(owner, labels); } #endif diff --git a/src/runtime/shell/window_menu_actions.c b/src/runtime/shell/window_menu_actions.c index 8aca3e31..ed2857da 100644 --- a/src/runtime/shell/window_menu_actions.c +++ b/src/runtime/shell/window_menu_actions.c @@ -28,12 +28,16 @@ static bool select_model(BongoCatApp *app, const char *id) { void bongo_cat_window_show_context_menu(BongoCatApp *app) { if (!app) return; - bool dark_theme = SDL_GetSystemTheme() == SDL_SYSTEM_THEME_DARK; + bool dark_theme = app->settings.app.theme == BONGO_CAT_THEME_DARK || + (app->settings.app.theme == BONGO_CAT_THEME_AUTO && + SDL_GetSystemTheme() == SDL_SYSTEM_THEME_DARK); BongoCatWindowMenuPreview preview; bongo_cat_window_menu_preview_init(&preview, app); const char *model_names[BONGO_CAT_MODEL_CAP]; + const char *model_cover_directories[BONGO_CAT_MODEL_CAP]; size_t current_model = app->models.count; for (size_t i = 0; i < app->models.count; ++i) { + model_cover_directories[i] = app->models.entries[i].adapter_directory; model_names[i] = bongo_cat_model_name(&app->settings, &app->models.entries[i]); if (!strcmp(app->models.entries[i].id, app->session.active_model_id)) @@ -70,7 +74,8 @@ void bongo_cat_window_show_context_menu(BongoCatApp *app) { bongo_cat_window_menu_restore, &preview, tr(app, "native.removeDesktopPet", "Close this desktop pet"), app->secondary_pet || (app->settings.model.multiple_pets && - app->session.additional_model_count > 0), NULL, NULL, NULL, 0}; + app->session.additional_model_count > 0), NULL, NULL, NULL, 0, + model_cover_directories}; char audio_names[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; bool audio_checked[BONGO_CAT_BEHAVIOR_CAP] = {false}; labels.audio = tr(app, "pages.preference.model.behaviorModal.labels.audio", "Audio"); From 393b7d58920374d2dcce44e6b58bec0cda606cb5 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 11 Sep 2026 20:05:22 +0800 Subject: [PATCH 269/334] Unify dial menu rendering across platforms --- cmake/PlatformRuntime.cmake | 11 +- cmake/RuntimeSources.cmake | 10 + scripts/generate-dial-icons.py | 12 +- src/platform/linux/linux.c | 4 - src/platform/linux/linux_internal.h | 2 - src/platform/linux/linux_menu.c | 275 ------------------ src/platform/macos/macos.m | 4 - src/platform/macos/macos_internal.h | 2 - src/platform/macos/macos_menu.m | 138 --------- src/platform/windows/dial/dial_covers.c | 84 ------ src/platform/windows/dial/dial_gdip.h | 74 ----- src/platform/windows/dial/dial_geometry.c | 91 ------ src/platform/windows/dial/dial_internal.h | 65 ----- src/platform/windows/dial/dial_paint.c | 238 --------------- src/platform/windows/dial/dial_window.c | 239 --------------- src/platform/windows/dial/windows_dial.h | 9 - src/platform/windows/windows_layered.c | 10 - src/platform/windows/windows_layered.h | 3 - src/platform/windows/windows_menu.c | 16 - src/ui/dial/dial_covers.c | 42 +++ src/ui/dial/dial_draw.c | 80 +++++ src/ui/dial/dial_fonts.c | 158 ++++++++++ src/ui/dial/dial_geometry.c | 125 ++++++++ .../windows => ui}/dial/dial_icons.c | 10 +- src/ui/dial/dial_input.c | 106 +++++++ src/ui/dial/dial_internal.h | 93 ++++++ .../windows => ui}/dial/dial_items.c | 4 +- src/ui/dial/dial_paint.c | 93 ++++++ src/ui/dial/dial_scene.c | 124 ++++++++ src/ui/dial/dial_window.c | 186 ++++++++++++ 30 files changed, 1025 insertions(+), 1283 deletions(-) delete mode 100644 src/platform/linux/linux_menu.c delete mode 100644 src/platform/macos/macos_menu.m delete mode 100644 src/platform/windows/dial/dial_covers.c delete mode 100644 src/platform/windows/dial/dial_gdip.h delete mode 100644 src/platform/windows/dial/dial_geometry.c delete mode 100644 src/platform/windows/dial/dial_internal.h delete mode 100644 src/platform/windows/dial/dial_paint.c delete mode 100644 src/platform/windows/dial/dial_window.c delete mode 100644 src/platform/windows/dial/windows_dial.h delete mode 100644 src/platform/windows/windows_menu.c create mode 100644 src/ui/dial/dial_covers.c create mode 100644 src/ui/dial/dial_draw.c create mode 100644 src/ui/dial/dial_fonts.c create mode 100644 src/ui/dial/dial_geometry.c rename src/{platform/windows => ui}/dial/dial_icons.c (97%) create mode 100644 src/ui/dial/dial_input.c create mode 100644 src/ui/dial/dial_internal.h rename src/{platform/windows => ui}/dial/dial_items.c (97%) create mode 100644 src/ui/dial/dial_paint.c create mode 100644 src/ui/dial/dial_scene.c create mode 100644 src/ui/dial/dial_window.c diff --git a/cmake/PlatformRuntime.cmake b/cmake/PlatformRuntime.cmake index 6b928e6b..18e19fa7 100644 --- a/cmake/PlatformRuntime.cmake +++ b/cmake/PlatformRuntime.cmake @@ -2,13 +2,6 @@ if(WIN32) enable_language(RC) target_sources(bongo_cat_runtime PRIVATE src/platform/windows/windows.c - src/platform/windows/windows_menu.c - src/platform/windows/dial/dial_window.c - src/platform/windows/dial/dial_items.c - src/platform/windows/dial/dial_geometry.c - src/platform/windows/dial/dial_paint.c - src/platform/windows/dial/dial_icons.c - src/platform/windows/dial/dial_covers.c src/platform/windows/windows_directory.c src/platform/windows/windows_package.c src/platform/windows/windows_startup.c @@ -37,7 +30,7 @@ if(WIN32) src/platform/windows) target_link_libraries(bongo_cat_runtime PRIVATE dwmapi ole32 shell32 user32 uuid windowscodecs advapi32 - winhttp gdiplus gdi32) + winhttp) elseif(APPLE) find_package(CURL REQUIRED) enable_language(OBJC) @@ -46,7 +39,6 @@ elseif(APPLE) src/platform/macos/macos_preferences.m src/platform/macos/macos_input.m src/platform/macos/macos_keys.m - src/platform/macos/macos_menu.m src/platform/macos/macos_tray.m) target_link_libraries(bongo_cat_runtime PRIVATE "-framework Cocoa" "-framework ApplicationServices" CURL::libcurl) @@ -59,7 +51,6 @@ else() endif() target_sources(bongo_cat_runtime PRIVATE src/platform/linux/linux.c - src/platform/linux/linux_menu.c src/platform/linux/linux_x11.c) target_link_libraries(bongo_cat_runtime PRIVATE X11::X11 X11::Xi X11::Xfixes m CURL::libcurl) diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 57faf392..7da3b089 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -178,6 +178,16 @@ set(BONGO_CAT_UI_BACKEND_SOURCES src/ui/backend/ui_tooltip.c) set(BONGO_CAT_UI_RENDERING_SOURCES + src/ui/dial/dial_window.c + src/ui/dial/dial_input.c + src/ui/dial/dial_items.c + src/ui/dial/dial_geometry.c + src/ui/dial/dial_paint.c + src/ui/dial/dial_draw.c + src/ui/dial/dial_scene.c + src/ui/dial/dial_fonts.c + src/ui/dial/dial_covers.c + src/ui/dial/dial_icons.c src/ui/rendering/ui_animation.c src/ui/rendering/ui_font.c src/ui/rendering/ui_font_atlas.c diff --git a/scripts/generate-dial-icons.py b/scripts/generate-dial-icons.py index 54196bf8..3df35f31 100644 --- a/scripts/generate-dial-icons.py +++ b/scripts/generate-dial-icons.py @@ -4,7 +4,7 @@ parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('reference', type=Path, help='Path to the reference index.html') parser.add_argument('--output', type=Path, default=Path(__file__).resolve().parents[1] / - 'src/platform/windows/dial/dial_icons.c') + 'src/ui/dial/dial_icons.c') args = parser.parse_args() html=args.reference.read_text(encoding='utf-8') icon_block = re.search(r'const\s+icons\s*=\s*\{(.*?)\n\s*\};', html, re.S) @@ -94,13 +94,8 @@ def path(data): lines+=['};','static const IconContour contours[] = {'] for j in range(0,len(contours),6): lines.append(' '+', '.join('{%d, %d}'%p for p in contours[j:j+6])+',') lines+=['};','static const IconContour icons[] = {',' '+', '.join('{%d, %d}'%p for p in ranges),'};', ''' -void dial_icon(GpGraphics *g, int icon, float x, float y, float size, DWORD color) { +void dial_icon(Dial *d, int icon, float x, float y, float size, uint32_t color) { if (icon < 0 || icon >= (int)(sizeof(icons) / sizeof(icons[0]))) return; - GpPen *pen = NULL; - if (GdipCreatePen1(color, 1.95f * size / 24, 2, &pen)) return; - GdipSetPenStartCap(pen, 2); - GdipSetPenEndCap(pen, 2); - GdipSetPenLineJoin(pen, 2); IconContour range = icons[icon]; for (int i = 0; i < range.count; ++i) { IconContour contour = contours[range.offset + i]; @@ -111,9 +106,8 @@ def path(data): transformed[j] = (DialPoint){x + (p.x - 12) * size / 24, y + (p.y - 12) * size / 24}; } - GdipDrawLines(g, pen, transformed, contour.count); + dial_stroke(d, transformed, contour.count, 1.95f * size / 24, color, true); } - GdipDeletePen(pen); } '''] assert max(n for _, n in contours) <= 512 diff --git a/src/platform/linux/linux.c b/src/platform/linux/linux.c index f3a44732..2aa099d1 100644 --- a/src/platform/linux/linux.c +++ b/src/platform/linux/linux.c @@ -242,10 +242,6 @@ BongoCatResult bongo_cat_platform_set_autostart(bool enabled, BongoCatError *err remove(path); bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, "Cannot write Linux autostart entry"); return BONGO_CAT_ERROR_IO; } -BongoCatMenuAction bongo_cat_platform_context_menu(BongoCatPlatform *platform, - const BongoCatMenuLabels *labels) { - return bongo_cat_linux_context_menu(platform, labels); -} BongoCatResult bongo_cat_platform_embedded_assets(const char *target, BongoCatError *error) { (void)target; (void)error; return BONGO_CAT_ERROR_PLATFORM; } diff --git a/src/platform/linux/linux_internal.h b/src/platform/linux/linux_internal.h index e5256d7c..7e78c091 100644 --- a/src/platform/linux/linux_internal.h +++ b/src/platform/linux/linux_internal.h @@ -9,7 +9,5 @@ bool bongo_cat_linux_x11_supported(const BongoCatPlatform *platform); void bongo_cat_linux_x11_click_through(BongoCatPlatform *platform, bool enabled); void bongo_cat_linux_x11_configure_capture_window(BongoCatPlatform *platform); void bongo_cat_linux_x11_begin_drag(BongoCatPlatform *platform); -BongoCatMenuAction bongo_cat_linux_context_menu(BongoCatPlatform *platform, - const BongoCatMenuLabels *labels); #endif diff --git a/src/platform/linux/linux_menu.c b/src/platform/linux/linux_menu.c deleted file mode 100644 index b51894e1..00000000 --- a/src/platform/linux/linux_menu.c +++ /dev/null @@ -1,275 +0,0 @@ -#include "linux_internal.h" - -#if !defined(_WIN32) && !defined(__APPLE__) -#include -#include -#include -#include -#include -#include - -typedef struct LinuxMenuRow { - const char *label; - BongoCatMenuAction action; -} LinuxMenuRow; - -#define LINUX_MENU_HINT ((BongoCatMenuAction)-4) -#define LINUX_MENU_MOTIONS ((BongoCatMenuAction)-5) -#define LINUX_MENU_EXPRESSIONS ((BongoCatMenuAction)-6) -#define LINUX_MENU_AUDIO ((BongoCatMenuAction)-8) -#define LINUX_MENU_SEPARATOR ((BongoCatMenuAction)-7) - -typedef struct LinuxMenuPalette { - unsigned long surface, field, border, text, muted, accent; -} LinuxMenuPalette; - -static BongoCatMenuAction finish(const BongoCatMenuLabels *labels, - BongoCatMenuAction action) { - if (labels->restore) labels->restore(labels->preview_userdata, action); - return action; -} - -static unsigned long color(Display *display, int screen, const char *value) { - XColor resolved; - Colormap map = DefaultColormap(display, screen); - return XParseColor(display, map, value, &resolved) && - XAllocColor(display, map, &resolved) ? resolved.pixel : - WhitePixel(display, screen); -} - -static LinuxMenuPalette palette(Display *display, bool dark) { - int screen = DefaultScreen(display); - LinuxMenuPalette value; - value.surface = color(display, screen, dark ? "#21242b" : "#ffffff"); - value.field = color(display, screen, dark ? "#2a2e37" : "#f3f5f8"); - value.border = color(display, screen, dark ? "#3b424f" : "#d8dee8"); - value.text = color(display, screen, dark ? "#f4f7fb" : "#182230"); - value.muted = color(display, screen, dark ? "#9aa4b2" : "#667085"); - value.accent = color(display, screen, "#54aeff"); - return value; -} - -static bool row_selectable(const LinuxMenuRow *row) { - return row->action != LINUX_MENU_HINT && - row->action != LINUX_MENU_SEPARATOR; -} - -static int row_height(const LinuxMenuRow *row) { - return row->action == LINUX_MENU_SEPARATOR ? 13 : 34; -} - -static int rows_height(const LinuxMenuRow *rows, int count) { - int height = 0; - for (int i = 0; i < count; ++i) height += row_height(&rows[i]); - return height; -} - -static int row_at(const LinuxMenuRow *rows, int count, int y) { - int top = 8; - for (int i = 0; i < count; ++i) { - int height = row_height(&rows[i]); - if (y >= top && y < top + height) return i; - top += height; - } - return -1; -} - -static void draw_menu(Display *display, Window window, GC gc, - const LinuxMenuRow *rows, int count, int hover, - const LinuxMenuPalette *colors) { - int height = rows_height(rows, count) + 16; - XSetForeground(display, gc, colors->surface); XFillRectangle(display, window, gc, - 0, 0, 340, (unsigned)height); - XSetForeground(display, gc, colors->border); XDrawRectangle(display, window, gc, - 0, 0, 339, (unsigned)(height - 1)); - int y = 8; - for (int index = 0; index < count; ++index) { - if (rows[index].action == LINUX_MENU_SEPARATOR) { - XSetForeground(display, gc, colors->border); - XDrawLine(display, window, gc, 12, y + 6, 327, y + 6); - y += row_height(&rows[index]); - continue; - } - bool hint = rows[index].action == LINUX_MENU_HINT; - if (index == hover && !hint) { - XSetForeground(display, gc, colors->field); - XFillRectangle(display, window, gc, 8, y, 324, 32); - } - XSetForeground(display, gc, hint ? colors->muted : - index == hover ? colors->accent : colors->text); - XDrawString(display, window, gc, 20, y + 21, rows[index].label, - (int)strlen(rows[index].label)); - y += row_height(&rows[index]); - } - XFlush(display); -} - -static int popup_rows(Display *display, Window owner, const LinuxMenuRow *rows, - int count, const BongoCatMenuLabels *labels) { - if (!display || !rows || count < 1) return -1; - Window root; int root_x, root_y, win_x, win_y; unsigned mask; - XQueryPointer(display, DefaultRootWindow(display), &root, &owner, - &root_x, &root_y, &win_x, &win_y, &mask); - int height = rows_height(rows, count) + 16; - int screen_width = DisplayWidth(display, DefaultScreen(display)); - int screen_height = DisplayHeight(display, DefaultScreen(display)); - if (root_x > screen_width - 342) root_x = screen_width - 342; - if (root_y > screen_height - height - 2) root_y = screen_height - height - 2; - LinuxMenuPalette colors = palette(display, labels->dark_theme); - XSetWindowAttributes attributes = {0}; - attributes.override_redirect = True; - attributes.background_pixel = colors.surface; - attributes.border_pixel = colors.border; - Window menu = XCreateWindow(display, DefaultRootWindow(display), root_x, - root_y, 340, (unsigned)height, 1, CopyFromParent, InputOutput, - CopyFromParent, CWOverrideRedirect | CWBackPixel | CWBorderPixel, - &attributes); - if (!menu) return -1; - XSelectInput(display, menu, ExposureMask | ButtonPressMask | - PointerMotionMask | LeaveWindowMask | KeyPressMask); - XMapRaised(display, menu); - int pointer_grab = XGrabPointer(display, menu, False, - ButtonPressMask | PointerMotionMask, GrabModeAsync, GrabModeAsync, - None, None, CurrentTime); - int keyboard_grab = XGrabKeyboard(display, menu, False, - GrabModeAsync, GrabModeAsync, CurrentTime); - GC gc = XCreateGC(display, menu, 0, NULL); - if (pointer_grab != GrabSuccess || keyboard_grab != GrabSuccess || !gc) { - if (pointer_grab == GrabSuccess) XUngrabPointer(display, CurrentTime); - if (keyboard_grab == GrabSuccess) XUngrabKeyboard(display, CurrentTime); - if (gc) XFreeGC(display, gc); - XDestroyWindow(display, menu); XFlush(display); - return -1; - } - int selected = -1, hover = -1; - while (selected < 0) { - if (!XPending(display) && labels->preview_tick) { - labels->preview_tick(labels->preview_userdata); - SDL_Delay(16); - continue; - } - XEvent event; XNextEvent(display, &event); - if (event.type == Expose) draw_menu(display, menu, gc, rows, count, - hover, &colors); - else if (event.type == MotionNotify) { - int next = row_at(rows, count, event.xmotion.y); - next = next >= 0 && row_selectable(&rows[next]) ? next : -1; - if (next != hover && labels->preview) - labels->preview(labels->preview_userdata, next >= 0 ? - rows[next].action : BONGO_CAT_MENU_NONE); - hover = next; - draw_menu(display, menu, gc, rows, count, hover, &colors); - } else if (event.type == LeaveNotify) { - hover = -1; - if (labels->preview) labels->preview(labels->preview_userdata, - BONGO_CAT_MENU_NONE); - draw_menu(display, menu, gc, rows, count, hover, &colors); - } else if (event.type == ButtonPress) { - int next = row_at(rows, count, event.xbutton.y); - if (next < 0 || next >= count) selected = -2; - else if (row_selectable(&rows[next])) selected = next; - } else if (event.type == KeyPress && - XLookupKeysym(&event.xkey, 0) == XK_Escape) selected = -2; - } - XUngrabPointer(display, CurrentTime); XUngrabKeyboard(display, CurrentTime); - XFreeGC(display, gc); - XDestroyWindow(display, menu); XFlush(display); - return selected >= 0 ? rows[selected].action : BONGO_CAT_MENU_NONE; -} - -BongoCatMenuAction bongo_cat_linux_context_menu(BongoCatPlatform *platform, - const BongoCatMenuLabels *labels) { - if (!platform || !labels) return BONGO_CAT_MENU_NONE; - SDL_PropertiesID properties = SDL_GetWindowProperties(platform->window); - Display *display = SDL_GetPointerProperty(properties, - SDL_PROP_WINDOW_X11_DISPLAY_POINTER, NULL); - Window owner = (Window)SDL_GetNumberProperty(properties, - SDL_PROP_WINDOW_X11_WINDOW_NUMBER, 0); - if (!display || !owner) return finish(labels, BONGO_CAT_MENU_NONE); - char pass[BONGO_CAT_ID_CAP], top[BONGO_CAT_ID_CAP]; - snprintf(pass, sizeof(pass), "%s%s", labels->pass_through_checked ? "[x] " : "", - labels->pass_through); - snprintf(top, sizeof(top), "%s%s", labels->always_on_top_checked ? "[x] " : "", - labels->always_on_top); - LinuxMenuRow main_rows[14]; int main_count = 0; - main_rows[main_count++] = (LinuxMenuRow){labels->preferences, BONGO_CAT_MENU_PREFERENCES}; - main_rows[main_count++] = (LinuxMenuRow){labels->hide, BONGO_CAT_MENU_HIDE}; - main_rows[main_count++] = (LinuxMenuRow){pass, BONGO_CAT_MENU_PASS_THROUGH}; - main_rows[main_count++] = (LinuxMenuRow){top, BONGO_CAT_MENU_ALWAYS_ON_TOP}; - main_rows[main_count++] = (LinuxMenuRow){labels->window_size, -1}; - main_rows[main_count++] = (LinuxMenuRow){labels->opacity, -2}; - if (labels->motion_count) main_rows[main_count++] = - (LinuxMenuRow){labels->motion, LINUX_MENU_MOTIONS}; - if (labels->expression_count) main_rows[main_count++] = - (LinuxMenuRow){labels->expression, LINUX_MENU_EXPRESSIONS}; - if (labels->audio_count) main_rows[main_count++] = - (LinuxMenuRow){labels->audio, LINUX_MENU_AUDIO}; - main_rows[main_count++] = (LinuxMenuRow){labels->model, -3}; - main_rows[main_count++] = (LinuxMenuRow){NULL, LINUX_MENU_SEPARATOR}; - main_rows[main_count++] = (LinuxMenuRow){labels->exit, BONGO_CAT_MENU_EXIT}; - if (labels->remove_pet_visible) { - main_rows[main_count++] = (LinuxMenuRow){NULL, - LINUX_MENU_SEPARATOR}; - main_rows[main_count++] = (LinuxMenuRow){labels->remove_pet, - BONGO_CAT_MENU_REMOVE_PET}; - } - BongoCatMenuAction action = popup_rows(display, owner, main_rows, - main_count, labels); - if (action == (BongoCatMenuAction)-1) { - const int values[] = {50,60,70,80,90,100,110,120,130,140,150,160,170,180,190,200}; - LinuxMenuRow rows[17]; char text[16][16]; - for (int i = 0; i < 16; ++i) { snprintf(text[i], sizeof(text[i]), "%d%%", values[i]); - rows[i] = (LinuxMenuRow){text[i], BONGO_CAT_MENU_SCALE_50 + i}; } - rows[16] = (LinuxMenuRow){labels->wheel_size_hint, LINUX_MENU_HINT}; - action = popup_rows(display, owner, rows, 17, labels); - } else if (action == (BongoCatMenuAction)-2) { - const int values[] = {10,20,30,40,50,60,70,80,90,100}; - LinuxMenuRow rows[11]; char text[10][16]; - for (int i = 0; i < 10; ++i) { snprintf(text[i], sizeof(text[i]), "%d%%", values[i]); - rows[i] = (LinuxMenuRow){text[i], BONGO_CAT_MENU_OPACITY_10 + i}; } - rows[10] = (LinuxMenuRow){labels->wheel_opacity_hint, LINUX_MENU_HINT}; - action = popup_rows(display, owner, rows, 11, labels); - } else if (action == (BongoCatMenuAction)-3) { - LinuxMenuRow rows[BONGO_CAT_MODEL_CAP + 1]; - for (size_t i = 0; i < labels->model_count; ++i) - rows[i] = (LinuxMenuRow){labels->model_names[i], - BONGO_CAT_MENU_MODEL_FIRST + (int)i}; - rows[labels->model_count] = (LinuxMenuRow){labels->add_model, - BONGO_CAT_MENU_MODEL_ADD}; - action = popup_rows(display, owner, rows, - (int)labels->model_count + 1, labels); - } else if (action == LINUX_MENU_MOTIONS) { - LinuxMenuRow rows[BONGO_CAT_BEHAVIOR_CAP]; - char text[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; - for (size_t i = 0; i < labels->motion_count; ++i) { - snprintf(text[i], sizeof(text[i]), "%s%s", - labels->motion_checked && labels->motion_checked[i] ? "[x] " : "", - labels->motion_names[i]); - rows[i] = (LinuxMenuRow){text[i], - BONGO_CAT_MENU_MOTION_FIRST + (int)i}; - } - action = popup_rows(display, owner, rows, (int)labels->motion_count, labels); - } else if (action == LINUX_MENU_AUDIO) { - LinuxMenuRow rows[BONGO_CAT_BEHAVIOR_CAP]; - char text[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP + 4]; - for (size_t i = 0; i < labels->audio_count; ++i) { - snprintf(text[i], sizeof(text[i]), "%s%s", - labels->audio_checked && labels->audio_checked[i] ? "[x] " : "", - labels->audio_names[i]); - rows[i] = (LinuxMenuRow){text[i], BONGO_CAT_MENU_AUDIO_FIRST + (int)i}; - } - action = popup_rows(display, owner, rows, (int)labels->audio_count, labels); - } else if (action == LINUX_MENU_EXPRESSIONS) { - LinuxMenuRow rows[BONGO_CAT_BEHAVIOR_CAP]; - char text[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_ID_CAP]; - for (size_t i = 0; i < labels->expression_count; ++i) { - snprintf(text[i], sizeof(text[i]), "%s%s", - i == labels->current_expression ? "[x] " : "", - labels->expression_names[i]); - rows[i] = (LinuxMenuRow){text[i], BONGO_CAT_MENU_EXPRESSION_FIRST + (int)i}; - } - action = popup_rows(display, owner, rows, (int)labels->expression_count, labels); - } - return finish(labels, action); -} -#endif diff --git a/src/platform/macos/macos.m b/src/platform/macos/macos.m index 8e5186fa..057a6b70 100644 --- a/src/platform/macos/macos.m +++ b/src/platform/macos/macos.m @@ -219,10 +219,6 @@ void bongo_cat_platform_single_instance_end(void) { [instance_observer release]; instance_observer = nil; } } -BongoCatMenuAction bongo_cat_platform_context_menu(BongoCatPlatform *platform, - const BongoCatMenuLabels *labels) { - return bongo_cat_macos_context_menu(platform, labels); -} BongoCatResult bongo_cat_platform_embedded_assets(const char *target, BongoCatError *error) { (void)target; (void)error; return BONGO_CAT_ERROR_PLATFORM; } diff --git a/src/platform/macos/macos_internal.h b/src/platform/macos/macos_internal.h index 4a715188..a673a1d1 100644 --- a/src/platform/macos/macos_internal.h +++ b/src/platform/macos/macos_internal.h @@ -6,7 +6,5 @@ bool bongo_cat_macos_input_start(BongoCatPlatform *platform, BongoCatError *error); void bongo_cat_macos_input_stop(BongoCatPlatform *platform); bool bongo_cat_macos_input_supported(void); -BongoCatMenuAction bongo_cat_macos_context_menu(BongoCatPlatform *platform, - const BongoCatMenuLabels *labels); #endif diff --git a/src/platform/macos/macos_menu.m b/src/platform/macos/macos_menu.m deleted file mode 100644 index 2c364516..00000000 --- a/src/platform/macos/macos_menu.m +++ /dev/null @@ -1,138 +0,0 @@ -#include "macos_internal.h" - -#ifdef __APPLE__ -#import -#include - -@interface BongoCatMenuTarget : NSObject { - NSInteger selected_; const BongoCatMenuLabels *labels_; -} -- (id)initWithLabels:(const BongoCatMenuLabels *)labels; -- (void)choose:(id)sender; -- (NSInteger)selected; -- (void)tickPreview:(NSTimer *)timer; -@end - -@implementation BongoCatMenuTarget -- (id)initWithLabels:(const BongoCatMenuLabels *)labels { - self = [super init]; if (self) labels_ = labels; return self; -} -- (void)choose:(id)sender { selected_ = [sender tag]; } -- (NSInteger)selected { return selected_; } -- (void)tickPreview:(NSTimer *)timer { - (void)timer; - if (labels_->preview_tick) labels_->preview_tick(labels_->preview_userdata); -} -- (void)menu:(NSMenu *)menu willHighlightItem:(NSMenuItem *)item { - (void)menu; - if (labels_->preview) labels_->preview(labels_->preview_userdata, - item ? (BongoCatMenuAction)[item tag] : BONGO_CAT_MENU_NONE); -} -@end - -static NSString *text(const char *value) { - return value ? [NSString stringWithUTF8String:value] : @""; -} - -static NSMenuItem *add_item(NSMenu *menu, BongoCatMenuTarget *target, - const char *label, NSInteger tag, bool checked) { - NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:text(label) - action:@selector(choose:) keyEquivalent:@""]; - [item setTarget:target]; [item setTag:tag]; - [item setState:checked ? NSControlStateValueOn : NSControlStateValueOff]; - [menu addItem:item]; [item release]; return item; -} - -static void add_scale_menu(NSMenu *menu, BongoCatMenuTarget *target, - const char *label, const char *hint_label, bool opacity) { - NSMenuItem *root = [[NSMenuItem alloc] initWithTitle:text(label) - action:nil keyEquivalent:@""]; - NSMenu *submenu = [[NSMenu alloc] initWithTitle:text(label)]; - [submenu setDelegate:target]; - const int values[] = {10,20,30,40,50,60,70,80,90,100}; - int count = opacity ? 10 : 16; - for (int i = 0; i < count; ++i) { - int value = opacity ? values[i] : 50 + i * 10; - char title[16]; snprintf(title, sizeof(title), "%d%%", value); - NSInteger tag = opacity ? BONGO_CAT_MENU_OPACITY_10 + i : BONGO_CAT_MENU_SCALE_50 + i; - add_item(submenu, target, title, tag, false); - } - NSMenuItem *hint = [[NSMenuItem alloc] initWithTitle:text(hint_label) - action:nil keyEquivalent:@""]; - [hint setEnabled:NO]; [submenu addItem:hint]; [hint release]; - [root setSubmenu:submenu]; [menu addItem:root]; - [submenu release]; [root release]; -} - -static void add_named_menu(NSMenu *menu, BongoCatMenuTarget *target, - const char *label, const char names[][BONGO_CAT_MENU_LABEL_CAP], - size_t count, NSInteger first, - const bool *checked, size_t selected) { - if (!count) return; - NSMenuItem *root = [[NSMenuItem alloc] initWithTitle:text(label) - action:nil keyEquivalent:@""]; - NSMenu *submenu = [[NSMenu alloc] initWithTitle:text(label)]; - [submenu setDelegate:target]; - for (size_t i = 0; i < count; ++i) - add_item(submenu, target, names[i], first + (NSInteger)i, - checked ? checked[i] : i == selected); - [root setSubmenu:submenu]; [menu addItem:root]; - [submenu release]; [root release]; -} - -BongoCatMenuAction bongo_cat_macos_context_menu(BongoCatPlatform *platform, - const BongoCatMenuLabels *labels) { - (void)platform; - if (!labels) return BONGO_CAT_MENU_NONE; - BongoCatMenuTarget *target = [[BongoCatMenuTarget alloc] initWithLabels:labels]; - NSMenu *menu = [[NSMenu alloc] initWithTitle:@""]; - [menu setDelegate:target]; - add_item(menu, target, labels->preferences, BONGO_CAT_MENU_PREFERENCES, false); - add_item(menu, target, labels->hide, BONGO_CAT_MENU_HIDE, false); - [menu addItem:[NSMenuItem separatorItem]]; - add_item(menu, target, labels->pass_through, BONGO_CAT_MENU_PASS_THROUGH, - labels->pass_through_checked); - add_item(menu, target, labels->always_on_top, BONGO_CAT_MENU_ALWAYS_ON_TOP, - labels->always_on_top_checked); - add_scale_menu(menu, target, labels->window_size, labels->wheel_size_hint, false); - add_scale_menu(menu, target, labels->opacity, labels->wheel_opacity_hint, true); - add_named_menu(menu, target, labels->motion, labels->motion_names, - labels->motion_count, BONGO_CAT_MENU_MOTION_FIRST, - labels->motion_checked, labels->motion_count); - add_named_menu(menu, target, labels->expression, labels->expression_names, - labels->expression_count, BONGO_CAT_MENU_EXPRESSION_FIRST, - NULL, labels->current_expression); - add_named_menu(menu, target, labels->audio, labels->audio_names, - labels->audio_count, BONGO_CAT_MENU_AUDIO_FIRST, - labels->audio_checked, labels->audio_count); - NSMenuItem *modelRoot = [[NSMenuItem alloc] initWithTitle:text(labels->model) - action:nil keyEquivalent:@""]; - NSMenu *models = [[NSMenu alloc] initWithTitle:text(labels->model)]; - [models setDelegate:target]; - for (size_t i = 0; i < labels->model_count; ++i) - add_item(models, target, labels->model_names[i], BONGO_CAT_MENU_MODEL_FIRST + i, - i == labels->current_model); - if (labels->model_count) [models addItem:[NSMenuItem separatorItem]]; - add_item(models, target, labels->add_model, BONGO_CAT_MENU_MODEL_ADD, false); - [modelRoot setSubmenu:models]; [menu addItem:modelRoot]; - [models release]; [modelRoot release]; - [menu addItem:[NSMenuItem separatorItem]]; - add_item(menu, target, labels->exit, BONGO_CAT_MENU_EXIT, false); - if (labels->remove_pet_visible) { - [menu addItem:[NSMenuItem separatorItem]]; - add_item(menu, target, labels->remove_pet, - BONGO_CAT_MENU_REMOVE_PET, false); - } - NSTimer *previewTimer = nil; - if (labels->preview_tick) { - previewTimer = [NSTimer timerWithTimeInterval:1.0 / 60.0 target:target - selector:@selector(tickPreview:) userInfo:nil repeats:YES]; - [[NSRunLoop currentRunLoop] addTimer:previewTimer forMode:NSRunLoopCommonModes]; - } - [menu popUpMenuPositioningItem:nil atLocation:[NSEvent mouseLocation] inView:nil]; - [previewTimer invalidate]; - BongoCatMenuAction result = (BongoCatMenuAction)[target selected]; - if (labels->restore) labels->restore(labels->preview_userdata, result); - [menu release]; [target release]; return result; -} -#endif diff --git a/src/platform/windows/dial/dial_covers.c b/src/platform/windows/dial/dial_covers.c deleted file mode 100644 index a2f617e9..00000000 --- a/src/platform/windows/dial/dial_covers.c +++ /dev/null @@ -1,84 +0,0 @@ -#include "dial_internal.h" -#include "bongo_cat/path.h" - -/* Fits inside every child sector, including the narrower paginated layout. */ -#define COVER_WIDTH 44.0f -#define COVER_HEIGHT 40.0f - -static void load_cover(Dial *d, size_t index) { - DialCover *cover = &d->covers[index]; - cover->attempted = true; - if (!d->labels->model_cover_directories || - !d->labels->model_cover_directories[index]) return; - char path[BONGO_CAT_PATH_CAP]; - WCHAR wide[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), - d->labels->model_cover_directories[index], "resources/cover.png") || - !MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, path, -1, - wide, BONGO_CAT_PATH_CAP)) return; - GpBitmap *source = NULL, *thumbnail = NULL; - GpGraphics *graphics = NULL; - UINT width = 0, height = 0; - if (GdipCreateBitmapFromFile(wide, &source) || - GdipGetImageWidth(source, &width) || GdipGetImageHeight(source, &height) || - !width || !height || (uint64_t)width * height > 64000000) goto done; - float fit = fminf(COVER_WIDTH / (float)width, COVER_HEIGHT / (float)height); - cover->width = (float)width * fit; - cover->height = (float)height * fit; - /* Retain only a DPI-sized thumbnail, never the full decoded cover. */ - float pixels = fminf(160.0f, ceilf(COVER_WIDTH * d->scale * 1.25f)); - int thumb_width = (int)fmaxf(1, ceilf(cover->width * pixels / COVER_WIDTH)); - int thumb_height = (int)fmaxf(1, ceilf(cover->height * pixels / COVER_WIDTH)); - if (GdipCreateBitmapFromScan0(thumb_width, thumb_height, 0, - 0x000e200b, NULL, &thumbnail) || - GdipGetImageGraphicsContext(thumbnail, &graphics)) goto done; - GdipGraphicsClear(graphics, 0); - GdipSetInterpolationMode(graphics, 7); - if (!GdipDrawImageRect(graphics, source, 0, 0, - (float)thumb_width, (float)thumb_height)) { - GdipFlush(graphics, 1); - cover->image = thumbnail; - thumbnail = NULL; - } -done: - if (graphics) GdipDeleteGraphics(graphics); - if (thumbnail) GdipDisposeImage(thumbnail); - if (source) GdipDisposeImage(source); -} - -void dial_covers_tick(Dial *d) { - if (d->active != 9) return; - /* Same one-image-per-tick budget as the preferences cover loader. - Failed loads are remembered too, avoiding repeated disk access. */ - for (int i = 0; i < dial_child_count(d); ++i) { - size_t index = (size_t)(d->page * DIAL_PAGE + i); - if (index >= d->labels->model_count || index >= BONGO_CAT_MODEL_CAP) continue; - if (!d->covers[index].attempted) { - load_cover(d, index); - d->dirty = true; - return; - } - } -} - -bool dial_cover_draw(Dial *d, int child, float x, float y) { - if (d->active != 9 || child < 0) return false; - size_t index = (size_t)(d->page * DIAL_PAGE + child); - if (index >= d->labels->model_count || index >= BONGO_CAT_MODEL_CAP) return false; - DialCover *cover = &d->covers[index]; - if (cover->image) { - GdipDrawImageRect(d->paint.graphics, cover->image, - x - cover->width / 2, y - cover->height / 2, cover->width, cover->height); - } else { - /* Missing/loading covers keep a recognizable model glyph. */ - dial_icon(d->paint.graphics, 9, x, y, 28, 0xffa78bfa); - } - return true; -} - -void dial_covers_free(Dial *d) { - for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) { - if (d->covers[i].image) GdipDisposeImage(d->covers[i].image); - d->covers[i] = (DialCover){0}; - } -} diff --git a/src/platform/windows/dial/dial_gdip.h b/src/platform/windows/dial/dial_gdip.h deleted file mode 100644 index db2ccd89..00000000 --- a/src/platform/windows/dial/dial_gdip.h +++ /dev/null @@ -1,74 +0,0 @@ -#ifndef BONGO_CAT_DIAL_GDIP_H -#define BONGO_CAT_DIAL_GDIP_H -/* GDI+ flat ABI: no C++ headers, wrappers or browser runtime. */ -#include -typedef struct GpBitmap GpBitmap; -typedef struct GpGraphics GpGraphics; -typedef struct GpPath GpPath; -typedef struct GpBrush GpBrush; -typedef struct GpPen GpPen; -typedef struct GpFontFamily GpFontFamily; -typedef struct GpFont GpFont; -typedef struct GpStringFormat GpStringFormat; -typedef struct DialPoint { float x, y; } DialPoint; -typedef struct DialRect { float x, y, w, h; } DialRect; -typedef struct DialStartup { - UINT32 version; - void *callback; - BOOL no_thread, no_codecs; -} DialStartup; -int WINAPI GdiplusStartup(ULONG_PTR *, const DialStartup *, void *); -void WINAPI GdiplusShutdown(ULONG_PTR); -int WINAPI GdipCreateBitmapFromScan0(INT, INT, INT, INT, BYTE *, GpBitmap **); -int WINAPI GdipGetImageGraphicsContext(void *, GpGraphics **); -int WINAPI GdipDisposeImage(void *); -int WINAPI GdipCreateBitmapFromFile(const WCHAR *, GpBitmap **); -int WINAPI GdipGetImageWidth(void *, UINT *); -int WINAPI GdipGetImageHeight(void *, UINT *); -int WINAPI GdipSetInterpolationMode(GpGraphics *, int); -int WINAPI GdipDrawImageRect(GpGraphics *, void *, float, float, float, float); -int WINAPI GdipDeleteGraphics(GpGraphics *); -int WINAPI GdipGraphicsClear(GpGraphics *, DWORD); -int WINAPI GdipSetSmoothingMode(GpGraphics *, int); -int WINAPI GdipSetTextRenderingHint(GpGraphics *, int); -int WINAPI GdipSetWorldTransform(GpGraphics *, void *); -int WINAPI GdipCreateMatrix2(float, float, float, float, float, float, void **); -int WINAPI GdipDeleteMatrix(void *); -int WINAPI GdipCreatePath(int, GpPath **); -int WINAPI GdipDeletePath(GpPath *); -int WINAPI GdipAddPathLine(GpPath *, float, float, float, float); -int WINAPI GdipAddPathArc(GpPath *, float, float, float, float, float, float); -int WINAPI GdipAddPathBezier(GpPath *, float, float, float, float, - float, float, float, float); -int WINAPI GdipClosePathFigure(GpPath *); -int WINAPI GdipStartPathFigure(GpPath *); -int WINAPI GdipFillPath(GpGraphics *, GpBrush *, GpPath *); -int WINAPI GdipDrawPath(GpGraphics *, GpPen *, GpPath *); -int WINAPI GdipGetPathWorldBounds(GpPath *, DialRect *, const void *, const GpPen *); -int WINAPI GdipFlush(GpGraphics *, int); -int WINAPI GdipCreateSolidFill(DWORD, GpBrush **); -int WINAPI GdipCreateLineBrush(const DialPoint *, const DialPoint *, - DWORD, DWORD, int, GpBrush **); -int WINAPI GdipSetLinePresetBlend(GpBrush *, const DWORD *, const float *, int); -int WINAPI GdipDeleteBrush(GpBrush *); -int WINAPI GdipCreatePen1(DWORD, float, int, GpPen **); -int WINAPI GdipSetPenStartCap(GpPen *, int); -int WINAPI GdipSetPenEndCap(GpPen *, int); -int WINAPI GdipSetPenLineJoin(GpPen *, int); -int WINAPI GdipDeletePen(GpPen *); -int WINAPI GdipDrawLines(GpGraphics *, GpPen *, const DialPoint *, int); -int WINAPI GdipFillEllipse(GpGraphics *, GpBrush *, float, float, float, float); -int WINAPI GdipDrawEllipse(GpGraphics *, GpPen *, float, float, float, float); -int WINAPI GdipCreateFontFamilyFromName(const WCHAR *, void *, GpFontFamily **); -int WINAPI GdipGetGenericFontFamilySansSerif(GpFontFamily **); -int WINAPI GdipDeleteFontFamily(GpFontFamily *); -int WINAPI GdipCreateFont(const GpFontFamily *, float, int, int, GpFont **); -int WINAPI GdipDeleteFont(GpFont *); -int WINAPI GdipCreateStringFormat(int, WORD, GpStringFormat **); -int WINAPI GdipSetStringFormatAlign(GpStringFormat *, int); -int WINAPI GdipSetStringFormatLineAlign(GpStringFormat *, int); -int WINAPI GdipSetStringFormatTrimming(GpStringFormat *, int); -int WINAPI GdipDeleteStringFormat(GpStringFormat *); -int WINAPI GdipDrawString(GpGraphics *, const WCHAR *, int, const GpFont *, - const DialRect *, const GpStringFormat *, const GpBrush *); -#endif diff --git a/src/platform/windows/dial/dial_geometry.c b/src/platform/windows/dial/dial_geometry.c deleted file mode 100644 index 0231b305..00000000 --- a/src/platform/windows/dial/dial_geometry.c +++ /dev/null @@ -1,91 +0,0 @@ -#include "dial_internal.h" - -static DialPoint polar(float r, float a) { - return (DialPoint){r * cosf(a), r * sinf(a)}; -} - -static void line(GpPath *p, DialPoint a, DialPoint b) { - GdipAddPathLine(p, a.x, a.y, b.x, b.y); -} - -static void quad(GpPath *p, DialPoint a, DialPoint c, DialPoint b) { - GdipAddPathBezier(p, a.x, a.y, a.x + (c.x - a.x) * 2 / 3, - a.y + (c.y - a.y) * 2 / 3, b.x + (c.x - b.x) * 2 / 3, - b.y + (c.y - b.y) * 2 / 3, b.x, b.y); -} - -static void arc(GpPath *p, float r, float a, float b) { - GdipAddPathArc(p, -r, -r, r * 2, r * 2, - a * 180 / DIAL_PI, (b - a) * 180 / DIAL_PI); -} - -GpPath *dial_sector(float inner, float outer, float start, float end, bool rim) { - /* Exact quadratic corners and circular arcs from index.html's sector(). */ - const float corner = 12, a = start + .024f, b = end - .024f; - const float ci = corner / inner, co = corner / outer; - GpPath *p = NULL; - if (GdipCreatePath(0, &p)) return NULL; - if (!rim) { - line(p, polar(outer - corner, b), polar(inner + corner, b)); - quad(p, polar(inner + corner, b), polar(inner, b), polar(inner, b - ci)); - arc(p, inner, b - ci, a + ci); - quad(p, polar(inner, a + ci), polar(inner, a), polar(inner + corner, a)); - line(p, polar(inner + corner, a), polar(outer - corner, a)); - } - quad(p, polar(outer - corner, a), polar(outer, a), polar(outer, a + co)); - arc(p, outer, a + co, b - co); - quad(p, polar(outer, b - co), polar(outer, b), polar(outer - corner, b)); - if (!rim) GdipClosePathFigure(p); - return p; -} - -void dial_child_paths(Dial *d) { - for (int i = 0; i < DIAL_PAGE; ++i) { - if (d->paint.children[i]) GdipDeletePath(d->paint.children[i]); - if (d->paint.child_rims[i]) GdipDeletePath(d->paint.child_rims[i]); - d->paint.children[i] = d->paint.child_rims[i] = NULL; - if (i < dial_child_count(d)) { - float angle = dial_child_angle(d, i), half = dial_child_step(d) / 2; - d->paint.children[i] = dial_sector(198, 262, angle - half, angle + half, false); - d->paint.child_rims[i] = dial_sector(198, 262, angle - half, angle + half, true); - } - } -} - -static float angle_distance(float a, float b) { - return atan2f(sinf(a - b), cosf(a - b)); -} - -void dial_hit(Dial *d, float x, float y, int *root, int *child) { - float radius = hypotf(x, y), angle = atan2f(y, x); - *root = -1; *child = -1; - if (d->active >= 0 && radius >= 190 && radius <= 277) { - for (int i = 0; i < dial_child_count(d); ++i) { - if (fabsf(angle_distance(angle, dial_child_angle(d, i))) < - dial_child_step(d) / 2) { - *root = d->active; *child = i; return; - } - } - } - if (radius >= 73 && radius <= 198) { - for (int i = 0; i < d->count; ++i) { - if (d->child_focus && i != d->active) continue; - float center = -DIAL_PI / 2 + i * 2 * DIAL_PI / d->count; - if (fabsf(angle_distance(angle, center)) <= DIAL_PI / d->count) { - *root = i; return; - } - } - } - /* Keep the iris open while crossing its radial gap or the center hub. */ - if (radius <= 280) *root = d->active; -} - -void dial_transform(Dial *d, float zoom, float x, float y) { - void *matrix = NULL; - float scale = d->scale * d->opening; - if (!GdipCreateMatrix2(scale * zoom, 0, 0, scale * zoom, - d->pixels / 2.0f + x * scale, d->pixels / 2.0f + y * scale, &matrix)) { - GdipSetWorldTransform(d->paint.graphics, matrix); - GdipDeleteMatrix(matrix); - } -} diff --git a/src/platform/windows/dial/dial_internal.h b/src/platform/windows/dial/dial_internal.h deleted file mode 100644 index 206da4ac..00000000 --- a/src/platform/windows/dial/dial_internal.h +++ /dev/null @@ -1,65 +0,0 @@ -#ifndef BONGO_CAT_DIAL_INTERNAL_H -#define BONGO_CAT_DIAL_INTERNAL_H -#include "windows_dial.h" -#include "dial_gdip.h" -#include -#define DIAL_PI 3.14159265358979323846f -#define DIAL_PAGE 16 -#define DIAL_ROOTS 12 -typedef struct DialItem { - const char *label; - BongoCatMenuAction command; - DWORD color; - int icon; - bool checked; - size_t children; -} DialItem; -typedef struct DialPaint { - ULONG_PTR token; - HDC dc; - HBITMAP dib; - HGDIOBJ old_bitmap; - GpBitmap *bitmap; - GpGraphics *graphics; - GpFontFamily *family; - GpFont *title_font, *value_font; - GpStringFormat *format; - GpPath *roots[DIAL_ROOTS], *root_rims[DIAL_ROOTS]; - GpPath *children[DIAL_PAGE], *child_rims[DIAL_PAGE]; -} DialPaint; -typedef struct DialCover { - GpBitmap *image; - float width, height; - bool attempted; -} DialCover; -typedef struct Dial { - HWND window, owner; - const BongoCatMenuLabels *labels; - DialItem items[DIAL_ROOTS]; - int count, active, child, page, pressed; - bool done, dark, reduced_motion, dirty, child_focus; - BongoCatMenuAction result, preview; - int pixels; - float scale, opening, lift[DIAL_ROOTS]; - ULONGLONG opened_at, changed_at; - DialPaint paint; - DialCover covers[BONGO_CAT_MODEL_CAP]; -} Dial; -void dial_covers_tick(Dial *dial); -void dial_covers_free(Dial *dial); -bool dial_cover_draw(Dial *dial, int child, float x, float y); -void dial_items(Dial *dial); -int dial_child_count(const Dial *dial); -float dial_child_angle(const Dial *dial, int index); -float dial_child_step(const Dial *dial); -DialItem dial_child_item(const Dial *dial, int index, char *text, size_t capacity); -void dial_select(Dial *dial, int root, int child); -void dial_hit(Dial *dial, float x, float y, int *root, int *child); -bool dial_paint_init(Dial *dial); -void dial_paint_free(Dial *dial); -bool dial_paint_frame(Dial *dial); -void dial_child_paths(Dial *dial); -GpPath *dial_sector(float inner, float outer, float start, float end, bool rim); -void dial_icon(GpGraphics *g, int icon, float x, float y, float size, DWORD color); -void dial_transform(Dial *dial, float zoom, float x, float y); -#endif diff --git a/src/platform/windows/dial/dial_paint.c b/src/platform/windows/dial/dial_paint.c deleted file mode 100644 index a60917db..00000000 --- a/src/platform/windows/dial/dial_paint.c +++ /dev/null @@ -1,238 +0,0 @@ -#include "dial_internal.h" -#include "../windows_layered.h" -#include -#include - -static DWORD alpha(DWORD color, float opacity) { - return (color & 0xffffff) | ((DWORD)((color >> 24) * opacity) << 24); -} - -static void stroke(GpGraphics *g, GpPath *path, DWORD color, float width) { - GpPen *pen = NULL; - if (!path || GdipCreatePen1(color, width, 2, &pen)) return; - GdipSetPenStartCap(pen, 2); - GdipSetPenEndCap(pen, 2); - GdipSetPenLineJoin(pen, 2); - GdipDrawPath(g, pen, path); - GdipDeletePen(pen); -} - -static void fill(GpGraphics *g, GpPath *path, DWORD color) { - GpBrush *brush = NULL; - if (!path || GdipCreateSolidFill(color, &brush)) return; - GdipFillPath(g, brush, path); - GdipDeleteBrush(brush); -} - -static void dot(GpGraphics *g, float x, float y, float r, DWORD color) { - GpBrush *brush = NULL; - if (GdipCreateSolidFill(color, &brush)) return; - GdipFillEllipse(g, brush, x - r, y - r, 2 * r, 2 * r); - GdipDeleteBrush(brush); -} - -static void surface(Dial *d, GpPath *path, GpPath *rim, - int tint, float opacity) { - if (!path) return; - GpGraphics *g = d->paint.graphics; - DWORD colors[3]; - if (tint == 1) { - colors[0] = 0xff6ec2ff; colors[1] = 0xff54aeff; colors[2] = 0xff1e82f0; - } else if (tint == 2) { - colors[0] = 0xffff97be; colors[1] = 0xfff77daa; colors[2] = 0xffe34882; - } else if (tint == 3) { - colors[0] = 0xffff6960; colors[1] = 0xffff453a; colors[2] = 0xffd10014; - } else if (d->dark) { - colors[0] = 0x2effffff; colors[1] = 0x14ffffff; colors[2] = 0x08ffffff; - } else { - colors[0] = 0xffffffff; colors[1] = 0xf0f6f9ff; colors[2] = 0xe0eef3fc; - } - /* Concentric translucent strokes approximate the SVG's soft glow without - allocating blur buffers or capturing the desktop on every frame. */ - DWORD glow = tint ? (colors[1] & 0xffffff) : (d->dark ? 0 : 0x325082); - for (int i = 6; i > 0; --i) - stroke(g, path, alpha(glow | ((DWORD)(tint ? 6 : 3) << 24), opacity), - (float)i * (tint ? 5 : 6)); - /* Solid backing keeps the desktop from showing through settled sectors. - Opacity is used only for the child entrance animation. */ - fill(g, path, alpha(d->dark ? 0xff161c2a : 0xffffffff, opacity)); - DialRect bounds; - GpBrush *brush = NULL; - if (!GdipGetPathWorldBounds(path, &bounds, NULL, NULL)) { - DialPoint from = {bounds.x, bounds.y}, to = {bounds.x + bounds.w, bounds.y + bounds.h}; - for (int i = 0; i < 3; ++i) colors[i] = alpha(colors[i], opacity); - if (!GdipCreateLineBrush(&from, &to, colors[0], colors[2], 0, &brush)) { - const float stops[] = {0, .5f, 1}; - GdipSetLinePresetBlend(brush, colors, stops, 3); - GdipFillPath(g, brush, path); - GdipDeleteBrush(brush); - } - } - stroke(g, path, alpha(tint || !d->dark ? 0xfaffffff : 0x38ffffff, opacity), 1.1f); - stroke(g, rim, alpha(d->dark ? 0xd3ffffff : 0xebffffff, opacity), 1.4f); -} - -static void text(Dial *d, const char *value, float x, float y, float width, - bool title, DWORD color) { - if (!value) return; - WCHAR wide[BONGO_CAT_MENU_LABEL_CAP + 64]; - int n = MultiByteToWideChar(CP_UTF8, 0, value, -1, wide, - (int)(sizeof(wide) / sizeof(wide[0]))); - if (!n) return; - if (!strncmp(value, "live2d_", 7)) wide[6] = L'\n'; - DialRect box = {x - width / 2, y - 20, width, 40}; - GpBrush *brush = NULL; - if (GdipCreateSolidFill(color, &brush)) return; - GdipDrawString(d->paint.graphics, wide, -1, - title ? d->paint.title_font : d->paint.value_font, - &box, d->paint.format, brush); - GdipDeleteBrush(brush); -} - -static int item_icon(DialItem item) { - return item.checked && item.icon == 2 ? 12 : - (item.checked && item.icon == 3 ? 13 : item.icon); -} - -static void root(Dial *d, int index) { - DialItem item = d->items[index]; - float angle = -DIAL_PI / 2 + index * 2 * DIAL_PI / d->count; - float x = 132 * cosf(angle), y = 132 * sinf(angle); - float lift = d->lift[index], zoom = 1 + .055f * lift; - float pull = 7.5f * lift; - if (d->pressed == index) { zoom = .96f; pull *= .4f; } - dial_transform(d, zoom, x * (1 - zoom) + cosf(angle) * pull, - y * (1 - zoom) + sinf(angle) * pull); - bool active = index == d->active; - float opacity = 1.0f; - int tint = active ? (index >= 10 ? 3 : 1) : 0; - surface(d, d->paint.roots[index], d->paint.root_rims[index], tint, opacity); - DWORD color = active ? 0xffffffff : (item.checked ? 0xfff77daa : item.color); - if (index >= 6 && index <= 8 && !item.children) opacity *= .4f; - dial_icon(d->paint.graphics, item_icon(item), x, y, 28 * (1 + .15f * lift), alpha(color, opacity)); - if (item.checked) { - for (int i = 4; i > 0; --i) - dot(d->paint.graphics, x + 17, y - 17, 3.2f + i * 1.5f, 0x0af77daa); - dot(d->paint.graphics, x + 17, y - 17, 3.2f, 0xfff77daa); - } -} - -static void center(Dial *d) { - if (d->active < 0) return; - dial_transform(d, 1, 0, 0); - GpGraphics *g = d->paint.graphics; - DialItem item = d->items[d->active]; - char buffer[32]; - bool child_hovered = d->child >= 0; - DWORD color = child_hovered ? 0xfff77daa : 0xff52a9f8; - const char *label = child_hovered ? - dial_child_item(d, d->child, buffer, sizeof(buffer)).label : item.label; - if (!child_hovered) - dial_icon(g, item_icon(item), 0, -5, 38, item.checked ? 0xfff77daa : item.color); - text(d, label, 0, child_hovered ? 0.0f : 24.0f, 116, true, color); - if (item.children > DIAL_PAGE) { - snprintf(buffer, sizeof(buffer), "%d / %d", d->page + 1, - ((int)item.children + DIAL_PAGE - 1) / DIAL_PAGE); - text(d, buffer, 0, 48, 80, false, color); - } -} - -static void children(Dial *d, ULONGLONG now) { - for (int i = 0; i < dial_child_count(d); ++i) { - float elapsed = (float)(now - d->changed_at) - i * 22; - float t = d->reduced_motion ? 1 : fmaxf(0, fminf(1, elapsed / 360)); - float ease = 1 - powf(1 - t, 3); - float angle = dial_child_angle(d, i), x = 230 * cosf(angle), y = 230 * sinf(angle); - bool hover = i == d->child; - float zoom = .72f + .28f * ease + (hover ? .06f : 0); - float pull = -26 * (1 - ease) + (hover ? 6 : 0); - dial_transform(d, zoom, x * (1 - zoom) + cosf(angle) * pull, - y * (1 - zoom) + sinf(angle) * pull); - char buffer[32]; - DialItem item = dial_child_item(d, i, buffer, sizeof(buffer)); - surface(d, d->paint.children[i], d->paint.child_rims[i], - item.checked ? 2 : (hover ? 1 : 0), ease); - DWORD color = hover || item.checked || d->dark ? 0xffffffff : 0xff181c28; - if (!dial_cover_draw(d, i, x, y)) - text(d, item.label, x, y, dial_child_step(d) > .4f ? 92.0f : 62.0f, - false, alpha(color, ease)); - } -} - -bool dial_paint_frame(Dial *d) { - if (GdipGraphicsClear(d->paint.graphics, 0)) return false; - for (int i = 0; i < d->count; ++i) { - if (!d->child_focus || i == d->active) root(d, i); - } - center(d); - children(d, GetTickCount64()); - GdipFlush(d->paint.graphics, 1); - float fade = d->reduced_motion ? 1 : fminf(1, - (float)(GetTickCount64() - d->opened_at) / 180); - return bongo_cat_windows_layered_present_popup(d->window, d->paint.dc, - d->pixels, d->pixels, (BYTE)(255 * fade)); -} - -bool dial_paint_init(Dial *d) { - DialPaint *p = &d->paint; - const DialStartup startup = {1, NULL, FALSE, FALSE}; - if (GdiplusStartup(&p->token, &startup, NULL)) return false; - p->dc = CreateCompatibleDC(NULL); - BITMAPINFO info = {0}; - info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); - info.bmiHeader.biWidth = d->pixels; - info.bmiHeader.biHeight = -d->pixels; - info.bmiHeader.biPlanes = 1; - info.bmiHeader.biBitCount = 32; - info.bmiHeader.biCompression = BI_RGB; - void *pixels = NULL; - p->dib = CreateDIBSection(p->dc, &info, DIB_RGB_COLORS, &pixels, NULL, 0); - if (!p->dc || !p->dib || !pixels) return false; - p->old_bitmap = SelectObject(p->dc, p->dib); - /* PARGB matches the layered presenter; no full-frame alpha conversion. */ - if (GdipCreateBitmapFromScan0(d->pixels, d->pixels, d->pixels * 4, - 0x000e200b, pixels, &p->bitmap) || - GdipGetImageGraphicsContext(p->bitmap, &p->graphics)) return false; - GdipSetSmoothingMode(p->graphics, 4); - GdipSetInterpolationMode(p->graphics, 7); - GdipSetTextRenderingHint(p->graphics, 4); - if (GdipCreateFontFamilyFromName(L"Microsoft YaHei UI", NULL, &p->family) && - GdipGetGenericFontFamilySansSerif(&p->family)) return false; - if (GdipCreateFont(p->family, 15, 1, 2, &p->title_font) || - GdipCreateFont(p->family, 10.5f, 1, 2, &p->value_font) || - GdipCreateStringFormat(0, 0, &p->format)) return false; - GdipSetStringFormatAlign(p->format, 1); - GdipSetStringFormatLineAlign(p->format, 1); - GdipSetStringFormatTrimming(p->format, 3); - for (int i = 0; i < d->count; ++i) { - float angle = -DIAL_PI / 2 + i * 2 * DIAL_PI / d->count; - float half = DIAL_PI / d->count; - p->roots[i] = dial_sector(82, 184, angle - half, angle + half, false); - p->root_rims[i] = dial_sector(82, 184, angle - half, angle + half, true); - if (!p->roots[i] || !p->root_rims[i]) return false; - } - return true; -} - -void dial_paint_free(Dial *d) { - DialPaint *p = &d->paint; - dial_covers_free(d); - for (int i = 0; i < DIAL_ROOTS; ++i) { - if (p->roots[i]) GdipDeletePath(p->roots[i]); - if (p->root_rims[i]) GdipDeletePath(p->root_rims[i]); - } - for (int i = 0; i < DIAL_PAGE; ++i) { - if (p->children[i]) GdipDeletePath(p->children[i]); - if (p->child_rims[i]) GdipDeletePath(p->child_rims[i]); - } - if (p->format) GdipDeleteStringFormat(p->format); - if (p->title_font) GdipDeleteFont(p->title_font); - if (p->value_font) GdipDeleteFont(p->value_font); - if (p->family) GdipDeleteFontFamily(p->family); - if (p->graphics) GdipDeleteGraphics(p->graphics); - if (p->bitmap) GdipDisposeImage(p->bitmap); - if (p->old_bitmap && p->old_bitmap != HGDI_ERROR) SelectObject(p->dc, p->old_bitmap); - if (p->dib) DeleteObject(p->dib); - if (p->dc) DeleteDC(p->dc); - if (p->token) GdiplusShutdown(p->token); -} diff --git a/src/platform/windows/dial/dial_window.c b/src/platform/windows/dial/dial_window.c deleted file mode 100644 index 1aa8faca..00000000 --- a/src/platform/windows/dial/dial_window.c +++ /dev/null @@ -1,239 +0,0 @@ -#include "dial_internal.h" -#include "../windows_popup.h" -#include - -#define DIAL_TIMER 1 -static const WCHAR dial_class[] = L"BongoCat.LuminousSpatialDial"; - -static UINT window_dpi(HWND owner) { - typedef UINT (WINAPI *GetWindowDpi)(HWND); - HMODULE user32 = GetModuleHandleW(L"user32.dll"); - GetWindowDpi get_dpi = user32 ? - (GetWindowDpi)(void *)GetProcAddress(user32, "GetDpiForWindow") : NULL; - UINT dpi = get_dpi ? get_dpi(owner) : 96; - return dpi ? dpi : 96; -} - -static void animate(Dial *d) { - ULONGLONG now = GetTickCount64(); - float t = fminf(1, (float)(now - d->opened_at) / 700); - float opening = d->reduced_motion ? 1 : .85f + .15f * (1 - powf(1 - t, 4)); - if (opening != d->opening) { d->opening = opening; d->dirty = true; } - for (int i = 0; i < d->count; ++i) { - if (d->child_focus && i != d->active) { - d->lift[i] = 0; - continue; - } - float target = d->active == i ? 1.0f : 0.0f; - float next = d->reduced_motion ? target : d->lift[i] + (target - d->lift[i]) * .24f; - if (fabsf(next - target) < .002f) next = target; - if (next != d->lift[i]) { d->lift[i] = next; d->dirty = true; } - } - if (!d->reduced_motion && dial_child_count(d) && - now - d->changed_at <= (ULONGLONG)(360 + (dial_child_count(d) - 1) * 22 + 16)) - d->dirty = true; - if (d->dirty) { - d->dirty = false; - if (!dial_paint_frame(d)) d->done = true; - } -} - -static void pointer(Dial *d, LPARAM position, bool click, int *root, int *child) { - float scale = d->scale * d->opening; - float x = (GET_X_LPARAM(position) - d->pixels / 2.0f) / scale; - float y = (GET_Y_LPARAM(position) - d->pixels / 2.0f) / scale; - float radius = hypotf(x, y); - /* Keep siblings hidden across child gaps; returning inward restores them. */ - if (d->child_focus && radius < 190) { - d->child_focus = false; - d->dirty = true; - } - dial_hit(d, x, y, root, child); - if (click && *child < 0 && *root >= 0) { - float angle = atan2f(y, x) - (-DIAL_PI / 2 + *root * 2 * DIAL_PI / d->count); - if (radius < 73 || radius > 198 || - fabsf(atan2f(sinf(angle), cosf(angle))) > DIAL_PI / d->count) - *root = -1; - } -} - -static void activate(Dial *d) { - if (d->active < 0) return; - char text[32]; - DialItem item = d->child < 0 ? d->items[d->active] : - dial_child_item(d, d->child, text, sizeof(text)); - if (item.children) { dial_select(d, d->active, 0); return; } - if (item.command == BONGO_CAT_MENU_NONE) return; - d->result = item.command; - d->done = true; -} - -static void page(Dial *d, int direction) { - if (d->active < 0 || d->items[d->active].children <= DIAL_PAGE) return; - int pages = ((int)d->items[d->active].children + DIAL_PAGE - 1) / DIAL_PAGE; - d->page = (d->page + direction + pages) % pages; - /* Clear the previous preview before rebinding the page's item indices. */ - d->child = -1; - if (d->preview != BONGO_CAT_MENU_NONE && d->labels->preview) - d->labels->preview(d->labels->preview_userdata, BONGO_CAT_MENU_NONE); - d->preview = BONGO_CAT_MENU_NONE; - d->changed_at = GetTickCount64(); - dial_child_paths(d); - d->dirty = true; -} - -static void key(Dial *d, WPARAM value) { - if (value == VK_ESCAPE) { - if (d->active >= 0 && dial_child_count(d)) dial_select(d, -1, -1); - else d->done = true; - } else if (value == VK_RETURN || value == VK_SPACE) { - activate(d); - } else if (value == VK_PRIOR || value == VK_NEXT) { - page(d, value == VK_NEXT ? 1 : -1); - } else if (value == VK_LEFT || value == VK_UP || value == VK_RIGHT || - value == VK_DOWN || value == VK_TAB) { - int direction = value == VK_LEFT || value == VK_UP || - (value == VK_TAB && (GetKeyState(VK_SHIFT) & 0x8000)) ? -1 : 1; - int count = dial_child_count(d); - if (d->child >= 0 && count) - dial_select(d, d->active, (d->child + direction + count) % count); - else dial_select(d, d->active < 0 ? 0 : - (d->active + direction + d->count) % d->count, -1); - } else if (value == VK_BACK) { - d->child_focus = false; - d->dirty = true; - dial_select(d, d->active, -1); - } -} - -static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM w, LPARAM l) { - Dial *d = (Dial *)GetWindowLongPtrW(window, GWLP_USERDATA); - if (message == WM_NCCREATE) { - d = ((CREATESTRUCTW *)l)->lpCreateParams; - d->window = window; - SetWindowLongPtrW(window, GWLP_USERDATA, (LONG_PTR)d); - } - if (!d) return DefWindowProcW(window, message, w, l); - switch (message) { - case WM_ERASEBKGND: return 1; - case WM_PAINT: { - PAINTSTRUCT paint; - BeginPaint(window, &paint); EndPaint(window, &paint); - return 0; - } - case WM_MOUSEMOVE: { - int root, child; - pointer(d, l, false, &root, &child); - dial_select(d, root, child); - SetCursor(LoadCursorW(NULL, MAKEINTRESOURCEW(root >= 0 ? 32649 : 32512))); - return 0; - } - case WM_LBUTTONDOWN: { - int root, child; - pointer(d, l, true, &root, &child); - if (root < 0) { d->done = true; return 0; } - dial_select(d, root, child); - d->pressed = child >= 0 ? DIAL_ROOTS + child : root; - d->dirty = true; - return 0; - } - case WM_LBUTTONUP: { - int root, child, pressed = d->pressed; - d->pressed = -1; d->dirty = true; - pointer(d, l, true, &root, &child); - if (root >= 0 && pressed == - (child >= 0 ? DIAL_ROOTS + child : root)) { - dial_select(d, root, child); activate(d); - } - return 0; - } - case WM_MOUSEWHEEL: page(d, GET_WHEEL_DELTA_WPARAM(w) < 0 ? 1 : -1); return 0; - case WM_KEYDOWN: key(d, w); return 0; - case WM_TIMER: - if (w == DIAL_TIMER && !d->done) { - if (d->labels->preview_tick) d->labels->preview_tick(d->labels->preview_userdata); - if (!IsWindow(d->owner)) d->done = true; - if (!d->done) dial_covers_tick(d); - if (!d->done) animate(d); - } - return 0; - case WM_ACTIVATE: - if (LOWORD(w) == WA_INACTIVE) d->done = true; - return 0; - case WM_CAPTURECHANGED: - if ((HWND)l != window) d->done = true; - return 0; - case WM_CANCELMODE: case WM_CLOSE: case WM_RBUTTONDOWN: case WM_MBUTTONDOWN: - case WM_DISPLAYCHANGE: case WM_DPICHANGED: - d->done = true; return 0; - case WM_NCDESTROY: - d->done = true; - SetWindowLongPtrW(window, GWLP_USERDATA, 0); - break; - } - return DefWindowProcW(window, message, w, l); -} - -BongoCatMenuAction bongo_cat_windows_dial_track(HWND owner, const BongoCatMenuLabels *labels) { - if (!owner || !labels) return BONGO_CAT_MENU_NONE; - Dial d = {0}; - d.owner = owner; d.labels = labels; d.dark = labels->dark_theme; - d.active = d.child = d.pressed = -1; - d.opening = 1; - d.dirty = true; - BOOL animations = TRUE; - SystemParametersInfoW(SPI_GETCLIENTAREAANIMATION, 0, &animations, 0); - d.reduced_motion = !animations; - dial_items(&d); - POINT cursor; - if (!GetCursorPos(&cursor)) return BONGO_CAT_MENU_NONE; - MONITORINFO monitor = {0}; - monitor.cbSize = sizeof(monitor); - if (!GetMonitorInfoW(MonitorFromPoint(cursor, MONITOR_DEFAULTTONEAREST), &monitor)) - return BONGO_CAT_MENU_NONE; - RECT work = monitor.rcWork; - d.scale = 740.0f / 560 * window_dpi(owner) / 96; - d.scale = fminf(d.scale, .96f * (float)min(work.right - work.left, work.bottom - work.top) / 608); - d.pixels = (int)ceilf(608 * d.scale); - if (d.pixels < 1) return BONGO_CAT_MENU_NONE; - WNDCLASSEXW cls = {0}; - cls.cbSize = sizeof(cls); cls.lpfnWndProc = window_proc; - cls.hInstance = GetModuleHandleW(NULL); cls.lpszClassName = dial_class; - cls.hCursor = LoadCursorW(NULL, MAKEINTRESOURCEW(32512)); - if (!RegisterClassExW(&cls) && GetLastError() != ERROR_CLASS_ALREADY_EXISTS) - return BONGO_CAT_MENU_NONE; - if (!dial_paint_init(&d)) { dial_paint_free(&d); return BONGO_CAT_MENU_NONE; } - int x = max(work.left, min(cursor.x - d.pixels / 2, work.right - d.pixels)); - int y = max(work.top, min(cursor.y - d.pixels / 2, work.bottom - d.pixels)); - HWND window = CreateWindowExW(WS_EX_LAYERED | WS_EX_TOOLWINDOW | WS_EX_TOPMOST, - dial_class, L"BongoCat", WS_POPUP, x, y, d.pixels, d.pixels, - owner, NULL, cls.hInstance, &d); - if (!window) { dial_paint_free(&d); return BONGO_CAT_MENU_NONE; } - d.opened_at = d.changed_at = GetTickCount64(); - animate(&d); - if (!d.done) { - ShowWindow(window, SW_SHOW); - SetForegroundWindow(window); - SetFocus(window); - SetCapture(window); - if (!SetTimer(window, DIAL_TIMER, 16, NULL)) d.done = true; - } - MSG msg; - while (!d.done) { - int status = (int)GetMessageW(&msg, NULL, 0, 0); - if (status <= 0) { - if (!status) PostQuitMessage((int)msg.wParam); - break; - } - TranslateMessage(&msg); - DispatchMessageW(&msg); - } - d.done = true; - KillTimer(window, DIAL_TIMER); - if (GetCapture() == window) ReleaseCapture(); - if (IsWindow(window)) DestroyWindow(window); - dial_paint_free(&d); - if (labels->restore) labels->restore(labels->preview_userdata, d.result); - bongo_cat_windows_popup_complete(owner); - return d.result; -} diff --git a/src/platform/windows/dial/windows_dial.h b/src/platform/windows/dial/windows_dial.h deleted file mode 100644 index d7937772..00000000 --- a/src/platform/windows/dial/windows_dial.h +++ /dev/null @@ -1,9 +0,0 @@ -#ifndef BONGO_CAT_WINDOWS_DIAL_H -#define BONGO_CAT_WINDOWS_DIAL_H -#include "bongo_cat/platform.h" -#ifdef _WIN32 -#include -BongoCatMenuAction bongo_cat_windows_dial_track(HWND owner, - const BongoCatMenuLabels *labels); -#endif -#endif diff --git a/src/platform/windows/windows_layered.c b/src/platform/windows/windows_layered.c index fbef6609..7841ca47 100644 --- a/src/platform/windows/windows_layered.c +++ b/src/platform/windows/windows_layered.c @@ -221,16 +221,6 @@ static bool read_frame(BongoCatWindowsLayered *value, int width, int height, STBIR_BGRA_PM) != NULL; } -bool bongo_cat_windows_layered_present_popup(HWND window, HDC dc, - int width, int height, BYTE opacity) { - if (!window || !dc || width <= 0 || height <= 0) return false; - POINT source = {0, 0}; - SIZE size = {width, height}; - BLENDFUNCTION blend = {AC_SRC_OVER, 0, opacity, AC_SRC_ALPHA}; - return UpdateLayeredWindow(window, NULL, NULL, &size, dc, - &source, 0, &blend, ULW_ALPHA) != FALSE; -} - bool bongo_cat_platform_present(BongoCatPlatform *platform, int width, int height) { if (!platform || !platform->window) return false; BongoCatWindowsLayered *value = platform->presenter; diff --git a/src/platform/windows/windows_layered.h b/src/platform/windows/windows_layered.h index d5bae367..a951a24e 100644 --- a/src/platform/windows/windows_layered.h +++ b/src/platform/windows/windows_layered.h @@ -12,9 +12,6 @@ void bongo_cat_windows_layered_set_click_through( void bongo_cat_windows_layered_set_always_on_top( BongoCatPlatform *platform, bool enabled); HWND bongo_cat_windows_layered_proxy(HWND source); -/* Present an owned popup's premultiplied BGRA surface without pet proxy state. */ -bool bongo_cat_windows_layered_present_popup(HWND window, HDC dc, - int width, int height, BYTE opacity); #endif #endif diff --git a/src/platform/windows/windows_menu.c b/src/platform/windows/windows_menu.c deleted file mode 100644 index 209edf89..00000000 --- a/src/platform/windows/windows_menu.c +++ /dev/null @@ -1,16 +0,0 @@ -#include "bongo_cat/platform.h" -#include "dial/windows_dial.h" - -#ifdef _WIN32 -#include -#include - -BongoCatMenuAction bongo_cat_platform_context_menu(BongoCatPlatform *platform, - const BongoCatMenuLabels *labels) { - if (!platform || !platform->window || !labels) return BONGO_CAT_MENU_NONE; - HWND owner = (HWND)SDL_GetPointerProperty( - SDL_GetWindowProperties(platform->window), - SDL_PROP_WINDOW_WIN32_HWND_POINTER, NULL); - return bongo_cat_windows_dial_track(owner, labels); -} -#endif diff --git a/src/ui/dial/dial_covers.c b/src/ui/dial/dial_covers.c new file mode 100644 index 00000000..7181ae48 --- /dev/null +++ b/src/ui/dial/dial_covers.c @@ -0,0 +1,42 @@ +#include "dial_internal.h" +#include "bongo_cat/image.h" +#include "bongo_cat/path.h" + +void dial_covers_tick(Dial *d) { + if (d->active != 9) return; + for (int i = 0; i < dial_child_count(d); ++i) { + size_t index = (size_t)(d->page*DIAL_PAGE+i); + if (index >= d->labels->model_count || index >= BONGO_CAT_MODEL_CAP) continue; + DialCover *cover = &d->covers[index]; + if (cover->attempted) continue; + cover->attempted = true; + const char *directory = d->labels->model_cover_directories ? + d->labels->model_cover_directories[index] : NULL; + char path[BONGO_CAT_PATH_CAP]; + if (directory && bongo_cat_path_join(path,sizeof(path),directory,"resources/cover.png")) { + int width = 0, height = 0; + BongoCatError ignored = {0}; + int pixels = (int)fminf(160,ceilf(44*d->scale*d->raster_scale*1.25f)); + cover->texture = bongo_cat_image_texture_thumbnail(path,pixels,pixels,&width,&height,&ignored); + if (cover->texture && width > 0 && height > 0) { + float fit = fminf(44.0f/(float)width,40.0f/(float)height); + cover->width = (float)width*fit; cover->height = (float)height*fit; + } + } + d->dirty = true; + return; /* One image per frame; failed reads are also cached. */ + } +} + +bool dial_cover_draw(Dial *d, int child, float x, float y, float opacity) { + if (d->active != 9 || child < 0) return false; + size_t index = (size_t)(d->page*DIAL_PAGE+child); + if (index >= d->labels->model_count || index >= BONGO_CAT_MODEL_CAP) return false; + DialCover *cover = &d->covers[index]; + uint32_t color = 0xffffff | ((uint32_t)(255*opacity)<<24); + if (cover->texture && cover->width > 0 && cover->height > 0) + dial_quad(d,cover->texture,x-cover->width/2,y-cover->height/2, + cover->width,cover->height,0,0,1,1,color); + else dial_icon(d,9,x,y,28,(color&0xff000000)|0xa78bfa); + return true; +} diff --git a/src/ui/dial/dial_draw.c b/src/ui/dial/dial_draw.c new file mode 100644 index 00000000..99ff7933 --- /dev/null +++ b/src/ui/dial/dial_draw.c @@ -0,0 +1,80 @@ +#include "dial_internal.h" +#include + +static void vertex(Dial *d, GLuint texture, float x, float y, float u, float v, + uint32_t color) { + DialPaint *p = &d->paint; + if (p->failed) return; + if (p->count == p->capacity) { + size_t capacity = p->capacity ? p->capacity * 2 : 32768; + if (capacity > 524288) { p->failed = true; return; } + void *memory = realloc(p->vertices, capacity * sizeof(*p->vertices)); + if (!memory) { p->failed = true; return; } + p->vertices = memory; p->capacity = capacity; + } + if (!p->batch_count || p->batches[p->batch_count-1].texture != texture) { + if (p->batch_count == 512) { p->failed = true; return; } + p->batches[p->batch_count++] = (DialBatch){texture, (int)p->count, 0}; + } + float scale = d->scale * d->opening; + p->vertices[p->count++] = (DialVertex){ + (float)d->width / 2 + (x*p->zoom+p->offset_x)*scale, + (float)d->height / 2 + (y*p->zoom+p->offset_y)*scale, u, v, + (uint8_t)(color>>16), (uint8_t)(color>>8), (uint8_t)color, + (uint8_t)((float)(color>>24)*p->alpha)}; + p->batches[p->batch_count-1].count++; +} + +void dial_triangle(Dial *d, DialPoint a, DialPoint b, DialPoint c, + uint32_t ca, uint32_t cb, uint32_t cc) { + vertex(d,d->paint.white,a.x,a.y,0,0,ca); + vertex(d,d->paint.white,b.x,b.y,0,0,cb); + vertex(d,d->paint.white,c.x,c.y,0,0,cc); +} + +void dial_quad(Dial *d, GLuint texture, float x, float y, float w, float h, + float u0, float v0, float u1, float v1, uint32_t color) { + vertex(d,texture,x,y,u0,v0,color); + vertex(d,texture,x+w,y,u1,v0,color); + vertex(d,texture,x+w,y+h,u1,v1,color); + vertex(d,texture,x,y,u0,v0,color); + vertex(d,texture,x+w,y+h,u1,v1,color); + vertex(d,texture,x,y+h,u0,v1,color); +} + +static void strip(Dial *d, DialPoint a, DialPoint b, float nx, float ny, + float inner, float outer, uint32_t ci, uint32_t co) { + DialPoint a0 = {a.x+nx*inner,a.y+ny*inner}, a1 = {a.x+nx*outer,a.y+ny*outer}; + DialPoint b0 = {b.x+nx*inner,b.y+ny*inner}, b1 = {b.x+nx*outer,b.y+ny*outer}; + dial_triangle(d,a0,b0,b1,ci,ci,co); + dial_triangle(d,a0,b1,a1,ci,co,co); +} + +void dial_dot(Dial *d, float x, float y, float r, uint32_t color) { + const int count = 20; + for (int i = 0; i < count; ++i) { + float a = (float)i*2*DIAL_PI/(float)count, b = (float)(i+1)*2*DIAL_PI/(float)count; + DialPoint p = {x+r*cosf(a),y+r*sinf(a)}, q = {x+r*cosf(b),y+r*sinf(b)}; + dial_triangle(d,(DialPoint){x,y},p,q,color,color,color); + } +} + +void dial_stroke(Dial *d, const DialPoint *points, int count, float width, + uint32_t color, bool smooth) { + float edge = .7f / fmaxf(.1f, d->scale*d->opening); + for (int i = 1; i < count; ++i) { + DialPoint a = points[i-1], b = points[i]; + float dx = b.x-a.x, dy = b.y-a.y, length = hypotf(dx,dy); + if (length < .001f) continue; + float nx = -dy/length, ny = dx/length; + strip(d,a,b,nx,ny,-width/2,width/2,color,color); + if (smooth) { + strip(d,a,b,nx,ny,width/2,width/2+edge,color,color&0xffffff); + strip(d,a,b,nx,ny,-width/2,-width/2-edge,color,color&0xffffff); + } + } + if (smooth && count > 1) { + dial_dot(d,points[0].x,points[0].y,width/2,color); + dial_dot(d,points[count-1].x,points[count-1].y,width/2,color); + } +} diff --git a/src/ui/dial/dial_fonts.c b/src/ui/dial/dial_fonts.c new file mode 100644 index 00000000..3a5884b4 --- /dev/null +++ b/src/ui/dial/dial_fonts.c @@ -0,0 +1,158 @@ +#include "dial_internal.h" +#include "ui_font.h" +#include "ui_font_atlas_internal.h" +#include +#include + +static void collect(uint8_t *used, const char *s) { + if (!s) return; + int length = (int)strlen(s); + while (length > 0) { + nk_rune rune; + int n = nk_utf_decode(s,&rune,length); + if (n <= 0) break; + if (rune >= 32 && rune < 0x110000) used[rune] = 1; + s += n; length -= n; + } +} + +static nk_rune *ranges(Dial *d) { + uint8_t *used = calloc(0x110000,1); + if (!used) return NULL; + for (int i = 32; i < 127; ++i) used[i] = 1; + for (int i = 0; i < d->count; ++i) collect(used,d->items[i].label); + collect(used,d->labels->add_model); + for (size_t i = 0; i < d->labels->model_count; ++i) collect(used,d->labels->model_names[i]); + for (size_t i = 0; i < d->labels->motion_count; ++i) collect(used,d->labels->motion_names[i]); + for (size_t i = 0; i < d->labels->expression_count; ++i) collect(used,d->labels->expression_names[i]); + for (size_t i = 0; i < d->labels->audio_count; ++i) collect(used,d->labels->audio_names[i]); + size_t count = 0; + for (nk_rune i = 32; i < 0x110000; ++i) if (used[i]) ++count; + nk_rune *result = calloc(count*2+1,sizeof(*result)); + if (result) { + size_t out = 0; + for (nk_rune i = 32; i < 0x110000; ++i) { + if (!used[i]) continue; + result[out++] = i; + while (i+1 < 0x110000 && used[i+1]) ++i; + result[out++] = i; + } + } + free(used); + return result; +} + +bool dial_fonts_init(Dial *d) { + DialPaint *p = &d->paint; + p->ranges[0] = ranges(d); + if (!p->ranges[0] || !bongo_cat_ui_font_split_ranges(p->ranges[0], + &p->ranges[1],&p->ranges[2],&p->ranges[3])) { + /* split_ranges frees partial allocations on failure. */ + p->ranges[1] = p->ranges[2] = p->ranges[3] = NULL; + return false; + } + char paths[3][BONGO_CAT_PATH_CAP]; + const char *names[] = { + bongo_cat_ui_system_font(paths[0],sizeof(paths[0]),false), + bongo_cat_ui_system_font(paths[1],sizeof(paths[1]),true), + bongo_cat_ui_system_korean_font(paths[2],sizeof(paths[2]))}; + UIFontSource sources[3] = {0}; + for (int i = 0; i < 3; ++i) + if (names[i]) bongo_cat_ui_font_source_load(&sources[i],names[i]); + nk_font_atlas_init_default(&p->atlas); + nk_font_atlas_begin(&p->atlas); + float height = 24 * fmaxf(1,fminf(2,d->scale*d->raster_scale)); + p->font = bongo_cat_ui_font_add_family(&p->atlas,&sources[0],&sources[1],&sources[2], + height,p->ranges[0],p->ranges[1],p->ranges[2],p->ranges[3]); + int width = 0, rows = 0, maximum = 0; + const void *pixels = p->font ? nk_font_atlas_bake(&p->atlas,&width,&rows,NK_FONT_ATLAS_RGBA32) : NULL; + glGetIntegerv(GL_MAX_TEXTURE_SIZE,&maximum); + bool ok = pixels && width > 0 && rows > 0 && width <= maximum && rows <= maximum; + if (ok) { + glGenTextures(1,&p->font_texture); + glBindTexture(GL_TEXTURE_2D,p->font_texture); + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_S,GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_T,GL_CLAMP_TO_EDGE); + glTexImage2D(GL_TEXTURE_2D,0,GL_RGBA8,width,rows,0,GL_RGBA,GL_UNSIGNED_BYTE,pixels); + ok = p->font_texture && glGetError() == GL_NO_ERROR; + nk_font_atlas_end(&p->atlas,nk_handle_id((int)p->font_texture),NULL); + } + for (int i = 0; i < 3; ++i) { + bongo_cat_ui_font_detach_source(&p->atlas,&sources[i]); + bongo_cat_ui_font_source_release(&sources[i]); + } + nk_font_atlas_cleanup(&p->atlas); + return ok; +} + +static void line(Dial *d, const char *s, int length, float x, float y, + float width, float size, uint32_t color) { + struct nk_font *font = d->paint.font; + const struct nk_font_glyph *glyphs[BONGO_CAT_MENU_LABEL_CAP]; + int count = 0; + float total = 0; + while (length > 0 && count < BONGO_CAT_MENU_LABEL_CAP) { + nk_rune rune; + int n = nk_utf_decode(s,&rune,length); + if (n <= 0) break; + const struct nk_font_glyph *g = nk_font_find_glyph(font,rune); + if (g) { glyphs[count++] = g; total += g->xadvance; } + s += n; length -= n; + } + float scale = size/font->info.height; + if (total*scale > width && total > 0) scale = width/total; + float left = x-total*scale/2, top = y-font->info.height*scale/2; + for (int i = 0; i < count; ++i) { + const struct nk_font_glyph *g = glyphs[i]; + if (g->w > 0 && g->h > 0) + dial_quad(d,d->paint.font_texture,left+g->x0*scale,top+g->y0*scale, + g->w*scale,g->h*scale,g->u0,g->v0,g->u1,g->v1,color); + left += g->xadvance*scale; + } +} + +void dial_text(Dial *d, const char *s, float x, float y, float width, + float size, uint32_t color) { + if (!s || !d->paint.font) return; + if (!strncmp(s,"live2d_",7)) { + line(d,"live2d",6,x,y-size*.55f,width,size,color); + line(d,s+7,(int)strlen(s+7),x,y+size*.55f,width,size,color); + } else { + /* Wrap at a UTF-8 boundary before shrinking a long name. Two lines + fit the child ring while keeping normal labels at full size. */ + int length = (int)strlen(s), offset = 0, split = 0; + float total = 0, best = width; + struct nk_font *font = d->paint.font; + float scale = size/font->info.height; + while (offset < length) { + nk_rune rune; + int n = nk_utf_decode(s+offset,&rune,length-offset); + if (n <= 0) break; + const struct nk_font_glyph *g = nk_font_find_glyph(font,rune); + if (g) total += g->xadvance*scale; + offset += n; + } + if (total > width) { + float advance = 0; + offset = 0; best = total; + while (offset < length) { + nk_rune rune; + int n = nk_utf_decode(s+offset,&rune,length-offset); + if (n <= 0) break; + const struct nk_font_glyph *g = nk_font_find_glyph(font,rune); + if (g) advance += g->xadvance*scale; + offset += n; + float difference = fabsf(total-2*advance); + if (offset < length && difference < best) { + best = difference; split = offset; + } + } + } + if (split) { + line(d,s,split,x,y-size*.55f,width,size,color); + line(d,s+split,length-split,x,y+size*.55f,width,size,color); + } else line(d,s,length,x,y,width,size,color); + } +} diff --git a/src/ui/dial/dial_geometry.c b/src/ui/dial/dial_geometry.c new file mode 100644 index 00000000..c8bc5f0f --- /dev/null +++ b/src/ui/dial/dial_geometry.c @@ -0,0 +1,125 @@ +#include "dial_internal.h" +#include + +static DialPoint polar(float r, float a) { + return (DialPoint){r * cosf(a), r * sinf(a)}; +} +static void point(DialPath *p, DialPoint v) { + if (p->count && hypotf(v.x - p->points[p->count-1].x, + v.y - p->points[p->count-1].y) < .001f) return; + if (p->count < DIAL_PATH_POINTS) p->points[p->count++] = v; +} +static void quad(DialPath *p, DialPoint a, DialPoint c, DialPoint b) { + point(p, a); + for (int i = 1; i <= 6; ++i) { + float t = (float)i / 6, u = 1 - t; + point(p, (DialPoint){u*u*a.x + 2*u*t*c.x + t*t*b.x, + u*u*a.y + 2*u*t*c.y + t*t*b.y}); + } +} +static void arc(DialPath *p, float r, float a, float b) { + int steps = (int)ceilf(fabsf(b-a) * r / 3); + if (steps < 1) steps = 1; + for (int i = 0; i <= steps; ++i) + point(p, polar(r, a + (b-a) * (float)i / (float)steps)); +} +static float cross(DialPoint a, DialPoint b, DialPoint c) { + return (b.x-a.x)*(c.y-a.y) - (b.y-a.y)*(c.x-a.x); +} +static void triangulate(DialPath *p) { + unsigned short remaining[DIAL_PATH_POINTS]; + float area = 0; + for (int i = 0; i < p->count; ++i) { + remaining[i] = (unsigned short)i; + DialPoint a = p->points[i], b = p->points[(i+1)%p->count]; + area += a.x*b.y - b.x*a.y; + } + float sign = area > 0 ? 1.0f : -1.0f; + int count = p->count; + while (count >= 3) { + bool found = false; + for (int i = 0; i < count; ++i) { + int a = remaining[(i+count-1)%count], b = remaining[i], c = remaining[(i+1)%count]; + if (cross(p->points[a], p->points[b], p->points[c])*sign <= .00001f) continue; + bool inside = false; + for (int j = 0; j < count; ++j) { + int v = remaining[j]; + if (v == a || v == b || v == c) continue; + DialPoint q = p->points[v]; + if (cross(p->points[a], p->points[b], q)*sign >= 0 && + cross(p->points[b], p->points[c], q)*sign >= 0 && + cross(p->points[c], p->points[a], q)*sign >= 0) { + inside = true; break; + } + } + if (inside) continue; + p->triangles[p->triangle_count++] = (unsigned short)a; + p->triangles[p->triangle_count++] = (unsigned short)b; + p->triangles[p->triangle_count++] = (unsigned short)c; + memmove(remaining+i, remaining+i+1, (size_t)(count-i-1)*sizeof(*remaining)); + --count; found = true; break; + } + if (!found) break; + } +} +void dial_sector(DialPath *p, float inner, float outer, float start, float end) { + memset(p, 0, sizeof(*p)); + const float corner = 12, a = start + .024f, b = end - .024f; + const float ci = corner / inner, co = corner / outer; + point(p, polar(outer-corner, b)); + quad(p, polar(inner+corner,b), polar(inner,b), polar(inner,b-ci)); + arc(p, inner,b-ci,a+ci); + quad(p, polar(inner,a+ci),polar(inner,a),polar(inner+corner,a)); + p->rim_start = p->count; + quad(p,polar(outer-corner,a),polar(outer,a),polar(outer,a+co)); + arc(p,outer,a+co,b-co); + quad(p,polar(outer,b-co),polar(outer,b),polar(outer-corner,b)); + if (p->count > 1) --p->count; /* Last point closes back onto the first. */ + p->min_x = p->min_y = 1000; p->max_x = p->max_y = -1000; + for (int i = 0; i < p->count; ++i) { + p->min_x = fminf(p->min_x, p->points[i].x); p->max_x = fmaxf(p->max_x,p->points[i].x); + p->min_y = fminf(p->min_y, p->points[i].y); p->max_y = fmaxf(p->max_y,p->points[i].y); + } + triangulate(p); +} +void dial_child_paths(Dial *d) { + memset(d->paint.children, 0, sizeof(d->paint.children)); + for (int i = 0; i < dial_child_count(d); ++i) { + float angle = dial_child_angle(d,i), half = dial_child_step(d)/2; + dial_sector(&d->paint.children[i],198,262,angle-half,angle+half); + } +} + +static float angle_distance(float a, float b) { + return atan2f(sinf(a - b), cosf(a - b)); +} + +void dial_hit(Dial *d, float x, float y, int *root, int *child) { + float radius = hypotf(x, y), angle = atan2f(y, x); + *root = -1; *child = -1; + if (d->active >= 0 && radius >= 190 && radius <= 277) { + for (int i = 0; i < dial_child_count(d); ++i) { + if (fabsf(angle_distance(angle, dial_child_angle(d, i))) < + dial_child_step(d) / 2) { + *root = d->active; *child = i; return; + } + } + } + if (radius >= 73 && radius <= 198) { + for (int i = 0; i < d->count; ++i) { + if (d->child_focus && i != d->active) continue; + float center = -DIAL_PI / 2 + i * 2 * DIAL_PI / d->count; + if (fabsf(angle_distance(angle, center)) <= DIAL_PI / d->count) { + *root = i; return; + } + } + } + /* Keep the iris open while crossing its radial gap or the center hub. */ + if (radius <= 280) *root = d->active; +} + +void dial_transform(Dial *d, float zoom, float x, float y) { + d->paint.zoom = zoom; + d->paint.offset_x = x; + d->paint.offset_y = y; +} diff --git a/src/platform/windows/dial/dial_icons.c b/src/ui/dial/dial_icons.c similarity index 97% rename from src/platform/windows/dial/dial_icons.c rename to src/ui/dial/dial_icons.c index 124c5aa1..625328e6 100644 --- a/src/platform/windows/dial/dial_icons.c +++ b/src/ui/dial/dial_icons.c @@ -193,13 +193,8 @@ static const IconContour icons[] = { {0, 2}, {2, 4}, {6, 4}, {10, 3}, {13, 4}, {17, 2}, {19, 1}, {20, 4}, {24, 3}, {27, 1}, {28, 3}, {31, 2}, {33, 2}, {35, 3} }; -void dial_icon(GpGraphics *g, int icon, float x, float y, float size, DWORD color) { +void dial_icon(Dial *d, int icon, float x, float y, float size, uint32_t color) { if (icon < 0 || icon >= (int)(sizeof(icons) / sizeof(icons[0]))) return; - GpPen *pen = NULL; - if (GdipCreatePen1(color, 1.95f * size / 24, 2, &pen)) return; - GdipSetPenStartCap(pen, 2); - GdipSetPenEndCap(pen, 2); - GdipSetPenLineJoin(pen, 2); IconContour range = icons[icon]; for (int i = 0; i < range.count; ++i) { IconContour contour = contours[range.offset + i]; @@ -210,7 +205,6 @@ void dial_icon(GpGraphics *g, int icon, float x, float y, float size, DWORD colo transformed[j] = (DialPoint){x + (p.x - 12) * size / 24, y + (p.y - 12) * size / 24}; } - GdipDrawLines(g, pen, transformed, contour.count); + dial_stroke(d, transformed, contour.count, 1.95f * size / 24, color, true); } - GdipDeletePen(pen); } diff --git a/src/ui/dial/dial_input.c b/src/ui/dial/dial_input.c new file mode 100644 index 00000000..1baffff8 --- /dev/null +++ b/src/ui/dial/dial_input.c @@ -0,0 +1,106 @@ +#include "dial_internal.h" + +static void pointer(Dial *d, float px, float py, bool click, int *root, int *child) { + float scale = d->scale*d->opening; + float x = (px-(float)d->width/2)/scale, y = (py-(float)d->height/2)/scale; + float radius = hypotf(x,y); + if (d->child_focus && radius < 190) { d->child_focus = false; d->dirty = true; } + dial_hit(d,x,y,root,child); + if (click && *child < 0 && *root >= 0) { + float angle = atan2f(y,x)-(-DIAL_PI/2+(float)*root*2*DIAL_PI/(float)d->count); + if (radius < 73 || radius > 198 || + fabsf(atan2f(sinf(angle),cosf(angle))) > DIAL_PI/(float)d->count) *root = -1; + } +} + +static void activate(Dial *d) { + if (d->active < 0) return; + char text[32]; + DialItem item = d->child < 0 ? d->items[d->active] : dial_child_item(d,d->child,text,sizeof(text)); + if (item.children) { dial_select(d,d->active,0); return; } + if (item.command != BONGO_CAT_MENU_NONE) { d->result = item.command; d->done = true; } +} + +static void page(Dial *d, int direction) { + if (d->active < 0 || d->items[d->active].children <= DIAL_PAGE) return; + int pages = ((int)d->items[d->active].children+DIAL_PAGE-1)/DIAL_PAGE; + d->page = (d->page+direction+pages)%pages; + d->child = -1; + if (d->preview != BONGO_CAT_MENU_NONE) dial_preview(d,BONGO_CAT_MENU_NONE); + d->preview = BONGO_CAT_MENU_NONE; + d->changed_at = SDL_GetTicks(); + dial_child_paths(d); d->dirty = true; +} + +static void key(Dial *d, const SDL_KeyboardEvent *event) { + SDL_Keycode value = event->key; + if (value == SDLK_ESCAPE) { + if (d->active >= 0 && dial_child_count(d)) dial_select(d,-1,-1); + else d->done = true; + } else if (value == SDLK_RETURN || value == SDLK_SPACE) activate(d); + else if (value == SDLK_PAGEUP || value == SDLK_PAGEDOWN) page(d,value == SDLK_PAGEDOWN ? 1 : -1); + else if (value == SDLK_LEFT || value == SDLK_UP || value == SDLK_RIGHT || + value == SDLK_DOWN || value == SDLK_TAB) { + int direction = value == SDLK_LEFT || value == SDLK_UP || + (value == SDLK_TAB && (event->mod & SDL_KMOD_SHIFT)) ? -1 : 1; + int count = dial_child_count(d); + if (d->child >= 0 && count) dial_select(d,d->active,(d->child+direction+count)%count); + else dial_select(d,d->active < 0 ? 0 : (d->active+direction+d->count)%d->count,-1); + } else if (value == SDLK_BACKSPACE) { + d->child_focus = false; d->dirty = true; dial_select(d,d->active,-1); + } +} + +void dial_event(Dial *d, const SDL_Event *e) { + int root, child; + switch (e->type) { + case SDL_EVENT_MOUSE_MOTION: + pointer(d,e->motion.x,e->motion.y,false,&root,&child); + dial_select(d,root,child); break; + case SDL_EVENT_MOUSE_BUTTON_DOWN: + if (e->button.button != SDL_BUTTON_LEFT) { d->done = true; break; } + pointer(d,e->button.x,e->button.y,true,&root,&child); + if (root < 0) { d->done = true; break; } + dial_select(d,root,child); + d->pressed = child >= 0 ? DIAL_ROOTS+child : root; d->dirty = true; + break; + case SDL_EVENT_MOUSE_BUTTON_UP: { + if (e->button.button != SDL_BUTTON_LEFT) break; + int pressed = d->pressed; d->pressed = -1; d->dirty = true; + pointer(d,e->button.x,e->button.y,true,&root,&child); + if (root >= 0 && pressed == (child >= 0 ? DIAL_ROOTS+child : root)) { + dial_select(d,root,child); activate(d); + } + break; + } + case SDL_EVENT_MOUSE_WHEEL: { + float delta = e->wheel.direction == SDL_MOUSEWHEEL_FLIPPED ? -e->wheel.y : e->wheel.y; + if (delta != 0) page(d,delta < 0 ? 1 : -1); + break; + } + case SDL_EVENT_KEY_DOWN: key(d,&e->key); break; + case SDL_EVENT_WINDOW_MOUSE_LEAVE: dial_select(d,-1,-1); break; + case SDL_EVENT_WINDOW_SHOWN: d->shown = true; d->dirty = true; break; + case SDL_EVENT_WINDOW_HIDDEN: + if (d->shown && (SDL_GetWindowFlags(d->window) & SDL_WINDOW_HIDDEN)) d->done = true; + break; + case SDL_EVENT_WINDOW_FOCUS_LOST: + /* Creation/show may queue a transient loss before focus settles. */ + if (d->shown && SDL_GetKeyboardFocus() != d->window) d->done = true; + break; + case SDL_EVENT_WINDOW_CLOSE_REQUESTED: d->done = true; break; + case SDL_EVENT_WINDOW_EXPOSED: d->dirty = true; break; + case SDL_EVENT_WINDOW_RESIZED: case SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED: + case SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED: + if (!SDL_GetWindowSize(d->window,&d->width,&d->height) || + !SDL_GetWindowSizeInPixels(d->window,&d->pixel_width,&d->pixel_height) || + d->width < 1 || d->height < 1) { d->done = true; break; } + d->scale = (float)SDL_min(d->width,d->height)/608.0f; + d->raster_scale = (float)d->pixel_width/(float)d->width; + /* SDL also sends DISPLAY_SCALE_CHANGED during window creation. + Keep the menu open; existing textures remain valid and new covers + use the updated density. Fonts are rebaked on the next opening. */ + d->dirty = true; + break; + } +} diff --git a/src/ui/dial/dial_internal.h b/src/ui/dial/dial_internal.h new file mode 100644 index 00000000..17da0753 --- /dev/null +++ b/src/ui/dial/dial_internal.h @@ -0,0 +1,93 @@ +#ifndef BONGO_CAT_DIAL_INTERNAL_H +#define BONGO_CAT_DIAL_INTERNAL_H +#include "bongo_cat/platform.h" +#include "bongo_cat/gl_api.h" +#include "nuklear_config.h" +#include +#include +#define DIAL_PI 3.14159265358979323846f +#define DIAL_PAGE 16 +#define DIAL_ROOTS 12 +#define DIAL_PATH_POINTS 160 +typedef struct DialPoint { float x, y; } DialPoint; +typedef struct DialPath { + DialPoint points[DIAL_PATH_POINTS]; + unsigned short triangles[DIAL_PATH_POINTS * 3]; + int count, triangle_count, rim_start; + float min_x, min_y, max_x, max_y; +} DialPath; +typedef struct DialItem { + const char *label; + BongoCatMenuAction command; + uint32_t color; + int icon; + bool checked; + size_t children; +} DialItem; +typedef struct DialVertex { float x, y, u, v; uint8_t r, g, b, a; } DialVertex; +typedef struct DialBatch { GLuint texture; int first, count; } DialBatch; +typedef struct DialCover { GLuint texture; float width, height; bool attempted; } DialCover; +typedef struct DialPaint { + BongoCatGL gl; + GLuint program, vao, vbo, white, font_texture; + GLint projection, sampler; + DialVertex *vertices; + size_t count, capacity; + DialBatch batches[512]; + int batch_count; + struct nk_font_atlas atlas; + struct nk_font *font; + nk_rune *ranges[4]; + DialPath roots[DIAL_ROOTS], children[DIAL_PAGE]; + float zoom, offset_x, offset_y, alpha; + bool failed; +} DialPaint; +typedef struct Dial { + SDL_Window *window, *owner; + SDL_GLContext context, previous_context; + SDL_Window *previous_window; + SDL_WindowID window_id; + const BongoCatMenuLabels *labels; + DialItem items[DIAL_ROOTS]; + int count, active, child, page, pressed; + bool done, dark, dirty, child_focus, shown, popup; + uint64_t input_after_ns; + BongoCatMenuAction result, preview; + int width, height, pixel_width, pixel_height; + float scale, opening, lift[DIAL_ROOTS], raster_scale; + uint64_t opened_at, changed_at, animation_at; + SDL_Event events[128]; + int event_count; + DialPaint paint; + DialCover covers[BONGO_CAT_MODEL_CAP]; +} Dial; +void dial_items(Dial *d); +int dial_child_count(const Dial *d); +float dial_child_angle(const Dial *d, int index); +float dial_child_step(const Dial *d); +DialItem dial_child_item(const Dial *d, int index, char *text, size_t capacity); +void dial_select(Dial *d, int root, int child); +void dial_hit(Dial *d, float x, float y, int *root, int *child); +void dial_child_paths(Dial *d); +void dial_sector(DialPath *path, float inner, float outer, float start, float end); +void dial_transform(Dial *d, float zoom, float x, float y); +bool dial_paint_init(Dial *d); +void dial_paint_free(Dial *d); +bool dial_paint_frame(Dial *d); +void dial_scene(Dial *d); +void dial_triangle(Dial *d, DialPoint a, DialPoint b, DialPoint c, + uint32_t ca, uint32_t cb, uint32_t cc); +void dial_quad(Dial *d, GLuint texture, float x, float y, float w, float h, + float u0, float v0, float u1, float v1, uint32_t color); +void dial_stroke(Dial *d, const DialPoint *points, int count, float width, + uint32_t color, bool smooth); +void dial_dot(Dial *d, float x, float y, float radius, uint32_t color); +void dial_icon(Dial *d, int icon, float x, float y, float size, uint32_t color); +bool dial_fonts_init(Dial *d); +void dial_text(Dial *d, const char *text, float x, float y, float width, + float size, uint32_t color); +void dial_covers_tick(Dial *d); +bool dial_cover_draw(Dial *d, int child, float x, float y, float opacity); +void dial_event(Dial *d, const SDL_Event *event); +void dial_preview(Dial *d, BongoCatMenuAction action); +#endif diff --git a/src/platform/windows/dial/dial_items.c b/src/ui/dial/dial_items.c similarity index 97% rename from src/platform/windows/dial/dial_items.c rename to src/ui/dial/dial_items.c index 39466846..8867c971 100644 --- a/src/platform/windows/dial/dial_items.c +++ b/src/ui/dial/dial_items.c @@ -93,7 +93,7 @@ void dial_select(Dial *d, int root, int child) { if (child >= 0) d->child_focus = true; if (changed) { d->page = 0; - d->changed_at = GetTickCount64(); + d->changed_at = SDL_GetTicks(); dial_child_paths(d); } char text[32]; @@ -101,7 +101,7 @@ void dial_select(Dial *d, int root, int child) { dial_child_item(d, child, text, sizeof(text)).command : BONGO_CAT_MENU_NONE; if (action != d->preview) { d->preview = action; - if (d->labels->preview) d->labels->preview(d->labels->preview_userdata, action); + dial_preview(d, action); } d->dirty = true; } diff --git a/src/ui/dial/dial_paint.c b/src/ui/dial/dial_paint.c new file mode 100644 index 00000000..cf5ccb78 --- /dev/null +++ b/src/ui/dial/dial_paint.c @@ -0,0 +1,93 @@ +#include "dial_internal.h" +#include +#include + +static const char *vertex_shader = + "#version 330 core\n" + "layout(location=0) in vec2 Position; layout(location=1) in vec2 UV;" + "layout(location=2) in vec4 Color; uniform mat4 Projection;" + "out vec2 uv; out vec4 color;" + "void main(){uv=UV;color=Color;gl_Position=Projection*vec4(Position,0,1);}"; +static const char *fragment_shader = + "#version 330 core\n" + "in vec2 uv; in vec4 color; uniform sampler2D Texture; out vec4 pixel;" + "void main(){pixel=color*texture(Texture,uv);}"; + +bool dial_paint_init(Dial *d) { + DialPaint *p = &d->paint; + BongoCatError error = {0}; + if (!bongo_cat_gl_load(&p->gl, &error)) return SDL_SetError("Radial menu GL loader: %s",error.message); + bongo_cat_gl_clear_errors(); + p->program = bongo_cat_gl_program(&p->gl,vertex_shader,fragment_shader,&error); + if (!p->program) { SDL_SetError("%s",error.message); return false; } + p->projection = p->gl.uniform_location(p->program,"Projection"); + p->sampler = p->gl.uniform_location(p->program,"Texture"); + p->gl.gen_vertex_arrays(1,&p->vao); + p->gl.gen_buffers(1,&p->vbo); + p->gl.bind_vertex_array(p->vao); + p->gl.bind_buffer(GL_ARRAY_BUFFER,p->vbo); + p->gl.enable_attribute(0); p->gl.enable_attribute(1); p->gl.enable_attribute(2); + p->gl.attribute_pointer(0,2,GL_FLOAT,GL_FALSE,sizeof(DialVertex),(void *)offsetof(DialVertex,x)); + p->gl.attribute_pointer(1,2,GL_FLOAT,GL_FALSE,sizeof(DialVertex),(void *)offsetof(DialVertex,u)); + p->gl.attribute_pointer(2,4,GL_UNSIGNED_BYTE,GL_TRUE,sizeof(DialVertex),(void *)offsetof(DialVertex,r)); + glGenTextures(1,&p->white); glBindTexture(GL_TEXTURE_2D,p->white); + const uint8_t white[] = {255,255,255,255}; + glTexImage2D(GL_TEXTURE_2D,0,GL_RGBA8,1,1,0,GL_RGBA,GL_UNSIGNED_BYTE,white); + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR); + if (!dial_fonts_init(d)) return SDL_SetError("Radial menu font atlas could not be created"); + for (int i = 0; i < d->count; ++i) { + float angle = -DIAL_PI/2 + (float)i*2*DIAL_PI/(float)d->count; + float half = DIAL_PI/(float)d->count; + dial_sector(&p->roots[i],82,184,angle-half,angle+half); + if (!p->roots[i].triangle_count) return SDL_SetError("Radial menu sector %d could not be triangulated",i); + } + GLenum status = glGetError(); + return status == GL_NO_ERROR || SDL_SetError("Radial menu GL initialization error: 0x%x",(unsigned)status); +} + +bool dial_paint_frame(Dial *d) { + DialPaint *p = &d->paint; + p->count = 0; p->batch_count = 0; + /* Never show a completely transparent first frame: it cannot receive + pointer hits on some native transparent-window implementations. */ + p->alpha = fmaxf(.15f,fminf(1,(float)(SDL_GetTicks()-d->opened_at)/180)); + dial_scene(d); + if (p->failed) return SDL_SetError("Radial menu geometry budget exceeded"); + glViewport(0,0,d->pixel_width,d->pixel_height); + glDisable(GL_SCISSOR_TEST); glDisable(GL_DEPTH_TEST); glDisable(GL_CULL_FACE); + glColorMask(GL_TRUE,GL_TRUE,GL_TRUE,GL_TRUE); + glClearColor(0,0,0,0); glClear(GL_COLOR_BUFFER_BIT); + glEnable(GL_BLEND); p->gl.blend_equation(GL_FUNC_ADD); + p->gl.blend_func_separate(GL_SRC_ALPHA,GL_ONE_MINUS_SRC_ALPHA,GL_ONE,GL_ONE_MINUS_SRC_ALPHA); + float width = (float)d->width, height = (float)d->height; + const float projection[16] = {2/width,0,0,0, 0,-2/height,0,0, 0,0,-1,0, -1,1,0,1}; + p->gl.use_program(p->program); + p->gl.uniform_matrix_4fv(p->projection,1,GL_FALSE,projection); + p->gl.uniform_1i(p->sampler,0); + p->gl.active_texture(GL_TEXTURE0); + p->gl.bind_vertex_array(p->vao); + p->gl.bind_buffer(GL_ARRAY_BUFFER,p->vbo); + p->gl.buffer_data(GL_ARRAY_BUFFER,(GLsizeiptr)(p->count*sizeof(*p->vertices)),p->vertices,GL_STREAM_DRAW); + for (int i = 0; i < p->batch_count; ++i) { + DialBatch b = p->batches[i]; + glBindTexture(GL_TEXTURE_2D,b.texture); + glDrawArrays(GL_TRIANGLES,b.first,b.count); + } + if (glGetError() != GL_NO_ERROR) return SDL_SetError("Radial menu OpenGL draw failed"); + return SDL_GL_SwapWindow(d->window); +} + +void dial_paint_free(Dial *d) { + DialPaint *p = &d->paint; + for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) + if (d->covers[i].texture) glDeleteTextures(1,&d->covers[i].texture); + if (p->font_texture) glDeleteTextures(1,&p->font_texture); + if (p->atlas.permanent.alloc) nk_font_atlas_clear(&p->atlas); + for (int i = 0; i < 4; ++i) free(p->ranges[i]); + if (p->white) glDeleteTextures(1,&p->white); + if (p->vbo) p->gl.delete_buffers(1,&p->vbo); + if (p->vao) p->gl.delete_vertex_arrays(1,&p->vao); + if (p->program) p->gl.delete_program(p->program); + free(p->vertices); +} diff --git a/src/ui/dial/dial_scene.c b/src/ui/dial/dial_scene.c new file mode 100644 index 00000000..bce62f8b --- /dev/null +++ b/src/ui/dial/dial_scene.c @@ -0,0 +1,124 @@ +#include "dial_internal.h" +#include + +static uint32_t alpha(uint32_t color, float opacity) { + return (color&0xffffff) | ((uint32_t)((float)(color>>24)*opacity)<<24); +} +static uint32_t mix(uint32_t a, uint32_t b, float t) { + uint32_t color = 0; + for (int shift = 0; shift <= 24; shift += 8) { + float av = (float)((a>>shift)&255), bv = (float)((b>>shift)&255); + color |= (uint32_t)(av+(bv-av)*t)<min_x)/(p->max_x-p->min_x)+(v.y-p->min_y)/(p->max_y-p->min_y)); + t = fmaxf(0,fminf(1,t)); + return alpha(t < .5f ? mix(colors[0],colors[1],t*2) : mix(colors[1],colors[2],(t-.5f)*2),opacity); +} +static void surface(Dial *d, const DialPath *p, int tint, float opacity) { + if (!p->count) return; + uint32_t colors[3]; + if (tint == 1) { colors[0]=0xff6ec2ff; colors[1]=0xff54aeff; colors[2]=0xff1e82f0; } + else if (tint == 2) { colors[0]=0xffff97be; colors[1]=0xfff77daa; colors[2]=0xffe34882; } + else if (tint == 3) { colors[0]=0xffff6960; colors[1]=0xffff453a; colors[2]=0xffd10014; } + else if (d->dark) { colors[0]=0xff010409; colors[1]=0xff010409; colors[2]=0xff010409; } + else { colors[0]=0xffffffff; colors[1]=0xfff6f9ff; colors[2]=0xffeef3fc; } + DialPoint closed[DIAL_PATH_POINTS+1]; + for (int i = 0; i < p->count; ++i) closed[i]=p->points[i]; + closed[p->count]=p->points[0]; + uint32_t glow = tint ? (colors[1]&0xffffff)|0x06000000 : 0x03000000; + for (int i = 6; i > 0; --i) + dial_stroke(d,closed,p->count+1,(float)i*5,alpha(glow,opacity),false); + /* Shared polygon vertices only need their gradient calculated once. */ + uint32_t vertex_colors[DIAL_PATH_POINTS]; + for (int i = 0; i < p->count; ++i) + vertex_colors[i] = gradient(p,p->points[i],colors,opacity); + for (int i = 0; i < p->triangle_count; i += 3) { + int a=p->triangles[i], b=p->triangles[i+1], c=p->triangles[i+2]; + dial_triangle(d,p->points[a],p->points[b],p->points[c], + vertex_colors[a],vertex_colors[b],vertex_colors[c]); + } + dial_stroke(d,closed,p->count+1,1.1f,alpha(tint || !d->dark ? 0xfaffffff : 0x38ffffff,opacity),true); + dial_stroke(d,closed+p->rim_start,p->count+1-p->rim_start,1.4f, + alpha(d->dark ? 0xd3ffffff : 0xebffffff,opacity),true); +} +static void text(Dial *d, const char *s, float x, float y, float width, bool title, uint32_t color) { + dial_text(d,s,x,y,width,title ? 19.0f : 14.0f,color); +} +static int item_icon(DialItem item) { + return item.checked && item.icon == 2 ? 12 : + (item.checked && item.icon == 3 ? 13 : item.icon); +} + +static void root(Dial *d, int index) { + DialItem item = d->items[index]; + float angle = -DIAL_PI / 2 + index * 2 * DIAL_PI / d->count; + float x = 132 * cosf(angle), y = 132 * sinf(angle); + float lift = d->lift[index], zoom = 1 + .055f * lift; + float pull = 7.5f * lift; + if (d->pressed == index) { zoom = .96f; pull *= .4f; } + dial_transform(d, zoom, x * (1 - zoom) + cosf(angle) * pull, + y * (1 - zoom) + sinf(angle) * pull); + bool active = index == d->active; + float opacity = 1.0f; + int tint = active ? (index >= 10 ? 3 : 1) : 0; + surface(d, &d->paint.roots[index], tint, opacity); + uint32_t color = active ? 0xffffffff : (item.checked ? 0xfff77daa : item.color); + if (index >= 6 && index <= 8 && !item.children) opacity *= .4f; + dial_icon(d, item_icon(item), x, y, 28 * (1 + .15f * lift), alpha(color, opacity)); + if (item.checked) { + for (int i = 4; i > 0; --i) + dial_dot(d, x + 17, y - 17, 3.2f + i * 1.5f, 0x0af77daa); + dial_dot(d, x + 17, y - 17, 3.2f, 0xfff77daa); + } +} + +static void center(Dial *d) { + if (d->active < 0) return; + dial_transform(d, 1, 0, 0); + DialItem item = d->items[d->active]; + char buffer[32]; + bool child_hovered = d->child >= 0; + uint32_t color = child_hovered ? 0xfff77daa : 0xff52a9f8; + const char *label = child_hovered ? + dial_child_item(d, d->child, buffer, sizeof(buffer)).label : item.label; + if (!child_hovered) + dial_icon(d, item_icon(item), 0, -5, 38, item.checked ? 0xfff77daa : item.color); + text(d, label, 0, child_hovered ? 0.0f : 30.0f, 140, true, color); + if (item.children > DIAL_PAGE) { + snprintf(buffer, sizeof(buffer), "%d / %d", d->page + 1, + ((int)item.children + DIAL_PAGE - 1) / DIAL_PAGE); + text(d, buffer, 0, 58, 80, false, color); + } +} + +static void children(Dial *d, uint64_t now) { + for (int i = 0; i < dial_child_count(d); ++i) { + float elapsed = (float)(now - d->changed_at) - i * 22; + float t = fmaxf(0, fminf(1, elapsed / 360)); + float ease = 1 - powf(1 - t, 3); + float angle = dial_child_angle(d, i), x = 230 * cosf(angle), y = 230 * sinf(angle); + bool hover = i == d->child; + float zoom = .72f + .28f * ease + (hover ? .06f : 0); + float pull = -26 * (1 - ease) + (hover ? 6 : 0); + dial_transform(d, zoom, x * (1 - zoom) + cosf(angle) * pull, + y * (1 - zoom) + sinf(angle) * pull); + char buffer[32]; + DialItem item = dial_child_item(d, i, buffer, sizeof(buffer)); + surface(d, &d->paint.children[i], + item.checked ? 2 : (hover ? 1 : 0), ease); + uint32_t color = hover || item.checked || d->dark ? 0xffffffff : 0xff181c28; + if (!dial_cover_draw(d, i, x, y, ease)) + text(d, item.label, x, y, dial_child_step(d) > .4f ? 92.0f : 62.0f, + false, alpha(color, ease)); + } +} + +void dial_scene(Dial *d) { + for (int i = 0; i < d->count; ++i) + if (!d->child_focus || i == d->active) root(d,i); + center(d); + children(d,SDL_GetTicks()); +} diff --git a/src/ui/dial/dial_window.c b/src/ui/dial/dial_window.c new file mode 100644 index 00000000..735544a7 --- /dev/null +++ b/src/ui/dial/dial_window.c @@ -0,0 +1,186 @@ +#include "dial_internal.h" +#include "ui_backend.h" +#include "bongo_cat/log.h" +#include + +static bool SDLCALL collect_event(void *userdata, SDL_Event *event) { + Dial *d = userdata; + if (event->type == SDL_EVENT_QUIT || event->type == SDL_EVENT_TERMINATING) { + d->done = true; return true; /* Let the main application handle shutdown. */ + } + SDL_Window *window = SDL_GetWindowFromEvent(event); + if (window != d->window) { + if ((event->type == SDL_EVENT_MOUSE_BUTTON_DOWN && + event->common.timestamp >= d->input_after_ns) || + (window == d->owner && event->type == SDL_EVENT_WINDOW_CLOSE_REQUESTED)) d->done = true; + return true; /* Never consume another pet's/preferences/input events. */ + } + bool input = (event->type >= SDL_EVENT_WINDOW_FIRST && event->type <= SDL_EVENT_WINDOW_LAST) || + event->type == SDL_EVENT_MOUSE_MOTION || event->type == SDL_EVENT_MOUSE_BUTTON_DOWN || + event->type == SDL_EVENT_MOUSE_BUTTON_UP || event->type == SDL_EVENT_MOUSE_WHEEL || + event->type == SDL_EVENT_KEY_DOWN || event->type == SDL_EVENT_KEY_UP; + if (!input || d->event_count == 128) return true; + d->events[d->event_count++] = *event; + return false; +} + +void dial_preview(Dial *d, BongoCatMenuAction action) { + if (!d->labels->preview) return; + if (d->previous_window && d->previous_context) + SDL_GL_MakeCurrent(d->previous_window,d->previous_context); + d->labels->preview(d->labels->preview_userdata,action); + if (!SDL_GL_MakeCurrent(d->window,d->context)) d->done = true; +} + +static void animate(Dial *d, uint64_t now) { + float elapsed = fminf(.1f,(float)(now-d->animation_at)/1000); + d->animation_at = now; + float t = fminf(1,(float)(now-d->opened_at)/700); + float opening = .85f+.15f*(1-powf(1-t,4)); + if (opening != d->opening) { d->opening = opening; d->dirty = true; } + float response = 1-expf(-17*elapsed); + for (int i = 0; i < d->count; ++i) { + if (d->child_focus && i != d->active) { d->lift[i] = 0; continue; } + float target = d->active == i ? 1.0f : 0.0f; + float next = d->lift[i]+(target-d->lift[i])*response; + if (fabsf(next-target) < .002f) next = target; + if (next != d->lift[i]) { d->lift[i] = next; d->dirty = true; } + } + if (dial_child_count(d) && now-d->changed_at <= + (uint64_t)(360+(dial_child_count(d)-1)*22+16)) d->dirty = true; + if (now-d->opened_at <= 196) d->dirty = true; +} + +static bool create(Dial *d) { + float layout = 1; + bongo_cat_ui_query_window_scale(d->owner,&layout,NULL); + SDL_Rect bounds; + if (!SDL_GetDisplayUsableBounds(SDL_GetDisplayForWindow(d->owner),&bounds)) return false; + float side = fminf(608*740.0f/560*layout,.96f*(float)SDL_min(bounds.w,bounds.h)); + int size = (int)ceilf(side); + if (size < 1) return false; + float x = 0, y = 0; + if (SDL_GetMouseFocus() == d->owner) SDL_GetMouseState(&x,&y); + else { + int width, height; + if (!SDL_GetWindowSize(d->owner,&width,&height)) return false; + x = (float)width/2; y = (float)height/2; + } + SDL_WindowFlags flags = SDL_WINDOW_OPENGL | SDL_WINDOW_TRANSPARENT | + SDL_WINDOW_HIGH_PIXEL_DENSITY | SDL_WINDOW_HIDDEN; + const char *driver = SDL_GetCurrentVideoDriver(); + d->popup = driver && SDL_strcmp(driver,"wayland") == 0; + if (d->popup) { + /* Wayland requires compositor-managed popup placement and input grabs. */ + d->window = SDL_CreatePopupWindow(d->owner,(int)x-size/2,(int)y-size/2, + size,size,flags | SDL_WINDOW_POPUP_MENU); + } else { + /* A desktop pet may be nonactivating or fully transparent. A native + child popup inherits its focus/stacking restrictions on Win32. + An independent SDL utility window owns its focus and GL surface. */ + d->window = SDL_CreateWindow("BongoCat - Menu",size,size,flags | + SDL_WINDOW_BORDERLESS | SDL_WINDOW_UTILITY | SDL_WINDOW_ALWAYS_ON_TOP); + if (d->window) { + SDL_GetGlobalMouseState(&x,&y); + SDL_Point position = {(int)x,(int)y}; + SDL_DisplayID display = SDL_GetDisplayForPoint(&position); + SDL_GetDisplayUsableBounds(display,&bounds); + int left = SDL_clamp((int)x-size/2,bounds.x,SDL_max(bounds.x,bounds.x+bounds.w-size)); + int top = SDL_clamp((int)y-size/2,bounds.y,SDL_max(bounds.y,bounds.y+bounds.h-size)); + SDL_SetWindowPosition(d->window,left,top); + } + } + if (!d->window) return false; + d->window_id = SDL_GetWindowID(d->window); + int sharing = 0; + SDL_GL_GetAttribute(SDL_GL_SHARE_WITH_CURRENT_CONTEXT,&sharing); + if (!SDL_GL_SetAttribute(SDL_GL_SHARE_WITH_CURRENT_CONTEXT,0)) return false; + d->context = SDL_GL_CreateContext(d->window); + SDL_GL_SetAttribute(SDL_GL_SHARE_WITH_CURRENT_CONTEXT,sharing); + if (!d->context || !SDL_GL_MakeCurrent(d->window,d->context)) return false; + SDL_GL_SetSwapInterval(0); /* The modal tick supplies a 16 ms frame budget. */ + if (!SDL_GetWindowSize(d->window,&d->width,&d->height) || + !SDL_GetWindowSizeInPixels(d->window,&d->pixel_width,&d->pixel_height) || + d->width < 1 || d->height < 1) return false; + d->scale = (float)SDL_min(d->width,d->height)/608; + d->raster_scale = (float)d->pixel_width/(float)d->width; + if (!dial_paint_init(d)) return false; + d->opened_at = d->changed_at = d->animation_at = SDL_GetTicks(); + d->opening = .85f; + d->input_after_ns = SDL_GetTicksNS(); + if (!dial_paint_frame(d) || !SDL_ShowWindow(d->window)) return false; + if (!d->popup) SDL_RaiseWindow(d->window); + d->dirty = true; + SDL_LogInfo(BONGO_CAT_LOG_LIFECYCLE, + "Radial menu opened: driver=%s window=%u size=%dx%d popup=%d", + driver ? driver : "unknown",(unsigned)d->window_id,d->width,d->height,d->popup); + return true; +} + +BongoCatMenuAction bongo_cat_platform_context_menu(BongoCatPlatform *platform, + const BongoCatMenuLabels *labels) { + if (!platform || !platform->window || !labels) return BONGO_CAT_MENU_NONE; + SDL_LogInfo(BONGO_CAT_LOG_LIFECYCLE,"Radial menu requested: owner=%u", + (unsigned)SDL_GetWindowID(platform->window)); + Dial *d = calloc(1,sizeof(*d)); + if (!d) return BONGO_CAT_MENU_NONE; + d->owner = platform->window; d->labels = labels; d->dark = labels->dark_theme; + d->previous_window = SDL_GL_GetCurrentWindow(); + d->previous_context = SDL_GL_GetCurrentContext(); + d->active = d->child = d->pressed = -1; + dial_items(d); + bool ready = create(d); + if (!ready) SDL_LogError(SDL_LOG_CATEGORY_APPLICATION,"Radial menu initialization failed: %s",SDL_GetError()); + while (ready && !d->done) { + uint64_t start = SDL_GetTicks(); + SDL_PumpEvents(); + if (!SDL_GetWindowFromID(d->window_id)) { d->done = true; break; } + d->event_count = 0; + SDL_FilterEvents(collect_event,d); + for (int i = 0; i < d->event_count && !d->done; ++i) { + dial_event(d,&d->events[i]); + if (d->done) SDL_LogInfo(BONGO_CAT_LOG_LIFECYCLE, + "Radial menu dismissed: event=0x%x action=%d elapsed_ms=%llu", + (unsigned)d->events[i].type,(int)d->result, + (unsigned long long)(SDL_GetTicks()-d->opened_at)); + } + if (d->done) break; + if (labels->preview_tick) { + if (d->previous_window && d->previous_context) + SDL_GL_MakeCurrent(d->previous_window,d->previous_context); + labels->preview_tick(labels->preview_userdata); + } + if (!SDL_GL_MakeCurrent(d->window,d->context)) { + SDL_LogError(SDL_LOG_CATEGORY_VIDEO,"Radial menu context activation failed: %s",SDL_GetError()); + break; + } + dial_covers_tick(d); + animate(d,SDL_GetTicks()); + if (d->dirty) { + d->dirty = false; + if (!dial_paint_frame(d)) { + SDL_LogError(SDL_LOG_CATEGORY_VIDEO,"Radial menu rendering failed: %s",SDL_GetError()); + break; + } + } + uint64_t elapsed = SDL_GetTicks()-start; + if (elapsed < 16) SDL_Delay((Uint32)(16-elapsed)); + } + if (!d->context || (SDL_GetWindowFromID(d->window_id) && + SDL_GL_MakeCurrent(d->window,d->context))) dial_paint_free(d); + else { + /* A destroyed window cannot safely own GL deletion calls. Its context + releases GPU allocations; release only CPU allocations here. */ + if (d->paint.atlas.permanent.alloc) nk_font_atlas_clear(&d->paint.atlas); + for (int i = 0; i < 4; ++i) free(d->paint.ranges[i]); + free(d->paint.vertices); + } + if (d->context) SDL_GL_DestroyContext(d->context); + if (d->window_id && SDL_GetWindowFromID(d->window_id)) SDL_DestroyWindow(d->window); + if (d->previous_window && d->previous_context) + SDL_GL_MakeCurrent(d->previous_window,d->previous_context); + if (labels->restore) labels->restore(labels->preview_userdata,d->result); + BongoCatMenuAction result = d->result; + free(d); + return result; +} From 23e99504943c01ea2113d27507c84ed4292132f3 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 11 Sep 2026 20:36:43 +0800 Subject: [PATCH 270/334] fix: synchronize model size before menu preview rendering --- src/runtime/lifecycle/app_loop.c | 1 + 1 file changed, 1 insertion(+) diff --git a/src/runtime/lifecycle/app_loop.c b/src/runtime/lifecycle/app_loop.c index b846a81f..9f4f57f3 100644 --- a/src/runtime/lifecycle/app_loop.c +++ b/src/runtime/lifecycle/app_loop.c @@ -36,6 +36,7 @@ static bool render(BongoCatApp *app, bool present) { return false; } app->render_retry_ns = 0; + bongo_cat_window_apply_pending_resize(app); int width, height; SDL_GetWindowSizeInPixels(app->window, &width, &height); glViewport(0, 0, width, height); From 6529ec719433e231e678eb964641da5c5d4585f2 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 11 Sep 2026 23:40:14 +0800 Subject: [PATCH 271/334] optimized the right-click menu --- include/bongo_cat/shortcut.h | 2 + src/core/config_defaults.c | 23 +------ src/core/shortcut.c | 62 +++++++++++++++++++ src/runtime/shell/modal_frame.c | 2 +- src/runtime/shell/window_menu_behavior.c | 5 +- src/runtime/shell/window_menu_behavior_test.c | 8 ++- src/ui/dial/dial_internal.h | 2 + src/ui/dial/dial_scene.c | 35 +++++++---- src/ui/dial/dial_window.c | 7 ++- src/ui/preferences/preferences.c | 6 -- src/ui/preferences/preferences_about.c | 2 +- src/ui/preferences/preferences_behavior_row.c | 5 +- src/ui/preferences/preferences_fonts.c | 17 +++-- src/ui/preferences/preferences_model_rename.c | 14 +++-- src/ui/preferences/preferences_render.c | 11 ++-- src/ui/preferences/preferences_shortcuts.c | 6 +- src/ui/preferences/preferences_state.h | 1 - .../preferences/preferences_widget_shortcut.c | 5 +- tests/core/test_config_validation.c | 40 +++++++----- tests/core/test_shortcut.c | 37 +++++++++++ tests/model_import/test_mver_import.c | 19 +++--- tests/ui/test_preferences_lifecycle.c | 31 ++++++++++ 22 files changed, 251 insertions(+), 89 deletions(-) diff --git a/include/bongo_cat/shortcut.h b/include/bongo_cat/shortcut.h index ca30a493..731fcffc 100644 --- a/include/bongo_cat/shortcut.h +++ b/include/bongo_cat/shortcut.h @@ -12,6 +12,8 @@ typedef struct BongoCatShortcutState { } BongoCatShortcutState; void bongo_cat_shortcut_init(BongoCatShortcutState *state); +/* Format UI labels without changing the stored binding. */ +void bongo_cat_shortcut_format(const char *shortcut, char *output, size_t capacity); bool bongo_cat_shortcut_update(BongoCatShortcutState *state, const BongoCatInputEvent *event); bool bongo_cat_shortcut_matches(const BongoCatShortcutState *state, const BongoCatInputEvent *event, const char *shortcut); diff --git a/src/core/config_defaults.c b/src/core/config_defaults.c index c39ecd57..59268038 100644 --- a/src/core/config_defaults.c +++ b/src/core/config_defaults.c @@ -31,8 +31,6 @@ static bool normalize_text(char *text, size_t capacity) { return true; } -static bool audio_binding(const BongoCatBehaviorShortcut *binding); - bool bongo_cat_settings_shortcut_conflicts(const BongoCatSettings *config, const char *shortcut, const char *exclude) { if (!config || !shortcut || !shortcut[0]) return false; @@ -45,27 +43,16 @@ bool bongo_cat_settings_shortcut_conflicts(const BongoCatSettings *config, size_t behavior_count = config->behavior_shortcut_count; if (behavior_count > BONGO_CAT_BEHAVIOR_BINDING_CAP) behavior_count = BONGO_CAT_BEHAVIOR_BINDING_CAP; - bool editing_audio = false, editing_behavior = false; + /* Model behaviors may share a key; global commands remain exclusive. */ for (size_t i = 0; i < behavior_count; ++i) - if (config->behavior_shortcuts[i].shortcut == exclude) { - editing_behavior = true; - editing_audio = audio_binding(&config->behavior_shortcuts[i]); - } + if (config->behavior_shortcuts[i].shortcut == exclude) return false; for (size_t i = 0; i < behavior_count; ++i) { const char *bound = config->behavior_shortcuts[i].shortcut; - if (bound != exclude && shortcut_equal(bound, shortcut) && - (!editing_behavior || (!editing_audio && - !audio_binding(&config->behavior_shortcuts[i])))) return true; + if (shortcut_equal(bound, shortcut)) return true; } return false; } -static bool audio_binding(const BongoCatBehaviorShortcut *binding) { - const char *index = strrchr(binding->id, ':'); - return index && index - binding->id >= 6 && - strncmp(index - 6, ":sound", 6) == 0; -} - static void validate_shortcuts(BongoCatSettings *config) { char *global[] = {config->shortcuts.toggle_pet_visibility, config->shortcuts.visible_preferences, config->shortcuts.mirror, @@ -81,10 +68,6 @@ static void validate_shortcuts(BongoCatSettings *config) { bool duplicate = false; for (size_t j = 0; j < sizeof(global) / sizeof(global[0]); ++j) duplicate = duplicate || shortcut_equal(shortcut, global[j]); - for (size_t j = 0; j < i; ++j) - duplicate = duplicate || (!audio_binding(&config->behavior_shortcuts[i]) && - !audio_binding(&config->behavior_shortcuts[j]) && shortcut_equal(shortcut, - config->behavior_shortcuts[j].shortcut)); if (duplicate) shortcut[0] = '\0'; } } diff --git a/src/core/shortcut.c b/src/core/shortcut.c index 3568c9ea..280ea7cf 100644 --- a/src/core/shortcut.c +++ b/src/core/shortcut.c @@ -58,6 +58,68 @@ static const char *canonical_key(const char *name, char output[16]) { return name; } +static const char *display_key(const char *token, size_t length) { + static const struct { const char *name; const char *label; } names[] = { + {"Control", "Ctrl"}, {"Ctrl", "Ctrl"}, +#if defined(_WIN32) + {"Meta", "Win"}, {"Super", "Win"}, {"Command", "Win"}, +#elif defined(__APPLE__) + {"Meta", "Cmd"}, {"Super", "Cmd"}, {"Command", "Cmd"}, +#else + {"Meta", "Super"}, {"Command", "Super"}, +#endif + {"Escape", "Esc"}, {"Delete", "Del"}, {"Insert", "Ins"}, + {"PageUp", "PgUp"}, {"PageDown", "PgDn"}, + {"PrintScreen", "PrtSc"}, {"Return", "Enter"}, + {"ArrowUp", "\xE2\x86\x91"}, {"ArrowDown", "\xE2\x86\x93"}, + {"ArrowLeft", "\xE2\x86\x90"}, {"ArrowRight", "\xE2\x86\x92"}, + {"UpArrow", "\xE2\x86\x91"}, {"DownArrow", "\xE2\x86\x93"}, + {"LeftArrow", "\xE2\x86\x90"}, {"RightArrow", "\xE2\x86\x92"}, + {"BackQuote", "`"}, {"Minus", "-"}, {"Equal", "="}, + {"BracketLeft", "["}, {"BracketRight", "]"}, + {"Backslash", "\\"}, {"Semicolon", ";"}, {"Quote", "'"}, + {"Comma", ","}, {"Period", "."}, {"Slash", "/"}, + {"Kp0", "Num 0"}, {"Kp1", "Num 1"}, {"Kp2", "Num 2"}, + {"Kp3", "Num 3"}, {"Kp4", "Num 4"}, {"Kp5", "Num 5"}, + {"Kp6", "Num 6"}, {"Kp7", "Num 7"}, {"Kp8", "Num 8"}, + {"Kp9", "Num 9"}, {"KpMultiply", "Num *"}, {"KpPlus", "Num +"}, + {"KpMinus", "Num -"}, {"KpDecimal", "Num ."}, {"KpDivide", "Num /"} + }; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) { + if (strlen(names[i].name) != length) continue; + size_t j = 0; + while (j < length && tolower((unsigned char)token[j]) == + tolower((unsigned char)names[i].name[j])) ++j; + if (j == length) return names[i].label; + } + return NULL; +} + +void bongo_cat_shortcut_format(const char *shortcut, char *output, size_t capacity) { + if (!output || !capacity) return; + output[0] = '\0'; + if (!shortcut) return; + if (strncmp(shortcut, "Gamepad:", 8) == 0) { + snprintf(output, capacity, "%s", shortcut); + return; + } + size_t used = 0; + while (*shortcut && used < capacity - 1) { + const char *plus = strchr(shortcut, '+'); + size_t length = plus ? (size_t)(plus - shortcut) : strlen(shortcut); + const char *label = display_key(shortcut, length); + const char *shown = label ? label : shortcut; + size_t count = label ? strlen(label) : length; + if (count > capacity - 1 - used) count = capacity - 1 - used; + memcpy(output + used, shown, count); + used += count; + if (!plus) break; + if (used < capacity - 1) output[used++] = '+'; + shortcut = plus + 1; + } + output[used] = '\0'; +} + static bool modifier_token(const char *token, size_t length, bool *control, bool *shift, bool *alt, bool *meta) { char value[24]; diff --git a/src/runtime/shell/modal_frame.c b/src/runtime/shell/modal_frame.c index 71cd0e53..42ec16c7 100644 --- a/src/runtime/shell/modal_frame.c +++ b/src/runtime/shell/modal_frame.c @@ -24,7 +24,7 @@ void bongo_cat_modal_frame_tick(void *userdata) { float elapsed = modal_elapsed(state, app->last_frame_ns, now); app->last_frame_ns = now; state->tick_count++; - bongo_cat_app_drain_input(app, false); + bongo_cat_app_drain_input(app, true); bongo_cat_app_update_hover(app, now); if (!app->smoke_freeze_model && elapsed > 0.0f) bongo_cat_app_step_live2d(app, elapsed); diff --git a/src/runtime/shell/window_menu_behavior.c b/src/runtime/shell/window_menu_behavior.c index cd4b3569..d6ca72cb 100644 --- a/src/runtime/shell/window_menu_behavior.c +++ b/src/runtime/shell/window_menu_behavior.c @@ -1,6 +1,7 @@ #include "window_menu.h" #include "bongo_cat/audio.h" #include "bongo_cat/i18n.h" +#include "bongo_cat/shortcut.h" #include #include @@ -68,8 +69,10 @@ static void menu_label(char output[BONGO_CAT_MENU_LABEL_CAP], const BongoCatApp *app, const BongoCatBehaviorEntry *entry) { const char *label = behavior_label(app, entry); const char *shortcut = behavior_shortcut(app, entry->id); + char shortcut_label[BONGO_CAT_SHORTCUT_CAP * 2]; + bongo_cat_shortcut_format(shortcut, shortcut_label, sizeof(shortcut_label)); if (shortcut && shortcut[0]) snprintf(output, BONGO_CAT_MENU_LABEL_CAP, - "%s - %s", label, shortcut); + "%s - %s", label, shortcut_label); else snprintf(output, BONGO_CAT_MENU_LABEL_CAP, "%s", label); } diff --git a/src/runtime/shell/window_menu_behavior_test.c b/src/runtime/shell/window_menu_behavior_test.c index 781abe7d..8ddd34ed 100644 --- a/src/runtime/shell/window_menu_behavior_test.c +++ b/src/runtime/shell/window_menu_behavior_test.c @@ -1,4 +1,5 @@ #include "window_menu.h" +#include "bongo_cat/shortcut.h" #include #include @@ -170,8 +171,11 @@ bool bongo_cat_window_behavior_self_test(BongoCatApp *app) { passed = motion_count > 0 && checked[0] && passed; bool initial_passed = passed; const char *first_shortcut = test_behavior_shortcut(app, first->id); - if (first_shortcut && first_shortcut[0]) - passed = strstr(motions[0], first_shortcut) != NULL && passed; + if (first_shortcut && first_shortcut[0]) { + char shortcut_label[BONGO_CAT_SHORTCUT_CAP * 2]; + bongo_cat_shortcut_format(first_shortcut, shortcut_label, sizeof(shortcut_label)); + passed = strstr(motions[0], shortcut_label) != NULL && passed; + } memcpy(before, checked, motion_count * sizeof(before[0])); size_t preview_position = test_nth_behavior(app, BONGO_CAT_BEHAVIOR_MOTION, 1) ? 1 : 0; diff --git a/src/ui/dial/dial_internal.h b/src/ui/dial/dial_internal.h index 17da0753..79c69f3c 100644 --- a/src/ui/dial/dial_internal.h +++ b/src/ui/dial/dial_internal.h @@ -8,6 +8,8 @@ #define DIAL_PI 3.14159265358979323846f #define DIAL_PAGE 16 #define DIAL_ROOTS 12 +#define DIAL_REVEAL_DELAY_MS 22 +#define DIAL_REVEAL_DURATION_MS 360 #define DIAL_PATH_POINTS 160 typedef struct DialPoint { float x, y; } DialPoint; typedef struct DialPath { diff --git a/src/ui/dial/dial_scene.c b/src/ui/dial/dial_scene.c index bce62f8b..7941d07a 100644 --- a/src/ui/dial/dial_scene.c +++ b/src/ui/dial/dial_scene.c @@ -1,6 +1,12 @@ #include "dial_internal.h" #include +static float reveal(uint64_t now, uint64_t started, int index) { + float elapsed = (float)(now - started) - index * DIAL_REVEAL_DELAY_MS; + float t = fmaxf(0, fminf(1, elapsed / DIAL_REVEAL_DURATION_MS)); + return 1 - powf(1 - t, 3); +} + static uint32_t alpha(uint32_t color, float opacity) { return (color&0xffffff) | ((uint32_t)((float)(color>>24)*opacity)<<24); } @@ -52,26 +58,29 @@ static int item_icon(DialItem item) { (item.checked && item.icon == 3 ? 13 : item.icon); } -static void root(Dial *d, int index) { +static void root(Dial *d, int index, uint64_t now) { DialItem item = d->items[index]; float angle = -DIAL_PI / 2 + index * 2 * DIAL_PI / d->count; float x = 132 * cosf(angle), y = 132 * sinf(angle); - float lift = d->lift[index], zoom = 1 + .055f * lift; - float pull = 7.5f * lift; - if (d->pressed == index) { zoom = .96f; pull *= .4f; } + float ease = reveal(now, d->opened_at, index); + /* Keep the first sector hittable on transparent native windows. */ + if (index == 0) ease = fmaxf(.15f, ease); + float lift = d->lift[index], zoom = .72f + .28f * ease + .055f * lift; + float pull = -26 * (1 - ease) + 7.5f * lift; + if (d->pressed == index) { zoom *= .96f; pull *= .4f; } dial_transform(d, zoom, x * (1 - zoom) + cosf(angle) * pull, y * (1 - zoom) + sinf(angle) * pull); bool active = index == d->active; - float opacity = 1.0f; + float opacity = ease; int tint = active ? (index >= 10 ? 3 : 1) : 0; surface(d, &d->paint.roots[index], tint, opacity); uint32_t color = active ? 0xffffffff : (item.checked ? 0xfff77daa : item.color); if (index >= 6 && index <= 8 && !item.children) opacity *= .4f; dial_icon(d, item_icon(item), x, y, 28 * (1 + .15f * lift), alpha(color, opacity)); - if (item.checked) { + if (item.checked && item.command != BONGO_CAT_MENU_ALWAYS_ON_TOP) { for (int i = 4; i > 0; --i) - dial_dot(d, x + 17, y - 17, 3.2f + i * 1.5f, 0x0af77daa); - dial_dot(d, x + 17, y - 17, 3.2f, 0xfff77daa); + dial_dot(d, x + 17, y - 17, 3.2f + i * 1.5f, alpha(0x0af77daa, ease)); + dial_dot(d, x + 17, y - 17, 3.2f, alpha(0xfff77daa, ease)); } } @@ -96,9 +105,8 @@ static void center(Dial *d) { static void children(Dial *d, uint64_t now) { for (int i = 0; i < dial_child_count(d); ++i) { - float elapsed = (float)(now - d->changed_at) - i * 22; - float t = fmaxf(0, fminf(1, elapsed / 360)); - float ease = 1 - powf(1 - t, 3); + /* Child indices increase with screen angle, so this reveals clockwise. */ + float ease = reveal(now, d->changed_at, i); float angle = dial_child_angle(d, i), x = 230 * cosf(angle), y = 230 * sinf(angle); bool hover = i == d->child; float zoom = .72f + .28f * ease + (hover ? .06f : 0); @@ -117,8 +125,9 @@ static void children(Dial *d, uint64_t now) { } void dial_scene(Dial *d) { + uint64_t now = SDL_GetTicks(); for (int i = 0; i < d->count; ++i) - if (!d->child_focus || i == d->active) root(d,i); + if (!d->child_focus || i == d->active) root(d,i,now); center(d); - children(d,SDL_GetTicks()); + children(d,now); } diff --git a/src/ui/dial/dial_window.c b/src/ui/dial/dial_window.c index 735544a7..11e6d9e7 100644 --- a/src/ui/dial/dial_window.c +++ b/src/ui/dial/dial_window.c @@ -47,8 +47,11 @@ static void animate(Dial *d, uint64_t now) { if (next != d->lift[i]) { d->lift[i] = next; d->dirty = true; } } if (dial_child_count(d) && now-d->changed_at <= - (uint64_t)(360+(dial_child_count(d)-1)*22+16)) d->dirty = true; - if (now-d->opened_at <= 196) d->dirty = true; + (uint64_t)(DIAL_REVEAL_DURATION_MS + + (dial_child_count(d)-1)*DIAL_REVEAL_DELAY_MS + 16)) + d->dirty = true; + if (now-d->opened_at <= (uint64_t)(DIAL_REVEAL_DURATION_MS + + (d->count-1)*DIAL_REVEAL_DELAY_MS + 16)) d->dirty = true; } static bool create(Dial *d) { diff --git a/src/ui/preferences/preferences.c b/src/ui/preferences/preferences.c index 732b5290..4f0780c7 100644 --- a/src/ui/preferences/preferences.c +++ b/src/ui/preferences/preferences.c @@ -46,7 +46,6 @@ BongoCatPreferences *bongo_cat_preferences_create(BongoCatApp *app) { if (!value->import_dialog) { free(value); return NULL; } } if (value && app->smoke_preference_page >= 0) value->page = app->smoke_preference_page; - if (value) value->model_glyphs_loaded = value->page == 1; return value; } @@ -262,11 +261,6 @@ void bongo_cat_preferences_models_changed(BongoCatPreferences *value) { if (!value) return; value->render_dirty = true; value->model_directory_watch_known = false; - if (value->page != 1) { - value->model_glyphs_loaded = false; - return; - } - value->model_glyphs_loaded = true; value->font_reload_pending = value->ui_initialized; /* Catalog refreshes finish between UI frames. Rebuild before drawing the new cards so names never appear with missing-glyph placeholders. */ diff --git a/src/ui/preferences/preferences_about.c b/src/ui/preferences/preferences_about.c index 9c31e617..a43bd1bc 100644 --- a/src/ui/preferences/preferences_about.c +++ b/src/ui/preferences/preferences_about.c @@ -229,6 +229,6 @@ void bongo_cat_preferences_about_projects(BongoCatPreferences *value, value->catime_height, "Catime", "https://cati.me/", false, p); project(value, context, canvas, nk_rect(center + 42, bounds.y + 112, card_width, 200), value->vlaina_texture, value->vlaina_width, - value->vlaina_height, "vlaina", "https://vlaina.com", + value->vlaina_height, "vlaina", "https://vlaina.com/r/bongocat", true, p); } diff --git a/src/ui/preferences/preferences_behavior_row.c b/src/ui/preferences/preferences_behavior_row.c index 0d210b31..13945670 100644 --- a/src/ui/preferences/preferences_behavior_row.c +++ b/src/ui/preferences/preferences_behavior_row.c @@ -1,5 +1,6 @@ #include "preferences_state.h" #include "bongo_cat/audio.h" +#include "bongo_cat/shortcut.h" #include "preferences_overlay.h" #include "preferences_shortcut_clear.h" #include "ui_animation.h" @@ -67,9 +68,11 @@ static bool shortcut_editor(BongoCatPreferences *value, (hover ? p.hover : p.field), opacity)); nk_stroke_rect(canvas, bounds, 10, 1, alpha(active ? p.pink : (hover ? p.accent : p.border_subtle), opacity)); + char shortcut_label[BONGO_CAT_SHORTCUT_CAP * 2]; + bongo_cat_shortcut_format(shortcut->shortcut, shortcut_label, sizeof(shortcut_label)); const char *shown = active ? bongo_cat_i18n_get(value->app->i18n, "components.shortcut.hints.pressRecordShortcut", "Press shortcut") : - (shortcut->shortcut[0] ? shortcut->shortcut : + (shortcut->shortcut[0] ? shortcut_label : bongo_cat_i18n_get(value->app->i18n, "components.shortcut.hints.clickRecordShortcut", "Click to record shortcut")); diff --git a/src/ui/preferences/preferences_fonts.c b/src/ui/preferences/preferences_fonts.c index 03ff71e0..9d550c34 100644 --- a/src/ui/preferences/preferences_fonts.c +++ b/src/ui/preferences/preferences_fonts.c @@ -33,13 +33,18 @@ void bongo_cat_preferences_fonts_resolve(BongoCatPreferences *value, if (!fonts->heading_fallback) fonts->heading_fallback = fonts->body_fallback; if (!value->app->i18n) return; - bongo_cat_i18n_glyph_ranges(value->app->i18n, value->glyph_ranges, + size_t range_capacity = sizeof(value->glyph_ranges) / sizeof(value->glyph_ranges[0]); + size_t range_count = bongo_cat_i18n_glyph_ranges(value->app->i18n, + value->glyph_ranges, range_capacity - 2); + /* Shortcut arrows must be available even before a binding is recorded. */ + if (range_count && range_count + 2 <= range_capacity) { + value->glyph_ranges[range_count - 1] = 0x2190; + value->glyph_ranges[range_count] = 0x2193; + value->glyph_ranges[range_count + 1] = 0; + } + /* Include label glyphs in the initial atlas so page switches need no bake. */ + bongo_cat_preferences_model_glyphs(value->app, value->glyph_ranges, sizeof(value->glyph_ranges) / sizeof(value->glyph_ranges[0])); - /* Model and behavior labels can contain hundreds of otherwise unused - glyphs. Bake them only for the pages that can display those labels. */ - if (value->model_glyphs_loaded) - bongo_cat_preferences_model_glyphs(value->app, value->glyph_ranges, - sizeof(value->glyph_ranges) / sizeof(value->glyph_ranges[0])); fonts->ranges = value->glyph_ranges; } diff --git a/src/ui/preferences/preferences_model_rename.c b/src/ui/preferences/preferences_model_rename.c index ce6233d0..998278f9 100644 --- a/src/ui/preferences/preferences_model_rename.c +++ b/src/ui/preferences/preferences_model_rename.c @@ -21,11 +21,13 @@ bool bongo_cat_preferences_model_name_draw(BongoCatPreferences *value, } const char *shown = renaming ? session->text : bongo_cat_model_name(&value->app->settings, entry); - if (!renaming && value->import_render_active && - value->font_reload_pending && - !bongo_cat_preferences_model_glyphs_ready(value, shown)) - shown = bongo_cat_i18n_get(value->app->i18n, "native.importing", - "Importing..."); + /* Page changes and catalog refreshes can draw before the atlas reload. */ + bool name_pending = !renaming && value->font_reload_pending && + !bongo_cat_preferences_model_glyphs_ready(value, shown); + if (name_pending) + shown = value->import_render_active ? + bongo_cat_i18n_get(value->app->i18n, "native.importing", + "Importing...") : "..."; struct nk_rect text_bounds = nk_rect(bounds.x + 5, bounds.y + 3, bounds.w - 10, 22); if (renaming && session->select_all) @@ -42,7 +44,7 @@ bool bongo_cat_preferences_model_name_draw(BongoCatPreferences *value, if (!session->select_all) nk_stroke_line(canvas, bounds.x + 5 + caret, bounds.y + 3, bounds.x + 5 + caret, bounds.y + bounds.h - 3, 1, p.pink); - } else if (clicked) + } else if (clicked && !name_pending) bongo_cat_preferences_model_rename_begin(value, entry, bounds); if (hover || renaming) bongo_cat_ui_cursor_hover_rect(context, bounds, BONGO_CAT_UI_CURSOR_TEXT); diff --git a/src/ui/preferences/preferences_render.c b/src/ui/preferences/preferences_render.c index afd2ac8c..7fa663d9 100644 --- a/src/ui/preferences/preferences_render.c +++ b/src/ui/preferences/preferences_render.c @@ -95,15 +95,10 @@ static bool draw_shell(BongoCatPreferences *value, struct nk_context *context, if (!value->page_seen) { value->page_seen = true; value->last_page = value->page; } else if (value->last_page != value->page) { - bool load_model_glyphs = value->page == 1 && !value->model_glyphs_loaded; bongo_cat_preferences_page_cache_clear(value, value->last_page, value->page); value->last_page = value->page; value->page_transition_ns = SDL_GetTicksNS(); - if (load_model_glyphs) { - value->model_glyphs_loaded = true; value->font_reload_pending = true; - value->font_reload_defer_once = true; - } value->render_dirty = true; } nk_group_end(context); @@ -114,6 +109,12 @@ static bool draw_shell(BongoCatPreferences *value, struct nk_context *context, close_requested = bongo_cat_ui_content_header(context, menus[value->page], menu_icons[value->page], !modal, dark, native_chrome); + if (close_requested) { + /* Scrolled cards can overlap the header's hit area despite clipping. */ + nk_group_end(context); + nk_layout_row_end(context); + return true; + } float body_height = interior_height - BONGO_CAT_UI_HEADER_HEIGHT; nk_layout_row_dynamic(context, NK_MAX(120.0f, body_height), 1); struct nk_rect body_bounds = nk_widget_bounds(context); diff --git a/src/ui/preferences/preferences_shortcuts.c b/src/ui/preferences/preferences_shortcuts.c index f342ed87..0a4b2b8a 100644 --- a/src/ui/preferences/preferences_shortcuts.c +++ b/src/ui/preferences/preferences_shortcuts.c @@ -77,9 +77,13 @@ static const char *primary_name(SDL_Keycode key, char output[24]) { if (key >= SDLK_0 && key <= SDLK_9) { output[0] = (char)('0' + key - SDLK_0); output[1] = '\0'; return output; } - if (key >= SDLK_F1 && key <= SDLK_F24) { + /* SDL places navigation and keypad keys between F12 and F13. */ + if (key >= SDLK_F1 && key <= SDLK_F12) { snprintf(output, 24, "F%d", (int)(key - SDLK_F1 + 1)); return output; } + if (key >= SDLK_F13 && key <= SDLK_F24) { + snprintf(output, 24, "F%d", (int)(key - SDLK_F13 + 13)); return output; + } switch (key) { case SDLK_RETURN: case SDLK_KP_ENTER: return "Enter"; case SDLK_TAB: return "Tab"; case SDLK_SPACE: return "Space"; diff --git a/src/ui/preferences/preferences_state.h b/src/ui/preferences/preferences_state.h index dce31914..cf8c6a13 100644 --- a/src/ui/preferences/preferences_state.h +++ b/src/ui/preferences/preferences_state.h @@ -57,7 +57,6 @@ struct BongoCatPreferences { bool font_reload_defer_once; size_t pending_import_name_count; char pending_import_names[BONGO_CAT_MODEL_CAP][BONGO_CAT_ID_CAP]; - bool model_glyphs_loaded; bool model_directory_watch_active; bool model_directory_watch_known; bool model_directory_watch_refresh; diff --git a/src/ui/preferences/preferences_widget_shortcut.c b/src/ui/preferences/preferences_widget_shortcut.c index 0df396d6..f1139f58 100644 --- a/src/ui/preferences/preferences_widget_shortcut.c +++ b/src/ui/preferences/preferences_widget_shortcut.c @@ -1,4 +1,5 @@ #include "preferences_widgets.h" +#include "bongo_cat/shortcut.h" #include "preferences_widgets_internal.h" #include "preferences_shortcut_clear.h" #include "ui_animation.h" @@ -16,8 +17,10 @@ int bongo_cat_pref_edit(struct nk_context *context, const char *id, bool recording, const char *idle_hint, const char *record_hint) { int lines = bongo_cat_pref_detail_lines(context, detail); FormStyle saved; if (!bongo_cat_pref_form_begin(context, id, lines, &saved)) return false; + char shortcut_label[BONGO_CAT_SHORTCUT_CAP * 2]; + bongo_cat_shortcut_format(value, shortcut_label, sizeof(shortcut_label)); const char *shown = recording ? record_hint : - (value && value[0] ? value : idle_hint); + (value && value[0] ? shortcut_label : idle_hint); const struct nk_user_font *font = bongo_cat_ui_body_font(context); float width = font->width(font->userdata, font->height, shown, nk_strlen(shown)); diff --git a/tests/core/test_config_validation.c b/tests/core/test_config_validation.c index f333d3ea..34fdd721 100644 --- a/tests/core/test_config_validation.c +++ b/tests/core/test_config_validation.c @@ -116,34 +116,44 @@ static void check_shortcuts(void) { settings.shortcuts.toggle_pet_visibility)); } -static void check_audio_companions(void) { +static void check_behavior_companions(void) { static BongoCatSettings settings, loaded; bongo_cat_settings_defaults(&settings); - settings.behavior_shortcut_count = 3; - const char *ids[] = {"lucia:motion:CAT_motion_lock:14", "lucia:sound:0", "lucia:sound:3"}; - for (size_t i = 0; i < 3; ++i) + const char *ids[] = {"lucia:motion:CAT_motion_lock:14", "lucia:sound:0", + "lucia:expression:0", "lucia:motion:Tap:0", "lucia:sound:3"}; + settings.behavior_shortcut_count = sizeof(ids) / sizeof(ids[0]); + for (size_t i = 0; i < settings.behavior_shortcut_count; ++i) snprintf(settings.behavior_shortcuts[i].id, sizeof(settings.behavior_shortcuts[i].id), "%s", ids[i]); - snprintf(settings.behavior_shortcuts[0].shortcut, BONGO_CAT_SHORTCUT_CAP, "Alt+O"); - snprintf(settings.behavior_shortcuts[1].shortcut, BONGO_CAT_SHORTCUT_CAP, "Alt+O"); - settings.behavior_shortcuts[2].shortcut_disabled = true; + for (size_t i = 0; i < 4; ++i) + snprintf(settings.behavior_shortcuts[i].shortcut, BONGO_CAT_SHORTCUT_CAP, "Alt+O"); + settings.behavior_shortcuts[4].shortcut_disabled = true; + snprintf(settings.shortcuts.mirror, BONGO_CAT_SHORTCUT_CAP, "Control+M"); + for (size_t i = 0; i < 4; ++i) { + CHECK(!bongo_cat_settings_shortcut_conflicts(&settings, "alt+o", + settings.behavior_shortcuts[i].shortcut)); + CHECK(bongo_cat_settings_shortcut_conflicts(&settings, "Control+M", + settings.behavior_shortcuts[i].shortcut)); + } bongo_cat_settings_validate(&settings); - CHECK(settings.behavior_shortcut_count == 3); - CHECK(!strcmp(settings.behavior_shortcuts[1].shortcut, "Alt+O")); - CHECK(!bongo_cat_settings_shortcut_conflicts(&settings, "Alt+O", settings.behavior_shortcuts[1].shortcut)); - CHECK(!bongo_cat_settings_shortcut_conflicts(&settings, "Alt+O", settings.behavior_shortcuts[0].shortcut)); + CHECK(settings.behavior_shortcut_count == 5); + for (size_t i = 0; i < 4; ++i) + CHECK(!strcmp(settings.behavior_shortcuts[i].shortcut, "Alt+O")); CHECK(bongo_cat_settings_shortcut_conflicts(&settings, "Alt+O", settings.shortcuts.mirror)); CHECK(bongo_cat_settings_save("bongocat-audio-config-test.json", &settings, NULL) == BONGO_CAT_OK); bongo_cat_settings_defaults(&loaded); CHECK(bongo_cat_settings_load("bongocat-audio-config-test.json", &loaded, NULL) == BONGO_CAT_OK); - CHECK(loaded.behavior_shortcut_count == 3); - CHECK(!strcmp(loaded.behavior_shortcuts[1].shortcut, "Alt+O")); - CHECK(loaded.behavior_shortcuts[2].shortcut_disabled); + CHECK(loaded.behavior_shortcut_count == 5); + for (size_t i = 0; i < 4; ++i) { + CHECK(!strcmp(loaded.behavior_shortcuts[i].id, ids[i])); + CHECK(!strcmp(loaded.behavior_shortcuts[i].shortcut, "Alt+O")); + } + CHECK(loaded.behavior_shortcuts[4].shortcut_disabled); CHECK(bongo_cat_file_remove("bongocat-audio-config-test.json")); } void test_config_validation(void) { check_defaults_and_validation(); - check_audio_companions(); + check_behavior_companions(); check_shortcuts(); check_override_canonicalization(); } diff --git a/tests/core/test_shortcut.c b/tests/core/test_shortcut.c index 6a0f9115..44ce49c6 100644 --- a/tests/core/test_shortcut.c +++ b/tests/core/test_shortcut.c @@ -10,6 +10,43 @@ static BongoCatInputEvent key(BongoCatInputKind kind, const char *name) { } void test_shortcut(void) { + static const struct { const char *stored; const char *shown; } labels[] = { + {"Control+A", "Ctrl+A"}, {"Ctrl+Shift+A", "Ctrl+Shift+A"}, + {"control+Comma", "Ctrl+,"}, {"Alt+PageUp", "Alt+PgUp"}, + {"Control+PageDown", "Ctrl+PgDn"}, {"PrintScreen", "PrtSc"}, + {"Escape", "Esc"}, {"Delete", "Del"}, {"Insert", "Ins"}, + {"Control+BracketLeft", "Ctrl+["}, {"Alt+Backslash", "Alt+\\"}, + {"Control+ArrowLeft", "Ctrl+\xE2\x86\x90"}, + {"ArrowUp", "\xE2\x86\x91"}, {"ArrowDown", "\xE2\x86\x93"}, + {"ArrowRight", "\xE2\x86\x92"}, + {"UpArrow", "\xE2\x86\x91"}, {"DownArrow", "\xE2\x86\x93"}, + {"LeftArrow", "\xE2\x86\x90"}, {"RightArrow", "\xE2\x86\x92"}, + {"Shift+KpPlus", "Shift+Num +"}, + {"F12", "F12"}, {"UnknownKey", "UnknownKey"}, + {"Gamepad:South", "Gamepad:South"}, {"", ""}, {NULL, ""}, +#if defined(_WIN32) + {"Meta+A", "Win+A"}, {"Super+A", "Win+A"}, +#elif defined(__APPLE__) + {"Meta+A", "Cmd+A"}, {"Command+A", "Cmd+A"}, +#else + {"Meta+A", "Super+A"}, +#endif + }; + char shown[BONGO_CAT_SHORTCUT_CAP * 2]; + for (size_t i = 0; i < sizeof(labels) / sizeof(labels[0]); ++i) { + bongo_cat_shortcut_format(labels[i].stored, shown, sizeof(shown)); + CHECK(strcmp(shown, labels[i].shown) == 0); + } + char small[5]; + bongo_cat_shortcut_format("Control+A", small, sizeof(small)); + CHECK(strcmp(small, "Ctrl") == 0); + bongo_cat_shortcut_format("Control+A", small, 1); + CHECK(small[0] == '\0'); + small[0] = 'x'; + bongo_cat_shortcut_format("Control+A", small, 0); + CHECK(small[0] == 'x'); + bongo_cat_shortcut_format("Control+A", NULL, 0); + BongoCatShortcutState state; bongo_cat_shortcut_init(&state); BongoCatInputEvent control = key(BONGO_CAT_INPUT_KEY_DOWN, "ControlLeft"); diff --git a/tests/model_import/test_mver_import.c b/tests/model_import/test_mver_import.c index e1c7d849..574fa00a 100644 --- a/tests/model_import/test_mver_import.c +++ b/tests/model_import/test_mver_import.c @@ -161,13 +161,18 @@ static void font_reload_defers_during_frame(void) { } static void model_change_reloads_glyphs_before_drawing(void) { - BongoCatPreferences value = {0}; - value.page = 1; - value.ui_initialized = true; - value.font_reload_defer_once = true; - bongo_cat_preferences_models_changed(&value); - CHECK(value.render_dirty && value.model_glyphs_loaded); - CHECK(value.font_reload_pending && !value.font_reload_defer_once); + for (int page = 0; page < 4; ++page) { + BongoCatPreferences value = {0}; + value.page = page; + value.ui_initialized = true; + value.font_reload_defer_once = true; + bongo_cat_preferences_models_changed(&value); + CHECK(value.render_dirty); + CHECK(value.font_reload_pending && !value.font_reload_defer_once); + } + BongoCatPreferences unopened = {0}; + bongo_cat_preferences_models_changed(&unopened); + CHECK(unopened.render_dirty && !unopened.font_reload_pending); } static void model_visual_expires_without_window(void) { diff --git a/tests/ui/test_preferences_lifecycle.c b/tests/ui/test_preferences_lifecycle.c index 21f844e4..fe63d503 100644 --- a/tests/ui/test_preferences_lifecycle.c +++ b/tests/ui/test_preferences_lifecycle.c @@ -1,5 +1,7 @@ #include "preferences_state.h" #include "preferences_import_internal.h" +#include "preferences_render_internal.h" +#include "ui_catime.h" #include "runtime.h" #include @@ -17,6 +19,34 @@ static void busy(BongoCatImportDialog *dialog, bool open, bool importing) { SDL_UnlockMutex(dialog->mutex); } +static void close_over_scrolled_models(BongoCatPreferences *value) { + if (bongo_cat_ui_native_chrome()) return; + struct nk_context *context = &value->ui.context; + value->page = 1; + value->page_seen = true; + value->last_page = 1; + value->page_transition_ns = 0; + for (int scroll = 0; scroll <= 800; scroll += 40) { + nk_input_begin(context); + nk_input_button(context, NK_BUTTON_LEFT, 746, 36, nk_false); + nk_input_end(context); + bongo_cat_preferences_draw_frame(value, 800, 500, false); + nk_clear(context); + value->scroll_ready[1] = true; + value->scroll_current[1] = (float)scroll; + value->scroll_target[1] = (float)scroll; + nk_input_begin(context); + nk_input_motion(context, 746, 36); + nk_input_button(context, NK_BUTTON_LEFT, 746, 36, nk_true); + nk_input_button(context, NK_BUTTON_LEFT, 746, 36, nk_false); + nk_input_end(context); + CHECK(bongo_cat_preferences_draw_frame(value, 800, 500, false)); + CHECK(!value->model_selection_pending); + value->model_selection_pending = false; + nk_clear(context); + } +} + int main(int argc, char **argv) { BongoCatApp *app = calloc(1, sizeof(*app)); BongoCatError error = {0}; @@ -59,6 +89,7 @@ int main(int argc, char **argv) { CHECK(!value->window && !value->gl_context && !value->ui_initialized); bongo_cat_preferences_show(value); CHECK(value->visible && value->ui_initialized); + close_over_scrolled_models(value); bongo_cat_preferences_close(value); } bongo_cat_app_shutdown(app, "test:complete", failures != 0); From 5f5e52287a9a85f9d1ee046cc6542d08d2e8cf24 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 12 Sep 2026 00:33:32 +0800 Subject: [PATCH 272/334] feat: optimiized some performanc issues --- src/runtime/input/shortcuts.c | 12 +++ src/runtime/lifecycle/app_input.c | 13 +++- src/runtime/shell/window_geometry.c | 14 +++- src/runtime/shell/window_wheel.c | 8 +- src/runtime/shell/window_wheel_test.c | 14 +++- src/ui/backend/ui_input.c | 25 +++--- src/ui/dial/dial_fonts.c | 30 +++++-- src/ui/dial/dial_geometry.c | 14 +++- src/ui/dial/dial_internal.h | 2 + src/ui/dial/dial_window.c | 3 + src/ui/preferences/preferences.c | 1 + src/ui/rendering/ui_font_atlas.c | 108 +++++++++++++------------- tests/ui/test_preferences_text.c | 46 +++++++++++ 13 files changed, 205 insertions(+), 85 deletions(-) diff --git a/src/runtime/input/shortcuts.c b/src/runtime/input/shortcuts.c index 8197d605..6582a217 100644 --- a/src/runtime/input/shortcuts.c +++ b/src/runtime/input/shortcuts.c @@ -144,5 +144,17 @@ bool bongo_cat_app_shortcuts_self_test(BongoCatApp *app) { app->settings.window.pass_through && app->settings.window.always_on_top && bongo_cat_preferences_visible(app->preferences); bongo_cat_preferences_close(app->preferences); + bool ignored = app->smoke_ignore_global_input; + app->smoke_ignore_global_input = false; + test_key(app, BONGO_CAT_INPUT_KEY_DOWN, "ControlLeft"); + BongoCatInputEvent release = {.kind = BONGO_CAT_INPUT_KEY_UP}; + snprintf(release.name, sizeof(release.name), "ControlLeft"); + bongo_cat_input_push(&app->input, &release); + bongo_cat_app_drain_input(app, false); + bool mirror = app->settings.model.mirror; + test_press(app, "KeyM"); + result = result && !app->shortcut_state.control && + app->settings.model.mirror == mirror; + app->smoke_ignore_global_input = ignored; return result; } diff --git a/src/runtime/lifecycle/app_input.c b/src/runtime/lifecycle/app_input.c index 3fa4243b..eb96c6ba 100644 --- a/src/runtime/lifecycle/app_input.c +++ b/src/runtime/lifecycle/app_input.c @@ -1,5 +1,6 @@ #include "runtime.h" #include "bongo_cat/preferences.h" +#include "bongo_cat/shortcut.h" #include @@ -13,9 +14,14 @@ void bongo_cat_app_drain_input(BongoCatApp *app, bool allow_shortcuts) { if (allow_shortcuts && !bongo_cat_preferences_shortcuts_blocked(app->preferences)) bongo_cat_app_shortcuts(app, &event); - else if (app->sound_shortcut_state.count) { - app->sound_shortcut_state.count = 0; - memset(app->sound_shortcut_active, 0, sizeof(app->sound_shortcut_active)); + else { + /* Suppress actions, not key transitions: releases may arrive + while a shortcut is being recorded or a modal menu is open. */ + bongo_cat_shortcut_update(&app->shortcut_state, &event); + if (app->sound_shortcut_state.count) { + app->sound_shortcut_state.count = 0; + memset(app->sound_shortcut_active, 0, sizeof(app->sound_shortcut_active)); + } } bongo_cat_app_apply_input(app, &event); } @@ -24,6 +30,7 @@ void bongo_cat_app_drain_input(BongoCatApp *app, bool allow_shortcuts) { if (allow_shortcuts && !bongo_cat_preferences_shortcuts_blocked(app->preferences)) bongo_cat_app_shortcuts(app, &event); + else bongo_cat_shortcut_update(&app->shortcut_state, &event); bongo_cat_app_apply_input(app, &event); } if (!app->smoke_ignore_global_input) bongo_cat_app_apply_mouse(app); diff --git a/src/runtime/shell/window_geometry.c b/src/runtime/shell/window_geometry.c index 0f166590..7d34092f 100644 --- a/src/runtime/shell/window_geometry.c +++ b/src/runtime/shell/window_geometry.c @@ -118,8 +118,11 @@ bool bongo_cat_window_set_scale(BongoCatApp *app, float scale) { } void bongo_cat_window_resize_by_pointer(BongoCatApp *app, const SDL_Event *event) { - bool shift = (SDL_GetModState() & SDL_KMOD_SHIFT) != 0 || - bongo_cat_input_shift_down(&app->input); + bool shift = bongo_cat_input_shift_down(&app->input); +#ifndef _WIN32 + shift = shift || (SDL_GetModState() & SDL_KMOD_SHIFT) != 0; +#endif + /* Match wheel handling: SDL may retain modifiers without keyboard focus. */ if (!(event->motion.state & SDL_BUTTON_RMASK) || !shift) return; bongo_cat_window_cancel_wheel_animation(app); if (!app->resize_gesture) { @@ -247,6 +250,13 @@ bool bongo_cat_window_geometry_self_test(BongoCatApp *app) { bongo_cat_input_push(&app->input, &shift); bongo_cat_input_pop(&app->input, &discarded); app->resize_gesture = false; +#ifdef _WIN32 + SDL_SetModState(old_modifiers | SDL_KMOD_SHIFT); + float released_scale = app->session.window.scale_percent; + bongo_cat_window_resize_by_pointer(app, &motion); + gesture = gesture && !app->resize_gesture && + app->session.window.scale_percent == released_scale; +#endif SDL_SetModState(old_modifiers); bongo_cat_window_apply_geometry(app, original_x, original_y, state_backup.scale_percent, original_width, original_height); diff --git a/src/runtime/shell/window_wheel.c b/src/runtime/shell/window_wheel.c index 57134fb4..0ad42dc5 100644 --- a/src/runtime/shell/window_wheel.c +++ b/src/runtime/shell/window_wheel.c @@ -90,8 +90,12 @@ void bongo_cat_window_wheel(BongoCatApp *app, const SDL_MouseWheelEvent *event) app->wheel_gesture_active = true; float old_opacity_target = app->wheel_opacity_target; float old_scale_target = app->wheel_scale_target; - bool control = (SDL_GetModState() & SDL_KMOD_CTRL) != 0 || - bongo_cat_input_control_down(&app->input); + bool control = bongo_cat_input_control_down(&app->input); +#ifndef _WIN32 + control = control || (SDL_GetModState() & SDL_KMOD_CTRL) != 0; +#endif + /* Windows Raw Input tracks releases without keyboard focus; SDL's + cached modifiers can retain Ctrl after it has been released. */ if (!control) { float minimum = SDL_max(10.0f, app->session.window.scale_percent - WHEEL_SCALE_TARGET_LEAD); diff --git a/src/runtime/shell/window_wheel_test.c b/src/runtime/shell/window_wheel_test.c index e6ee9c80..d4924d88 100644 --- a/src/runtime/shell/window_wheel_test.c +++ b/src/runtime/shell/window_wheel_test.c @@ -10,6 +10,8 @@ bool bongo_cat_window_wheel_self_test(BongoCatApp *app) { SDL_GetWindowPosition(app->window, &original_x, &original_y); SDL_GetWindowSize(app->window, &original_width, &original_height); SDL_Keymod modifiers = SDL_GetModState(); + uint_fast8_t original_control = atomic_load(&app->input.control); + atomic_store(&app->input.control, 1); SDL_SetModState(modifiers | SDL_KMOD_CTRL); app->session.window.opacity_percent = 80.0f; app->session.window.scale_percent = 100.0f; @@ -31,7 +33,11 @@ bool bongo_cat_window_wheel_self_test(BongoCatApp *app) { bongo_cat_window_update_wheel_animation(app, started + i * 16666667ull); bool opacity = SDL_fabsf( app->session.window.opacity_percent - 75.0f) < 0.1f; + atomic_store(&app->input.control, 0); +#ifndef _WIN32 SDL_SetModState(modifiers & ~SDL_KMOD_CTRL); +#endif + /* On Windows leave SDL Ctrl set to reproduce a stale unfocused cache. */ wheel.y = 1.0f; bongo_cat_window_wheel(app, &wheel); started = app->wheel_animation_ns; @@ -41,7 +47,9 @@ bool bongo_cat_window_wheel_self_test(BongoCatApp *app) { app->session.window.scale_percent - 105.0f) < 0.1f; bongo_cat_window_update_wheel_animation(app, app->wheel_input_ns + BONGO_CAT_WHEEL_GESTURE_IDLE_NS + 1); - bool scale = responsive && !app->wheel_animation_active; + bool scale = responsive && !app->wheel_animation_active && + SDL_fabsf(app->session.window.opacity_percent - 75.0f) < 0.1f; + SDL_SetModState(modifiers & ~SDL_KMOD_CTRL); bongo_cat_window_cancel_wheel_animation(app); bongo_cat_window_apply_geometry(app, original_x, original_y, 100.0f, original_width, original_height); @@ -87,7 +95,8 @@ bool bongo_cat_window_wheel_self_test(BongoCatApp *app) { bongo_cat_window_wheel(app, &wheel); bool minimum = !app->wheel_animation_active; bongo_cat_window_cancel_wheel_animation(app); - SDL_SetModState(modifiers | SDL_KMOD_CTRL); + /* Right Ctrl must work even when SDL has not received the key press. */ + atomic_store(&app->input.control, 2); app->session.window.opacity_percent = 100.0f; wheel.y = 1.0f; bongo_cat_window_wheel(app, &wheel); @@ -100,6 +109,7 @@ bool bongo_cat_window_wheel_self_test(BongoCatApp *app) { bool rounding = bongo_cat_window_wheel_round_position(-10.6f) == -11 && bongo_cat_window_wheel_round_position(10.6f) == 11; SDL_SetModState(modifiers); + atomic_store(&app->input.control, original_control); app->session.window = backup; bongo_cat_window_cancel_wheel_animation(app); bongo_cat_platform_set_opacity(&app->platform, diff --git a/src/ui/backend/ui_input.c b/src/ui/backend/ui_input.c index e73a8af7..8dcd65fc 100644 --- a/src/ui/backend/ui_input.c +++ b/src/ui/backend/ui_input.c @@ -40,8 +40,10 @@ static void key_event(BongoCatUIBackend *ui, const SDL_KeyboardEvent *event) { bool control = (event->mod & SDL_KMOD_CTRL) != 0; struct nk_context *context = &ui->context; switch (event->key) { - case SDLK_LSHIFT: case SDLK_RSHIFT: nk_input_key(context, NK_KEY_SHIFT, down); break; - case SDLK_LALT: case SDLK_RALT: nk_input_key(context, NK_KEY_ALT, down); break; + case SDLK_LSHIFT: case SDLK_RSHIFT: + nk_input_key(context, NK_KEY_SHIFT, (event->mod & SDL_KMOD_SHIFT) != 0); break; + case SDLK_LALT: case SDLK_RALT: + nk_input_key(context, NK_KEY_ALT, (event->mod & SDL_KMOD_ALT) != 0); break; case SDLK_DELETE: nk_input_key(context, NK_KEY_DEL, down); break; case SDLK_RETURN: case SDLK_KP_ENTER: nk_input_key(context, NK_KEY_ENTER, down); break; case SDLK_TAB: nk_input_key(context, NK_KEY_TAB, down); break; @@ -59,16 +61,18 @@ static void key_event(BongoCatUIBackend *ui, const SDL_KeyboardEvent *event) { case SDLK_UP: nk_input_key(context, NK_KEY_UP, down); break; case SDLK_DOWN: nk_input_key(context, NK_KEY_DOWN, down); break; case SDLK_LEFT: - nk_input_key(context, control ? NK_KEY_TEXT_WORD_LEFT : NK_KEY_LEFT, down); + nk_input_key(context, NK_KEY_TEXT_WORD_LEFT, down && control); + nk_input_key(context, NK_KEY_LEFT, down && !control); break; case SDLK_RIGHT: - nk_input_key(context, control ? NK_KEY_TEXT_WORD_RIGHT : NK_KEY_RIGHT, down); + nk_input_key(context, NK_KEY_TEXT_WORD_RIGHT, down && control); + nk_input_key(context, NK_KEY_RIGHT, down && !control); break; - case SDLK_A: if (control) nk_input_key(context, NK_KEY_TEXT_SELECT_ALL, down); break; - case SDLK_C: if (control) nk_input_key(context, NK_KEY_COPY, down); break; - case SDLK_V: if (control) nk_input_key(context, NK_KEY_PASTE, down); break; - case SDLK_X: if (control) nk_input_key(context, NK_KEY_CUT, down); break; - case SDLK_Z: if (control) nk_input_key(context, NK_KEY_TEXT_UNDO, down); break; + case SDLK_A: nk_input_key(context, NK_KEY_TEXT_SELECT_ALL, down && control); break; + case SDLK_C: nk_input_key(context, NK_KEY_COPY, down && control); break; + case SDLK_V: nk_input_key(context, NK_KEY_PASTE, down && control); break; + case SDLK_X: nk_input_key(context, NK_KEY_CUT, down && control); break; + case SDLK_Z: nk_input_key(context, NK_KEY_TEXT_UNDO, down && control); break; default: break; } } @@ -135,8 +139,7 @@ bool bongo_cat_ui_event(BongoCatUIBackend *ui, const SDL_Event *event) { nk_input_scroll(context, nk_vec2(event->wheel.x, event->wheel.y)); return true; case SDL_EVENT_WINDOW_FOCUS_LOST: - ui->last_left_click_ns = 0; - ui->double_click_down = false; + bongo_cat_ui_input_reset(ui); return false; default: return false; } diff --git a/src/ui/dial/dial_fonts.c b/src/ui/dial/dial_fonts.c index 3a5884b4..6c47803f 100644 --- a/src/ui/dial/dial_fonts.c +++ b/src/ui/dial/dial_fonts.c @@ -4,6 +4,10 @@ #include #include +static bool contains(const uint8_t *used, nk_rune rune) { + return (used[rune >> 3] & (1u << (rune & 7))) != 0; +} + static void collect(uint8_t *used, const char *s) { if (!s) return; int length = (int)strlen(s); @@ -11,15 +15,17 @@ static void collect(uint8_t *used, const char *s) { nk_rune rune; int n = nk_utf_decode(s,&rune,length); if (n <= 0) break; - if (rune >= 32 && rune < 0x110000) used[rune] = 1; + if (rune >= 32 && rune < 0x110000) + used[rune >> 3] |= (uint8_t)(1u << (rune & 7)); s += n; length -= n; } } static nk_rune *ranges(Dial *d) { - uint8_t *used = calloc(0x110000,1); + uint8_t *used = calloc(0x110000 / 8,1); if (!used) return NULL; - for (int i = 32; i < 127; ++i) used[i] = 1; + for (int i = 32; i < 127; ++i) + used[i >> 3] |= (uint8_t)(1u << (i & 7)); for (int i = 0; i < d->count; ++i) collect(used,d->items[i].label); collect(used,d->labels->add_model); for (size_t i = 0; i < d->labels->model_count; ++i) collect(used,d->labels->model_names[i]); @@ -27,14 +33,15 @@ static nk_rune *ranges(Dial *d) { for (size_t i = 0; i < d->labels->expression_count; ++i) collect(used,d->labels->expression_names[i]); for (size_t i = 0; i < d->labels->audio_count; ++i) collect(used,d->labels->audio_names[i]); size_t count = 0; - for (nk_rune i = 32; i < 0x110000; ++i) if (used[i]) ++count; + for (nk_rune i = 32; i < 0x110000; ++i) + if (contains(used,i) && (i == 32 || !contains(used,i-1))) ++count; nk_rune *result = calloc(count*2+1,sizeof(*result)); if (result) { size_t out = 0; for (nk_rune i = 32; i < 0x110000; ++i) { - if (!used[i]) continue; + if (!contains(used,i)) continue; result[out++] = i; - while (i+1 < 0x110000 && used[i+1]) ++i; + while (i+1 < 0x110000 && contains(used,i+1)) ++i; result[out++] = i; } } @@ -65,7 +72,7 @@ bool dial_fonts_init(Dial *d) { p->font = bongo_cat_ui_font_add_family(&p->atlas,&sources[0],&sources[1],&sources[2], height,p->ranges[0],p->ranges[1],p->ranges[2],p->ranges[3]); int width = 0, rows = 0, maximum = 0; - const void *pixels = p->font ? nk_font_atlas_bake(&p->atlas,&width,&rows,NK_FONT_ATLAS_RGBA32) : NULL; + const void *pixels = p->font ? nk_font_atlas_bake(&p->atlas,&width,&rows,NK_FONT_ATLAS_ALPHA8) : NULL; glGetIntegerv(GL_MAX_TEXTURE_SIZE,&maximum); bool ok = pixels && width > 0 && rows > 0 && width <= maximum && rows <= maximum; if (ok) { @@ -75,7 +82,14 @@ bool dial_fonts_init(Dial *d) { glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR); glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_S,GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_T,GL_CLAMP_TO_EDGE); - glTexImage2D(GL_TEXTURE_2D,0,GL_RGBA8,width,rows,0,GL_RGBA,GL_UNSIGNED_BYTE,pixels); + /* Match the preferences atlas: white RGB with single-channel alpha. */ + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_SWIZZLE_R,GL_ONE); + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_SWIZZLE_G,GL_ONE); + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_SWIZZLE_B,GL_ONE); + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_SWIZZLE_A,GL_RED); + glPixelStorei(GL_UNPACK_ALIGNMENT,1); + glTexImage2D(GL_TEXTURE_2D,0,GL_R8,width,rows,0,GL_RED,GL_UNSIGNED_BYTE,pixels); + glPixelStorei(GL_UNPACK_ALIGNMENT,4); ok = p->font_texture && glGetError() == GL_NO_ERROR; nk_font_atlas_end(&p->atlas,nk_handle_id((int)p->font_texture),NULL); } diff --git a/src/ui/dial/dial_geometry.c b/src/ui/dial/dial_geometry.c index c8bc5f0f..d57368d5 100644 --- a/src/ui/dial/dial_geometry.c +++ b/src/ui/dial/dial_geometry.c @@ -83,11 +83,23 @@ void dial_sector(DialPath *p, float inner, float outer, float start, float end) triangulate(p); } void dial_child_paths(Dial *d) { + int count = dial_child_count(d); + float step = dial_child_step(d); + DialPaint *p = &d->paint; + /* Page contents do not affect geometry. Keep the last layout even when + the child ring is temporarily hidden; the draw loop uses child_count. */ + if (!count) return; + if (p->child_path_count == count && p->child_path_active == d->active && + p->child_path_roots == d->count && p->child_path_step == step) return; memset(d->paint.children, 0, sizeof(d->paint.children)); - for (int i = 0; i < dial_child_count(d); ++i) { + for (int i = 0; i < count; ++i) { float angle = dial_child_angle(d,i), half = dial_child_step(d)/2; dial_sector(&d->paint.children[i],198,262,angle-half,angle+half); } + p->child_path_count = count; + p->child_path_active = d->active; + p->child_path_roots = d->count; + p->child_path_step = step; } static float angle_distance(float a, float b) { diff --git a/src/ui/dial/dial_internal.h b/src/ui/dial/dial_internal.h index 79c69f3c..5a9fd00a 100644 --- a/src/ui/dial/dial_internal.h +++ b/src/ui/dial/dial_internal.h @@ -41,6 +41,8 @@ typedef struct DialPaint { struct nk_font *font; nk_rune *ranges[4]; DialPath roots[DIAL_ROOTS], children[DIAL_PAGE]; + int child_path_count, child_path_active, child_path_roots; + float child_path_step; float zoom, offset_x, offset_y, alpha; bool failed; } DialPaint; diff --git a/src/ui/dial/dial_window.c b/src/ui/dial/dial_window.c index 11e6d9e7..de9fba5c 100644 --- a/src/ui/dial/dial_window.c +++ b/src/ui/dial/dial_window.c @@ -1,6 +1,7 @@ #include "dial_internal.h" #include "ui_backend.h" #include "bongo_cat/log.h" +#include "bongo_cat/memory.h" #include static bool SDLCALL collect_event(void *userdata, SDL_Event *event) { @@ -185,5 +186,7 @@ BongoCatMenuAction bongo_cat_platform_context_menu(BongoCatPlatform *platform, if (labels->restore) labels->restore(labels->preview_userdata,d->result); BongoCatMenuAction result = d->result; free(d); + /* Trim only after GL/CPU teardown and preview restoration have finished. */ + bongo_cat_platform_trim_memory(); return result; } diff --git a/src/ui/preferences/preferences.c b/src/ui/preferences/preferences.c index 4f0780c7..a1949525 100644 --- a/src/ui/preferences/preferences.c +++ b/src/ui/preferences/preferences.c @@ -230,6 +230,7 @@ bool bongo_cat_preferences_event(BongoCatPreferences *value, const SDL_Event *ev } if (bongo_cat_preferences_scale_event(value, event)) return true; if (event->type == SDL_EVENT_WINDOW_FOCUS_LOST) { + bongo_cat_ui_input_reset(&value->ui); if (value->chrome_dragging) SDL_CaptureMouse(false); value->chrome_dragging = false; value->import_drop_active = false; diff --git a/src/ui/rendering/ui_font_atlas.c b/src/ui/rendering/ui_font_atlas.c index 684496af..e57d48fa 100644 --- a/src/ui/rendering/ui_font_atlas.c +++ b/src/ui/rendering/ui_font_atlas.c @@ -4,57 +4,58 @@ #include #include +/* Reuse mappings only within this bake. Owners remain in sources[] and are + detached/released once, including on failure. */ +static const UIFontSource *load_source(UIFontSource *sources, + const char *const *paths, int index) { + if (!paths[index]) return NULL; + for (int i = 0; i < index; ++i) + if (sources[i].data && paths[i] && strcmp(paths[i], paths[index]) == 0) + return &sources[i]; + return bongo_cat_ui_font_source_load(&sources[index], paths[index]) ? + &sources[index] : NULL; +} + bool bongo_cat_ui_font_atlas_create(BongoCatUIBackend *ui, const char *body_path, const char *body_fallback_path, const char *body_korean_fallback_path, const char *heading_path, const char *heading_fallback_path, const char *heading_korean_fallback_path, const nk_rune *glyph_ranges, float raster_scale) { - UIFontSource body = {0}, body_fallback = {0}, body_korean_fallback = {0}; - UIFontSource heading = {0}, heading_fallback = {0}; - UIFontSource heading_korean_fallback = {0}; - bool body_loaded = body_path && - bongo_cat_ui_font_source_load(&body, body_path); - bool body_fallback_loaded = body_fallback_path && - (!body_path || strcmp(body_path, body_fallback_path) != 0) && - bongo_cat_ui_font_source_load(&body_fallback, body_fallback_path); - bool body_korean_fallback_loaded = body_korean_fallback_path && + UIFontSource sources[6] = {0}; + const char *paths[] = {body_path, body_fallback_path, + body_korean_fallback_path, heading_path, heading_fallback_path, + heading_korean_fallback_path}; + const UIFontSource *body = load_source(sources, paths, 0); + const UIFontSource *body_fallback = body_fallback_path && + (!body_path || strcmp(body_path, body_fallback_path) != 0) ? + load_source(sources, paths, 1) : NULL; + const UIFontSource *body_korean_fallback = body_korean_fallback_path && (!body_path || strcmp(body_path, body_korean_fallback_path) != 0) && (!body_fallback_path || strcmp(body_fallback_path, - body_korean_fallback_path) != 0) && - bongo_cat_ui_font_source_load(&body_korean_fallback, - body_korean_fallback_path); - bool heading_loaded = heading_path && - bongo_cat_ui_font_source_load(&heading, heading_path); - bool heading_fallback_loaded = heading_fallback_path && - (!heading_path || strcmp(heading_path, heading_fallback_path) != 0) && - bongo_cat_ui_font_source_load(&heading_fallback, - heading_fallback_path); - bool heading_korean_fallback_loaded = heading_korean_fallback_path && - (!heading_path || strcmp(heading_path, - heading_korean_fallback_path) != 0) && + body_korean_fallback_path) != 0) ? + load_source(sources, paths, 2) : NULL; + const UIFontSource *heading = load_source(sources, paths, 3); + const UIFontSource *heading_fallback = heading_fallback_path && + (!heading_path || strcmp(heading_path, heading_fallback_path) != 0) ? + load_source(sources, paths, 4) : NULL; + const UIFontSource *heading_korean_fallback = heading_korean_fallback_path && + (!heading_path || strcmp(heading_path, heading_korean_fallback_path) != 0) && (!heading_fallback_path || strcmp(heading_fallback_path, - heading_korean_fallback_path) != 0) && - bongo_cat_ui_font_source_load(&heading_korean_fallback, - heading_korean_fallback_path); - const UIFontSource *heading_source = heading_loaded ? &heading : - (body_loaded ? &body : NULL); - const UIFontSource *heading_fallback_source = heading_fallback_loaded ? - &heading_fallback : (body_fallback_loaded ? &body_fallback : NULL); + heading_korean_fallback_path) != 0) ? + load_source(sources, paths, 5) : NULL; + const UIFontSource *heading_source = heading ? heading : body; + const UIFontSource *heading_fallback_source = heading_fallback ? + heading_fallback : body_fallback; const UIFontSource *heading_korean_fallback_source = - heading_korean_fallback_loaded ? &heading_korean_fallback : - (body_korean_fallback_loaded ? &body_korean_fallback : - heading_fallback_source); + heading_korean_fallback ? heading_korean_fallback : + (body_korean_fallback ? body_korean_fallback : heading_fallback_source); nk_rune *primary_ranges = NULL, *cjk_ranges = NULL; nk_rune *korean_ranges = NULL; if (!bongo_cat_ui_font_split_ranges(glyph_ranges, &primary_ranges, &cjk_ranges, &korean_ranges)) { - bongo_cat_ui_font_source_release(&heading_korean_fallback); - bongo_cat_ui_font_source_release(&heading_fallback); - bongo_cat_ui_font_source_release(&heading); - bongo_cat_ui_font_source_release(&body_korean_fallback); - bongo_cat_ui_font_source_release(&body_fallback); - bongo_cat_ui_font_source_release(&body); + for (int i = 0; i < 6; ++i) + bongo_cat_ui_font_source_release(&sources[i]); return false; } nk_font_atlas_init_default(&ui->atlas); @@ -70,11 +71,10 @@ bool bongo_cat_ui_font_atlas_create(BongoCatUIBackend *ui, "Preferences font raster scale limited to %.2fx by the UI memory " "budget (GPU texture limit %d)", font_scale, maximum_texture); - const UIFontSource *body_source = body_loaded ? &body : NULL; - const UIFontSource *fallback_source = body_fallback_loaded ? - &body_fallback : NULL; + const UIFontSource *body_source = body; + const UIFontSource *fallback_source = body_fallback; const UIFontSource *body_korean_fallback_source = - body_korean_fallback_loaded ? &body_korean_fallback : fallback_source; + body_korean_fallback ? body_korean_fallback : fallback_source; struct nk_font *caption_font = bongo_cat_ui_font_add_family(&ui->atlas, body_source, fallback_source, body_korean_fallback_source, 18.0f * font_scale, glyph_ranges, primary_ranges, cjk_ranges, @@ -83,7 +83,12 @@ bool bongo_cat_ui_font_atlas_create(BongoCatUIBackend *ui, body_source, fallback_source, body_korean_fallback_source, 20.0f * font_scale, glyph_ranges, primary_ranges, cjk_ranges, korean_ranges); - struct nk_font *label_font = bongo_cat_ui_font_add_family(&ui->atlas, + /* Identical families at 20 px can share glyphs and the font handle. */ + bool shared_label = body_source == heading_source && + fallback_source == heading_fallback_source && + body_korean_fallback_source == heading_korean_fallback_source; + struct nk_font *label_font = shared_label ? body_font : + bongo_cat_ui_font_add_family(&ui->atlas, heading_source, heading_fallback_source, heading_korean_fallback_source, 20.0f * font_scale, glyph_ranges, primary_ranges, cjk_ranges, korean_ranges); @@ -119,22 +124,13 @@ bool bongo_cat_ui_font_atlas_create(BongoCatUIBackend *ui, } ui->font_path_found = body_path != NULL || body_fallback_path != NULL || body_korean_fallback_path != NULL; - ui->font_file_loaded = body_loaded || body_fallback_loaded || - body_korean_fallback_loaded; + ui->font_file_loaded = body || body_fallback || body_korean_fallback; ui->custom_font_loaded = ui->font_file_loaded && body_font != NULL; - bongo_cat_ui_font_detach_source(&ui->atlas, &heading_korean_fallback); - bongo_cat_ui_font_detach_source(&ui->atlas, &heading_fallback); - bongo_cat_ui_font_detach_source(&ui->atlas, &heading); - bongo_cat_ui_font_detach_source(&ui->atlas, &body_korean_fallback); - bongo_cat_ui_font_detach_source(&ui->atlas, &body_fallback); - bongo_cat_ui_font_detach_source(&ui->atlas, &body); + for (int i = 0; i < 6; ++i) + bongo_cat_ui_font_detach_source(&ui->atlas, &sources[i]); nk_font_atlas_cleanup(&ui->atlas); - bongo_cat_ui_font_source_release(&heading_korean_fallback); - bongo_cat_ui_font_source_release(&heading_fallback); - bongo_cat_ui_font_source_release(&heading); - bongo_cat_ui_font_source_release(&body_korean_fallback); - bongo_cat_ui_font_source_release(&body_fallback); - bongo_cat_ui_font_source_release(&body); + for (int i = 0; i < 6; ++i) + bongo_cat_ui_font_source_release(&sources[i]); return uploaded; } diff --git a/tests/ui/test_preferences_text.c b/tests/ui/test_preferences_text.c index 7528e968..d67902fd 100644 --- a/tests/ui/test_preferences_text.c +++ b/tests/ui/test_preferences_text.c @@ -1,5 +1,6 @@ #include "preferences_text_edit.h" #include "preferences_text_session.h" +#include "ui_backend.h" #include #include @@ -82,9 +83,54 @@ static void text_session(void) { CHECK(!bongo_cat_preferences_text_session_active(&session)); } +static void input_release_order(void) { + BongoCatUIBackend ui = {0}; + const SDL_Keycode keys[] = {SDLK_A, SDLK_C, SDLK_V, SDLK_X, SDLK_Z, + SDLK_LEFT, SDLK_RIGHT}; + const enum nk_keys actions[] = {NK_KEY_TEXT_SELECT_ALL, NK_KEY_COPY, + NK_KEY_PASTE, NK_KEY_CUT, NK_KEY_TEXT_UNDO, + NK_KEY_TEXT_WORD_LEFT, NK_KEY_TEXT_WORD_RIGHT}; + for (size_t i = 0; i < sizeof(keys) / sizeof(keys[0]); ++i) { + SDL_Event event = {.type = SDL_EVENT_KEY_DOWN}; + event.key.key = keys[i]; + event.key.down = true; + event.key.mod = SDL_KMOD_CTRL; + bongo_cat_ui_event(&ui, &event); + CHECK(ui.context.input.keyboard.keys[actions[i]].down); + event.type = SDL_EVENT_KEY_UP; + event.key.key = SDLK_LCTRL; + event.key.down = false; + event.key.mod = SDL_KMOD_NONE; + bongo_cat_ui_event(&ui, &event); + event.key.key = keys[i]; + bongo_cat_ui_event(&ui, &event); + CHECK(!ui.context.input.keyboard.keys[actions[i]].down); + CHECK(!ui.context.input.keyboard.keys[NK_KEY_LEFT].down); + CHECK(!ui.context.input.keyboard.keys[NK_KEY_RIGHT].down); + } + SDL_Event event = {.type = SDL_EVENT_KEY_UP}; + event.key.key = SDLK_LSHIFT; + event.key.mod = SDL_KMOD_RSHIFT; + bongo_cat_ui_event(&ui, &event); + CHECK(ui.context.input.keyboard.keys[NK_KEY_SHIFT].down); + event.key.key = SDLK_RSHIFT; + event.key.mod = SDL_KMOD_NONE; + bongo_cat_ui_event(&ui, &event); + CHECK(!ui.context.input.keyboard.keys[NK_KEY_SHIFT].down); + event.type = SDL_EVENT_KEY_DOWN; + event.key.key = SDLK_LEFT; + event.key.down = true; + bongo_cat_ui_event(&ui, &event); + CHECK(ui.context.input.keyboard.keys[NK_KEY_LEFT].down); + event.type = SDL_EVENT_WINDOW_FOCUS_LOST; + bongo_cat_ui_event(&ui, &event); + CHECK(!ui.context.input.keyboard.keys[NK_KEY_LEFT].down); +} + int test_preferences_text(void) { failures = 0; text_edit(); text_session(); + input_release_order(); return failures; } From 7cba4f525c77d629bb712abf7f79e4dc4f83b5c3 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 12 Sep 2026 15:37:41 +0800 Subject: [PATCH 273/334] Update README.md --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index 7755e222..e0c5e69f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,14 @@ +> [!TIP] +> +> ### 💼 Looking for Remote Work Opportunities +> +> I'm currently looking for a **remote development position**. If your team is looking for a developer and you think my experience or open-source work could be a good fit, feel free to contact me at: +> +> +> 我目前正在寻找一份远程开发相关的工作。如果您的团队正在寻找开发者,并且认为我的经验或开源项目经历可能适合你们,欢迎通过以下邮箱联系我: +> +> 📧 [**vladelaina@gmail.com**](mailto:vladelaina@gmail.com) +
From d96257a19b0c92f3ee4a22306802d94f51099b2d Mon Sep 17 00:00:00 2001 From: Linming-XHL Date: Sat, 12 Sep 2026 19:46:57 +0800 Subject: [PATCH 274/334] =?UTF-8?q?fix.=20=E4=BF=AE=E5=A4=8DLinux=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E9=83=A8=E5=88=86=E5=8F=91=E8=A1=8C=E7=89=88=E7=9A=84?= =?UTF-8?q?=E5=AD=97=E4=BD=93=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ui/rendering/ui_font.c | 117 +++++++++++++++++++++++++++++++------ 1 file changed, 100 insertions(+), 17 deletions(-) diff --git a/src/ui/rendering/ui_font.c b/src/ui/rendering/ui_font.c index 0405dc09..d895401c 100644 --- a/src/ui/rendering/ui_font.c +++ b/src/ui/rendering/ui_font.c @@ -30,20 +30,59 @@ const char *bongo_cat_ui_system_font(char *path, size_t capacity, bool multiling const char *candidates[] = {multilingual ? "/System/Library/Fonts/PingFang.ttc" : "/System/Library/Fonts/Helvetica.ttc", "/System/Library/Fonts/PingFang.ttc", "/System/Library/Fonts/Hiragino Sans GB.ttc"}; + for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); ++i) { + if (!readable(candidates[i])) continue; + snprintf(path, capacity, "%s", candidates[i]); + return path; + } #else - const char *candidates[] = {multilingual ? - "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc" : - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + /* Every Linux distribution ships fonts to different directories, so the + candidate lists must cover the common layouts. CJK-capable fonts come + first for multilingual fallbacks; the CJK-less DejaVu remains last as a + final resort so Chinese text never silently falls back to it. */ + static const char *const cjk[] = { + /* Arch/Manjaro and openSUSE: noto-fonts-cjk */ + "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", + /* Fedora/RHEL: google-noto-sans-cjk-fonts */ + "/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Regular.ttc", + /* Debian/Ubuntu: fonts-noto-cjk (and language-specific extras) */ + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf", + "/usr/share/fonts/opentype/noto/NotoSansCJKtc-Regular.otf", + "/usr/share/fonts/opentype/noto/NotoSansSC-Regular.otf", "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.otf", - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"}; -#endif -#ifndef _WIN32 - for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); ++i) { + /* WenQuanYi micro hei/zen hei (Debian, Arch, openSUSE) */ + "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", + "/usr/share/fonts/wenquanyi/wqy-zenhei/wqy-zenhei.ttc", + "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", + /* Older Ubuntu: droid fallback */ + "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf"}; + static const char *const latin[] = { + /* Debian/Ubuntu, Arch, Fedora ttf-dejavu layouts */ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/TTF/DejaVuSans.ttf", + "/usr/share/fonts/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", + "/usr/share/fonts/noto/NotoSans-Regular.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/liberation-sans/LiberationSans-Regular.ttf", + "/usr/share/fonts/truetype/freefont/FreeSans.ttf", + "/usr/share/fonts/freefont/FreeSans.ttf"}; + const char *const *candidates = multilingual ? cjk : latin; + size_t count = multilingual ? + sizeof(cjk) / sizeof(cjk[0]) : sizeof(latin) / sizeof(latin[0]); + for (size_t i = 0; i < count; ++i) { if (!readable(candidates[i])) continue; snprintf(path, capacity, "%s", candidates[i]); return path; } + if (multilingual) + for (size_t i = 0; i < sizeof(latin) / sizeof(latin[0]); ++i) { + if (!readable(latin[i])) continue; + snprintf(path, capacity, "%s", latin[i]); + return path; + } #endif return NULL; } @@ -66,20 +105,47 @@ const char *bongo_cat_ui_system_heading_font(char *path, size_t capacity, const char *candidates[] = {multilingual ? "/System/Library/Fonts/PingFang.ttc" : "/System/Library/Fonts/Helvetica.ttc", "/System/Library/Fonts/PingFang.ttc", "/System/Library/Fonts/Hiragino Sans GB.ttc"}; + for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); ++i) { + if (!readable(candidates[i])) continue; + snprintf(path, capacity, "%s", candidates[i]); + return path; + } #else - const char *candidates[] = {multilingual ? - "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc" : - "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + static const char *const cjk[] = { + "/usr/share/fonts/noto-cjk/NotoSansCJK-Bold.ttc", + "/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Bold.ttc", + "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc", + "/usr/share/fonts/opentype/noto/NotoSansCJKsc-Bold.otf", + "/usr/share/fonts/opentype/noto/NotoSansCJKtc-Bold.otf", "/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.ttc", - "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", - "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"}; -#endif -#ifndef _WIN32 - for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); ++i) { + "/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.otf", + "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", + "/usr/share/fonts/wenquanyi/wqy-zenhei/wqy-zenhei.ttc", + "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf"}; + static const char *const latin[] = { + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", + "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf", + "/usr/share/fonts/noto/NotoSans-Bold.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", + "/usr/share/fonts/liberation-sans/LiberationSans-Bold.ttf", + "/usr/share/fonts/truetype/freefont/FreeSans-Bold.ttf", + "/usr/share/fonts/freefont/FreeSans-Bold.ttf"}; + const char *const *candidates = multilingual ? cjk : latin; + size_t count = multilingual ? + sizeof(cjk) / sizeof(cjk[0]) : sizeof(latin) / sizeof(latin[0]); + for (size_t i = 0; i < count; ++i) { if (!readable(candidates[i])) continue; snprintf(path, capacity, "%s", candidates[i]); return path; } + if (multilingual) + for (size_t i = 0; i < sizeof(latin) / sizeof(latin[0]); ++i) { + if (!readable(latin[i])) continue; + snprintf(path, capacity, "%s", latin[i]); + return path; + } #endif return NULL; } @@ -101,9 +167,18 @@ const char *bongo_cat_ui_system_korean_font(char *path, size_t capacity) { "/System/Library/Fonts/Supplemental/AppleGothic.ttf"}; #else const char *candidates[] = { + /* NotoSansCJK covers Korean on every major distribution */ + "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Regular.ttc", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", + /* Language-specific Noto Sans KR (Debian/Ubuntu, Fedora) */ "/usr/share/fonts/opentype/noto/NotoSansKR-Regular.otf", - "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc"}; + "/usr/share/fonts/google-noto-sans-kr-fonts/NotoSansKR-Regular.otf", + /* Nanum Gothic (Debian/Ubuntu, Arch, Fedora) */ + "/usr/share/fonts/truetype/nanum/NanumGothic.ttf", + "/usr/share/fonts/nanum/NanumGothic.ttf", + "/usr/share/fonts/nanum-gothic-fonts/NanumGothic.ttf"}; #endif #ifndef _WIN32 for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); ++i) { @@ -133,9 +208,17 @@ const char *bongo_cat_ui_system_korean_heading_font(char *path, "/System/Library/Fonts/Supplemental/AppleGothic.ttf"}; #else const char *candidates[] = { + "/usr/share/fonts/noto-cjk/NotoSansCJK-Bold.ttc", + "/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Bold.ttc", "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc", "/usr/share/fonts/opentype/noto/NotoSansKR-Bold.otf", - "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"}; + "/usr/share/fonts/google-noto-sans-kr-fonts/NotoSansKR-Bold.otf", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.ttc", + "/usr/share/fonts/truetype/nanum/NanumGothicBold.ttf", + "/usr/share/fonts/nanum/NanumGothicBold.ttf", + "/usr/share/fonts/nanum-gothic-fonts/NanumGothicBold.ttf", + /* Regular weight as a last resort for headings with no bold face */ + "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc"}; #endif #ifndef _WIN32 for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); ++i) { From a854dce9e8e020a19dce90a5974a5b20155ebdaa Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 12 Sep 2026 22:14:44 +0800 Subject: [PATCH 275/334] Update README.md --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e0c5e69f..f8b1513f 100644 --- a/README.md +++ b/README.md @@ -44,12 +44,13 @@
+image + + > [!TIP] > The model featured in this demonstration is from [宇痕冫](https://space.bilibili.com/348616056). > > 🎁 Looking for **free** models? We work with talented model creators to bring you a wide variety of free models, while continuously exploring more fun desktop experiences! Visit our official website: [bongocat.pet](https://bongocat.pet/models) - -

From 8a02d05491f07fc8ebcd7ab337982762af90956d Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 12 Sep 2026 22:52:32 +0800 Subject: [PATCH 276/334] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f8b1513f..b3edeabe 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ - +

From f380f737fb59aebfa6ef5a5122024d63e8d678db Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 12 Sep 2026 23:01:13 +0800 Subject: [PATCH 277/334] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b3edeabe..cd80c94b 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,8 @@

- - + +

From fe991bbdb9d7a2e57e9e92325160eee069d8a6c6 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 12 Sep 2026 23:01:40 +0800 Subject: [PATCH 278/334] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cd80c94b..49e27d7b 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@

- 💘C/C++ × SDL3 × OpenGL, stir it up, mash it together! Bong~ Bongo Cat!!! + 💘C × SDL3 × OpenGL, stir it up, mash it together! Bong~ Bongo Cat!!!

English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia From 81168f68052b5ee8a9c8b14fa8761eeeb15d4c01 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 12 Sep 2026 23:04:08 +0800 Subject: [PATCH 279/334] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 49e27d7b..b886619b 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@

- 💘C × SDL3 × OpenGL, stir it up, mash it together! Bong~ Bongo Cat!!! + 💘C × SDL3 × OpenGL — Three Mysterious Forces, United as One! Bong~ Bongocat!!!

English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia From 51655871c9e8dc03fa45b5c8b4d1236b30da5459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B7=E7=9F=B3Boy?= Date: Sat, 12 Sep 2026 23:36:34 +0800 Subject: [PATCH 280/334] feat: add mouse in and moust out fade animation --- include/bongo_cat/app.h | 3 ++ include/bongo_cat/config.h | 3 ++ resources/assets/locales/de-DE.json | 1 + resources/assets/locales/en-US.json | 1 + resources/assets/locales/es-ES.json | 1 + resources/assets/locales/fr-FR.json | 1 + resources/assets/locales/ja-JP.json | 1 + resources/assets/locales/ko-KR.json | 1 + resources/assets/locales/pt-BR.json | 1 + resources/assets/locales/ru-RU.json | 1 + resources/assets/locales/zh-CN.json | 1 + resources/assets/locales/zh-Hant.json | 1 + src/core/config_defaults.c | 4 +++ src/core/config_io.c | 2 ++ src/core/config_settings_save.c | 2 ++ src/runtime/input/mouse.c | 47 +++++++++++++++++++++++-- src/runtime/lifecycle/app_loop.c | 1 + src/runtime/lifecycle/frame_clock.c | 1 + src/runtime/lifecycle/runtime_flow.c | 1 + src/runtime/model/multi_pet_selection.c | 1 + src/runtime/runtime.h | 2 ++ src/runtime/shell/window.c | 1 + src/runtime/shell/window_geometry.c | 24 +++++++++++-- src/runtime/shell/window_hit.c | 1 + src/runtime/shell/window_menu_preview.c | 2 ++ src/runtime/shell/window_wheel.c | 6 ++-- src/ui/preferences/preferences_pages.c | 10 +++++- 27 files changed, 112 insertions(+), 9 deletions(-) diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index 100af5b6..6b0c764c 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -141,6 +141,9 @@ typedef struct BongoCatApp { BongoCatMverPointerState mver_pointer; bool hover_inside; bool hover_hidden; + bool hover_fade_active; + float hover_fade_from, hover_fade_to; + uint64_t hover_fade_start_ns; bool pointer_known; bool pointer_hit_dirty; bool pointer_transparent; diff --git a/include/bongo_cat/config.h b/include/bongo_cat/config.h index f299ca29..4d661516 100644 --- a/include/bongo_cat/config.h +++ b/include/bongo_cat/config.h @@ -8,6 +8,8 @@ #define BONGO_CAT_DEFAULT_WINDOW_OPACITY_PERCENT 100.0f #define BONGO_CAT_DEFAULT_RANDOM_EXPRESSION_SECONDS 5.0f #define BONGO_CAT_DEFAULT_WINDOW_CORNER_PERCENT 6.0f +#define BONGO_CAT_DEFAULT_HIDE_FADE_SECONDS 0.3f +#define BONGO_CAT_MAX_HIDE_FADE_SECONDS 3.0f typedef enum BongoCatTheme { BONGO_CAT_THEME_AUTO, BONGO_CAT_THEME_LIGHT, BONGO_CAT_THEME_DARK } BongoCatTheme; typedef enum BongoCatLanguage { @@ -55,6 +57,7 @@ typedef struct BongoCatWindowPreferences { bool rounded_corners; BongoCatObsBackgroundColor obs_background_color; float hide_delay_seconds; + float hide_fade_seconds; float random_expression_interval_seconds; float corner_radius_percent; } BongoCatWindowPreferences; diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index 79affe62..92d19707 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -26,6 +26,7 @@ "randomExpression": "Zufällige Ausdrücke", "obsBackground": "Einfarbiger Hintergrund", "hideOnHover": "Beim Darüberfahren ausblenden", + "hideFadeSeconds": "Ausblendedauer (s)", "maxFPS": "Maximale Bildrate" }, "hints": { diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index d1970ff3..bb370fbb 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -26,6 +26,7 @@ "randomExpression": "Random Expressions", "obsBackground": "Solid Background", "hideOnHover": "Hide on Hover", + "hideFadeSeconds": "Fade Duration (s)", "maxFPS": "Max Frame Rate" }, "hints": { diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index 0e96be60..f946acb5 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -26,6 +26,7 @@ "randomExpression": "Expresiones aleatorias", "obsBackground": "Fondo sólido", "hideOnHover": "Ocultar al pasar el ratón", + "hideFadeSeconds": "Duración del fundido (s)", "maxFPS": "Frecuencia máxima de fotogramas" }, "hints": { diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index b2296ed2..cb938b7d 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -26,6 +26,7 @@ "randomExpression": "Expressions aléatoires", "obsBackground": "Arrière-plan uni", "hideOnHover": "Masquer au survol", + "hideFadeSeconds": "Durée du fondu (s)", "maxFPS": "Fréquence d'images maximale" }, "hints": { diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index d1586986..06b51756 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -26,6 +26,7 @@ "randomExpression": "ランダム表情", "obsBackground": "単色背景", "hideOnHover": "ホバー時に非表示", + "hideFadeSeconds": "フェード時間 (秒)", "maxFPS": "最大フレームレート" }, "hints": { diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index d99019c7..b3679ff3 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -26,6 +26,7 @@ "randomExpression": "무작위 표정", "obsBackground": "단색 배경", "hideOnHover": "마우스를 올리면 숨기기", + "hideFadeSeconds": "페이드 시간 (초)", "maxFPS": "최대 프레임 속도" }, "hints": { diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index add0777c..f113340f 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -26,6 +26,7 @@ "randomExpression": "Expressões Aleatórias", "obsBackground": "Fundo sólido", "hideOnHover": "Ocultar ao Passar o Mouse", + "hideFadeSeconds": "Duração do fade (s)", "maxFPS": "Taxa Máxima de Quadros" }, "hints": { diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index 1cc82c1c..40d76fed 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -26,6 +26,7 @@ "randomExpression": "Случайные выражения", "obsBackground": "Однотонный фон", "hideOnHover": "Скрывать при наведении", + "hideFadeSeconds": "Длительность затухания (с)", "maxFPS": "Максимальная частота кадров" }, "hints": { diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index 97b9c63c..eedb3a31 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -26,6 +26,7 @@ "randomExpression": "随机表情", "obsBackground": "纯色背景", "hideOnHover": "鼠标移入隐藏", + "hideFadeSeconds": "隐藏渐变时长 (秒)", "maxFPS": "最大帧率" }, "hints": { diff --git a/resources/assets/locales/zh-Hant.json b/resources/assets/locales/zh-Hant.json index 0f7ddfa6..0d73e0e4 100644 --- a/resources/assets/locales/zh-Hant.json +++ b/resources/assets/locales/zh-Hant.json @@ -26,6 +26,7 @@ "randomExpression": "隨機表情", "obsBackground": "純色背景", "hideOnHover": "滑鼠游標移入隱藏", + "hideFadeSeconds": "隱藏漸變時長 (秒)", "maxFPS": "最大幀率" }, "hints": { diff --git a/src/core/config_defaults.c b/src/core/config_defaults.c index 59268038..d2017511 100644 --- a/src/core/config_defaults.c +++ b/src/core/config_defaults.c @@ -198,6 +198,7 @@ void bongo_cat_settings_defaults(BongoCatSettings *config) { config->window.keep_in_screen = false; config->window.obs_background_color = BONGO_CAT_OBS_BACKGROUND_GREEN; config->window.corner_radius_percent = BONGO_CAT_DEFAULT_WINDOW_CORNER_PERCENT; + config->window.hide_fade_seconds = BONGO_CAT_DEFAULT_HIDE_FADE_SECONDS; config->window.random_expression_interval_seconds = BONGO_CAT_DEFAULT_RANDOM_EXPRESSION_SECONDS; config->app.tray_visible = true; @@ -212,6 +213,9 @@ void bongo_cat_settings_validate(BongoCatSettings *config) { if (config->model.max_fps > 240) config->model.max_fps = 240; config->window.hide_delay_seconds = clampf_or( config->window.hide_delay_seconds, 0.0f, 60.0f, 0.0f); + config->window.hide_fade_seconds = clampf_or( + config->window.hide_fade_seconds, 0.0f, + BONGO_CAT_MAX_HIDE_FADE_SECONDS, BONGO_CAT_DEFAULT_HIDE_FADE_SECONDS); config->window.corner_radius_percent = clampf_or( config->window.corner_radius_percent, 0.0f, 50.0f, BONGO_CAT_DEFAULT_WINDOW_CORNER_PERCENT); diff --git a/src/core/config_io.c b/src/core/config_io.c index 59631b1a..1d6f9bd1 100644 --- a/src/core/config_io.c +++ b/src/core/config_io.c @@ -74,6 +74,8 @@ static bool read_window(yyjson_val *object, BongoCatWindowPreferences *value, error) || !read_float(object, "hideDelaySeconds", &value->hide_delay_seconds, error) || + !read_float(object, "hideFadeSeconds", &value->hide_fade_seconds, + error) || !read_float(object, "randomExpressionIntervalSeconds", &value->random_expression_interval_seconds, error)) return false; diff --git a/src/core/config_settings_save.c b/src/core/config_settings_save.c index 5da24b9b..e6104783 100644 --- a/src/core/config_settings_save.c +++ b/src/core/config_settings_save.c @@ -43,6 +43,8 @@ static bool write_window(yyjson_mut_doc *doc, yyjson_mut_val *object, value->obs_background_color)) && yyjson_mut_obj_add_real(doc, object, "hideDelaySeconds", value->hide_delay_seconds) && + yyjson_mut_obj_add_real(doc, object, "hideFadeSeconds", + value->hide_fade_seconds) && yyjson_mut_obj_add_real(doc, object, "randomExpressionIntervalSeconds", value->random_expression_interval_seconds); diff --git a/src/runtime/input/mouse.c b/src/runtime/input/mouse.c index 4f16ca3e..8a506ef4 100644 --- a/src/runtime/input/mouse.c +++ b/src/runtime/input/mouse.c @@ -12,8 +12,50 @@ void bongo_cat_app_track_hover(BongoCatApp *app, double x, double y) { bongo_cat_app_update_hover(app, SDL_GetTicksNS()); } +/* Fade progress follows absolute timestamps, so a duration change or an + interrupted reversal restarts from the opacity currently on screen. */ +void bongo_cat_app_update_hover_fade(BongoCatApp *app, uint64_t now) { + if (!app || !app->hover_fade_active) return; + float duration = app->settings.window.hide_fade_seconds; + float target = app->hover_fade_to; + if (duration <= 0.0f) { + app->hover_fade_active = false; + bongo_cat_platform_set_opacity(&app->platform, target); + return; + } + float seconds = (float)((double)(now >= app->hover_fade_start_ns ? + now - app->hover_fade_start_ns : 0) / 1000000000.0); + float t = seconds / duration; + if (t >= 1.0f) { + app->hover_fade_active = false; + t = 1.0f; + } else if (t < 0.0f) t = 0.0f; + float eased = t * t * (3.0f - 2.0f * t); + bongo_cat_platform_set_opacity(&app->platform, + app->hover_fade_from + + (target - app->hover_fade_from) * eased); +} + +void bongo_cat_app_cancel_hover_fade(BongoCatApp *app) { + if (app) app->hover_fade_active = false; +} + +static void hover_start_fade(BongoCatApp *app, bool hidden, uint64_t now) { + float target = hidden ? 0.0f : + app->session.window.opacity_percent / 100.0f; + if (app->settings.window.hide_fade_seconds <= 0.0f) { + app->hover_fade_active = false; + bongo_cat_platform_set_opacity(&app->platform, target); + return; + } + app->hover_fade_from = bongo_cat_platform_get_opacity(&app->platform); + app->hover_fade_to = target; + app->hover_fade_start_ns = now; + app->hover_fade_active = true; + bongo_cat_app_update_hover_fade(app, now); +} + void bongo_cat_app_update_hover(BongoCatApp *app, uint64_t now) { - (void)now; if (!app || !app->window) return; bool enabled = app->settings.window.hide_on_hover && app->settings.window.pass_through && app->settings.window.always_on_top && @@ -29,8 +71,7 @@ void bongo_cat_app_update_hover(BongoCatApp *app, uint64_t now) { app->hover_inside = inside; app->hover_deadline_ns = 0; if (app->hover_hidden == inside) return; - bongo_cat_platform_set_opacity(&app->platform, - inside ? 0.0f : app->session.window.opacity_percent / 100.0f); + hover_start_fade(app, inside, now); app->hover_hidden = inside; bongo_cat_window_sync_click_through(app); } diff --git a/src/runtime/lifecycle/app_loop.c b/src/runtime/lifecycle/app_loop.c index 9f4f57f3..8ec41e87 100644 --- a/src/runtime/lifecycle/app_loop.c +++ b/src/runtime/lifecycle/app_loop.c @@ -198,6 +198,7 @@ void bongo_cat_app_loop(BongoCatApp *app) { bongo_cat_runtime_flow_update(app, now); bongo_cat_window_apply_pending_resize(app); bongo_cat_app_update_hover(app, now); + bongo_cat_app_update_hover_fade(app, now); bongo_cat_app_drain_input(app, true); if (bongo_cat_model_frame_due(app, now)) update_model(app, now); else if (!app->session.window.visible || app->window_minimized) { diff --git a/src/runtime/lifecycle/frame_clock.c b/src/runtime/lifecycle/frame_clock.c index 13b6c10a..db93be7d 100644 --- a/src/runtime/lifecycle/frame_clock.c +++ b/src/runtime/lifecycle/frame_clock.c @@ -34,6 +34,7 @@ int bongo_cat_window_wait_timeout(const BongoCatApp *app, uint64_t now) { if (bongo_cat_preferences_needs_frame(app->preferences) && wait_ms > 4) wait_ms = 4; if (app->wheel_animation_active && wait_ms > 8) wait_ms = 8; + if (app->hover_fade_active && wait_ms > 8) wait_ms = 8; if (app->session.window.visible && !app->window_minimized && (app->click_through_applied || (app->settings.window.pass_through && app->settings.window.always_on_top && app->settings.window.hide_on_hover)) && diff --git a/src/runtime/lifecycle/runtime_flow.c b/src/runtime/lifecycle/runtime_flow.c index 47698e89..5e52df45 100644 --- a/src/runtime/lifecycle/runtime_flow.c +++ b/src/runtime/lifecycle/runtime_flow.c @@ -20,6 +20,7 @@ static void scale(BongoCatApp *app, float value) { static void opacity(BongoCatApp *app, float value) { app->session.window.opacity_percent = value; + bongo_cat_app_cancel_hover_fade(app); bongo_cat_platform_set_opacity(&app->platform, value / 100.0f); } diff --git a/src/runtime/model/multi_pet_selection.c b/src/runtime/model/multi_pet_selection.c index 42132f68..a89a846d 100644 --- a/src/runtime/model/multi_pet_selection.c +++ b/src/runtime/model/multi_pet_selection.c @@ -24,6 +24,7 @@ static void adopt_pet_window(BongoCatApp *app, if (!bongo_cat_window_apply_geometry(app, window->x, window->y, window->scale_percent, window->width, window->height)) return; app->session.window.opacity_percent = window->opacity_percent; + bongo_cat_app_cancel_hover_fade(app); bongo_cat_platform_set_opacity(&app->platform, window->opacity_percent / 100.0f); } diff --git a/src/runtime/runtime.h b/src/runtime/runtime.h index 4bf4776b..ba56309d 100644 --- a/src/runtime/runtime.h +++ b/src/runtime/runtime.h @@ -94,6 +94,8 @@ bool bongo_cat_app_audit_screen_pointer(BongoCatApp *app); bool bongo_cat_app_audit_display_pointer(BongoCatApp *app); void bongo_cat_app_track_hover(BongoCatApp *app, double x, double y); void bongo_cat_app_update_hover(BongoCatApp *app, uint64_t now); +void bongo_cat_app_update_hover_fade(BongoCatApp *app, uint64_t now); +void bongo_cat_app_cancel_hover_fade(BongoCatApp *app); bool bongo_cat_app_shortcuts_self_test(BongoCatApp *app); bool bongo_cat_app_sound_shortcuts(BongoCatApp *app, const BongoCatInputEvent *event, bool *changed); diff --git a/src/runtime/shell/window.c b/src/runtime/shell/window.c index 851af73d..1e0d27bd 100644 --- a/src/runtime/shell/window.c +++ b/src/runtime/shell/window.c @@ -98,6 +98,7 @@ BongoCatResult bongo_cat_window_create(BongoCatApp *app, BongoCatError *error) { void bongo_cat_window_apply(BongoCatApp *app) { BongoCatWindowPreferences *preferences = &app->settings.window; BongoCatWindowState *state = &app->session.window; + bongo_cat_app_cancel_hover_fade(app); bongo_cat_platform_set_opacity(&app->platform, state->opacity_percent / 100.0f); SDL_SetWindowSize(app->window, state->width, state->height); diff --git a/src/runtime/shell/window_geometry.c b/src/runtime/shell/window_geometry.c index 7d34092f..22ff77dc 100644 --- a/src/runtime/shell/window_geometry.c +++ b/src/runtime/shell/window_geometry.c @@ -179,6 +179,7 @@ bool bongo_cat_window_geometry_self_test(BongoCatApp *app) { &app->platform) - 0.5f) < 0.02f; app->settings.window.hide_on_hover = true; app->settings.window.hide_delay_seconds = 0.0f; + app->settings.window.hide_fade_seconds = 0.0f; app->settings.window.pass_through = true; app->settings.window.always_on_top = true; app->settings.window.obs_background = true; @@ -209,6 +210,23 @@ bool bongo_cat_window_geometry_self_test(BongoCatApp *app) { app->settings.window.hide_on_hover = false; bongo_cat_app_update_hover(app, SDL_GetTicksNS()); restored = restored && !app->hover_hidden; + /* With a fade duration the hide no longer snaps: progress animates from + the on-screen opacity toward the target and completes there. */ + app->settings.window.hide_on_hover = true; + app->settings.window.hide_fade_seconds = 0.5f; + bongo_cat_app_track_hover(app, x + 10, y + 10); + uint64_t fade_started = SDL_GetTicksNS(); + bongo_cat_app_update_hover_fade(app, fade_started + 125000000ull); + float faded = bongo_cat_platform_get_opacity(&app->platform); + bongo_cat_app_update_hover_fade(app, fade_started + 600000000ull); + bool fade = app->hover_hidden && faded > 0.02f && faded < 0.98f && + bongo_cat_platform_get_opacity(&app->platform) < 0.02f; + bongo_cat_app_track_hover(app, bounds.x - 10, bounds.y - 10); + bongo_cat_app_update_hover_fade(app, SDL_GetTicksNS() + 700000000ull); + fade = fade && !app->hover_hidden && + SDL_fabsf(bongo_cat_platform_get_opacity(&app->platform) - 1.0f) < 0.02f; + app->settings.window.hide_fade_seconds = 0.0f; + app->settings.window.hide_on_hover = false; float safe_scale; int safe_width, safe_height; bool bounded = bongo_cat_window_scaled_size(8000, 4000, 100.0f, 500.0f, @@ -267,10 +285,10 @@ bool bongo_cat_window_geometry_self_test(BongoCatApp *app) { bongo_cat_window_sync_click_through(app); SDL_SyncWindow(app->window); bool passed = clamped && anchor_reset && scaled && opacity && hidden && restored && - bounded && gesture && display_reset; + fade && bounded && gesture && display_reset; if (!passed) fprintf(stderr, "geometry self-test: clamped=%d scaled=%d(%dx%d) " - "anchor=%d opacity=%d hidden=%d restored=%d bounded=%d gesture=%d(%dx%d) display=%d\n", + "anchor=%d opacity=%d hidden=%d restored=%d fade=%d bounded=%d gesture=%d(%dx%d) display=%d\n", clamped, scaled, scaled_width, scaled_height, anchor_reset, opacity, hidden, restored, - bounded, gesture, gesture_width, gesture_height, display_reset); + fade, bounded, gesture, gesture_width, gesture_height, display_reset); return passed; } diff --git a/src/runtime/shell/window_hit.c b/src/runtime/shell/window_hit.c index b92863d5..2c6b7130 100644 --- a/src/runtime/shell/window_hit.c +++ b/src/runtime/shell/window_hit.c @@ -74,6 +74,7 @@ void bongo_cat_window_set_visible(BongoCatApp *app, bool visible) { SDL_RestoreWindow(app->window); app->window_minimized = false; app->hover_hidden = false; + bongo_cat_app_cancel_hover_fade(app); bongo_cat_app_reset_pointer_tracking(app); bongo_cat_platform_set_opacity(&app->platform, app->session.window.opacity_percent / 100.0f); diff --git a/src/runtime/shell/window_menu_preview.c b/src/runtime/shell/window_menu_preview.c index 3b3e04d6..709ca753 100644 --- a/src/runtime/shell/window_menu_preview.c +++ b/src/runtime/shell/window_menu_preview.c @@ -60,6 +60,7 @@ void bongo_cat_window_menu_preview(void *userdata, BongoCatMenuAction action) { action <= BONGO_CAT_MENU_OPACITY_100) { app->session.window.opacity_percent = (float)(10 * (action - BONGO_CAT_MENU_OPACITY_10 + 1)); + bongo_cat_app_cancel_hover_fade(app); bongo_cat_platform_set_opacity(&app->platform, app->session.window.opacity_percent / 100.0f); } else if (action == state->applied && @@ -122,6 +123,7 @@ void bongo_cat_window_menu_restore(void *userdata, BongoCatMenuAction selected) if (!keep_opacity && SDL_fabsf(app->session.window.opacity_percent - state->opacity) > .01f) { app->session.window.opacity_percent = state->opacity; + bongo_cat_app_cancel_hover_fade(app); bongo_cat_platform_set_opacity(&app->platform, state->opacity / 100.0f); changed = true; diff --git a/src/runtime/shell/window_wheel.c b/src/runtime/shell/window_wheel.c index 0ad42dc5..45fe869c 100644 --- a/src/runtime/shell/window_wheel.c +++ b/src/runtime/shell/window_wheel.c @@ -174,8 +174,10 @@ void bongo_cat_window_update_wheel_animation(BongoCatApp *app, uint64_t now) { SDL_fabsf(scale - app->session.window.scale_percent) > 0.001f; if (SDL_fabsf(opacity - app->session.window.opacity_percent) > 0.001f) { app->session.window.opacity_percent = opacity; - if (!app->hover_hidden) bongo_cat_platform_set_opacity( - &app->platform, opacity / 100.0f); + if (!app->hover_hidden) { + bongo_cat_app_cancel_hover_fade(app); + bongo_cat_platform_set_opacity(&app->platform, opacity / 100.0f); + } } apply_scale(app, scale); bool reached = diff --git a/src/ui/preferences/preferences_pages.c b/src/ui/preferences/preferences_pages.c index 834dd182..8b2bba78 100644 --- a/src/ui/preferences/preferences_pages.c +++ b/src/ui/preferences/preferences_pages.c @@ -53,6 +53,12 @@ static void page_display(BongoCatApp *app, struct nk_context *context) { "pages.preference.cat.labels.hideOnHover", "Hide on Hover"), "", &window->hide_on_hover)) bongo_cat_app_update_hover(app, SDL_GetTicksNS()); + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_SHORTCUT_VISIBILITY); + bongo_cat_pref_float(context, "hide-fade", tr(app, + "pages.preference.cat.labels.hideFadeSeconds", "Fade Duration (s)"), + "", 0.0f, &window->hide_fade_seconds, + BONGO_CAT_MAX_HIDE_FADE_SECONDS, 0.1f, + BONGO_CAT_DEFAULT_HIDE_FADE_SECONDS); } bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_KEEP_IN_SCREEN); if (bongo_cat_pref_toggle(context, "keep-in-screen", tr(app, @@ -102,9 +108,11 @@ static void page_display(BongoCatApp *app, struct nk_context *context) { BONGO_CAT_DEFAULT_WINDOW_OPACITY_PERCENT); if (old_opacity != window_state->opacity_percent) bongo_cat_window_cancel_wheel_animation(app); - if (old_opacity != window_state->opacity_percent && !app->hover_hidden) + if (old_opacity != window_state->opacity_percent && !app->hover_hidden) { + bongo_cat_app_cancel_hover_fade(app); bongo_cat_platform_set_opacity(&app->platform, window_state->opacity_percent / 100.0f); + } bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_RANDOM_EXPRESSION); bongo_cat_pref_toggle_float(context, "random-expression", tr(app, "pages.preference.cat.labels.randomExpression", "Random Expressions"), From 4365b230cb43d6c0cce61e2bb6045baf9c477624 Mon Sep 17 00:00:00 2001 From: May-u-love Date: Sun, 13 Sep 2026 09:38:41 +0800 Subject: [PATCH 281/334] Fix macOS rendering: core-profile GL support and preferences interaction The macOS build crashed on launch and, once running, rendered no model and had an unclickable settings window. Four independent issues: 1. Launch crash: glewInit() was compiled out on macOS, leaving the GLEW function table all-NULL; the first GL call in the Cubism renderer jumped to 0x0. Include CSM_TARGET_MAC_GL in the GLEW guard. 2. The core profile rejects the GLSL 1.20 Standard shaders. Translate them to GLSL 330 at configure time (new cmake/CubismCoreProfile.cmake) and stage the translated files wherever framework shaders are copied. 3. Core profile requires a bound VAO for vertex-attrib calls; the GLES2-style renderer never binds one. Bind a dedicated VAO before renderer creation and each frame draw. 4. Core profile silently ignores client-side vertex arrays (no GL error, zero pixels). Patch DrawMeshOpenGL (build time, CSM_TARGET_MAC_GL only) to re-host the mesh in reused stream VBOs, and pin a_position to attrib location 0 at link time. Also fix the preferences window on macOS 26: applying NSWindowStyleMaskTitled to the SDL borderless window makes the system swallow every mouse event after the first mouse-down, so Nuklear never sees the mouse-up and the UI ignores all clicks. Keep the window borderless and draw the window controls in the UI (native_chrome off, the same path Windows/Linux use). Verified on macOS 26.6 arm64 (M5): launch, standard/gamepad model rendering (framebuffer dumps), key-left audit, preferences clicks, and the full CTest suite. --- cmake/Cubism.cmake | 3 + cmake/CubismAssets.cmake | 8 +- cmake/CubismCoreProfile.cmake | 124 +++++++++++++++++++++++++ cmake/CubismShaderOptimize.cmake | 12 +++ src/live2d/cubism_bridge.cpp | 4 +- src/live2d/cubism_model.hpp | 3 + src/live2d/cubism_model_renderer.cpp | 15 +++ src/live2d/cubism_model_update.cpp | 1 + src/platform/macos/macos_preferences.m | 2 +- src/ui/theme/ui_catime.c | 8 +- 10 files changed, 169 insertions(+), 11 deletions(-) create mode 100644 cmake/CubismCoreProfile.cmake diff --git a/cmake/Cubism.cmake b/cmake/Cubism.cmake index 3b8bdb07..e79adf0d 100644 --- a/cmake/Cubism.cmake +++ b/cmake/Cubism.cmake @@ -52,6 +52,9 @@ include(cmake/CubismUserModelSafety.cmake) bongo_cat_harden_cubism_user_model(Framework) include(cmake/CubismShaderOptimize.cmake) bongo_cat_optimize_cubism_shaders(Framework) +include(cmake/CubismCoreProfile.cmake) +bongo_cat_core_profile_prepare_shaders() +bongo_cat_core_profile_patch_renderer(Framework) if(WIN32) target_compile_definitions(Framework PUBLIC CSM_TARGET_WIN_GL GLEW_NO_GLU) diff --git a/cmake/CubismAssets.cmake b/cmake/CubismAssets.cmake index 8e8dcb78..cae42e9d 100644 --- a/cmake/CubismAssets.cmake +++ b/cmake/CubismAssets.cmake @@ -4,11 +4,11 @@ function(bongo_cat_configure_embedded_cubism_assets) return() endif() file(GLOB_RECURSE shader_inputs CONFIGURE_DEPENDS - "${CUBISM_FRAMEWORK_PATH}/src/Rendering/OpenGL/Shaders/Standard/*") + "${BONGO_CAT_CUBISM_SHADER_SOURCE_DIR}/*") set(BONGO_CAT_ASSET_INPUTS ${BONGO_CAT_ASSET_INPUTS} ${shader_inputs} PARENT_SCOPE) set(BONGO_CAT_ASSET_EXTRA_COMMANDS COMMAND ${CMAKE_COMMAND} -E copy_directory - "${CUBISM_FRAMEWORK_PATH}/src/Rendering/OpenGL/Shaders/Standard" + "${BONGO_CAT_CUBISM_SHADER_SOURCE_DIR}" "${BONGO_CAT_ASSET_STAGE}/assets/FrameworkShaders" PARENT_SCOPE) endfunction() @@ -25,12 +25,12 @@ function(bongo_cat_stage_cubism_assets target) endif() add_custom_command(TARGET ${target} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory - "${BONGO_CAT_CUBISM_SDK}/Framework/src/Rendering/OpenGL/Shaders/Standard" + "${BONGO_CAT_CUBISM_SHADER_SOURCE_DIR}" "${shader_destination}" VERBATIM) if(UNIX AND NOT APPLE AND target STREQUAL "bongo_cat") install(DIRECTORY - "${CUBISM_FRAMEWORK_PATH}/src/Rendering/OpenGL/Shaders/Standard/" + "${BONGO_CAT_CUBISM_SHADER_SOURCE_DIR}/" DESTINATION assets/FrameworkShaders COMPONENT Runtime) endif() endfunction() diff --git a/cmake/CubismCoreProfile.cmake b/cmake/CubismCoreProfile.cmake new file mode 100644 index 00000000..9a37877f --- /dev/null +++ b/cmake/CubismCoreProfile.cmake @@ -0,0 +1,124 @@ +# Core-profile (macOS) compatibility for the Cubism GLES2 rendering path. +# +# The macOS build requests an OpenGL core profile context, which differs from +# the compatibility contexts used on Windows and Linux in three ways that the +# GLES2-style Cubism renderer does not expect: +# +# 1. GLSL 1.20 shaders do not compile, so the desktop `Standard` shaders are +# translated to GLSL 330 at configure time. The translated files replace +# the SDK ones everywhere the framework shaders are staged. +# 2. Vertex-attributable calls require a bound VAO (handled in the runtime +# by NativeModel::bind_model_vao). +# 3. Client-side vertex arrays are silently ignored, so DrawMeshOpenGL +# re-hosts the mesh in stream VBOs. +# +# Everything is guarded by CSM_TARGET_MAC_GL or applied to shader text only, +# so Windows and Linux binaries are unchanged. + +set(BONGO_CAT_CUBISM_SHADER_SOURCE_DIR + "${CMAKE_CURRENT_BINARY_DIR}/generated/cubism-shaders") + +function(bongo_cat_core_profile_convert_shader input output) + file(READ "${input}" text) + string(REPLACE "\r\n" "\n" text "${text}") + string(REPLACE "#version 120" "#version 330" text "${text}") + get_filename_component(name "${input}" NAME) + if(name STREQUAL "FragShaderSrcColorBlend.frag" OR + name STREQUAL "FragShaderSrcAlphaBlend.frag") + # Appended blend-mode snippets: pure functions without legacy syntax. + elseif(input MATCHES "\\.vert$") + string(REPLACE "attribute " "in " text "${text}") + string(REPLACE "varying " "out " text "${text}") + else() + string(REPLACE "varying " "in " text "${text}") + string(REPLACE "texture2D(" "texture(" text "${text}") + string(REPLACE "gl_FragColor" "BongoCatFragColor" text "${text}") + string(REPLACE "#version 330\n" + "#version 330\n\nout vec4 BongoCatFragColor;\n" text "${text}") + endif() + foreach(legacy IN ITEMS "attribute " "varying " "texture2D(" "gl_FragColor" + "#version 120") + string(FIND "${text}" "${legacy}" position) + if(NOT position EQUAL -1) + message(FATAL_ERROR "Core-profile shader conversion failed for ${name}: " + "'${legacy}' remains") + endif() + endforeach() + file(WRITE "${output}" "${text}") +endfunction() + +function(bongo_cat_core_profile_prepare_shaders) + set(source_dir "${CUBISM_FRAMEWORK_PATH}/src/Rendering/OpenGL/Shaders/Standard") + file(MAKE_DIRECTORY "${BONGO_CAT_CUBISM_SHADER_SOURCE_DIR}") + file(GLOB shader_files "${source_dir}/*") + foreach(input IN LISTS shader_files) + get_filename_component(name "${input}" NAME) + bongo_cat_core_profile_convert_shader("${input}" + "${BONGO_CAT_CUBISM_SHADER_SOURCE_DIR}/${name}") + endforeach() +endfunction() + +function(bongo_cat_core_profile_patch_renderer target) + set(source_path "${CUBISM_FRAMEWORK_PATH}/src/Rendering/OpenGL/CubismRenderer_OpenGLES2.cpp") + set(output_dir "${CMAKE_CURRENT_BINARY_DIR}/generated/cubism") + set(output_source "${output_dir}/CubismRenderer_OpenGLES2.cpp") + file(READ "${source_path}" source) + string(REPLACE "\r\n" "\n" source "${source}") + + set(legacy_draw [=[ + csmInt32 indexCount = model.GetDrawableVertexIndexCount(index); + csmUint16* indexArray = const_cast(model.GetDrawableVertexIndices(index)); + glDrawElements(GL_TRIANGLES, indexCount, GL_UNSIGNED_SHORT, indexArray);]=]) + set(vbo_draw [=[ + csmInt32 indexCount = model.GetDrawableVertexIndexCount(index); + csmUint16* indexArray = const_cast(model.GetDrawableVertexIndices(index)); +#if defined(CSM_TARGET_MAC_GL) + // Core-profile contexts reject client-side vertex arrays, so the + // GLES2-style mesh pointers are re-hosted in reused stream VBOs. + static GLuint meshVbo[3] = {0, 0, 0}; + if (meshVbo[0] == 0) + { + glGenBuffers(3, meshVbo); + } + GLint positionLocation = glGetAttribLocation(currentProgram, "a_position"); + GLint texCoordLocation = glGetAttribLocation(currentProgram, "a_texCoord"); + csmInt32 vertexCount = model.GetDrawableVertexCount(index); + if (positionLocation >= 0) + { + glBindBuffer(GL_ARRAY_BUFFER, meshVbo[0]); + glBufferData(GL_ARRAY_BUFFER, vertexCount * sizeof(csmFloat32) * 2, + model.GetDrawableVertices(index), GL_STREAM_DRAW); + glVertexAttribPointer(positionLocation, 2, GL_FLOAT, GL_FALSE, + sizeof(csmFloat32) * 2, NULL); + } + if (texCoordLocation >= 0) + { + glBindBuffer(GL_ARRAY_BUFFER, meshVbo[1]); + glBufferData(GL_ARRAY_BUFFER, vertexCount * sizeof(csmFloat32) * 2, + model.GetDrawableVertexUvs(index), GL_STREAM_DRAW); + glVertexAttribPointer(texCoordLocation, 2, GL_FLOAT, GL_FALSE, + sizeof(csmFloat32) * 2, NULL); + } + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, meshVbo[2]); + glBufferData(GL_ELEMENT_ARRAY_BUFFER, indexCount * sizeof(csmUint16), + indexArray, GL_STREAM_DRAW); + glDrawElements(GL_TRIANGLES, indexCount, GL_UNSIGNED_SHORT, NULL); + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0); +#else + glDrawElements(GL_TRIANGLES, indexCount, GL_UNSIGNED_SHORT, indexArray); +#endif]=]) + string(FIND "${source}" "${legacy_draw}" position) + if(position EQUAL -1) + message(FATAL_ERROR "Core-profile renderer patch mismatch: mesh draw") + endif() + string(REPLACE "${legacy_draw}" "${vbo_draw}" source "${source}") + + file(MAKE_DIRECTORY "${output_dir}") + file(WRITE "${output_source}" "${source}") + get_target_property(framework_sources ${target} SOURCES) + list(REMOVE_ITEM framework_sources "${source_path}") + set_property(TARGET ${target} PROPERTY SOURCES "${framework_sources}") + target_sources(${target} PRIVATE "${output_source}") + target_include_directories(${target} PRIVATE + "${CUBISM_FRAMEWORK_PATH}/src/Rendering/OpenGL") +endfunction() diff --git a/cmake/CubismShaderOptimize.cmake b/cmake/CubismShaderOptimize.cmake index 3d2f92ce..5fea86bf 100644 --- a/cmake/CubismShaderOptimize.cmake +++ b/cmake/CubismShaderOptimize.cmake @@ -44,6 +44,18 @@ function(bongo_cat_optimize_cubism_shaders target) "/* Model upload owns minification filtering and fallback. */" "model texture minification ownership") + # Core-profile GL requires vertex attrib array 0 to be enabled; pin the + # position attribute there so GLES2-style draw calls stay valid on macOS. + bongo_cat_replace_cubism_text(source + [=[ + GLint status; + glLinkProgram(shaderProgram);]=] + [=[ + GLint status; + glBindAttribLocation(shaderProgram, 0, "a_position"); + glLinkProgram(shaderProgram);]=] + "core-profile attribute-0 pin") + set(compile_anchor [=[ _shaderSets[ShaderNames_MultMaskedInvertedPremultipliedAlpha]->ShaderProgram = _shaderSets[ShaderNames_NormalMaskedInvertedPremultipliedAlpha]->ShaderProgram;]=]) set(block_start [=[ { diff --git a/src/live2d/cubism_bridge.cpp b/src/live2d/cubism_bridge.cpp index f2082909..37ae17be 100644 --- a/src/live2d/cubism_bridge.cpp +++ b/src/live2d/cubism_bridge.cpp @@ -1,6 +1,6 @@ #include "bongo_cat/file.h" #include "bongo_cat/model.h" -#if defined(CSM_TARGET_WIN_GL) || defined(CSM_TARGET_LINUX_GL) +#if defined(CSM_TARGET_WIN_GL) || defined(CSM_TARGET_LINUX_GL) || defined(CSM_TARGET_MAC_GL) #include #endif #include "cubism_runtime.hpp" @@ -76,7 +76,7 @@ void release_file(Csm::csmByte *bytes) { std::free(bytes); } bool start_framework(BongoCatError *error) { if (runtime_count++) return true; -#if defined(CSM_TARGET_WIN_GL) || defined(CSM_TARGET_LINUX_GL) +#if defined(CSM_TARGET_WIN_GL) || defined(CSM_TARGET_LINUX_GL) || defined(CSM_TARGET_MAC_GL) glewExperimental = GL_TRUE; GLenum glew_result = glewInit(); glGetError(); diff --git a/src/live2d/cubism_model.hpp b/src/live2d/cubism_model.hpp index be64d6ae..cd7d88b5 100644 --- a/src/live2d/cubism_model.hpp +++ b/src/live2d/cubism_model.hpp @@ -132,6 +132,7 @@ class NativeModel final : public Csm::CubismUserModel { void release_renderer(); bool create_renderer(BongoCatError *error); void bind_textures(); + void bind_model_vao(); std::vector read(const std::string &path, size_t maximum = (size_t)-1) const; std::string path(const char *relative) const; @@ -157,6 +158,8 @@ class NativeModel final : public Csm::CubismUserModel { Csm::csmVector eye_blink_ids_; Csm::csmVector lip_sync_ids_; std::string directory_; + void *model_vao_context_ = nullptr; + GLuint model_vao_ = 0; int width_ = 612; int height_ = 354; int renderer_width_ = 0; diff --git a/src/live2d/cubism_model_renderer.cpp b/src/live2d/cubism_model_renderer.cpp index b4a6f4ac..7b42b932 100644 --- a/src/live2d/cubism_model_renderer.cpp +++ b/src/live2d/cubism_model_renderer.cpp @@ -32,12 +32,27 @@ void NativeModel::release_render_resources() { release_renderer(); } +void NativeModel::bind_model_vao() { + // Core-profile contexts (macOS 4.1) require a bound VAO for vertex-attrib + // calls; the GLES2-style Cubism renderer never creates one of its own. + SDL_GLContext current = SDL_GL_GetCurrentContext(); + auto gen = (PFNGLGENVERTEXARRAYSPROC)SDL_GL_GetProcAddress("glGenVertexArrays"); + auto bind = (PFNGLBINDVERTEXARRAYPROC)SDL_GL_GetProcAddress("glBindVertexArray"); + if (!current || !gen || !bind) return; + if (!model_vao_ || current != (SDL_GLContext)model_vao_context_) { + gen(1, &model_vao_); + model_vao_context_ = (void *)current; + } + bind(model_vao_); +} + bool NativeModel::create_renderer(BongoCatError *error) { if (!SDL_GL_GetCurrentContext()) { bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, "Cannot create the Live2D renderer without an OpenGL context"); return false; } + bind_model_vao(); if (!bongo_cat_gl_clear_errors()) { bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, "Cannot clear the OpenGL error state before creating the Live2D renderer"); diff --git a/src/live2d/cubism_model_update.cpp b/src/live2d/cubism_model_update.cpp index 0933bd50..fa500fb4 100644 --- a/src/live2d/cubism_model_update.cpp +++ b/src/live2d/cubism_model_update.cpp @@ -112,6 +112,7 @@ void NativeModel::build_projection(Csm::CubismMatrix44 &projection, void NativeModel::draw() { if (!_model || width_ <= 0 || height_ <= 0) return; + bind_model_vao(); auto *manager = Csm::Rendering::CubismOffscreenManager_OpenGLES2::GetInstance(); manager->BeginFrameProcess(); Csm::CubismMatrix44 projection; diff --git a/src/platform/macos/macos_preferences.m b/src/platform/macos/macos_preferences.m index 14dfd7b7..11840538 100644 --- a/src/platform/macos/macos_preferences.m +++ b/src/platform/macos/macos_preferences.m @@ -44,7 +44,7 @@ void bongo_cat_platform_configure_preferences_window(SDL_Window *sdl_window) { SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, NULL); if (!window) return; NSWindowStyleMask style = [window styleMask]; - style |= NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | + style |= NSWindowStyleMaskClosable | NSWindowStyleMaskMiniaturizable | NSWindowStyleMaskResizable | NSWindowStyleMaskFullSizeContentView; [window setStyleMask:style]; diff --git a/src/ui/theme/ui_catime.c b/src/ui/theme/ui_catime.c index 02d4673a..4a6c0198 100644 --- a/src/ui/theme/ui_catime.c +++ b/src/ui/theme/ui_catime.c @@ -43,11 +43,11 @@ float bongo_cat_ui_sidebar_width(float window_width) { } bool bongo_cat_ui_native_chrome(void) { -#ifdef __APPLE__ - return true; -#else + /* Applying NSWindowStyleMaskTitled to the SDL borderless preferences + window makes macOS 26 swallow every mouse event after the first + mouse-down, so the window stays borderless and the UI draws its own + window controls on every platform. */ return false; -#endif } void bongo_cat_ui_shell_draw(struct nk_context *context, float width, From 0f14566a31736deefba1541aaf0b4138b25c5fc8 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 13 Sep 2026 10:10:32 +0800 Subject: [PATCH 282/334] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b886619b..49aceea0 100644 --- a/README.md +++ b/README.md @@ -381,7 +381,7 @@ we need. ## 🙏 Special Thanks > [!TIP] -> Every step BongoCat takes is powered by the spirit of open source. We sincerely thank all our community contributors for their selfless contributions (listed below in chronological order by contribution date). It is your support that makes desktop companionship more free and genuine.❤️‍🔥 +> Every step BongoCat takes is powered by the spirit of open source. We sincerely thank all our community contributors for their selfless contributions It is your support that makes desktop companionship more free and genuine.❤️‍🔥 From f357d38c32e2daa811e81b71ffc0fdb3979b4439 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 13 Sep 2026 10:39:40 +0800 Subject: [PATCH 283/334] fix: improve hover fading and preferences styling --- cmake/Tests.cmake | 10 ++ include/bongo_cat/app.h | 4 +- include/bongo_cat/platform.h | 1 + resources/assets/locales/de-DE.json | 1 + resources/assets/locales/en-US.json | 1 + resources/assets/locales/es-ES.json | 1 + resources/assets/locales/fr-FR.json | 1 + resources/assets/locales/ja-JP.json | 1 + resources/assets/locales/ko-KR.json | 1 + resources/assets/locales/pt-BR.json | 1 + resources/assets/locales/ru-RU.json | 1 + resources/assets/locales/zh-CN.json | 3 +- resources/assets/locales/zh-Hant.json | 1 + src/platform/linux/linux.c | 7 + src/platform/macos/macos.m | 2 + src/platform/windows/windows_layered.c | 2 + .../windows/windows_layered_internal.h | 2 + src/platform/windows/windows_layered_state.c | 14 +- src/runtime/input/hover_fade.h | 31 ++++ src/runtime/input/mouse.c | 92 ++++++---- src/runtime/lifecycle/app_loop.c | 3 +- src/runtime/model/model_files.c | 2 + src/runtime/model/multi_pet_selection.c | 2 + src/runtime/shell/modal_frame.c | 2 + src/runtime/shell/window_menu_actions.c | 7 +- src/runtime/shell/window_menu_preview.c | 16 +- src/ui/preferences/preferences_icons.h | 1 + src/ui/preferences/preferences_number_edit.c | 4 + src/ui/preferences/preferences_pages.c | 13 +- src/ui/preferences/preferences_row_icons.c | 14 +- tests/core/test_hover_fade.c | 167 ++++++++++++++++++ 31 files changed, 358 insertions(+), 50 deletions(-) create mode 100644 src/runtime/input/hover_fade.h create mode 100644 tests/core/test_hover_fade.c diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index dd5a38da..bae63faf 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -1,4 +1,14 @@ if(BUILD_TESTING) + add_executable(bongo_cat_hover_fade_tests + tests/core/test_hover_fade.c src/runtime/input/mouse.c + src/runtime/shell/modal_frame.c) + target_include_directories(bongo_cat_hover_fade_tests PRIVATE + "${BONGO_CAT_GENERATED_INCLUDE_DIR}" include tests/support + ${BONGO_CAT_RUNTIME_INTERNAL_INCLUDE_DIRS}) + target_link_libraries(bongo_cat_hover_fade_tests PRIVATE + SDL3::SDL3-static bongo_cat_warnings) + add_test(NAME hover-fade COMMAND bongo_cat_hover_fade_tests) + add_executable(bongo_cat_audio_tests tests/media/test_audio.c) target_include_directories(bongo_cat_audio_tests PRIVATE src/media/audio tests/support) target_link_libraries(bongo_cat_audio_tests PRIVATE bongo_cat_runtime bongo_cat_warnings) diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index 6b0c764c..91b8487a 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -142,8 +142,8 @@ typedef struct BongoCatApp { bool hover_inside; bool hover_hidden; bool hover_fade_active; - float hover_fade_from, hover_fade_to; - uint64_t hover_fade_start_ns; + float hover_fade_phase, hover_fade_target, hover_fade_opacity; + uint64_t hover_fade_tick_ns, hover_fade_next_ns; bool pointer_known; bool pointer_hit_dirty; bool pointer_transparent; diff --git a/include/bongo_cat/platform.h b/include/bongo_cat/platform.h index ed15b5f6..dd0c9cf3 100644 --- a/include/bongo_cat/platform.h +++ b/include/bongo_cat/platform.h @@ -15,6 +15,7 @@ typedef struct BongoCatPlatform { void *presenter; uint32_t wake_event_type; float window_opacity; + bool hover_hide_unavailable; } BongoCatPlatform; typedef enum BongoCatMenuAction { diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index 92d19707..79588f03 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -30,6 +30,7 @@ "maxFPS": "Maximale Bildrate" }, "hints": { + "hoverUnavailable": "Ausblenden bei Mausberührung ist in dieser Desktop-Umgebung nicht verfügbar.", "gameInput": "Desktop-Tier reagiert im Spiel nicht?", "gameInputHelp": "Versuche, BongoCat als Administrator auszuführen und das Spiel in den Fenstermodus zu versetzen.", "passThrough": "Wenn aktiviert, gehen Klicks durch das Fenster hindurch, ohne es zu beeinflussen.", diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index bb370fbb..92c591a5 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -30,6 +30,7 @@ "maxFPS": "Max Frame Rate" }, "hints": { + "hoverUnavailable": "Hover hiding is unavailable in the current desktop environment.", "gameInput": "Pet not responding in games?", "gameInputHelp": "Try running BongoCat as administrator and setting the game to windowed mode.", "passThrough": "When enabled, clicks pass through the window without affecting it.", diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index f946acb5..3999dd9d 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -30,6 +30,7 @@ "maxFPS": "Frecuencia máxima de fotogramas" }, "hints": { + "hoverUnavailable": "Ocultar al pasar el cursor no está disponible en el entorno de escritorio actual.", "gameInput": "¿La mascota no responde al jugar?", "gameInputHelp": "Prueba a ejecutar BongoCat como administrador y a poner el juego en modo ventana.", "passThrough": "Al activarlo, los clics atraviesan la ventana sin afectarla.", diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index cb938b7d..ee9e4b6f 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -30,6 +30,7 @@ "maxFPS": "Fréquence d'images maximale" }, "hints": { + "hoverUnavailable": "Le masquage au survol est indisponible dans cet environnement de bureau.", "gameInput": "La mascotte ne réagit pas en jeu ?", "gameInputHelp": "Essayez de lancer BongoCat en tant qu’administrateur et de passer le jeu en mode fenêtré.", "passThrough": "Lorsque cette option est activée, les clics traversent la fenêtre sans l'affecter.", diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index 06b51756..f8ef5cb9 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -30,6 +30,7 @@ "maxFPS": "最大フレームレート" }, "hints": { + "hoverUnavailable": "現在のデスクトップ環境では、マウスを重ねて非表示にする機能は利用できません。", "gameInput": "ゲーム中にデスクトップペットが反応しない?", "gameInputHelp": "BongoCat を管理者として起動し、ゲームをウィンドウモードに設定してみてください。", "passThrough": "有効にすると、クリックはウィンドウに影響せず背後へ通過します。", diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index b3679ff3..22030e3c 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -30,6 +30,7 @@ "maxFPS": "최대 프레임 속도" }, "hints": { + "hoverUnavailable": "현재 데스크톱 환경에서는 마우스를 올려 숨기기 기능을 사용할 수 없습니다.", "gameInput": "게임 중 데스크톱 펫이 반응하지 않나요?", "gameInputHelp": "BongoCat을 관리자 권한으로 실행하고 게임을 창 모드로 설정해 보세요.", "passThrough": "활성화하면 클릭이 창에 영향을 주지 않고 뒤로 통과합니다.", diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index f113340f..9eee0191 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -30,6 +30,7 @@ "maxFPS": "Taxa Máxima de Quadros" }, "hints": { + "hoverUnavailable": "Ocultar ao passar o mouse não está disponível no ambiente de desktop atual.", "gameInput": "O mascote não responde ao jogar?", "gameInputHelp": "Tente executar o BongoCat como administrador e colocar o jogo em modo janela.", "passThrough": "Quando ativado, a janela não afetará operações em outros aplicativos.", diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index 40d76fed..b24ae0e1 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -30,6 +30,7 @@ "maxFPS": "Максимальная частота кадров" }, "hints": { + "hoverUnavailable": "Скрытие при наведении недоступно в текущем окружении рабочего стола.", "gameInput": "Питомец не реагирует в игре?", "gameInputHelp": "Попробуйте запустить BongoCat от имени администратора и перевести игру в оконный режим.", "passThrough": "Если включено, щелчки проходят сквозь окно, не воздействуя на него.", diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index eedb3a31..1acfa4bc 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -26,10 +26,11 @@ "randomExpression": "随机表情", "obsBackground": "纯色背景", "hideOnHover": "鼠标移入隐藏", - "hideFadeSeconds": "隐藏渐变时长 (秒)", + "hideFadeSeconds": "鼠标移入淡出时长(秒)", "maxFPS": "最大帧率" }, "hints": { + "hoverUnavailable": "当前桌面环境暂不支持鼠标移入隐藏。", "gameInput": "游戏中桌宠没反应?", "gameInputHelp": "试试以管理员身份启动 BongoCat,并将游戏设为窗口模式。", "passThrough": "启用后,窗口不影响对其他应用程序的操作。", diff --git a/resources/assets/locales/zh-Hant.json b/resources/assets/locales/zh-Hant.json index 0d73e0e4..c647c426 100644 --- a/resources/assets/locales/zh-Hant.json +++ b/resources/assets/locales/zh-Hant.json @@ -30,6 +30,7 @@ "maxFPS": "最大幀率" }, "hints": { + "hoverUnavailable": "目前桌面環境暫不支援滑鼠移入隱藏。", "gameInput": "遊戲中桌寵沒反應?", "gameInputHelp": "試試以系統管理員身分啟動 BongoCat,並將遊戲設為視窗模式。", "passThrough": "啟用後,視窗不影響對其他應用程式的操作。", diff --git a/src/platform/linux/linux.c b/src/platform/linux/linux.c index 2aa099d1..7592c82a 100644 --- a/src/platform/linux/linux.c +++ b/src/platform/linux/linux.c @@ -75,6 +75,11 @@ BongoCatResult bongo_cat_platform_init(BongoCatPlatform *platform, SDL_Window *w platform->window = window; platform->input = input; platform->window_opacity = 1.0f; + const char *driver = SDL_GetCurrentVideoDriver(); + platform->hover_hide_unavailable = !driver || strcmp(driver, "x11") != 0; + if (platform->hover_hide_unavailable) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Hover hiding requires the X11 backend on Linux"); platform->wake_event_type = SDL_RegisterEvents(1); if (platform->wake_event_type == (Uint32)-1) { bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, @@ -98,6 +103,8 @@ void bongo_cat_platform_set_click_through(BongoCatPlatform *platform, } bool bongo_cat_platform_set_opacity(BongoCatPlatform *platform, float opacity) { if (!platform || !platform->window) return false; + opacity = SDL_clamp(opacity, 0.0f, 1.0f); + if (opacity == platform->window_opacity) return true; if (!SDL_SetWindowOpacity(platform->window, opacity)) return false; platform->window_opacity = opacity; return true; diff --git a/src/platform/macos/macos.m b/src/platform/macos/macos.m index 057a6b70..2745270b 100644 --- a/src/platform/macos/macos.m +++ b/src/platform/macos/macos.m @@ -98,6 +98,8 @@ void bongo_cat_platform_set_click_through(BongoCatPlatform *platform, } bool bongo_cat_platform_set_opacity(BongoCatPlatform *platform, float opacity) { if (!platform || !platform->window) return false; + opacity = SDL_clamp(opacity, 0.0f, 1.0f); + if (opacity == platform->window_opacity) return true; if (!SDL_SetWindowOpacity(platform->window, opacity)) return false; platform->window_opacity = opacity; return true; diff --git a/src/platform/windows/windows_layered.c b/src/platform/windows/windows_layered.c index 7841ca47..7215a224 100644 --- a/src/platform/windows/windows_layered.c +++ b/src/platform/windows/windows_layered.c @@ -116,6 +116,8 @@ bool bongo_cat_windows_layered_update_proxy(BongoCatPlatform *platform, if (screen) ReleaseDC(NULL, screen); if (!presented) return SDL_SetError( "UpdateLayeredWindow failed (%lu)", (unsigned long)failure); + value->applied_alpha = blend.SourceConstantAlpha; + value->applied_alpha_valid = true; return true; } diff --git a/src/platform/windows/windows_layered_internal.h b/src/platform/windows/windows_layered_internal.h index 19bca1bc..d8efa4a7 100644 --- a/src/platform/windows/windows_layered_internal.h +++ b/src/platform/windows/windows_layered_internal.h @@ -17,6 +17,8 @@ typedef struct BongoCatWindowsLayered { int width, height, source_width, source_height; bool readback_valid, active, has_frame, mode_logged; bool source_transparent, visible, topmost; + BYTE applied_alpha; + bool applied_alpha_valid; } BongoCatWindowsLayered; bool bongo_cat_windows_layered_update_proxy(BongoCatPlatform *platform, diff --git a/src/platform/windows/windows_layered_state.c b/src/platform/windows/windows_layered_state.c index e9fe4b74..ff139efa 100644 --- a/src/platform/windows/windows_layered_state.c +++ b/src/platform/windows/windows_layered_state.c @@ -16,11 +16,19 @@ bool bongo_cat_platform_set_opacity(BongoCatPlatform *platform, float opacity) { if (!platform || !platform->window) return false; BongoCatWindowsLayered *value = platform->presenter; + float previous = platform->window_opacity; platform->window_opacity = SDL_clamp(opacity, 0.0f, 1.0f); - if (value && value->active) - return !value->has_frame || + bool updated; + if (value && value->active) { + BYTE alpha = (BYTE)(platform->window_opacity * 255.0f + 0.5f); + updated = !value->has_frame || + (value->applied_alpha_valid && value->applied_alpha == alpha) || bongo_cat_windows_layered_update_proxy(platform, value); - return SDL_SetWindowOpacity(platform->window, platform->window_opacity); + } else { + updated = SDL_SetWindowOpacity(platform->window, platform->window_opacity); + } + if (!updated) platform->window_opacity = previous; + return updated; } bool bongo_cat_platform_frame_alpha(const BongoCatPlatform *platform, diff --git a/src/runtime/input/hover_fade.h b/src/runtime/input/hover_fade.h new file mode 100644 index 00000000..ecb48db9 --- /dev/null +++ b/src/runtime/input/hover_fade.h @@ -0,0 +1,31 @@ +#ifndef BONGO_CAT_HOVER_FADE_H +#define BONGO_CAT_HOVER_FADE_H + +#include + +static inline float bongo_cat_hover_fade_curve(float phase) { + return phase * phase * (3.0f - 2.0f * phase); +} + +/* Recover the curve position from the last successfully displayed opacity. */ +static inline float bongo_cat_hover_fade_phase(float opacity) { + if (opacity <= 0.0f) return 0.0f; + if (opacity >= 1.0f) return 1.0f; + float low = 0.0f, high = 1.0f; + for (int i = 0; i < 20; ++i) { + float mid = (low + high) * 0.5f; + if (bongo_cat_hover_fade_curve(mid) < opacity) low = mid; + else high = mid; + } + return (low + high) * 0.5f; +} + +static inline float bongo_cat_hover_fade_step(float phase, bool hidden, + float elapsed, float duration) { + if (duration <= 0.0f) return hidden ? 0.0f : 1.0f; + if (elapsed <= 0.0f) return phase; + phase += (hidden ? -elapsed : elapsed) / duration; + return phase < 0.0f ? 0.0f : phase > 1.0f ? 1.0f : phase; +} + +#endif diff --git a/src/runtime/input/mouse.c b/src/runtime/input/mouse.c index 8a506ef4..92661f6c 100644 --- a/src/runtime/input/mouse.c +++ b/src/runtime/input/mouse.c @@ -1,4 +1,5 @@ #include "runtime.h" +#include "hover_fade.h" #include @@ -12,66 +13,93 @@ void bongo_cat_app_track_hover(BongoCatApp *app, double x, double y) { bongo_cat_app_update_hover(app, SDL_GetTicksNS()); } -/* Fade progress follows absolute timestamps, so a duration change or an - interrupted reversal restarts from the opacity currently on screen. */ +static bool hover_opacity(BongoCatApp *app, float opacity) { + if (bongo_cat_platform_set_opacity(&app->platform, opacity)) return true; + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Hover hiding disabled after opacity update failed: %s", SDL_GetError()); + app->platform.hover_hide_unavailable = true; + app->hover_fade_active = false; + app->hover_hidden = app->hover_inside = false; + /* Best effort: retain the successful cached opacity if restoration fails. */ + bongo_cat_platform_set_opacity(&app->platform, + app->session.window.opacity_percent / 100.0f); + bongo_cat_window_sync_click_through(app); + return false; +} + +/* Direction changes retrace the same curve, without restarting its easing. + Integrating elapsed time also makes duration edits continuous. */ void bongo_cat_app_update_hover_fade(BongoCatApp *app, uint64_t now) { if (!app || !app->hover_fade_active) return; float duration = app->settings.window.hide_fade_seconds; - float target = app->hover_fade_to; - if (duration <= 0.0f) { - app->hover_fade_active = false; - bongo_cat_platform_set_opacity(&app->platform, target); - return; - } - float seconds = (float)((double)(now >= app->hover_fade_start_ns ? - now - app->hover_fade_start_ns : 0) / 1000000000.0); - float t = seconds / duration; - if (t >= 1.0f) { - app->hover_fade_active = false; - t = 1.0f; - } else if (t < 0.0f) t = 0.0f; - float eased = t * t * (3.0f - 2.0f * t); - bongo_cat_platform_set_opacity(&app->platform, - app->hover_fade_from + - (target - app->hover_fade_from) * eased); + if (duration > 0.0f && now < app->hover_fade_next_ns) return; + float seconds = now > app->hover_fade_tick_ns ? + (float)((double)(now - app->hover_fade_tick_ns) / 1000000000.0) : 0.0f; + app->hover_fade_tick_ns = now; + app->hover_fade_next_ns = now + 8000000ull; + bool decreasing = app->hover_fade_target < app->hover_fade_phase; + float next = bongo_cat_hover_fade_step(app->hover_fade_phase, + decreasing, seconds, duration); + app->hover_fade_phase = decreasing ? SDL_max(next, app->hover_fade_target) : + SDL_min(next, app->hover_fade_target); + float phase = app->hover_fade_phase; + float opacity = phase == app->hover_fade_target ? + (app->hover_hidden ? 0.0f : app->session.window.opacity_percent / 100.0f) : + app->hover_fade_opacity * bongo_cat_hover_fade_curve(phase); + if (hover_opacity(app, opacity)) + app->hover_fade_active = phase != app->hover_fade_target; } void bongo_cat_app_cancel_hover_fade(BongoCatApp *app) { - if (app) app->hover_fade_active = false; + if (app) { + app->hover_fade_active = false; + app->hover_fade_next_ns = 0; + } } static void hover_start_fade(BongoCatApp *app, bool hidden, uint64_t now) { float target = hidden ? 0.0f : app->session.window.opacity_percent / 100.0f; + app->hover_hidden = hidden; if (app->settings.window.hide_fade_seconds <= 0.0f) { app->hover_fade_active = false; - bongo_cat_platform_set_opacity(&app->platform, target); + hover_opacity(app, target); return; } - app->hover_fade_from = bongo_cat_platform_get_opacity(&app->platform); - app->hover_fade_to = target; - app->hover_fade_start_ns = now; - app->hover_fade_active = true; - bongo_cat_app_update_hover_fade(app, now); + float current = bongo_cat_platform_get_opacity(&app->platform); + app->hover_fade_opacity = SDL_max(current, + app->session.window.opacity_percent / 100.0f); + app->hover_fade_phase = bongo_cat_hover_fade_phase( + app->hover_fade_opacity > 0.0f ? current / app->hover_fade_opacity : 0.0f); + app->hover_fade_target = bongo_cat_hover_fade_phase( + app->hover_fade_opacity > 0.0f ? target / app->hover_fade_opacity : 0.0f); + app->hover_fade_tick_ns = now; + app->hover_fade_next_ns = now + 8000000ull; + app->hover_fade_active = current != target; } void bongo_cat_app_update_hover(BongoCatApp *app, uint64_t now) { if (!app || !app->window) return; - bool enabled = app->settings.window.hide_on_hover && + bool enabled = !app->platform.hover_hide_unavailable && + app->settings.window.hide_on_hover && app->settings.window.pass_through && app->settings.window.always_on_top && app->session.window.visible && !app->window_minimized && !app->startup_visibility_pending; - float local_x, local_y; + float local_x = 0.0f, local_y = 0.0f; /* Window opacity does not change the frame alpha, so hidden pets can still detect the pointer leaving their visible model pixels. */ bool inside = enabled && app->pointer_known && bongo_cat_platform_pointer_local(&app->platform, - app->pointer_x, app->pointer_y, &local_x, &local_y) && - bongo_cat_window_visible_at_pointer(app, local_x, local_y); + app->pointer_x, app->pointer_y, &local_x, &local_y); + if (inside) { + /* Bound GPU readbacks on backends without a CPU alpha cache, even + when high-rate input wakes the event loop faster than rendering. */ + if (now < app->hover_deadline_ns) return; + app->hover_deadline_ns = now + 8000000ull; + inside = bongo_cat_window_visible_at_pointer(app, local_x, local_y); + } else if (!enabled) app->hover_deadline_ns = 0; app->hover_inside = inside; - app->hover_deadline_ns = 0; if (app->hover_hidden == inside) return; hover_start_fade(app, inside, now); - app->hover_hidden = inside; bongo_cat_window_sync_click_through(app); } diff --git a/src/runtime/lifecycle/app_loop.c b/src/runtime/lifecycle/app_loop.c index 8ec41e87..cadf5411 100644 --- a/src/runtime/lifecycle/app_loop.c +++ b/src/runtime/lifecycle/app_loop.c @@ -197,9 +197,10 @@ void bongo_cat_app_loop(BongoCatApp *app) { bongo_cat_window_update_display_recovery(app, now); bongo_cat_runtime_flow_update(app, now); bongo_cat_window_apply_pending_resize(app); + bongo_cat_app_drain_input(app, true); + now = SDL_GetTicksNS(); bongo_cat_app_update_hover(app, now); bongo_cat_app_update_hover_fade(app, now); - bongo_cat_app_drain_input(app, true); if (bongo_cat_model_frame_due(app, now)) update_model(app, now); else if (!app->session.window.visible || app->window_minimized) { app->last_frame_ns = now; diff --git a/src/runtime/model/model_files.c b/src/runtime/model/model_files.c index 3322aa0f..feb6af3f 100644 --- a/src/runtime/model/model_files.c +++ b/src/runtime/model/model_files.c @@ -83,7 +83,9 @@ static void model_load_progress(void *userdata, float progress) { app->model_load_last_frame_ns = now; float elapsed = (float)((now - previous) / 1000000000.0); bongo_cat_app_drain_input(app, false); + now = SDL_GetTicksNS(); bongo_cat_app_update_hover(app, now); + bongo_cat_app_update_hover_fade(app, now); if (!app->smoke_freeze_model && elapsed > 0.0f) bongo_cat_app_step_live2d(app, elapsed); app->last_frame_ns = now; diff --git a/src/runtime/model/multi_pet_selection.c b/src/runtime/model/multi_pet_selection.c index a89a846d..1178bfbf 100644 --- a/src/runtime/model/multi_pet_selection.c +++ b/src/runtime/model/multi_pet_selection.c @@ -25,6 +25,8 @@ static void adopt_pet_window(BongoCatApp *app, window->scale_percent, window->width, window->height)) return; app->session.window.opacity_percent = window->opacity_percent; bongo_cat_app_cancel_hover_fade(app); + app->hover_hidden = app->hover_inside = false; + app->hover_deadline_ns = 0; bongo_cat_platform_set_opacity(&app->platform, window->opacity_percent / 100.0f); } diff --git a/src/runtime/shell/modal_frame.c b/src/runtime/shell/modal_frame.c index 42ec16c7..2a3851bc 100644 --- a/src/runtime/shell/modal_frame.c +++ b/src/runtime/shell/modal_frame.c @@ -25,7 +25,9 @@ void bongo_cat_modal_frame_tick(void *userdata) { app->last_frame_ns = now; state->tick_count++; bongo_cat_app_drain_input(app, true); + now = SDL_GetTicksNS(); bongo_cat_app_update_hover(app, now); + bongo_cat_app_update_hover_fade(app, now); if (!app->smoke_freeze_model && elapsed > 0.0f) bongo_cat_app_step_live2d(app, elapsed); bongo_cat_app_render_now(app); diff --git a/src/runtime/shell/window_menu_actions.c b/src/runtime/shell/window_menu_actions.c index ed2857da..6a6f4f5a 100644 --- a/src/runtime/shell/window_menu_actions.c +++ b/src/runtime/shell/window_menu_actions.c @@ -126,8 +126,11 @@ void bongo_cat_window_menu_action(BongoCatApp *app, bongo_cat_window_cancel_wheel_animation(app); app->session.window.opacity_percent = (float)(10 * (action - BONGO_CAT_MENU_OPACITY_10 + 1)); - bongo_cat_platform_set_opacity(&app->platform, - app->session.window.opacity_percent / 100.0f); + if (!app->hover_hidden) { + bongo_cat_app_cancel_hover_fade(app); + bongo_cat_platform_set_opacity(&app->platform, + app->session.window.opacity_percent / 100.0f); + } } else if (bongo_cat_window_behavior_action(app, action)) { bongo_cat_app_render_now(app); } else if (action >= BONGO_CAT_MENU_MODEL_FIRST && diff --git a/src/runtime/shell/window_menu_preview.c b/src/runtime/shell/window_menu_preview.c index 709ca753..aeac2d1a 100644 --- a/src/runtime/shell/window_menu_preview.c +++ b/src/runtime/shell/window_menu_preview.c @@ -60,9 +60,11 @@ void bongo_cat_window_menu_preview(void *userdata, BongoCatMenuAction action) { action <= BONGO_CAT_MENU_OPACITY_100) { app->session.window.opacity_percent = (float)(10 * (action - BONGO_CAT_MENU_OPACITY_10 + 1)); - bongo_cat_app_cancel_hover_fade(app); - bongo_cat_platform_set_opacity(&app->platform, - app->session.window.opacity_percent / 100.0f); + if (!app->hover_hidden) { + bongo_cat_app_cancel_hover_fade(app); + bongo_cat_platform_set_opacity(&app->platform, + app->session.window.opacity_percent / 100.0f); + } } else if (action == state->applied && bongo_cat_window_behavior_menu_action(action)) { bongo_cat_modal_frame_tick(&state->modal_frame); @@ -123,9 +125,11 @@ void bongo_cat_window_menu_restore(void *userdata, BongoCatMenuAction selected) if (!keep_opacity && SDL_fabsf(app->session.window.opacity_percent - state->opacity) > .01f) { app->session.window.opacity_percent = state->opacity; - bongo_cat_app_cancel_hover_fade(app); - bongo_cat_platform_set_opacity(&app->platform, - state->opacity / 100.0f); + if (!app->hover_hidden) { + bongo_cat_app_cancel_hover_fade(app); + bongo_cat_platform_set_opacity(&app->platform, + state->opacity / 100.0f); + } changed = true; } if (!keep_expression && diff --git a/src/ui/preferences/preferences_icons.h b/src/ui/preferences/preferences_icons.h index f7dda850..d5068840 100644 --- a/src/ui/preferences/preferences_icons.h +++ b/src/ui/preferences/preferences_icons.h @@ -29,6 +29,7 @@ typedef enum BongoCatPrefIcon { BONGO_CAT_PREF_ICON_THEME, BONGO_CAT_PREF_ICON_SHORTCUT_VISIBILITY, BONGO_CAT_PREF_ICON_SHORTCUT_PREFERENCES, + BONGO_CAT_PREF_ICON_HIDE_FADE, BONGO_CAT_PREF_ICON_COUNT } BongoCatPrefIcon; diff --git a/src/ui/preferences/preferences_number_edit.c b/src/ui/preferences/preferences_number_edit.c index 6eac3fe1..8a78c562 100644 --- a/src/ui/preferences/preferences_number_edit.c +++ b/src/ui/preferences/preferences_number_edit.c @@ -83,6 +83,10 @@ bool bongo_cat_pref_number_edit(struct nk_context *context, const char *id, context->style.edit.text_normal = p.text; context->style.edit.text_hover = p.text; context->style.edit.text_active = p.text; + context->style.edit.selected_normal = nk_rgb(83, 172, 252); + context->style.edit.selected_hover = nk_rgb(83, 172, 252); + context->style.edit.selected_text_normal = nk_rgb(255, 255, 255); + context->style.edit.selected_text_hover = nk_rgb(255, 255, 255); context->style.edit.cursor_normal = p.text; context->style.edit.cursor_hover = p.text; context->style.edit.border = 0; diff --git a/src/ui/preferences/preferences_pages.c b/src/ui/preferences/preferences_pages.c index 8b2bba78..cdef5b1e 100644 --- a/src/ui/preferences/preferences_pages.c +++ b/src/ui/preferences/preferences_pages.c @@ -47,13 +47,22 @@ static void page_display(BongoCatApp *app, struct nk_context *context) { bongo_cat_window_mark_hit_dirty(app); bongo_cat_window_sync_click_through(app); } - if (window->pass_through && window->always_on_top) { + if (window->pass_through && window->always_on_top && + app->platform.hover_hide_unavailable) { + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_SHORTCUT_VISIBILITY); + bongo_cat_pref_status(context, "hover-unavailable", tr(app, + "pages.preference.cat.labels.hideOnHover", "Hide on Hover"), tr(app, + "pages.preference.cat.hints.hoverUnavailable", + "Hover hiding is unavailable in the current desktop environment.")); + } + if (window->pass_through && window->always_on_top && + !app->platform.hover_hide_unavailable) { bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_SHORTCUT_VISIBILITY); if (bongo_cat_pref_toggle(context, "hide-on-hover", tr(app, "pages.preference.cat.labels.hideOnHover", "Hide on Hover"), "", &window->hide_on_hover)) bongo_cat_app_update_hover(app, SDL_GetTicksNS()); - bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_SHORTCUT_VISIBILITY); + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_HIDE_FADE); bongo_cat_pref_float(context, "hide-fade", tr(app, "pages.preference.cat.labels.hideFadeSeconds", "Fade Duration (s)"), "", 0.0f, &window->hide_fade_seconds, diff --git a/src/ui/preferences/preferences_row_icons.c b/src/ui/preferences/preferences_row_icons.c index e07c0830..9aef286f 100644 --- a/src/ui/preferences/preferences_row_icons.c +++ b/src/ui/preferences/preferences_row_icons.c @@ -159,6 +159,18 @@ static void shortcut_preferences(struct nk_command_buffer *c, struct nk_rect b, line(c, b, 2, 14, 16, 14, color); dot(c, b, 8, 14, 4, color); } +static void hide_fade(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + nk_stroke_circle(c, nk_rect(b.x + 1, b.y + 3, 12, 12), 1.5f, color); + line(c, b, 7, 6, 7, 9, color); + line(c, b, 7, 9, 9, 10, color); + struct nk_color trail = color; + trail.a = (nk_byte)((unsigned int)color.a * 3 / 5); + line(c, b, 15, 5, 15, 13, trail); + trail.a = (nk_byte)((unsigned int)color.a * 3 / 10); + line(c, b, 18, 7, 18, 11, trail); +} + bool bongo_cat_pref_row_icon_draw(struct nk_command_buffer *canvas, struct nk_rect bounds, BongoCatPrefIcon icon, struct nk_color color) { typedef void (*Draw)(struct nk_command_buffer *, struct nk_rect, @@ -167,7 +179,7 @@ bool bongo_cat_pref_row_icon_draw(struct nk_command_buffer *canvas, keep_in_screen, solid_background, window_size, window_corners, opacity, random_expression, mirror, mouse_mirror, mouse_centered, ignore_mouse, max_fps, autostart, language, theme, shortcut_visibility, - shortcut_preferences}; + shortcut_preferences, hide_fade}; int index = icon - BONGO_CAT_PREF_ICON_MULTIPLE_MODELS; int count = (int)(sizeof(draws) / sizeof(draws[0])); if (index < 0 || index >= count) return false; diff --git a/tests/core/test_hover_fade.c b/tests/core/test_hover_fade.c new file mode 100644 index 00000000..369aed8a --- /dev/null +++ b/tests/core/test_hover_fade.c @@ -0,0 +1,167 @@ +#include "test.h" +#include "runtime.h" +#include "modal_frame.h" + +#include +#include + +int bongo_cat_test_failures; +static unsigned opacity_calls, hit_calls; +static bool fail_opacity, hit = true; + +bool bongo_cat_platform_set_opacity(BongoCatPlatform *platform, float opacity) { + opacity_calls++; + if (fail_opacity) { + fail_opacity = false; + return SDL_SetError("injected opacity failure"); + } + platform->window_opacity = opacity; + return true; +} +float bongo_cat_platform_get_opacity(const BongoCatPlatform *platform) { + return platform->window_opacity; +} +bool bongo_cat_platform_pointer_local(BongoCatPlatform *platform, + double x, double y, float *local_x, float *local_y) { + (void)platform; + *local_x = (float)x; *local_y = (float)y; + return x >= 0 && x < 100 && y >= 0 && y < 100; +} +bool bongo_cat_window_visible_at_pointer(BongoCatApp *app, float x, float y) { + (void)app; (void)x; (void)y; + hit_calls++; + return hit; +} +void bongo_cat_window_schedule_pointer_hit(BongoCatApp *app) { (void)app; } +void bongo_cat_window_sync_click_through(BongoCatApp *app) { (void)app; } +void bongo_cat_app_drain_input(BongoCatApp *app, bool shortcuts) { + (void)app; (void)shortcuts; +} +bool bongo_cat_app_step_live2d(BongoCatApp *app, float elapsed) { + (void)app; (void)elapsed; + return true; +} +void bongo_cat_app_render_now(BongoCatApp *app) { (void)app; } + +static void reset(BongoCatApp *app) { + memset(app, 0, sizeof(*app)); + app->window = app->platform.window = (SDL_Window *)app; + app->platform.window_opacity = 0.8f; + app->session.window.opacity_percent = 80.0f; + app->session.window.visible = true; + app->settings.window.hide_on_hover = true; + app->settings.window.always_on_top = true; + app->settings.window.pass_through = true; + app->settings.window.hide_fade_seconds = 0.3f; + app->pointer_known = true; + app->pointer_x = app->pointer_y = 50; + opacity_calls = hit_calls = 0; + fail_opacity = false; + hit = true; +} + +static void tick(BongoCatApp *app, uint64_t now, bool inside) { + app->pointer_x = inside ? 50 : -10; + bongo_cat_app_update_hover(app, now); + bongo_cat_app_update_hover_fade(app, now); +} + +int main(void) { + BongoCatApp *app = calloc(1, sizeof(*app)); + if (!app) return 1; + const uint64_t start = 1000000000ull; + reset(app); + tick(app, start, true); + tick(app, start + 100000000ull, true); + float partial = app->platform.window_opacity; + CHECK(partial > 0.0f && partial < 0.8f); + tick(app, start + 100000000ull, false); + CHECK(app->platform.window_opacity == partial); + tick(app, start + 201000000ull, false); + CHECK(!app->hover_fade_active && app->platform.window_opacity == 0.8f); + + /* Repeated reversal stays bounded and finishes at the user's opacity. */ + reset(app); + uint64_t now = start; + for (int i = 0; i < 100; ++i) { + bool inside = i % 2 == 0; + tick(app, now, inside); + now += 24000000ull; + tick(app, now, inside); + CHECK(app->platform.window_opacity >= 0.0f && + app->platform.window_opacity <= 0.8f); + } + tick(app, now + 1000000000ull, false); + CHECK(!app->hover_hidden && !app->hover_fade_active && + app->platform.window_opacity == 0.8f); + + /* 1000 Hz input must not generate 1000 opacity writes or hit reads. */ + reset(app); + app->settings.window.hide_fade_seconds = 3.0f; + for (uint64_t i = 0; i <= 1000; ++i) + tick(app, start + i * 1000000ull, true); + CHECK(opacity_calls <= 125 && hit_calls <= 126); + CHECK(opacity_calls > 0 && app->hover_fade_active); + partial = app->platform.window_opacity; + app->settings.window.hide_fade_seconds = 1.0f; + tick(app, start + 1008000000ull, true); + CHECK(app->platform.window_opacity < partial && + partial - app->platform.window_opacity < 0.03f); + app->settings.window.hide_fade_seconds = 0.0f; + tick(app, start + 1009000000ull, true); + CHECK(!app->hover_fade_active && app->platform.window_opacity == 0.0f); + tick(app, start + 1010000000ull, false); + CHECK(app->platform.window_opacity == 0.8f); + + reset(app); + for (uint64_t i = 0; i <= 1000; ++i) + tick(app, start + i * 1000000ull, i % 2 == 0); + CHECK(hit_calls <= 126 && opacity_calls <= 125); + + /* A lower base opacity chosen during fade-out is the new restore target. */ + reset(app); + tick(app, start, true); + tick(app, start + 100000000ull, true); + app->session.window.opacity_percent = 20.0f; + tick(app, start + 101000000ull, false); + tick(app, start + 1000000000ull, false); + CHECK(SDL_fabsf(app->platform.window_opacity - 0.2f) < 0.00001f && + !app->hover_fade_active); + + /* Disabled capability performs no sampling or opacity work. */ + reset(app); + app->platform.hover_hide_unavailable = true; + tick(app, start, true); + CHECK(!app->hover_hidden && !app->hover_fade_active && + opacity_calls == 0 && hit_calls == 0); + reset(app); + tick(app, start, true); + fail_opacity = true; + tick(app, start + 10000000ull, true); + CHECK(app->platform.hover_hide_unavailable && !app->hover_hidden && + !app->hover_fade_active && app->platform.window_opacity == 0.8f); + unsigned calls = opacity_calls; + tick(app, start + 1000000000ull, true); + CHECK(opacity_calls == calls); + + /* Model alpha changes while the pointer is stationary are still sampled. */ + reset(app); + tick(app, start, true); + tick(app, start + 100000000ull, true); + hit = false; + tick(app, start + 108000000ull, true); + CHECK(!app->hover_hidden); + tick(app, start + 500000000ull, true); + CHECK(app->platform.window_opacity == 0.8f); + + /* The menu/modal loop must advance an existing fade without the app loop. */ + reset(app); + tick(app, SDL_GetTicksNS(), true); + app->settings.window.hide_fade_seconds = 0.0f; + BongoCatModalFrame modal; + bongo_cat_modal_frame_init(&modal, app); + bongo_cat_modal_frame_tick(&modal); + CHECK(app->platform.window_opacity == 0.0f && !app->hover_fade_active); + free(app); + return bongo_cat_test_failures ? 1 : 0; +} From 89b0d6de9cce96b63d5fd30b88899aba51c49e6a Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 13 Sep 2026 10:38:20 +0800 Subject: [PATCH 284/334] fix: complete macOS core-profile rendering and resource cleanup Cover mesh and composite uploads with model-owned buffers, restore GL bindings, and restrict the adaptation to macOS. Drain retired models while idle, validate required GL functions, and add rendering/resource regression coverage. Keep startup and model-selection errors in logs without user notifications. --- cmake/Cubism.cmake | 8 +- cmake/CubismCoreProfile.cmake | 117 +++++++++------------ cmake/CubismShaderOptimize.cmake | 16 +-- cmake/Tests.cmake | 7 ++ src/live2d/cubism_bridge.cpp | 10 +- src/live2d/cubism_core_profile.hpp | 68 ++++++++++++ src/live2d/cubism_model.hpp | 10 +- src/live2d/cubism_model_renderer.cpp | 21 ++-- src/live2d/cubism_model_update.cpp | 4 +- src/runtime/lifecycle/startup.c | 38 ------- src/ui/preferences/preferences_model.c | 5 +- src/ui/theme/ui_catime.c | 5 +- tests/live2d/test_core_profile.cpp | 139 +++++++++++++++++++++++++ 13 files changed, 300 insertions(+), 148 deletions(-) create mode 100644 src/live2d/cubism_core_profile.hpp create mode 100644 tests/live2d/test_core_profile.cpp diff --git a/cmake/Cubism.cmake b/cmake/Cubism.cmake index e79adf0d..1dec5d94 100644 --- a/cmake/Cubism.cmake +++ b/cmake/Cubism.cmake @@ -50,11 +50,13 @@ set(FRAMEWORK_SOURCE OpenGL) add_subdirectory("${CUBISM_FRAMEWORK_PATH}" "${CMAKE_BINARY_DIR}/cubism-framework") include(cmake/CubismUserModelSafety.cmake) bongo_cat_harden_cubism_user_model(Framework) +include(cmake/CubismCoreProfile.cmake) include(cmake/CubismShaderOptimize.cmake) bongo_cat_optimize_cubism_shaders(Framework) -include(cmake/CubismCoreProfile.cmake) -bongo_cat_core_profile_prepare_shaders() -bongo_cat_core_profile_patch_renderer(Framework) +if(APPLE) + bongo_cat_core_profile_prepare_shaders() + bongo_cat_core_profile_patch_renderer(Framework) +endif() if(WIN32) target_compile_definitions(Framework PUBLIC CSM_TARGET_WIN_GL GLEW_NO_GLU) diff --git a/cmake/CubismCoreProfile.cmake b/cmake/CubismCoreProfile.cmake index 9a37877f..03398f6e 100644 --- a/cmake/CubismCoreProfile.cmake +++ b/cmake/CubismCoreProfile.cmake @@ -1,22 +1,10 @@ -# Core-profile (macOS) compatibility for the Cubism GLES2 rendering path. -# -# The macOS build requests an OpenGL core profile context, which differs from -# the compatibility contexts used on Windows and Linux in three ways that the -# GLES2-style Cubism renderer does not expect: -# -# 1. GLSL 1.20 shaders do not compile, so the desktop `Standard` shaders are -# translated to GLSL 330 at configure time. The translated files replace -# the SDK ones everywhere the framework shaders are staged. -# 2. Vertex-attributable calls require a bound VAO (handled in the runtime -# by NativeModel::bind_model_vao). -# 3. Client-side vertex arrays are silently ignored, so DrawMeshOpenGL -# re-hosts the mesh in stream VBOs. -# -# Everything is guarded by CSM_TARGET_MAC_GL or applied to shader text only, -# so Windows and Linux binaries are unchanged. - +# macOS core-profile shaders and buffer-backed drawing. set(BONGO_CAT_CUBISM_SHADER_SOURCE_DIR - "${CMAKE_CURRENT_BINARY_DIR}/generated/cubism-shaders") + "${CUBISM_FRAMEWORK_PATH}/src/Rendering/OpenGL/Shaders/Standard") +if(APPLE) + set(BONGO_CAT_CUBISM_SHADER_SOURCE_DIR + "${CMAKE_CURRENT_BINARY_DIR}/generated/cubism-shaders") +endif() function(bongo_cat_core_profile_convert_shader input output) file(READ "${input}" text) @@ -50,7 +38,8 @@ endfunction() function(bongo_cat_core_profile_prepare_shaders) set(source_dir "${CUBISM_FRAMEWORK_PATH}/src/Rendering/OpenGL/Shaders/Standard") file(MAKE_DIRECTORY "${BONGO_CAT_CUBISM_SHADER_SOURCE_DIR}") - file(GLOB shader_files "${source_dir}/*") + file(GLOB shader_files CONFIGURE_DEPENDS "${source_dir}/*") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${shader_files}) foreach(input IN LISTS shader_files) get_filename_component(name "${input}" NAME) bongo_cat_core_profile_convert_shader("${input}" @@ -58,67 +47,59 @@ function(bongo_cat_core_profile_prepare_shaders) endforeach() endfunction() +function(bongo_cat_core_profile_patch_shader variable) + set(source "${${variable}}") + string(ASCII 239 187 191 bom) + string(REPLACE "${bom}" "" source "${source}") + foreach(spec IN ITEMS + "AttributePositionLocation|vertexArray|model.GetDrawableVertexCount(index) * sizeof(csmFloat32) * 2|0" + "AttributeTexCoordLocation|uvArray|model.GetDrawableVertexCount(index) * sizeof(csmFloat32) * 2|1" + "AttributePositionLocation|renderTargetVertexArray|sizeof(renderTargetVertexArray)|0" + "AttributeTexCoordLocation|renderTargetUvArray|sizeof(renderTargetUvArray)|1" + "AttributeTexCoordLocation|renderTargetReverseUvArray|sizeof(renderTargetReverseUvArray)|1") + string(REPLACE "|" ";" fields "${spec}") + list(GET fields 0 attribute) + list(GET fields 1 data) + list(GET fields 2 size) + list(GET fields 3 slot) + bongo_cat_replace_cubism_text(source + "glVertexAttribPointer(shaderSet->${attribute}, 2, GL_FLOAT, GL_FALSE, sizeof(csmFloat32) * 2, ${data});" + "bongo_cat::CoreProfileBinding::attribute(${slot}, shaderSet->${attribute}, ${data}, ${size});" + "core-profile ${data}") + endforeach() + if(source MATCHES "glVertexAttribPointer[ \t\r\n]*\\(") + message(FATAL_ERROR "Unpatched Cubism client-side vertex attributes") + endif() + set(${variable} "#include \"cubism_core_profile.hpp\"\n${source}" PARENT_SCOPE) +endfunction() + function(bongo_cat_core_profile_patch_renderer target) set(source_path "${CUBISM_FRAMEWORK_PATH}/src/Rendering/OpenGL/CubismRenderer_OpenGLES2.cpp") set(output_dir "${CMAKE_CURRENT_BINARY_DIR}/generated/cubism") set(output_source "${output_dir}/CubismRenderer_OpenGLES2.cpp") file(READ "${source_path}" source) + string(ASCII 239 187 191 bom) + string(REPLACE "${bom}" "" source "${source}") string(REPLACE "\r\n" "\n" source "${source}") - - set(legacy_draw [=[ - csmInt32 indexCount = model.GetDrawableVertexIndexCount(index); - csmUint16* indexArray = const_cast(model.GetDrawableVertexIndices(index)); - glDrawElements(GL_TRIANGLES, indexCount, GL_UNSIGNED_SHORT, indexArray);]=]) - set(vbo_draw [=[ - csmInt32 indexCount = model.GetDrawableVertexIndexCount(index); - csmUint16* indexArray = const_cast(model.GetDrawableVertexIndices(index)); -#if defined(CSM_TARGET_MAC_GL) - // Core-profile contexts reject client-side vertex arrays, so the - // GLES2-style mesh pointers are re-hosted in reused stream VBOs. - static GLuint meshVbo[3] = {0, 0, 0}; - if (meshVbo[0] == 0) - { - glGenBuffers(3, meshVbo); - } - GLint positionLocation = glGetAttribLocation(currentProgram, "a_position"); - GLint texCoordLocation = glGetAttribLocation(currentProgram, "a_texCoord"); - csmInt32 vertexCount = model.GetDrawableVertexCount(index); - if (positionLocation >= 0) - { - glBindBuffer(GL_ARRAY_BUFFER, meshVbo[0]); - glBufferData(GL_ARRAY_BUFFER, vertexCount * sizeof(csmFloat32) * 2, - model.GetDrawableVertices(index), GL_STREAM_DRAW); - glVertexAttribPointer(positionLocation, 2, GL_FLOAT, GL_FALSE, - sizeof(csmFloat32) * 2, NULL); - } - if (texCoordLocation >= 0) - { - glBindBuffer(GL_ARRAY_BUFFER, meshVbo[1]); - glBufferData(GL_ARRAY_BUFFER, vertexCount * sizeof(csmFloat32) * 2, - model.GetDrawableVertexUvs(index), GL_STREAM_DRAW); - glVertexAttribPointer(texCoordLocation, 2, GL_FLOAT, GL_FALSE, - sizeof(csmFloat32) * 2, NULL); - } - glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, meshVbo[2]); - glBufferData(GL_ELEMENT_ARRAY_BUFFER, indexCount * sizeof(csmUint16), - indexArray, GL_STREAM_DRAW); - glDrawElements(GL_TRIANGLES, indexCount, GL_UNSIGNED_SHORT, NULL); - glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0); -#else - glDrawElements(GL_TRIANGLES, indexCount, GL_UNSIGNED_SHORT, indexArray); -#endif]=]) - string(FIND "${source}" "${legacy_draw}" position) - if(position EQUAL -1) - message(FATAL_ERROR "Core-profile renderer patch mismatch: mesh draw") + bongo_cat_replace_cubism_text(source + "glDrawElements(GL_TRIANGLES, indexCount, GL_UNSIGNED_SHORT, indexArray);" + "bongo_cat::CoreProfileBinding::draw(indexCount, indexArray);" + "core-profile mesh indices") + bongo_cat_replace_cubism_text(source + "glDrawElements(GL_TRIANGLES, sizeof(ModelRenderTargetIndexArray) / sizeof(csmUint16), GL_UNSIGNED_SHORT, ModelRenderTargetIndexArray);" + "bongo_cat::CoreProfileBinding::draw(sizeof(ModelRenderTargetIndexArray) / sizeof(csmUint16), ModelRenderTargetIndexArray);" + "core-profile composite indices") + if(source MATCHES "glDrawElements[ \t\r\n]*\\(") + message(FATAL_ERROR "Unpatched Cubism client-side index draw") endif() - string(REPLACE "${legacy_draw}" "${vbo_draw}" source "${source}") - file(MAKE_DIRECTORY "${output_dir}") - file(WRITE "${output_source}" "${source}") + file(WRITE "${output_source}" "#include \"cubism_core_profile.hpp\"\n${source}") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${source_path}") get_target_property(framework_sources ${target} SOURCES) list(REMOVE_ITEM framework_sources "${source_path}") set_property(TARGET ${target} PROPERTY SOURCES "${framework_sources}") target_sources(${target} PRIVATE "${output_source}") target_include_directories(${target} PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/src/live2d" "${CUBISM_FRAMEWORK_PATH}/src/Rendering/OpenGL") endfunction() diff --git a/cmake/CubismShaderOptimize.cmake b/cmake/CubismShaderOptimize.cmake index 5fea86bf..4eb7264e 100644 --- a/cmake/CubismShaderOptimize.cmake +++ b/cmake/CubismShaderOptimize.cmake @@ -44,18 +44,6 @@ function(bongo_cat_optimize_cubism_shaders target) "/* Model upload owns minification filtering and fallback. */" "model texture minification ownership") - # Core-profile GL requires vertex attrib array 0 to be enabled; pin the - # position attribute there so GLES2-style draw calls stay valid on macOS. - bongo_cat_replace_cubism_text(source - [=[ - GLint status; - glLinkProgram(shaderProgram);]=] - [=[ - GLint status; - glBindAttribLocation(shaderProgram, 0, "a_position"); - glLinkProgram(shaderProgram);]=] - "core-profile attribute-0 pin") - set(compile_anchor [=[ _shaderSets[ShaderNames_MultMaskedInvertedPremultipliedAlpha]->ShaderProgram = _shaderSets[ShaderNames_NormalMaskedInvertedPremultipliedAlpha]->ShaderProgram;]=]) set(block_start [=[ { @@ -120,6 +108,10 @@ void CubismShader_OpenGLES2::SetupShaderProgramForDrawable]=]) bongo_cat_replace_cubism_text(source "${offscreen_setup}" "${offscreen_lazy}" "offscreen lazy shader call") + if(APPLE) + bongo_cat_core_profile_patch_shader(source) + endif() + file(MAKE_DIRECTORY "${output_dir}") file(REMOVE "${output_dir}/CubismShader_OpenGLES2.hpp") file(WRITE "${output_source}" "${source}") diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index bae63faf..5d5acb2b 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -132,6 +132,13 @@ if(BUILD_TESTING) endif() if(BONGO_CAT_CUBISM_ENABLED) + add_executable(bongo_cat_core_profile_tests tests/live2d/test_core_profile.cpp) + target_include_directories(bongo_cat_core_profile_tests PRIVATE src/live2d) + target_link_libraries(bongo_cat_core_profile_tests PRIVATE + bongo_cat_runtime bongo_cat_warnings) + add_test(NAME live2d-core-profile COMMAND bongo_cat_core_profile_tests) + set_tests_properties(live2d-core-profile PROPERTIES TIMEOUT 30) + add_test(NAME model-startup-recovery COMMAND ${CMAKE_COMMAND} "-DEXECUTABLE=$" "-DASSET_ROOT=${CMAKE_CURRENT_SOURCE_DIR}/resources/assets" diff --git a/src/live2d/cubism_bridge.cpp b/src/live2d/cubism_bridge.cpp index 37ae17be..d2257b69 100644 --- a/src/live2d/cubism_bridge.cpp +++ b/src/live2d/cubism_bridge.cpp @@ -87,7 +87,10 @@ bool start_framework(BongoCatError *error) { return false; } if (!glCreateShader || !glShaderSource || !glCompileShader || - !glGetShaderiv || !glCreateProgram || !glGenFramebuffers) { + !glGetShaderiv || !glCreateProgram || !glGenFramebuffers || + !glGenBuffers || !glBindBuffer || !glBufferData || !glDeleteBuffers || + !glGenVertexArrays || !glBindVertexArray || !glDeleteVertexArrays || + !glEnableVertexAttribArray || !glVertexAttribPointer) { runtime_count = 0; bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, "Required OpenGL 3.3 functions are unavailable"); @@ -184,7 +187,10 @@ extern "C" void bongo_cat_live2d_reshape(BongoCatLive2D *runtime, int width, int if (runtime->model) runtime->model->reshape(width, height); } extern "C" bool bongo_cat_live2d_update(BongoCatLive2D *runtime, float elapsed) { - return runtime && runtime->model && runtime->model->update(elapsed); + if (!runtime) return false; + bool changed = runtime->model && runtime->model->update(elapsed); + // Finish deferred releases even when the replacement model is static. + return changed || runtime->retired_count > 0; } extern "C" void bongo_cat_live2d_draw(BongoCatLive2D *runtime) { if (!runtime) return; diff --git a/src/live2d/cubism_core_profile.hpp b/src/live2d/cubism_core_profile.hpp new file mode 100644 index 00000000..cddc45db --- /dev/null +++ b/src/live2d/cubism_core_profile.hpp @@ -0,0 +1,68 @@ +#pragma once + +#include + +namespace bongo_cat { + +struct CoreProfileBuffers { + GLuint vao = 0; + GLuint buffers[3] = {}; + + CoreProfileBuffers() = default; + CoreProfileBuffers(const CoreProfileBuffers &) = delete; + CoreProfileBuffers &operator=(const CoreProfileBuffers &) = delete; + + void release() { + glDeleteVertexArrays(1, &vao); + glDeleteBuffers(3, buffers); + vao = 0; + for (auto &buffer : buffers) buffer = 0; + } +}; + +// SDK callbacks use the buffers of the model currently being drawn. +class CoreProfileBinding { +public: + explicit CoreProfileBinding(CoreProfileBuffers &resources) + : resources_(resources), previous_(active_) { + glGetIntegerv(GL_VERTEX_ARRAY_BINDING, &previous_vao_); + glGetIntegerv(GL_ARRAY_BUFFER_BINDING, &previous_array_); + if (!resources_.vao) glGenVertexArrays(1, &resources_.vao); + if (!resources_.buffers[0]) glGenBuffers(3, resources_.buffers); + glBindVertexArray(resources_.vao); + active_ = this; + } + + ~CoreProfileBinding() { + glBindVertexArray((GLuint)previous_vao_); + glBindBuffer(GL_ARRAY_BUFFER, (GLuint)previous_array_); + active_ = previous_; + } + + CoreProfileBinding(const CoreProfileBinding &) = delete; + CoreProfileBinding &operator=(const CoreProfileBinding &) = delete; + + static void attribute(unsigned slot, GLuint location, + const void *data, GLsizeiptr size) { + glBindBuffer(GL_ARRAY_BUFFER, active_->resources_.buffers[slot]); + glBufferData(GL_ARRAY_BUFFER, size, data, GL_STREAM_DRAW); + glVertexAttribPointer(location, 2, GL_FLOAT, GL_FALSE, + sizeof(GLfloat) * 2, nullptr); + } + + static void draw(GLsizei count, const GLushort *indices) { + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, active_->resources_.buffers[2]); + glBufferData(GL_ELEMENT_ARRAY_BUFFER, count * sizeof(GLushort), + indices, GL_STREAM_DRAW); + glDrawElements(GL_TRIANGLES, count, GL_UNSIGNED_SHORT, nullptr); + } + +private: + CoreProfileBuffers &resources_; + CoreProfileBinding *previous_; + GLint previous_vao_ = 0; + GLint previous_array_ = 0; + inline static thread_local CoreProfileBinding *active_ = nullptr; +}; + +} // namespace bongo_cat diff --git a/src/live2d/cubism_model.hpp b/src/live2d/cubism_model.hpp index cd7d88b5..b1db2b2d 100644 --- a/src/live2d/cubism_model.hpp +++ b/src/live2d/cubism_model.hpp @@ -1,6 +1,10 @@ #ifndef BONGO_CAT_CUBISM_MODEL_HPP #define BONGO_CAT_CUBISM_MODEL_HPP +#ifdef CSM_TARGET_MAC_GL +#include "cubism_core_profile.hpp" +#endif + #include "bongo_cat/model.h" #include "bongo_cat/image.h" @@ -132,7 +136,6 @@ class NativeModel final : public Csm::CubismUserModel { void release_renderer(); bool create_renderer(BongoCatError *error); void bind_textures(); - void bind_model_vao(); std::vector read(const std::string &path, size_t maximum = (size_t)-1) const; std::string path(const char *relative) const; @@ -158,8 +161,9 @@ class NativeModel final : public Csm::CubismUserModel { Csm::csmVector eye_blink_ids_; Csm::csmVector lip_sync_ids_; std::string directory_; - void *model_vao_context_ = nullptr; - GLuint model_vao_ = 0; +#ifdef CSM_TARGET_MAC_GL + CoreProfileBuffers core_buffers_; +#endif int width_ = 612; int height_ = 354; int renderer_width_ = 0; diff --git a/src/live2d/cubism_model_renderer.cpp b/src/live2d/cubism_model_renderer.cpp index 7b42b932..81edfcc5 100644 --- a/src/live2d/cubism_model_renderer.cpp +++ b/src/live2d/cubism_model_renderer.cpp @@ -23,6 +23,9 @@ void NativeModel::release_textures() { void NativeModel::release_renderer() { DeleteRenderer(); +#ifdef CSM_TARGET_MAC_GL + core_buffers_.release(); +#endif renderer_width_ = 0; renderer_height_ = 0; } @@ -32,32 +35,20 @@ void NativeModel::release_render_resources() { release_renderer(); } -void NativeModel::bind_model_vao() { - // Core-profile contexts (macOS 4.1) require a bound VAO for vertex-attrib - // calls; the GLES2-style Cubism renderer never creates one of its own. - SDL_GLContext current = SDL_GL_GetCurrentContext(); - auto gen = (PFNGLGENVERTEXARRAYSPROC)SDL_GL_GetProcAddress("glGenVertexArrays"); - auto bind = (PFNGLBINDVERTEXARRAYPROC)SDL_GL_GetProcAddress("glBindVertexArray"); - if (!current || !gen || !bind) return; - if (!model_vao_ || current != (SDL_GLContext)model_vao_context_) { - gen(1, &model_vao_); - model_vao_context_ = (void *)current; - } - bind(model_vao_); -} - bool NativeModel::create_renderer(BongoCatError *error) { if (!SDL_GL_GetCurrentContext()) { bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, "Cannot create the Live2D renderer without an OpenGL context"); return false; } - bind_model_vao(); if (!bongo_cat_gl_clear_errors()) { bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, "Cannot clear the OpenGL error state before creating the Live2D renderer"); return false; } +#ifdef CSM_TARGET_MAC_GL + CoreProfileBinding binding(core_buffers_); +#endif CreateRenderer((Csm::csmUint32)width_, (Csm::csmUint32)height_); auto *renderer = GetRenderer(); if (renderer) bind_textures(); diff --git a/src/live2d/cubism_model_update.cpp b/src/live2d/cubism_model_update.cpp index fa500fb4..bf3bab48 100644 --- a/src/live2d/cubism_model_update.cpp +++ b/src/live2d/cubism_model_update.cpp @@ -112,7 +112,9 @@ void NativeModel::build_projection(Csm::CubismMatrix44 &projection, void NativeModel::draw() { if (!_model || width_ <= 0 || height_ <= 0) return; - bind_model_vao(); +#ifdef CSM_TARGET_MAC_GL + CoreProfileBinding binding(core_buffers_); +#endif auto *manager = Csm::Rendering::CubismOffscreenManager_OpenGLES2::GetInstance(); manager->BeginFrameProcess(); Csm::CubismMatrix44 projection; diff --git a/src/runtime/lifecycle/startup.c b/src/runtime/lifecycle/startup.c index 34548d38..491616e6 100644 --- a/src/runtime/lifecycle/startup.c +++ b/src/runtime/lifecycle/startup.c @@ -1,7 +1,6 @@ #include "runtime.h" #include "runtime_state.h" #include "bongo_cat/file.h" -#include "bongo_cat/i18n.h" #include "bongo_cat/log.h" #include "bongo_cat/path.h" #include "storage_paths.h" @@ -13,7 +12,6 @@ #ifdef _WIN32 #include "windows_package.h" -#include #endif static char runtime_log_path[BONGO_CAT_PATH_CAP]; @@ -189,46 +187,10 @@ void bongo_cat_startup_ready(BongoCatApp *app) { SDL_LogInfo(BONGO_CAT_LOG_LIFECYCLE, "[runtime] Startup ready"); } -#ifdef _WIN32 -static void native_error_box(const char *message) { - int count = MultiByteToWideChar(CP_UTF8, 0, message, -1, NULL, 0); - wchar_t *wide = count > 0 ? calloc((size_t)count, sizeof(*wide)) : NULL; - if (wide) { - MultiByteToWideChar(CP_UTF8, 0, message, -1, wide, count); - MessageBoxW(NULL, wide, BONGO_CAT_NAME_W, MB_OK | MB_ICONERROR); - free(wide); - } -} -#endif - -static const char *startup_tr(BongoCatApp *app, const char *key, - const char *fallback) { - return app && app->i18n - ? bongo_cat_i18n_get(app->i18n, key, fallback) : fallback; -} - void bongo_cat_startup_failure(BongoCatApp *app, const BongoCatError *error) { const char *message = error && error->message[0] ? error->message : "Initialization failed"; if (app) bongo_cat_startup_stage(app, "failed"); SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Startup failed: %s", message); - char body[BONGO_CAT_PATH_CAP + 384]; - const char *heading = startup_tr(app, "native.startup.failed", - "BongoCat could not start."); - const char *detail = startup_tr(app, "native.startup.detail", - "See the diagnostic log for technical details."); - const char *diagnostic = startup_tr(app, "native.startup.diagnosticLog", - "Diagnostic log:"); - bool has_log = runtime_log_path[0] != '\0'; - snprintf(body, sizeof(body), "%s\n\n%s%s%s%s%s", heading, detail, - has_log ? "\n\n" : "", has_log ? diagnostic : "", - has_log ? "\n" : "", has_log ? runtime_log_path : ""); - if (app && app->smoke) return; - if (!SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, BONGO_CAT_NAME, body, - app ? app->window : NULL)) { -#ifdef _WIN32 - native_error_box(body); -#endif - } } void bongo_cat_startup_ci_failure(BongoCatApp *app, diff --git a/src/ui/preferences/preferences_model.c b/src/ui/preferences/preferences_model.c index be134f86..50ecd479 100644 --- a/src/ui/preferences/preferences_model.c +++ b/src/ui/preferences/preferences_model.c @@ -125,8 +125,9 @@ void bongo_cat_preferences_process_model_selection(BongoCatPreferences *value) { } value->model_loading = false; value->loading_model_id[0] = '\0'; - if (!selected) bongo_cat_preferences_notice_show(value->app, tr(value->app, - "native.modelLoadFailed", "Unable to display this model"), true); + if (!selected) SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "Model selection failed: id=%s error=%s", id, + error.message[0] ? error.message : "Unable to display this model"); bongo_cat_preferences_invalidate(value); /* Render on the next input frame. A nested render here would replay the card click that queued this selection and immediately toggle it back. */ diff --git a/src/ui/theme/ui_catime.c b/src/ui/theme/ui_catime.c index 4a6c0198..6ea0ef1d 100644 --- a/src/ui/theme/ui_catime.c +++ b/src/ui/theme/ui_catime.c @@ -43,10 +43,7 @@ float bongo_cat_ui_sidebar_width(float window_width) { } bool bongo_cat_ui_native_chrome(void) { - /* Applying NSWindowStyleMaskTitled to the SDL borderless preferences - window makes macOS 26 swallow every mouse event after the first - mouse-down, so the window stays borderless and the UI draws its own - window controls on every platform. */ + /* Keep SDL borderless input handling consistent on macOS. */ return false; } diff --git a/tests/live2d/test_core_profile.cpp b/tests/live2d/test_core_profile.cpp new file mode 100644 index 00000000..a381bcfb --- /dev/null +++ b/tests/live2d/test_core_profile.cpp @@ -0,0 +1,139 @@ +#include "cubism_core_profile.hpp" +#include "cubism_runtime.hpp" +#include +#include + +#define CHECK(condition) do { if (!(condition)) { \ + std::fprintf(stderr, "Failed at line %d: %s (%s)\n", __LINE__, \ + #condition, SDL_GetError()); return 1; } } while (0) + +static GLuint shader(GLenum type, const char *source) { + GLuint result = glCreateShader(type); + glShaderSource(result, 1, &source, nullptr); + glCompileShader(result); + return result; +} + +int main() { + CHECK(SDL_Init(SDL_INIT_VIDEO)); +#ifdef __APPLE__ + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 4); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 1); +#else + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3); +#endif + SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); + SDL_Window *window = SDL_CreateWindow("Core profile test", 32, 32, + SDL_WINDOW_OPENGL | SDL_WINDOW_HIDDEN); + CHECK(window); + SDL_GLContext context = SDL_GL_CreateContext(window); + CHECK(context); + glewExperimental = GL_TRUE; + CHECK(glewInit() == GLEW_OK); + while (glGetError() != GL_NO_ERROR) {} + GLuint vertex = shader(GL_VERTEX_SHADER, + "#version 330 core\nlayout(location=3) in vec2 p;" + "layout(location=7) in vec2 uv; out vec2 v;" + "void main(){gl_Position=vec4(p,0,1);v=uv;}"); + GLuint fragment = shader(GL_FRAGMENT_SHADER, + "#version 330 core\nin vec2 v; out vec4 color;" + "void main(){color=vec4(v,0,1);}"); + GLuint program = glCreateProgram(); + glAttachShader(program, vertex); + glAttachShader(program, fragment); + glLinkProgram(program); + GLint linked = 0; + glGetProgramiv(program, GL_LINK_STATUS, &linked); + CHECK(linked); + glUseProgram(program); + glViewport(0, 0, 32, 32); + const GLfloat triangle[] = {-1,-1, 3,-1, -1,3}; + const GLfloat quad[] = {-1,-1, 1,-1, 1,1, -1,1}; + const GLushort triangle_indices[] = {0,1,2}; + const GLushort quad_indices[] = {0,1,2, 2,3,0}; + const GLfloat triangle_uv[] = {1,0, 1,0, 1,0}; + const GLfloat quad_uv[] = {1,0, 1,0, 1,0, 1,0}; + GLuint host_vao, host_buffers[2]; + glGenVertexArrays(1, &host_vao); + glGenBuffers(2, host_buffers); + glBindVertexArray(host_vao); + glBindBuffer(GL_ARRAY_BUFFER, host_buffers[0]); + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, host_buffers[1]); + for (int iteration = 0; iteration < 32; ++iteration) { + bongo_cat::CoreProfileBuffers outer, inner; + GLuint retained_vao = 0, retained_buffer = 0; + for (int frame = 0; frame < 8; ++frame) { + bongo_cat::CoreProfileBinding binding(outer); + if (frame) { + CHECK(outer.vao == retained_vao); + CHECK(outer.buffers[0] == retained_buffer); + } + retained_vao = outer.vao; + retained_buffer = outer.buffers[0]; + glEnableVertexAttribArray(3); + glEnableVertexAttribArray(7); + bongo_cat::CoreProfileBinding::attribute(0, 3, triangle, sizeof(triangle)); + bongo_cat::CoreProfileBinding::attribute(1, 7, triangle_uv, sizeof(triangle_uv)); + bongo_cat::CoreProfileBinding::draw(3, triangle_indices); + { + bongo_cat::CoreProfileBinding nested(inner); + glEnableVertexAttribArray(3); + glEnableVertexAttribArray(7); + bongo_cat::CoreProfileBinding::attribute(0, 3, quad, sizeof(quad)); + bongo_cat::CoreProfileBinding::attribute(1, 7, quad_uv, sizeof(quad_uv)); + glClearColor(0, 0, 0, 0); + glClear(GL_COLOR_BUFFER_BIT); + // Cubism PreDraw clears the element binding before some passes. + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0); + bongo_cat::CoreProfileBinding::draw(6, quad_indices); + unsigned char pixel[4] = {}; + glReadPixels(16, 16, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + CHECK(pixel[0] == 255 && pixel[1] == 0 && pixel[2] == 0); + } + GLint current = 0; + glGetIntegerv(GL_VERTEX_ARRAY_BINDING, ¤t); + CHECK((GLuint)current == outer.vao); + bongo_cat::CoreProfileBinding::draw(3, triangle_indices); + unsigned char pixel[4] = {}; + glReadPixels(16, 16, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + CHECK(pixel[0] == 255 && pixel[1] == 0 && pixel[2] == 0); + } + GLint current = -1; + glGetIntegerv(GL_VERTEX_ARRAY_BINDING, ¤t); + CHECK((GLuint)current == host_vao); + glGetIntegerv(GL_ARRAY_BUFFER_BINDING, ¤t); + CHECK((GLuint)current == host_buffers[0]); + glGetIntegerv(GL_ELEMENT_ARRAY_BUFFER_BINDING, ¤t); + CHECK((GLuint)current == host_buffers[1]); + GLuint vao = outer.vao; + GLuint buffers[] = {outer.buffers[0], outer.buffers[1], outer.buffers[2], + inner.buffers[0], inner.buffers[1], inner.buffers[2]}; + for (GLuint buffer : buffers) CHECK(glIsBuffer(buffer)); + outer.release(); + inner.release(); + CHECK(!glIsVertexArray(vao)); + for (GLuint buffer : buffers) CHECK(!glIsBuffer(buffer)); + outer.release(); + CHECK(outer.vao == 0 && outer.buffers[0] == 0); + CHECK(glGetError() == GL_NO_ERROR); + } + BongoCatLive2D runtime{}; + runtime.retired_count = 1; + runtime.retired[0].frames_remaining = 3; + for (int frame = 0; frame < 3; ++frame) { + CHECK(bongo_cat_live2d_update(&runtime, 1.0f / 60.0f)); + bongo_cat_live2d_draw(&runtime); + } + CHECK(runtime.retired_count == 0); + CHECK(!bongo_cat_live2d_update(&runtime, 1.0f / 60.0f)); + glDeleteVertexArrays(1, &host_vao); + glDeleteBuffers(2, host_buffers); + glDeleteProgram(program); + glDeleteShader(vertex); + glDeleteShader(fragment); + SDL_GL_DestroyContext(context); + SDL_DestroyWindow(window); + SDL_Quit(); + return 0; +} From 9aea18e0dfd7152d783d6cff71f161e7eb9a3a77 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 13 Sep 2026 11:08:26 +0800 Subject: [PATCH 285/334] fix(ui): match model card backgrounds to theme surface --- src/ui/preferences/preferences_model_card.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/preferences/preferences_model_card.c b/src/ui/preferences/preferences_model_card.c index fa991576..ad8d1f02 100644 --- a/src/ui/preferences/preferences_model_card.c +++ b/src/ui/preferences/preferences_model_card.c @@ -159,7 +159,7 @@ static void draw_actions(BongoCatPreferences *value, bool *action_hover) { struct nk_rect actions = nk_rect(bounds.x + 1, bounds.y + bounds.h - 39, bounds.w - 2, 38); - nk_fill_rect(canvas, actions, 0, p.field); + nk_fill_rect(canvas, actions, 0, p.surface); bool deletable = !entry->preset && !entry->managed; bool delete_enabled = deletable && !storage_busy; float width = actions.w / (deletable ? 3.0f : 2.0f); @@ -236,7 +236,7 @@ void bongo_cat_preferences_model_card(BongoCatPreferences *value, float preview_height = NK_MIN(128.0f, bounds.w * 354.0f / 612.0f); struct nk_rect preview = nk_rect(bounds.x + 1, bounds.y + 1, bounds.w - 2, preview_height); - nk_fill_rect(canvas, preview, 12, p.field); + nk_fill_rect(canvas, preview, 12, p.surface); draw_cover(value, canvas, preview, entry, p); if (app->settings.model.multiple_pets) bongo_cat_preferences_model_card_draw_selection_badge( From 7038ad6c30fdde92ceaf826e4d6d37f721e2e5a4 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sun, 13 Sep 2026 11:55:39 +0800 Subject: [PATCH 286/334] fix(input): restore lost background subscriptions and add diagnostics --- .gitignore | 1 + SECURITY.md | 12 +++ docs/windows-multimonitor-input-validation.md | 75 +++++++++++++++++++ include/bongo_cat/app.h | 5 ++ include/bongo_cat/log.h | 6 +- src/core/app_state.c | 21 ++++-- src/platform/windows/windows_input.c | 61 ++++++++++++++- src/platform/windows/windows_input_devices.c | 13 +++- src/platform/windows/windows_input_internal.h | 11 +++ src/platform/windows/windows_input_packets.c | 10 ++- src/platform/windows/windows_input_receiver.c | 2 + .../windows/windows_input_registration.c | 35 +++++++++ src/runtime/lifecycle/app_input.c | 37 ++++++++- src/runtime/lifecycle/app_loop.c | 1 + src/runtime/lifecycle/startup.c | 1 + tests/core/test_log_policy.c | 5 +- tests/platform/test_windows_raw_receiver.c | 64 ++++++++++++++++ 17 files changed, 344 insertions(+), 16 deletions(-) create mode 100644 docs/windows-multimonitor-input-validation.md diff --git a/.gitignore b/.gitignore index 655a299d..8f59ac20 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,7 @@ Desktop.ini !/docs/README.es-ES.md !/docs/README.id-ID.md !/docs/wechat.png +!/docs/windows-multimonitor-input-validation.md # Local Windows audit tools /cmake/*.ps1 diff --git a/SECURITY.md b/SECURITY.md index 2413e969..ddda5eeb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -15,8 +15,20 @@ timing, and window/cursor metadata, rather than typed text. Development tools that simulate input are separate `EXCLUDE_FROM_ALL` targets and are not part of the application target or its installation rules. +Input diagnostics are enabled in the normal session log. At most once every +10 seconds per running loop, they report cumulative receiver/consumer counters +and a current display snapshot, including the foreground PID and monitor +bounds. They do not log key names, scan codes, text, window titles, process +paths, or device identifiers. Counter updates do not write a log per input +event. Receiver startup/shutdown and registration failures are logged separately. + Registration success alone does not establish that input messages are arriving. BongoCat does not attempt to bypass a game's input restrictions. +The receiver checks its process-local registrations once per second and +restores missing subscriptions or background flags on its own receiver. +It leaves registrations targeting another receiver untouched. Recovery is +not triggered by a period without input or by the foreground monitor, and +is deferred while the normal input desktop is unavailable. Using documented Windows APIs does not guarantee acceptance by every anti-cheat product. The source checks in diff --git a/docs/windows-multimonitor-input-validation.md b/docs/windows-multimonitor-input-validation.md new file mode 100644 index 00000000..d525dd5e --- /dev/null +++ b/docs/windows-multimonitor-input-validation.md @@ -0,0 +1,75 @@ +# Windows 双屏键盘输入回归 + +当前键盘链路使用独立消息窗口接收后台 Raw Input,不根据桌宠位置、 +鼠标坐标或前台窗口所在屏幕过滤按键。鼠标桌面位置另行读取,所以 +“鼠标跟随正常”并不能证明键盘订阅正常。 + +本次修复补齐注册丢失后的恢复:每秒检查进程内的键鼠订阅;订阅消失, +或本接收窗口丢失后台接收标志时,重新订阅对应设备类别。先释放旧的 +按下状态,避免恢复后卡键。另一接收窗口持有的注册不会被覆盖; +无法确认注册情况或当前不在正常输入桌面时,不尝试恢复。 + +这修复了代码中确认存在的恢复缺口,但尚未证明它就是用户反馈的原因。 +此次未编译、未运行新增 C 测试,也未完成真实双屏及游戏验证。 + +## 编译后验证 + +1. 普通权限启动桌宠,在主副屏各打开一个记事本。桌宠分别放在主屏、 + 副屏,各测试两个记事本中的按下、长按、松开和连续输入。 +2. 副屏分别放在主屏左侧、右侧、上方;使用不同缩放比例;切换主屏。 + 各组合都应响应键盘,鼠标跟随保持正常。 +3. 在两个记事本之间切换焦点,打开并关闭桌宠设置,拔插外接键盘、 + 断开再接上副屏,锁屏后解锁。确认恢复后无卡键或持续不响应。 +4. 在游戏允许后台桌面应用的前提下,分别测试窗口、无边框及全屏模式, + 并在游戏和副屏记事本之间切换。输入应正常送达游戏,桌宠不抢焦点。 + 游戏限制后台输入时,不通过提权、输入钩子或模拟输入绕过。 +5. 编译 Windows 输入测试目标 `bongo_cat_windows_input_tests` 后,运行 + `ctest --test-dir <构建目录> -R '^windows-input$' --output-on-failure`。 + 新增用例覆盖键盘注册丢失、 + 后台标志退化、保留鼠标订阅、不覆盖其他接收窗口,以及工作线程恢复。 + +## 仍能复现时 + +请用户在同一次运行中,先在主屏记事本连续敲击、松开按键至少 20 秒, +再在副屏重复至少 20 秒,每个阶段保持记事本在前台,并记录大致时间。 +之后打开设置的关于页面,点击“打开日志”,复制 `BongoCat.log` 发回。 +**先保存日志再重启桌宠**:主进程每次启动会清空上次会话日志。 +不需要开启调试模式,也不需要以管理员身份启动。 + +记录系统和桌宠版本、屏幕布局、问题软件、权限级别,以及同一软件移到 +另一屏幕后问题是否随之移动。优先用普通权限记事本区分屏幕因素和软件因素。 + +日志中的 `[input] Raw Input ownership changed` 表示监听注册发生变化, +`background registration restored` 表示已恢复注册,`recovery failed` 后的 +错误码用于定位恢复失败。类别位 `1` 为鼠标,`2` 为键盘,`3` 为两者。 +这些日志不记录用户输入内容。注册恢复不等于已收到真实键盘事件。 + +`[input] receiver` 在启动、退出和运行期间每 10 秒记录一次; +`[input] consumer` 在主循环运行期间每 10 秒记录一次。数值是本进程本次 +运行的累计计数,分析时比较相邻记录的增量,不能把累计数量当成当前按下键数。 +双屏相关字段只是打印时的快照,不代表整个统计区间的输入都来自该屏幕。 + +| 字段 | 用途 | +| --- | --- | +| `keys` / `background_keys` / `mouse` | 收到的有效原始键盘包、其中后台键盘包及鼠标包;按键长按重复也计数 | +| `filtered_keys` / `duplicate_keys` | 被规范化过滤的包及重复状态包;增长不一定是错误 | +| `queued_keys` | 成功送入主线程队列的键盘事件;包含清理状态产生的释放事件 | +| `queue_failures` / `wake_failures` | 入队和 SDL 唤醒失败的尝试次数,包含后续可能成功的重试 | +| `invalid` / `device_drops` / `read_failures` | 无效包、设备状态分配失败或达上限、系统读包失败次数 | +| `device_changes` / `devices` | 设备到达和移除通知次数、当前追踪设备数 | +| `owned` / `desktop_unavailable` / `query_error` | 后台订阅归属、输入桌面是否无法访问或不匹配、注册查询错误码 | +| `recovery_error` / `last_read_error` | 最近恢复失败或读包失败的错误码;最后读包错误不会因成功读取而清零 | +| `drained_keys` / `ignored_keys` | 主循环取出的键盘事件数、其中被诊断模式忽略的数量 | +| `mapped_keys` / `unmapped_keys` / `no_model_keys` | 模型按键映射接受、未接受、模型未就绪的事件次数;包含程序内部补发的事件 | +| `frames` / `visible` / `minimized` / `dirty` | 成功提交画面次数及当前窗口、重绘状态;提交成功不保证屏幕实际可见 | +| `monitors` / `foreground_pid` / `foreground_primary` / `foreground_rect` | 当前屏幕数量、前台 PID、前台是否在主屏及所在屏幕边界(左、上、右、下) | +| `pet_position` / `pet_display` / `pet_primary` / `pet_bounds` | 桌宠位置、SDL 屏幕 ID、是否在主屏及屏幕区域(横坐标、纵坐标、宽、高) | + +先确认各 `known` 字段再解读位置数据。若副屏阶段 `keys` 不增长,应检查 +系统投递、订阅归属及输入桌面;若 `keys` 增长而 `queued_keys` 不增长, +检查过滤、重复状态和入队失败;若已入队但 `drained_keys` 不增长,检查 +主循环是否卡住;若已消费,再看模型映射、显示状态和提交画面计数。 +日志不会记录具体按键、输入文字、窗口标题或进程路径。 + +游戏兼容性保持普通 Raw Input 订阅、不注入、不读写游戏内存、不安装驱动、 +不模拟输入的边界;这不构成任何反作弊产品的兼容性认证。 diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index 91b8487a..7fa9423e 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -25,6 +25,11 @@ typedef struct BongoCatApp { BongoCatSettings settings; BongoCatSessionState session; BongoCatInputState input; + /* Main-thread-only cumulative input diagnostics. */ + struct { + uint64_t log_ms, drained_keys, ignored_keys, mapped_keys; + uint64_t unmapped_keys, no_model_keys, presented_frames; + } input_diagnostics; BongoCatShortcutState shortcut_state; BongoCatSoundShortcutState sound_shortcut_state; bool sound_shortcut_active[BONGO_CAT_BEHAVIOR_BINDING_CAP]; diff --git a/include/bongo_cat/log.h b/include/bongo_cat/log.h index 78bf8464..772346b7 100644 --- a/include/bongo_cat/log.h +++ b/include/bongo_cat/log.h @@ -5,14 +5,16 @@ enum { BONGO_CAT_LOG_LIFECYCLE = SDL_LOG_CATEGORY_CUSTOM, - BONGO_CAT_LOG_UPDATE + BONGO_CAT_LOG_UPDATE, + BONGO_CAT_LOG_INPUT }; static inline bool bongo_cat_log_enabled(int category, SDL_LogPriority priority) { return priority >= SDL_LOG_PRIORITY_WARN || (priority == SDL_LOG_PRIORITY_INFO && (category == BONGO_CAT_LOG_LIFECYCLE || - category == BONGO_CAT_LOG_UPDATE)); + category == BONGO_CAT_LOG_UPDATE || + category == BONGO_CAT_LOG_INPUT)); } #endif diff --git a/src/core/app_state.c b/src/core/app_state.c index 6a5f4143..bfd4a37b 100644 --- a/src/core/app_state.c +++ b/src/core/app_state.c @@ -65,11 +65,12 @@ static void update_hands(BongoCatApp *app) { (right_stick || bongo_cat_overlay_hand_active(app->overlay, true)) ? 1.0f : 0.0f); } -static void apply_key(BongoCatApp *app, const char *name, bool pressed) { +static bool apply_key(BongoCatApp *app, const char *name, bool pressed) { int hand = bongo_cat_overlay_key(app->overlay, name, pressed); - if (hand < 0) return; + if (hand < 0) return false; update_hands(app); app->dirty = true; + return true; } static void set_axis(BongoCatApp *app, const char *id, float input) { @@ -143,11 +144,21 @@ void bongo_cat_app_reset_gamepad(BongoCatApp *app) { } void bongo_cat_app_apply_input(BongoCatApp *app, const BongoCatInputEvent *event) { - if (!app || !event || !app->live2d) return; + if (!app || !event) return; + bool keyboard = event->kind == BONGO_CAT_INPUT_KEY_DOWN || + event->kind == BONGO_CAT_INPUT_KEY_UP; + if (!app->live2d) { + if (keyboard) app->input_diagnostics.no_model_keys++; + return; + } active_input_update(app, event); switch (event->kind) { - case BONGO_CAT_INPUT_KEY_DOWN: apply_key(app, event->name, true); break; - case BONGO_CAT_INPUT_KEY_UP: apply_key(app, event->name, false); break; + case BONGO_CAT_INPUT_KEY_DOWN: + case BONGO_CAT_INPUT_KEY_UP: + if (apply_key(app, event->name, event->kind == BONGO_CAT_INPUT_KEY_DOWN)) + app->input_diagnostics.mapped_keys++; + else app->input_diagnostics.unmapped_keys++; + break; case BONGO_CAT_INPUT_MOUSE_DOWN: case BONGO_CAT_INPUT_MOUSE_UP: { bool left = strcmp(event->name, "Left") == 0; diff --git a/src/platform/windows/windows_input.c b/src/platform/windows/windows_input.c index 109a1648..de866282 100644 --- a/src/platform/windows/windows_input.c +++ b/src/platform/windows/windows_input.c @@ -23,6 +23,7 @@ void bongo_cat_windows_input_wake(WindowsInputState *state) { SDL_zero(wake); wake.type = platform->wake_event_type; if (SDL_PushEvent(&wake)) state->wake_pending = false; + else state->diagnostic_wake_failures++; } ReleaseSRWLockShared(&state->platform_lock); } @@ -40,6 +41,9 @@ bool bongo_cat_windows_input_push_event(WindowsInputState *state, event.value = value; snprintf(event.name, sizeof(event.name), "%s", name); pushed = bongo_cat_input_push(platform->input, &event); + if (!pushed) state->diagnostic_queue_failures++; + else if (kind == BONGO_CAT_INPUT_KEY_DOWN || kind == BONGO_CAT_INPUT_KEY_UP) + state->diagnostic_queued_keys++; } ReleaseSRWLockShared(&state->platform_lock); if (pushed) state->wake_pending = true; @@ -62,21 +66,69 @@ static bool input_desktop_available(void) { static void check_receiver(WindowsInputState *state) { unsigned ownership = bongo_cat_windows_input_ownership(state); - if (state->registration_error) ownership = state->ownership; + bool inspected = state->registration_error == ERROR_SUCCESS; + if (!inspected) ownership = state->ownership; bool unavailable = !input_desktop_available(); if (ownership != state->ownership || unavailable != state->desktop_unavailable) { + if (ownership != state->ownership) SDL_LogWarn(SDL_LOG_CATEGORY_INPUT, + "[input] Raw Input ownership changed: previous=%u current=%u " + "mouse_flags=%lu keyboard_flags=%lu", state->ownership, ownership, + (unsigned long)state->mouse_registration_flags, + (unsigned long)state->keyboard_registration_flags); if (ownership != state->ownership || unavailable) bongo_cat_windows_input_clear_devices(state); else bongo_cat_windows_input_clear_motion(state); state->ownership = ownership; state->desktop_unavailable = unavailable; } + /* Repair a lost subscription on the normal input desktop, after releasing + stale keys. Never infer failure from silence or foreground monitor. */ + if (inspected && !unavailable) + ownership = bongo_cat_windows_input_restore(state, ownership); + state->ownership = ownership; AcquireSRWLockExclusive(&state->relative_lock); /* Desktop inspection may be denied even when valid WM_INPUT still arrives. */ state->receiving = (ownership & 1u) != 0; ReleaseSRWLockExclusive(&state->relative_lock); } +static void log_receiver(WindowsInputState *state, const char *phase) { + HWND foreground = GetForegroundWindow(); + DWORD pid = 0; + if (foreground) GetWindowThreadProcessId(foreground, &pid); + MONITORINFO monitor = {.cbSize = sizeof(monitor)}; + bool monitor_known = foreground && GetMonitorInfoW( + MonitorFromWindow(foreground, MONITOR_DEFAULTTONULL), &monitor); + /* Snapshot only: counters span the whole interval, not just this window. + No process access, titles, device identifiers, or key values are logged. */ + SDL_LogInfo(BONGO_CAT_LOG_INPUT, + "[input] receiver phase=%s uptime_ms=%llu keys=%llu mouse=%llu " + "background_keys=%llu filtered_keys=%llu duplicate_keys=%llu " + "queued_keys=%llu queue_failures=%llu wake_failures=%llu " + "invalid=%llu device_drops=%llu read_failures=%llu device_changes=%llu " + "owned=%u mouse_flags=%lu keyboard_flags=%lu desktop_unavailable=%d " + "query_error=%lu recovery_error=%lu last_read_error=%lu devices=%u " + "monitors=%d foreground_pid=%lu foreground_monitor_known=%d " + "foreground_primary=%d foreground_rect=%ld,%ld,%ld,%ld", + phase, (unsigned long long)GetTickCount64(), + state->diagnostic_keys, state->diagnostic_mouse, + state->diagnostic_background_keys, state->diagnostic_filtered_keys, + state->diagnostic_duplicate_keys, + state->diagnostic_queued_keys, state->diagnostic_queue_failures, + state->diagnostic_wake_failures, state->diagnostic_invalid, + state->diagnostic_device_drops, state->diagnostic_read_failures, + state->diagnostic_device_changes, state->ownership, + (unsigned long)state->mouse_registration_flags, + (unsigned long)state->keyboard_registration_flags, + state->desktop_unavailable, (unsigned long)state->registration_error, + (unsigned long)state->recovery_error, (unsigned long)state->read_error, + state->device_count, + GetSystemMetrics(SM_CMONITORS), (unsigned long)pid, monitor_known, + (monitor.dwFlags & MONITORINFOF_PRIMARY) != 0, + monitor.rcMonitor.left, monitor.rcMonitor.top, + monitor.rcMonitor.right, monitor.rcMonitor.bottom); +} + static DWORD WINAPI input_thread(void *context) { WindowsInputState *state = context; if (WaitForSingleObject(state->stop, state->test_start_delay_ms) == WAIT_OBJECT_0) { @@ -90,6 +142,8 @@ static DWORD WINAPI input_thread(void *context) { if (state->registered) { state->ownership = 3; check_receiver(state); + log_receiver(state, "started"); + state->diagnostic_ms = GetTickCount64(); } SetEvent(state->ready); ULONGLONG last_check = GetTickCount64(), last_wake = 0; @@ -109,6 +163,10 @@ static DWORD WINAPI input_thread(void *context) { check_receiver(state); last_check = now; } + if (now - state->diagnostic_ms >= 10000) { + log_receiver(state, "periodic"); + state->diagnostic_ms = now; + } bongo_cat_windows_input_flush(state); if (now - last_wake >= 8) { bongo_cat_windows_input_wake(state); @@ -120,6 +178,7 @@ static DWORD WINAPI input_thread(void *context) { ReleaseSRWLockExclusive(&state->relative_lock); bongo_cat_windows_input_clear_devices(state); bongo_cat_windows_input_wake(state); + if (state->registered) log_receiver(state, "stopping"); bongo_cat_windows_input_receiver_destroy(state); release_state(state); return 0; diff --git a/src/platform/windows/windows_input_devices.c b/src/platform/windows/windows_input_devices.c index f297cca9..dc7a4070 100644 --- a/src/platform/windows/windows_input_devices.c +++ b/src/platform/windows/windows_input_devices.c @@ -59,9 +59,11 @@ void bongo_cat_windows_input_key(WindowsInputState *state, WindowsRawDevice *device, const RAWKEYBOARD *key) { if ((key->Flags & RI_KEY_E0) && (key->MakeCode == 0x2a || key->MakeCode == 0x36)) { + state->diagnostic_filtered_keys++; return; } if ((key->Flags & RI_KEY_E1) && key->MakeCode == 0x1d) { + state->diagnostic_filtered_keys++; device->pending_e1 = true; return; } @@ -73,15 +75,18 @@ void bongo_cat_windows_input_key(WindowsInputState *state, key = &normalized; char buffer[16]; const char *name = bongo_cat_windows_key_name(key, buffer); - if (!name) return; + if (!name) { state->diagnostic_filtered_keys++; return; } unsigned index = bongo_cat_windows_key_index(key); - if (index >= BONGO_CAT_WINDOWS_RAW_KEY_COUNT) return; + if (index >= BONGO_CAT_WINDOWS_RAW_KEY_COUNT) { + state->diagnostic_filtered_keys++; + return; + } unsigned slot = device->keys[index]; bool down = !(key->Flags & RI_KEY_BREAK); - if (down == (slot != 0)) return; + if (down == (slot != 0)) { state->diagnostic_duplicate_keys++; return; } if (down) { int found = held_slot(state, name); - if (found < 0) return; + if (found < 0) { state->diagnostic_filtered_keys++; return; } slot = (unsigned)found + 1; device->keys[index] = (unsigned short)slot; state->keys[slot - 1].references++; diff --git a/src/platform/windows/windows_input_internal.h b/src/platform/windows/windows_input_internal.h index 4969d8d0..70a7b007 100644 --- a/src/platform/windows/windows_input_internal.h +++ b/src/platform/windows/windows_input_internal.h @@ -4,6 +4,7 @@ #include "windows_input.h" #include "windows_input_detection.h" #include "windows_keys.h" +#include "bongo_cat/log.h" #ifdef _WIN32 #define BONGO_CAT_WINDOWS_RAW_DEVICE_LIMIT 64 @@ -37,6 +38,15 @@ typedef struct WindowsInputState { ATOM window_class; bool registered; DWORD startup_error, read_error, registration_error; + DWORD recovery_error; + /* Receiver-thread-only cumulative diagnostics; never store typed content. */ + unsigned long long diagnostic_keys, diagnostic_mouse, diagnostic_invalid; + unsigned long long diagnostic_background_keys, diagnostic_filtered_keys; + unsigned long long diagnostic_duplicate_keys; + unsigned long long diagnostic_device_drops, diagnostic_read_failures; + unsigned long long diagnostic_queued_keys, diagnostic_queue_failures; + unsigned long long diagnostic_wake_failures, diagnostic_device_changes; + ULONGLONG diagnostic_ms; DWORD mouse_registration_flags, keyboard_registration_flags; DWORD test_start_delay_ms; unsigned ownership; @@ -83,6 +93,7 @@ unsigned long long bongo_cat_windows_input_take_observation( bool bongo_cat_windows_input_register(WindowsInputState *state); void bongo_cat_windows_input_unregister(WindowsInputState *state); unsigned bongo_cat_windows_input_ownership(WindowsInputState *state); +unsigned bongo_cat_windows_input_restore(WindowsInputState *state, unsigned owned); bool bongo_cat_windows_input_receiver_create(WindowsInputState *state); void bongo_cat_windows_input_receiver_destroy(WindowsInputState *state); bool bongo_cat_windows_input_dispatch(WindowsInputState *state); diff --git a/src/platform/windows/windows_input_packets.c b/src/platform/windows/windows_input_packets.c index 0ae9959b..9ad48045 100644 --- a/src/platform/windows/windows_input_packets.c +++ b/src/platform/windows/windows_input_packets.c @@ -7,6 +7,7 @@ void bongo_cat_windows_input_packet(WindowsInputState *state, const RAWINPUT *packet, UINT bytes) { if (!state || !packet) return; if (bytes < sizeof(RAWINPUTHEADER) || packet->header.dwSize != bytes) { + state->diagnostic_invalid++; return; } UINT payload; @@ -16,11 +17,18 @@ void bongo_cat_windows_input_packet(WindowsInputState *state, default: return; } if (bytes < offsetof(RAWINPUT, data) + payload) { + state->diagnostic_invalid++; return; } + if (packet->header.dwType == RIM_TYPEKEYBOARD) { + state->diagnostic_keys++; + if (GET_RAWINPUT_CODE_WPARAM(packet->header.wParam) == RIM_INPUTSINK) + state->diagnostic_background_keys++; + } + else state->diagnostic_mouse++; WindowsRawDevice *device = bongo_cat_windows_input_device(state, packet->header.hDevice); - if (!device) return; + if (!device) { state->diagnostic_device_drops++; return; } if (packet->header.dwType == RIM_TYPEKEYBOARD) { bongo_cat_windows_input_key(state, device, &packet->data.keyboard); return; diff --git a/src/platform/windows/windows_input_receiver.c b/src/platform/windows/windows_input_receiver.c index c593e7fd..dab9c9ea 100644 --- a/src/platform/windows/windows_input_receiver.c +++ b/src/platform/windows/windows_input_receiver.c @@ -20,6 +20,7 @@ static LRESULT CALLBACK receiver_proc(HWND window, UINT message, &bytes, sizeof(RAWINPUTHEADER)); if (received == (UINT)-1) { DWORD error = GetLastError(); + state->diagnostic_read_failures++; if (error != state->read_error) SDL_LogError(SDL_LOG_CATEGORY_INPUT, "Raw Input read failed: error=%lu", (unsigned long)error); state->read_error = error; @@ -27,6 +28,7 @@ static LRESULT CALLBACK receiver_proc(HWND window, UINT message, /* DefWindowProc performs the required foreground WM_INPUT cleanup. */ } else if (state && message == WM_INPUT_DEVICE_CHANGE) { if (wparam == GIDC_REMOVAL || wparam == GIDC_ARRIVAL) { + state->diagnostic_device_changes++; bongo_cat_windows_input_remove_device(state, (HANDLE)lparam); bongo_cat_windows_input_clear_motion(state); } diff --git a/src/platform/windows/windows_input_registration.c b/src/platform/windows/windows_input_registration.c index 3a2bb66d..599cf859 100644 --- a/src/platform/windows/windows_input_registration.c +++ b/src/platform/windows/windows_input_registration.c @@ -93,6 +93,41 @@ bool bongo_cat_windows_input_register(WindowsInputState *state) { return true; } +unsigned bongo_cat_windows_input_restore(WindowsInputState *state, unsigned owned) { + if (owned == 3) return owned; + RAWINPUTDEVICE *items; + UINT count; + if (!registrations(state, &items, &count)) return owned; + unsigned blocked = 0; + for (UINT i = 0; i < count; ++i) { + /* Registrations are process-local. Leave another receiver (including + a foreground-only registration with no target) alone. */ + if (items[i].hwndTarget != state->window) + blocked |= device_class(&items[i]); + } + free(items); + unsigned missing = 3u & ~(owned | blocked); + if (!missing) return owned; + RAWINPUTDEVICE devices[2]; + UINT pending = 0; + if (missing & 1u) devices[pending++] = (RAWINPUTDEVICE){ + 1, 2, RIDEV_INPUTSINK | RIDEV_DEVNOTIFY, state->window}; + if (missing & 2u) devices[pending++] = (RAWINPUTDEVICE){ + 1, 6, RIDEV_INPUTSINK | RIDEV_DEVNOTIFY, state->window}; + if (!RegisterRawInputDevices(devices, pending, sizeof(devices[0]))) { + DWORD error = GetLastError(); + if (error != state->recovery_error) SDL_LogWarn(SDL_LOG_CATEGORY_INPUT, + "[input] Raw Input recovery failed: classes=%u error=%lu", + missing, (unsigned long)error); + state->recovery_error = error; + return owned; + } + state->recovery_error = ERROR_SUCCESS; + SDL_LogInfo(BONGO_CAT_LOG_INPUT, + "[input] Raw Input background registration restored: classes=%u", missing); + return owned | missing; +} + void bongo_cat_windows_input_unregister(WindowsInputState *state) { RAWINPUTDEVICE *items; UINT count; diff --git a/src/runtime/lifecycle/app_input.c b/src/runtime/lifecycle/app_input.c index eb96c6ba..02431485 100644 --- a/src/runtime/lifecycle/app_input.c +++ b/src/runtime/lifecycle/app_input.c @@ -1,14 +1,48 @@ #include "runtime.h" #include "bongo_cat/preferences.h" #include "bongo_cat/shortcut.h" +#include "bongo_cat/log.h" #include +static void log_input(BongoCatApp *app, uint64_t now) { + if (now - app->input_diagnostics.log_ms < 10000) return; + app->input_diagnostics.log_ms = now; + int x = 0, y = 0; + bool position_known = app->window && SDL_GetWindowPosition(app->window, &x, &y); + SDL_DisplayID display = app->window ? SDL_GetDisplayForWindow(app->window) : 0; + SDL_Rect bounds = {0}; + bool display_known = display && SDL_GetDisplayBounds(display, &bounds); + SDL_LogInfo(BONGO_CAT_LOG_INPUT, + "[input] consumer ticks_ms=%llu drained_keys=%llu ignored_keys=%llu " + "mapped_keys=%llu unmapped_keys=%llu no_model_keys=%llu frames=%llu " + "model_ready=%d visible=%d minimized=%d dirty=%d " + "position_known=%d pet_position=%d,%d display_known=%d " + "pet_display=%u pet_primary=%d pet_bounds=%d,%d,%d,%d", + (unsigned long long)now, + (unsigned long long)app->input_diagnostics.drained_keys, + (unsigned long long)app->input_diagnostics.ignored_keys, + (unsigned long long)app->input_diagnostics.mapped_keys, + (unsigned long long)app->input_diagnostics.unmapped_keys, + (unsigned long long)app->input_diagnostics.no_model_keys, + (unsigned long long)app->input_diagnostics.presented_frames, + app->live2d != NULL, app->session.window.visible, app->window_minimized, + app->dirty, position_known, x, y, display_known, (unsigned)display, + display && display == SDL_GetPrimaryDisplay(), + bounds.x, bounds.y, bounds.w, bounds.h); +} + void bongo_cat_app_drain_input(BongoCatApp *app, bool allow_shortcuts) { if (app && app->secondary_pet) allow_shortcuts = false; BongoCatInputEvent event; while (bongo_cat_input_pop(&app->input, &event)) { - if (app->smoke_ignore_global_input) continue; + bool keyboard = event.kind == BONGO_CAT_INPUT_KEY_DOWN || + event.kind == BONGO_CAT_INPUT_KEY_UP; + if (keyboard) app->input_diagnostics.drained_keys++; + if (app->smoke_ignore_global_input) { + if (keyboard) app->input_diagnostics.ignored_keys++; + continue; + } if (strcmp(event.name, "CapsLock") == 0) bongo_cat_input_schedule_release(&app->input, &event, 100); if (allow_shortcuts && @@ -34,4 +68,5 @@ void bongo_cat_app_drain_input(BongoCatApp *app, bool allow_shortcuts) { bongo_cat_app_apply_input(app, &event); } if (!app->smoke_ignore_global_input) bongo_cat_app_apply_mouse(app); + log_input(app, now); } diff --git a/src/runtime/lifecycle/app_loop.c b/src/runtime/lifecycle/app_loop.c index cadf5411..17c42b61 100644 --- a/src/runtime/lifecycle/app_loop.c +++ b/src/runtime/lifecycle/app_loop.c @@ -109,6 +109,7 @@ static bool render(BongoCatApp *app, bool present) { } if (reveal_startup) app->startup_visibility_pending = false; bongo_cat_frame_presented_audit(app); + app->input_diagnostics.presented_frames++; bongo_cat_startup_ready(app); bongo_cat_memory_policy_frame_presented(); app->dirty = false; diff --git a/src/runtime/lifecycle/startup.c b/src/runtime/lifecycle/startup.c index 491616e6..b5cee757 100644 --- a/src/runtime/lifecycle/startup.c +++ b/src/runtime/lifecycle/startup.c @@ -135,6 +135,7 @@ static void begin_log(BongoCatApp *app) { SDL_SetLogPriorities(SDL_LOG_PRIORITY_WARN); SDL_SetLogPriority(BONGO_CAT_LOG_LIFECYCLE, SDL_LOG_PRIORITY_INFO); SDL_SetLogPriority(BONGO_CAT_LOG_UPDATE, SDL_LOG_PRIORITY_INFO); + SDL_SetLogPriority(BONGO_CAT_LOG_INPUT, SDL_LOG_PRIORITY_INFO); SDL_LogInfo(BONGO_CAT_LOG_LIFECYCLE, "[runtime] Process started: version=%s platform=%s", BONGO_CAT_VERSION, SDL_GetPlatform()); diff --git a/tests/core/test_log_policy.c b/tests/core/test_log_policy.c index 51cf9ab0..3bdeec05 100644 --- a/tests/core/test_log_policy.c +++ b/tests/core/test_log_policy.c @@ -7,7 +7,7 @@ int main(void) { const int categories[] = {SDL_LOG_CATEGORY_APPLICATION, SDL_LOG_CATEGORY_INPUT, SDL_LOG_CATEGORY_VIDEO, BONGO_CAT_LOG_LIFECYCLE, BONGO_CAT_LOG_UPDATE, - BONGO_CAT_LOG_UPDATE + 1}; + BONGO_CAT_LOG_INPUT, BONGO_CAT_LOG_INPUT + 1}; for (unsigned i = 0; i < sizeof(categories) / sizeof(categories[0]); ++i) { int category = categories[i]; for (int value = SDL_LOG_PRIORITY_TRACE; @@ -16,7 +16,8 @@ int main(void) { bool expected = priority >= SDL_LOG_PRIORITY_WARN || (priority == SDL_LOG_PRIORITY_INFO && (category == BONGO_CAT_LOG_LIFECYCLE || - category == BONGO_CAT_LOG_UPDATE)); + category == BONGO_CAT_LOG_UPDATE || + category == BONGO_CAT_LOG_INPUT)); if (bongo_cat_log_enabled(category, priority) != expected) { fprintf(stderr, "Unexpected log policy: category=%d priority=%d\n", category, value); diff --git a/tests/platform/test_windows_raw_receiver.c b/tests/platform/test_windows_raw_receiver.c index ebfe0794..3b0f8d1a 100644 --- a/tests/platform/test_windows_raw_receiver.c +++ b/tests/platform/test_windows_raw_receiver.c @@ -89,8 +89,71 @@ static void test_registration_flags(void) { DestroyWindow(receiver); } +static void test_registration_recovery(void) { + HWND receiver = CreateWindowExW(0, L"STATIC", L"", 0, + 0, 0, 0, 0, HWND_MESSAGE, NULL, GetModuleHandleW(NULL), NULL); + HWND other = CreateWindowExW(0, L"STATIC", L"", 0, + 0, 0, 0, 0, HWND_MESSAGE, NULL, GetModuleHandleW(NULL), NULL); + CHECK(receiver && other); + if (!receiver || !other) { + if (receiver) DestroyWindow(receiver); + if (other) DestroyWindow(other); + return; + } + WindowsInputState state = {.window = receiver}; + RAWINPUTDEVICE mouse = {1, 2, RIDEV_INPUTSINK, receiver}; + CHECK(RegisterRawInputDevices(&mouse, 1, sizeof(mouse))); + /* Restore only the missing keyboard; keep the healthy mouse unchanged. */ + CHECK(bongo_cat_windows_input_restore(&state, registered_to(receiver)) == 3); + CHECK(registered_to(receiver) == 3); + CHECK(bongo_cat_windows_input_ownership(&state) == 3); + CHECK(state.mouse_registration_flags == RIDEV_INPUTSINK); + CHECK(state.keyboard_registration_flags & RIDEV_INPUTSINK); + CHECK(bongo_cat_windows_input_restore(&state, 3) == 3); + + RAWINPUTDEVICE keyboard = {1, 6, 0, receiver}; + CHECK(RegisterRawInputDevices(&keyboard, 1, sizeof(keyboard))); + CHECK(registered_to(receiver) == 1); + CHECK(bongo_cat_windows_input_restore(&state, 1) == 3); + CHECK(registered_to(receiver) == 3); + + /* A competing receiver must not be overwritten, even without INPUTSINK. */ + keyboard.hwndTarget = other; + CHECK(RegisterRawInputDevices(&keyboard, 1, sizeof(keyboard))); + CHECK(bongo_cat_windows_input_restore(&state, registered_to(receiver)) == 1); + keyboard.dwFlags = RIDEV_INPUTSINK; + CHECK(RegisterRawInputDevices(&keyboard, 1, sizeof(keyboard))); + CHECK(bongo_cat_windows_input_restore(&state, 1) == 1); + CHECK(registered_to(other) == 2); + keyboard.dwFlags = 0; + keyboard.hwndTarget = NULL; + CHECK(RegisterRawInputDevices(&keyboard, 1, sizeof(keyboard))); + CHECK(bongo_cat_windows_input_restore(&state, 1) == 1); + + keyboard.dwFlags = RIDEV_REMOVE; + CHECK(RegisterRawInputDevices(&keyboard, 1, sizeof(keyboard))); + CHECK(bongo_cat_windows_input_restore(&state, 1) == 3); + bongo_cat_windows_input_unregister(&state); + CHECK(registered_to(receiver) == 0); + CHECK(bongo_cat_windows_input_restore(&state, 0) == 3); + CHECK(registered_to(receiver) == 3); + bongo_cat_windows_input_unregister(&state); + DestroyWindow(other); + DestroyWindow(receiver); +} + +static void test_worker_recovery(HWND receiver) { + RAWINPUTDEVICE keyboard = {1, 6, RIDEV_REMOVE, NULL}; + CHECK(RegisterRawInputDevices(&keyboard, 1, sizeof(keyboard))); + ULONGLONG deadline = GetTickCount64() + 3000; + while (registered_to(receiver) != 3 && GetTickCount64() < deadline) + SDL_Delay(10); + CHECK(registered_to(receiver) == 3); +} + void test_windows_raw_receiver(void) { test_registration_flags(); + test_registration_recovery(); BongoCatInputState input; bongo_cat_input_init(&input); BongoCatPlatform platform = {.input = &input}; @@ -123,6 +186,7 @@ void test_windows_raw_receiver(void) { CHECK(preferences != NULL); SDL_PumpEvents(); CHECK(registered_to(receiver) == 3); + test_worker_recovery(receiver); SDL_DestroyWindow(preferences); bongo_cat_windows_input_stop(&platform); CHECK(platform.native == NULL && registered_to(receiver) == 0); From 4ab5628e3529306dc062a818299ed2fba21fc8ca Mon Sep 17 00:00:00 2001 From: Linming-XHL Date: Sun, 13 Sep 2026 13:39:55 +0800 Subject: [PATCH 287/334] fix. Fix the issue of mouse penetration malfunction in the Wayland environment --- src/platform/linux/linux.c | 6 +++++- src/platform/linux/linux_internal.h | 1 + src/platform/linux/linux_x11.c | 15 +++++++++++++++ src/runtime/shell/window_hit.c | 3 +++ 4 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/platform/linux/linux.c b/src/platform/linux/linux.c index 7592c82a..40a99be1 100644 --- a/src/platform/linux/linux.c +++ b/src/platform/linux/linux.c @@ -181,7 +181,11 @@ void bongo_cat_platform_begin_drag(BongoCatPlatform *platform, bongo_cat_linux_x11_begin_drag(platform); } bool bongo_cat_platform_dynamic_hit_supported(void) { - return bongo_cat_linux_x11_supported(active_platform); + /* XWayland only forwards pointer events while one of its surfaces has + pointer focus, so an empty input region would hide the pointer + permanently and the pet could never become clickable again. */ + return bongo_cat_linux_x11_supported(active_platform) && + !bongo_cat_linux_x11_xwayland(active_platform); } bool bongo_cat_platform_open_directory(const char *path) { diff --git a/src/platform/linux/linux_internal.h b/src/platform/linux/linux_internal.h index 7e78c091..e340d4eb 100644 --- a/src/platform/linux/linux_internal.h +++ b/src/platform/linux/linux_internal.h @@ -6,6 +6,7 @@ bool bongo_cat_linux_x11_start(BongoCatPlatform *platform, BongoCatError *error); void bongo_cat_linux_x11_stop(BongoCatPlatform *platform); bool bongo_cat_linux_x11_supported(const BongoCatPlatform *platform); +bool bongo_cat_linux_x11_xwayland(const BongoCatPlatform *platform); void bongo_cat_linux_x11_click_through(BongoCatPlatform *platform, bool enabled); void bongo_cat_linux_x11_configure_capture_window(BongoCatPlatform *platform); void bongo_cat_linux_x11_begin_drag(BongoCatPlatform *platform); diff --git a/src/platform/linux/linux_x11.c b/src/platform/linux/linux_x11.c index d256cd4c..93785da8 100644 --- a/src/platform/linux/linux_x11.c +++ b/src/platform/linux/linux_x11.c @@ -1,5 +1,6 @@ #include "linux_internal.h" #include "bongo_cat/common.h" +#include "bongo_cat/log.h" #if !defined(_WIN32) && !defined(__APPLE__) #include @@ -22,6 +23,7 @@ typedef struct LinuxX11State { Window window; SDL_Thread *thread; bool key_down[BONGO_CAT_INPUT_KEY_STATE_CAP]; + bool xwayland; atomic_bool running; atomic_bool supported; } LinuxX11State; @@ -164,6 +166,14 @@ bool bongo_cat_linux_x11_start(BongoCatPlatform *platform, BongoCatError *error) state->platform = platform; state->display = display; state->window = window; atomic_init(&state->running, true); atomic_init(&state->supported, false); platform->native = state; + /* XWayland advertises a marker extension that native X servers lack. + XWayland stops delivering pointer events for a window whose input region + is empty, so dynamic hit testing cannot restore the region by itself. */ + int marker_opcode = 0, marker_event = 0, marker_error = 0; + state->xwayland = display && XQueryExtension(display, "XWAYLAND", + &marker_opcode, &marker_event, &marker_error); + if (state->xwayland) SDL_LogInfo(BONGO_CAT_LOG_LIFECYCLE, + "XWayland detected; automatic transparent-pixel click-through is disabled"); if (!display || !window) return true; state->thread = SDL_CreateThread(input_thread, BONGO_CAT_SLUG "-x11-input", state); @@ -187,6 +197,11 @@ bool bongo_cat_linux_x11_supported(const BongoCatPlatform *platform) { return state && atomic_load(&state->supported); } +bool bongo_cat_linux_x11_xwayland(const BongoCatPlatform *platform) { + const LinuxX11State *state = platform ? platform->native : NULL; + return state && state->xwayland; +} + void bongo_cat_linux_x11_click_through(BongoCatPlatform *platform, bool enabled) { LinuxX11State *state = platform ? platform->native : NULL; if (!state || !state->display || !state->window) return; diff --git a/src/runtime/shell/window_hit.c b/src/runtime/shell/window_hit.c index 2c6b7130..2f93c3bc 100644 --- a/src/runtime/shell/window_hit.c +++ b/src/runtime/shell/window_hit.c @@ -44,7 +44,10 @@ bool bongo_cat_window_visible_at_pointer(BongoCatApp *app, float x, float y) { } void bongo_cat_window_capture_pointer_hit(BongoCatApp *app) { + /* Backends without dynamic hit testing would discard the readback, so the + alpha sample would only stall the pipeline for every presented frame. */ if (!app || !app->window || !app->pointer_known || + !bongo_cat_platform_dynamic_hit_supported() || app->settings.window.pass_through || app->hover_hidden || app->left_mouse_down || app->right_mouse_down) return; float local_x, local_y; From 09fec837eee83ef1161456047f2678c97140d2e3 Mon Sep 17 00:00:00 2001 From: kmou424 Date: Mon, 14 Sep 2026 00:37:35 +0800 Subject: [PATCH 288/334] fix(macos): bundle the application icon - Include the application icon through CMake bundle resources. - Verify the icon declaration, resource presence, and decoding. Co-Authored-By: GPT-6 Astra --- cmake/ApplicationResources.cmake | 7 ++++ cmake/CheckMacOSBundleIcon.cmake | 67 +++++++++++++++++++++++++++++++ cmake/Info.plist.in | 1 + cmake/Tests.cmake | 6 +++ resources/icons/icon.icns | Bin 0 -> 479254 bytes 5 files changed, 81 insertions(+) create mode 100644 cmake/CheckMacOSBundleIcon.cmake create mode 100644 resources/icons/icon.icns diff --git a/cmake/ApplicationResources.cmake b/cmake/ApplicationResources.cmake index 9f0a61d1..07c0f3da 100644 --- a/cmake/ApplicationResources.cmake +++ b/cmake/ApplicationResources.cmake @@ -1,10 +1,17 @@ if(APPLE) + set(BONGO_CAT_APP_ICON "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/icon.icns") set_target_properties(bongo_cat PROPERTIES MACOSX_BUNDLE TRUE MACOSX_BUNDLE_BUNDLE_NAME "BongoCat" MACOSX_BUNDLE_GUI_IDENTIFIER com.bongocat.desktop + MACOSX_BUNDLE_ICON_FILE "icon.icns" MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/cmake/Info.plist.in") + # MACOSX_PACKAGE_LOCATION Resources lands the icon in Contents/Resources, + # which is where the CFBundleIconFile above is resolved from. + set_source_files_properties("${BONGO_CAT_APP_ICON}" + PROPERTIES MACOSX_PACKAGE_LOCATION Resources) + target_sources(bongo_cat PRIVATE "${BONGO_CAT_APP_ICON}") add_custom_command(TARGET bongo_cat POST_BUILD COMMAND ${CMAKE_COMMAND} -E rm -rf "$/MacOS/assets" diff --git a/cmake/CheckMacOSBundleIcon.cmake b/cmake/CheckMacOSBundleIcon.cmake new file mode 100644 index 00000000..ed40df9c --- /dev/null +++ b/cmake/CheckMacOSBundleIcon.cmake @@ -0,0 +1,67 @@ +# Verifies that the macOS bundle's app icon is declared, present, and decodable. +# Any one of those failing leaves Finder showing the system default icon. +# +# Run from a build directory: +# cmake -DBUNDLE= -P cmake/CheckMacOSBundleIcon.cmake + +if(NOT DEFINED BUNDLE) + message(FATAL_ERROR "BUNDLE is required (path to the .app to inspect)") +endif() +if(NOT IS_DIRECTORY "${BUNDLE}") + message(FATAL_ERROR "no application bundle at ${BUNDLE}") +endif() + +set(plist "${BUNDLE}/Contents/Info.plist") +set(resources "${BUNDLE}/Contents/Resources") +if(NOT EXISTS "${plist}") + message(FATAL_ERROR "bundle has no Contents/Info.plist: ${plist}") +endif() + +# PlistBuddy reports an absent key on stderr; any other failure means the tool +# could not read the plist, which is a different diagnosis. +execute_process( + COMMAND /usr/libexec/PlistBuddy -c "Print :CFBundleIconFile" "${plist}" + RESULT_VARIABLE plist_result + OUTPUT_VARIABLE icon_name + ERROR_VARIABLE plist_error + OUTPUT_STRIP_TRAILING_WHITESPACE) +if(NOT plist_result EQUAL 0) + if(plist_error MATCHES "Does Not Exist") + message(FATAL_ERROR "Info.plist declares no CFBundleIconFile, so Finder falls " + "back to the system default icon") + endif() + message(FATAL_ERROR "PlistBuddy could not read ${plist}: ${plist_error}") +endif() + +set(icon_path "${resources}/${icon_name}") +if(NOT EXISTS "${icon_path}") + set(icon_path "${resources}/${icon_name}.icns") +endif() +if(NOT EXISTS "${icon_path}") + message(FATAL_ERROR "CFBundleIconFile names '${icon_name}', but ${resources} " + "contains no such resource") +endif() + +# Container bytes alone do not prove the image data is usable; a decode does. The +# uniquely named scratch directory is the only thing this run removes. +execute_process( + COMMAND /usr/bin/mktemp -d "macos-bundle-icon.XXXXXXXX" + RESULT_VARIABLE mktemp_result + OUTPUT_VARIABLE scratch_dir + ERROR_VARIABLE mktemp_error + OUTPUT_STRIP_TRAILING_WHITESPACE) +if(NOT mktemp_result EQUAL 0 OR NOT IS_DIRECTORY "${scratch_dir}") + message(FATAL_ERROR "could not create a scratch directory: ${mktemp_error}") +endif() + +execute_process( + COMMAND iconutil -c iconset "${icon_path}" -o "${scratch_dir}/icon.iconset" + RESULT_VARIABLE decode_result + ERROR_VARIABLE decode_error + OUTPUT_QUIET) +file(REMOVE_RECURSE "${scratch_dir}") +if(NOT decode_result EQUAL 0) + message(FATAL_ERROR "iconutil cannot decode ${icon_path}: ${decode_error}") +endif() + +message(STATUS "bundle icon OK: CFBundleIconFile='${icon_name}' -> ${icon_path}") diff --git a/cmake/Info.plist.in b/cmake/Info.plist.in index 514f1f12..d87b9320 100644 --- a/cmake/Info.plist.in +++ b/cmake/Info.plist.in @@ -6,6 +6,7 @@ CFBundleDevelopmentRegionen CFBundleDisplayNameBongoCat CFBundleExecutableBongoCat + CFBundleIconFile${MACOSX_BUNDLE_ICON_FILE} CFBundleIdentifiercom.bongocat.desktop CFBundleInfoDictionaryVersion6.0 CFBundleNameBongoCat diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index 5d5acb2b..5b6173f1 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -194,4 +194,10 @@ if(BUILD_TESTING) target_link_libraries(bongo_cat_ui_tests PRIVATE m) target_link_libraries(bongo_cat_app_state_tests PRIVATE m) endif() + + if(APPLE) + add_test(NAME macos-bundle-icon COMMAND ${CMAKE_COMMAND} + "-DBUNDLE=$" + -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/CheckMacOSBundleIcon.cmake") + endif() endif() diff --git a/resources/icons/icon.icns b/resources/icons/icon.icns new file mode 100644 index 0000000000000000000000000000000000000000..aaaa04b49fc125b8c392500a2ffa735ee87ade9b GIT binary patch literal 479254 zcmb@NQ*ZL^~rvt!$~ZQHi(bkgY?Cmq}9Wcr&mYi1tiaUOQR6lFy8vKRu&qs5*#H0x^e7c2X(V`jcmM!^Br7AK_Rov_XRy%!j@8yq z+y5Mxi<-0;pnjh4^j|~5Tu0VIQ4v7*FNOtxMOXtM|4aF2`2P$5fG7k5K>YJy|MeAu z|L;+eLWutp|CcDGD%J=9pu5USh-!F(UFsn;8fbcbu5TZMKp+wHZE6XbSdrL3D_B@X zD9psjR#M2=__DUT1UyUJip$jY_&D~&_Lb8`JPW>+>VpmAY7PjEWwKDUU=2(p^2EqH z1=1i~vTM-cpWK$`v**9IUp@_e*InG)d88I+a`T3^U*`c00e#O{uZnM9mtY>Wf8Jv< zkgk#hrKwI!A5>2KH9OUp<1+*VcZolG3^3BXRA64lX3E_wQ1nwe533ap8JPXM@4;bS zKH20Nu}DoAzM;tlSb$}R2$9(f`wpuV*#7BtVGBN{SNh2AUGH;Qj%IE^(0>!2P*ZF| zVd#5&D_#4PqyI6dPUc13g@R5d^T-Mf&xQ9SR@#)jH~J{ifM@*xwRmQNPmj|K>?}pT zJV2jnp6?g9-y!_Pes@$wpV4Pr>12;?f~(`XuKU)}(Y|>(AT!vR@;vr0X@yOS&P9HhLiD&1kJ`uFMmr`^SA&}(X)3SIfePa&#q zY`I)D^*~YE4RgPch@za3U=#o+%>&C>Bbc1vU{lzQQt)Oe3YBKbC#`3t9Vv1vOz8?< zmq+3K=maPl3rMIo13$}ca5FYlzh#(D$u#H7S{s;zCt_^Q8*Gcw#I{^+Ef5D0RjlH~ zo;_yaED^ppmE|(ZC}42-QbpbqPG$6-`%=LjxlpK=3~A9+go)aVdx&X%ckRl*Ecbh0 z#mUl$s(D{n!D0@KCSTz1oxAOBE)z8WJsWQptJElnP<7l=A8#GU@oZdUxu|w4QtnRp z2sL6`7DLvdR|8abjb)5!w?o19rD1LGK|D3^6>DZ>K-Is31tUP6tRah4Z>XxaJ-O7l zuXl>G7}pPxlUn-+V2z}&SS}QzqLBfQbs={o3(vc1;G{v_Mx*embc)1VEm?jZ&0{)hKSCP!caeN1g~pJW(TSK}u#!^6 zO;9W6Ve-RPxf&K24WiOeqla&~q7h#4I6hY^Vh}{04i}FLto^=CTN@%)%nVxH9hoqX zu&!%45Ip1JN_uG+fNjjg*p7wmT|<&_SlmL@_Xd7T&=HMDlKAmUk-VXwOsEc8#@vAq zE1JzkhlH{!$9i8L=3=vh9nVggetccUAiPqyoKVaF7+&F>n|sSHP<)eDWz3^mD|dc5 z6g>pyWI31q(KWXp&?L@cqsz#NLN?$b;oCuTHS7^V!lH&4jYpWW^J;L}j|^dEprjIC zZ6%jdR7_b00ZS|1M@yhKiI6Ty|1-7`XC`NxmWn%;reQcJ^|Bl>lF%h}QrQp_hJqC+ z%8@0;^=V6+;2K=i@B_mgh1)HflEgQml!R9qBqHx^WE6M8VSs=zn1gCwoHfSkTYJ+) z3`J5l6jGhag&&8f+WQ-~kPD`&$O5y7HS!J;nj%&ZnwRN}-Z)5SJ<3tRe<#5;ojiW$ zBmD53TMog1tn0%sS)fp{;9p+R>vYOFP&PhUNGgE6b@C zzv8isf_A{fwyv5*3dsU89*KI&f5ff)_1C!|Ezx>&41Ds`g% zlgXH4Gbw2GR5tju{xL(I>TlnSSc%Fu*oB}Wh}?euH!76)*{&)BMczg|B5VYodVGgqO6pScfrlWC^{B{?)GVx%g~v3TR!RdC`oahlU`S*UssD}I4YSLI zt+mPkWq_#VBGOb2uNDZ_kN*^2>y>9Hy2+_yV(`I^>q1?72jaJsdz03dd1`y7>bWFy7Mni2&Cu2b|))&!8sn&|4F&Hod9>nx#``?A=%zo&6pyZ=*bHE}+a{-JD!Uvk< z14QOV6u+|AF=A_FvNjc&*$+Oi&X=V@K`F z6qzDx`M*La%OGq~zD~aG4|E72+7HT<0|WV=tejjdEW>%@|i?zuYwK`Aw-8tZRHwWQ-#Q z3;2ZM5hd)Go}oK&UYO~U((W^`{641x_32DQxU!;ktdul?LnV(*3m%6yW(9EeovbNx zNQDU0q`rLjWoJ(3oD1$I5K$k3opl@IPj4Fb7bX_xBhDe^W6>KJn!;GnKB+w@uZ+Vw zAk|QgwugMu^jRZnaMbKrRMf;XMIJK6WUK#c7K3^{4OoubG)k4Tb@HVxOL!o$-7jPrzF(Z3@@=dpA1)QuBX zFXOUTR<3mA^3_CCmp5fqMK1$&!bE?Khw>8X94_5eY(tSs;9w87K+{1bro&-PM?|D$ zcC0KML?V^+ocZnfPT7COA2Roa=jrxn1iBHE?LEiIJSo$Uh=yO@%SL?>Zck?QuA!x_ zaSus&hnb0J7`*L&liRg|V8^m9^@hY1KY};6vik^GDifKVfSW(D>h+Ex9>Ev-n_^3q zmK;HY_{h}X3GEw@&?zXc|c6kE|lLQ5q_#p{o(oP(_DBZO@)l z2K^$S5@9UkNea+oV2EoqfCQ-Yxl4d73@Ay7t}iNn1w?KrjM>tL)H{%~)!?rc-4{25 zIq($Tb-RVJk!!>(mUM=DSk9i&8wF_~m-u=AmE2z!iiwG{-j5$9g8}j-sKd=;^Nij4 zJVjk>&=dOr<~;TVfWki!E-W0wM|zF;5@s1qPI&qcintDj!1SLH0gp&IAT3^in0L%p$o^7ifi5>gk_X8CW|$<1-}gsOM}`L z*w1AfUshr(yi$SMTy}^E6q041?*6OV7vg-i3)J>EU6a13u=-DaQIn8?Km z2MSnwnYvBoZLp;E{y0N~(yLQlmNLOLHi~E^nL`b3Ssuu$!MY`Cb6`c-R-ixmEC$It z(gnxU7cv~g<0rvLd$lp$s^u^ub71C?yD#7R>{W3k;#CgTdiVVoUFuk*s(D3VgxZCq zb@hcyPA5qD)duzHG`Uid2&;@d0Q8G*$T6_-#b`eyoFTDFN<7xGi6hq`J?r=Zd-MJ& zxx9L}G!KiX=(z;3G!pL?oA z@Anl{wt5Vj?|A$k#UD`-kxIgTVfOK!^jix5?%XA1Z6R%~a%bvj?Q!~j{`I+kJ^|$b zl85(8=?@KZ``QoMP&j}L3e8OV4`Xu=+K-daE_TXS7G$f&0_*bckSD$gf)AeU8Hk?h zA!EsxU5_Ry?@t!zsN*OkFP8~a?1PS%d5_RBj)Lw1zqpAP+XhBN72dk&hfua!qii|+ zoVTTKDCB>I{XU^xBM10PWe2`u#+o{KutMW1<+wt++fGF|%HR`(IDe7~xqsw1MDz)s z!}c5ujJX6D^&%(*y@X|K?2Qu;GbjDvY)a$~H=Su;20`Fws5Q0}5KWtZy%y4qw8R2S9`U`Os$10(5X! zUwX&+_0TuF>QK{&ha8+);dB!c(nsKd^y zG@t?XA)X`>3o4I4RmsB5N+_m)BuMS!uZ+Vc&Use%A+Ma+e%HVhb1xc$&&&%@$D(jT zPF_gv8-9qt)cRQ?jl-6&VC+Xxd+rs}E76Td0rr4R-Jz9nglX*|dkia}Lz*AWRP8N7 zn{;_Mx6aCoXLau&%IPQ_9uq1Yq1)zeMZ5I4<#KD!uCHj7yvpY&ppy9aV-#joV7;Er^+LEpV5Ho^1y&zcV1Ee$;*?`7SKjw1PmGG zLp|XMeW1L}Kjlz(*vVbJQI*I>DeV1k=gB_Ux#zgLR?3sg;R^KZm;cz8%+J$@=Gh(M zly$a>GQt2wN=Mfl8HtP?R)e>3-Xeda!w<4N=8(<{FeIe9BR{_LX>6KEm2UeueL#E zMg$Q1)AP$m1!G3@pmbzpg)7Ev<|*^*ku1S0@PyD%H#kc?4`z!DB)z4(&;FnJf4RGb z0{FPgA*?JtrWj)ycXjj4UL9!Eo04-})Ls;&ch;Ci$sH2aU1mA1I6XhWL-CQ%5k^h1URjrPG-#uQWch&1INYvX3p9vLtrfG z^~1{!9F{``ZU)eALp43@)i z2{xVveO(KvfY7k7y_fXjX~SJ6LPcK(P25`hDr0_-t%j&v#!Hiu#fSaQG(TZ9Ib(h!dVM7zqBQjzs&^7s3kL3 z7oX3l#X~aD0z*4fFJ97H@SdYTE2bN+c8W@o@hX=p_U1{1<#lG_ad^)nlI0v=SK{IMXaw zD&UASY4?)V<4=~XT%pajyQ?3T{RijccF!HMeOpupe5Z`kb3b;T+P&>>&h|b)Kn^@T z@gP+WMvGoZ3|dBRHh9M3CYzI7p`W(L@y4P8o$CR~$K#7#aV1C*X5+(;WM+MsyxkW% z3UBR`Lm9OxeiB#H&PcKw^(FmnGKD?w;#Nq8rs{RTO!^380et4QgIgn}9mkd5C<4z3 z{!+IK0vA^Q?#7Dyn`E^3no*s(cxKnaYal#PyoC6O&-hh z%FPu{1Bf>uZ_+sx(f3?Mk_HF-Nvc)?vA8r=E`MZzy_kph%36Z2gF?lP;45F4jtChv z^dW}0)v?6CFFltP_+xhlB(e#(?nKQ^qPP40pgY3jEFGk|@c zT4K22n8YGvPLRsDQQS%eOn8fN9}yCAP~@amE5l$r6y%VnK!02JkK=P3rGWPxxov2! z{Q)q6qT1ZMh%cwk#+x17v4iSlh}jT4n3EJ~R@>tKa7gpH6B!5y%)N~n>|g_Wn!|03 zv)&Uc!!p4a_RKpz%a!4CY?DBh2q|#QruwXp+=LnBz+CNW4)%{oWvQT~`{r~zGoBm` z60nB^U^ysNhu?DpWB1Njzpj3}|3gnt$>;n%Z|A$u-ImICzSql!yq)%=zdV0t#%Xz= zppO*}DZxscJ$sa4Z|@1{msOc-JJCWAcSrFmsZbqJ3JIweJaqrKZTS``)wk5_KUE8= z$fSddIzg5r#jEn7T=9x%IfpYy2x$#9J8j6Y5XA4H$U%x~)+x9%3c;Nh*i zNVNq?GylGQXf(gQFyYw{s=Cbng_Jy!Ea=WbZ%A>RE-sin^)s)r{7~nYlLmQGM-K7m z-s;>@G6x>sEm6WdVTL(0_a8>r)TUC*$M)bKdcI(H&ZRO>znTcXrI6Qh!E4L<6v9zW zV=(XXIyhUu5*=yKn3=Lf(1%TV`W{Tr|1RkJ`{1E3)2$n&j%-{0c3QDrl1=bA<;VjP z675iqst?7m5s{d)hC-j&28=2S|AZUrK7mZ&sg;Y0eBmYW8sF%6K@;AS*so5;OjD?< zgdk;7?-sM)&Pf^!;m#%}ULJ9-t1IpFGsKWoS2B{zZX*6-;I}CjDXGbbj8hh>@A!Ro z^o zAR8>K+D6#ols!*e>au+s#oR_mJZZ(8ql+O^$Xb0df27+d2Cth$`HYbp+WLKe#nyaC z$#t@T7OQB85$n(pYkp|(0G2*p-1vUMIpshrV7@Ehx^h+Bd8-mp5K3_xmyISK8yhFV z=v(@BVv(}BOLNuqQHr}jseskzhY62B=Ush)j=+N1H*F=vJwnS|7zFV?9Av{$TN^M( z2@BB*?~4lbP#@1F;c!vd&Z1o~5x!G&uOOrMEP3F zT~;dn!!2QG4!@`o*^;TVlg(E>?F2@lwgbN5ZtD%G*6H8S1DxCgo;PQnYpKQbg@ILz zzI?dP)n27UAzkN%) zQ(rFMjee?B8bMyn1#bmfqPJstR#)g2T5WU#l)$o# zZ#k~={_&5iX=e{q9ZL|ie~0MX!otGdYwhd&AzZI7O7h0oS7|PJd|wA&uV?3wP!0wY z6v~457rK%{UKh!xNWZ1m%CB82D4q9O*sM`s(mzJsLtk^Too;#6xAH;gC#-Df6Nzdq z)d}AS8Q(Y<;YODryCkRM8@OyhXpnr#;eP$iSjAw0WzdNmfC-yXQ(;?l-l8RY6ZBAA z-g3e)5YuL6`L6rAH|$no8sp+|L0Ka(cL64KKX#9O{0D_#O&~+mTE<=a58~TheQ7s! zS5;gZ{#37`5j}7JOcHyT>0`R)vAuqky4j^bAK{R720pYx=sEHcF+hwUqMe}N?PM|l z75{5zKp@nIn3UMeaK}}za^N{d#mJwrVMyF*@u!Nwn^i7$e5xIY5MhiyA1xfg#rUq{ z7~eoc&0E4x4MMt4BKMdI5t9hli}vUyR+#48QT-}2Eo5Q=u>A{-|Iz|_(9Hg3nActJ zY~vJ{__3CU?3D0 zdbI+9=7(Tb=v`o)@y&QfC^?sZ&{f;p>iWpxLqqtR1))wo{d2*K94*P$E{o?;iiYqbaff%uO6DG})9?AdzO+|Of|$JuWuEI)S5+_p$j64= z|KN7`pUQDsuR`*wiekmtdz$dY%$D|$yH38|VS6h05w1S4M(@GH%5H`P8Fno^uolKW zXwpWcjGNrV`kB;t&0tB#`3zF-$+>jZWXsWD=}iskZW;N!Zdne%>l z&j(?|k`D}Gj5g-sLsKL8lm)*T{5*=UQI6$g+#!`~5EgYnREYg?^KJjL;P7tf#2NaM z+j-)2#Dh;w1!udgWwlb#6-k=O2m*`k0cDsRVw7$6w{#fU~__|B>e03_{X( zVst&RDH=}vq#Jp2EsLMGk;g{a943AeG9O@Mf&og|>rs@yCuLdRstBC&n8b7Dr9h*< z1+s5OA<|ZHTIh;=pG=(;u~72m>*3e3j!S|LOfn?G5aBS?F^`=xu9|I!57mw2y|I+N zDh;F(9PP*SMRyG`>_}%HCN~bz8JGnewrROas7UCuxv8~ytXjJTumF9gf!(--tfOA0 zSRLMD+b@d~E$S05W=%Y}MFeAgo@6Tor2+#0VHNNZG<){~!nOas$I#5y(bg3g%_OlrBRSpr}>tig@RI?W@I7@2sVae6$sPZ zy0C+f;}YIRC$LW7U^i!f3Ala1*mHX5`>0nkzo7MEZ>1xsZG)*2OdnA$)^*ebL5!mc za3ThLVcPnhMU&QeFh)48#o=>nAg1ftOADUpT=S79fa|65mQ|4&6FpL!Ym7}rgox^F zbw8?9Jo;UK8>IJB7X3UQ?zQuQwSSPG{v)Nroc=fCgu*~6KNBsopeKXloAOrg#ZPpw zs+ty2_V41(jt9T38eDx3Rigs@mXsY0Ckhvo`JHG?gk zI{7|ENP^Xc+jNl1&2*3{;qeXU5L_P0-WwgZjP0Toy6Ai0o}4IVYr!+h;$_5nKz&JG zOUgH*_@Mn9Lwx8?lR-6R^qOda2l1)L!~8Zwcc#sWxU;S)^TLy=XG~xkCM;i1ylv<# z@DQ=*Jv}*ZoTmP43mlv_#c{aP$xGnO#PBgZ`$4RUn-_sd5h&U&XE+3oH(n|tX6Tta zI@H>5mpFIsdBkcpqM4i`aS zSJHE)ADQ9Sl;pC&A0ZDMZopCq4C&lJnw5YLdX@2=@85Z|Gmy)m-oXv+X2~3z(N;PV zsX?)a-iM<|e|FYui*e7-@S?fnHd}=-nglbw&AV4h?C`%HdXeSA!T!N?OeQ3!}z0>@D86RPja6PQ??4z;!QNyfhp zO5ANo)gOdHjQop(sXLQo{=P@nQs7 zHA|3V;r|37`>t;U{{%GZIqg&!fVbwj%=~Z4xzoZfLY#8Epi~7}is`INGNn?izW=x| z*@OBcv`FrqOzHHXisg)-IvdCEo8+iB@AO?ir;wq|bsi4EbO3xhS9Y4SRMMi7v>l1Y zegUvbME}o$@?dF>X7|OQeSo#(3!gSg;C)N-K@&>!2J1eESvT&Lak@>qUmyG_^->!B zUJqOBaf>8kFh?QPjd@m_Fla^^x;RBPVjkM993jaY)koxTkpN(l7vCC-j{llOk!vUZ z;A$hbonXUBeDnS2#L-F0A6vPp0_QMFLC1(oeWeCY4yn}FO!Bo}ycq*FS=?_DY?Vhi z;&=L?67pMgNPEmYcoImFwFSxRF8V`f1c=%|OJ5vgL`<4Yl@!=q(dEahf$K;YMB*S~ z-HsXvA{L))*QiPXT@yLdJwXTB1E^D}#XrjdJ=w(8Xl?Wy6B|slcll@R1{X5p;GOT}B`?taNJEQ%^&3$!coH zkY>jrD$nVQVb>^vc~-af2yH>#^3J3FXb`H?Yr-SbCt;}Eq2?Ee_--*hk-xSdp#w`M z3rfq$7$}v#9aL^s9LW*ilIM-P$=FAZOitc|eDZGueyJd|eI&e5fijcSWk#Jpru-Hg z2AAuc3ACOZm!af)4uKOf!Jg~&pSO=w4f=?~vZO0Hxyg|C2`jSSFz`e-@xt_ev@UNY z(H3+Y*Qyf6_JpGu?x3$b0f0kht^1I^-FlrHo^iKs$VsXTU4BpIh4f*4Eo3WZ98_MC z;pe)* z-7kMoIN$!v5p=7VoaiV`*xXH2#=wB|AlT-2R@`Od{R^{7%gPRSvG0GfFn`O6bl{~| z9rixaH)RbQ^37l9i*iEQG1bGOsti%9uk4$)BUs!P$|rmRGoa7&*F11 z@f@LZ0NBsnFbO;04whUEE9M;L!CDJg2FA+h?&f^BL+}XiHTDDy?it^7V~F4GzE@ZG z-&K$SKMjrzMK)a9p-AGV16=*$t~y~mNl=mbWo*I0uWnwkf-W-b6%S&ZKq@pHiK51! zAOa#9c8dC7yq16?49EK^Z6YUzK(9b@B@%8S=!QuOHclq$Le(KBd>Q(60zjQekQ19o z_VhA#NR^m`>@--B>Sp78{?MuH&s;HyL(TSUeDs+p56nuo^X?ZOn;9A*N<{yA$)a&I zt*-}ch7AE`jpO~UDD&8du1bom2~x5B$F~JmeCd$$c+wt=TJZYDjTEp0=`Q-rNw`8^ zz+LCF2+N1XdA0Lw5fl~kXz+@T;OiU8g$0@tTvl+EkdsqzIy$wHreOgr1G^96qK)XZ z#cD8uKcgWUwL2zLjCB{>qM;+bPez^J1M=%_#rVu=9*itSf@jy(jU`bsOz^6xu4p;_ zA!{YR02nia*Fg+t7rLXg!yOC>VoFPY_zXP{(MnT!G|?B+tm~NB3pcZ3c4|U^%5aD2 z@0#M^WH?*9x4>*k#?FkiKNGNN!l&vQPAq($W9;HQcPbPU2E%79JMd5y5mPkHci4sV z*c$V-ecS~S#qOGPbI5=q730!Qj2fkqCOR#IHqFRp7p%(EuUOLLDYzs8*ptMQ1~VSk zv6=H&jvs=Zp)kB+2d_9C#xv^!sM0tVzx(g{SUwL1P_XQm&MIM&c{$*zR27^_%Y)Am zn3z4=NPp?ze%~fJr}H#m66rykoY76(mV?``{h>^8s%#Jk+?#3;VozoEOa_)_=00h< zePxG`YJ&$|#m$>g%;{|Y^sV(>O>+J&=p$jo4mb?N-q}Eq(7AFvDv8%poTAQ)mh-BS zkp@_Q95;h_-Xvv5S$BTHO^gAr1tO`PhZi+bKG&Ed1|Q#d2EgT8Kgm{SYO;?j?2^%?lKj=QOtMc~@OGoJoA2NGLmT(&wZgGq`oKj5 zO2579)VddpZLW;Zrtuhp=tfjE;I^kZGPB!dBFUR33X$ctwxrZ+G&`n(l~aJ^o6jM{ z@Gj#3cJ#3@*=hahuJD^@+#s#%7eK&3`CtWBOIUg=iB`M0F0)+?ibqg8$aWS|${GKt zoictFa3ZaUpaf2NdPvNwLLdx{db?q@?I?u{rxE#+7uF{#66jKOFv^r6>HZgHm+xzA zBC2`+?nnF!Yh%jy#DxiiRCMN4a@s3Ioa5cM8k}`bYRk}QPVl1x7VKS(CK*J2UJ{uH zydTZFhQtg!=D^;eV`$zCmFB%2UuW7bKG+H@Q^-6PB!N%m0L=&kdC4ZCjGBE1f>#2- z062TGQi-%)Ez{1E_Jbi*BqH)YTB1q91E$i9&XAy*4NqEgoK=f64)-#D0bvStQtlEH zjr%`ZayxZIhoAlJ``!t@02;SiJdeaoQ?(28VsWel-Dj3YVwb6lQ}P1w!CQ_ibHGin zsd>O{?nD^)0bD((X@pizdRJEk-(krG4le|)8#BRQgceson#A5e)*Ce;AxZIb->zEq zB=tz>>V!@fGzqi)uiQ1D5vNphNsGu1B(PcLr&-}avJ|zS<0x+Mi?1O1mF?F%D>whtQ^!NF@g<$rf=>%r z{hx7g#po4j`yNJUbV`_iAO5H?@4d=C!Hyn<;9A`{D$oAex)))187!Soim(c@UdKz- zF)h12gea)1X)r+ixRm@JGRXIltfeb@nQa%D)&F$DHz2<9@AbJ<%yFSol*!$%QmL=p zTtb#b8iM{kLuy*8(EYV(^#<6Hy-9aFxla;L+c|$lCS9z``u6vY>dZX_l&(i%G_6#e zS9Q4-=aTmoCM-o^pq4rz4F3GY>O0KAR*^ga)$IPz_~^uK>4$Hz&K(V6z%c&KCrl@{ zX+`Yfn7y`4{Rlnyt$iM6&8R>RX9Cngi#+ldg0i?vpH0SVZF9WL>Cxd4LxAtIPX7FC z2IutFX- zJ90{<@1kyCzs?ZmE@LHwo6VZmA^0`PvqY#i*=HPsUm=h zm|?3oo<)ilt0*fW7ltwuVhgeXtb_ZI0V#g4m6Zhg($L6}iBR|(?jS$}hiN0SB?oJX z0*5gW@TTQHJx8VW-LgW_#L}{kn3VzUqQ%@ymF#c(KoEuw{$BIVu=gxfj-;yfC20X> zumV*)6s~Iuyd1%L@MFw9Q~{C(Pz~j#JXXtD(14g81JMhgc&6N_yv@@a4~on&1oN>` ztmP6Z&=sB(<=;9wZ>~aOle1ZD9wJ&|4xX%MQ$NmJ(BX{lVS>PG1!hfOSFIIp6hx36 zz!2U!p2T5#S3qZcY(o(&IhEQJ+^l2KiE^!1;CFjqc?9+WoY6a!Zs%-?KsjS#6dd8! z&;f0_5JnR_1^V7b5Tz!XoPHBpYNSMz48x$-o`kMp1{Q&tRx)u~(8PaC;x76NF4wOznI}Z~dmOqA3fX3{ zK&FI1tF)z!^xjAD5yxd}M@|$1*ymf@{EW1~oYY9t3;km4DJHELveMJWhfSFX@>3le z@BN#J{6S+0w?9d-{HMxqI>&JiO90X@-DB8NFrmjKFQH|$Bp@}!Sh8c&t(wwihVtO3z~Afr=8Iuoo-Va@%#8!CM4a; zfd z&{UjWas15XZTX;3JWLB_zoX22XpQPjpQN#9bMZtDaoCikf5jDvBb~QHeSh3L4(>HB z#k6`G%eH5ekw%Ce<@9-tmHs`F8G74fjebBQXu-lp;38kvNL+uIHxOv~>d6ee360n| z$@!T4*~e?12$xKe73bWQg1IOw7OI?N379h04OI+FQG;3^F@}rzv@os?ZmblqP0x+R znd)X!f2eH!Rm%$jwDFRKS^+xs7IeebF_yRRn0#oorpnev1e zWC;Vn{7&BmKJ27eVv25lxO9$-ADi^HyY585(eEejc{oOaEjWBwVgw6zf8afC2;P=7 z8BSxo{YaJ&@Ux2e_-hKUvtqu>7i9Q&e6@9JvieKMk;A!mh>F~ptIfiCeu18(us>t=?4=U`wEX+5{v)#B_$}r!7IWMDmIVcR5CS#~vOXN}Nj62CP!xC)Ym6BC3 z)y!@t#hXb5HRETvI6{g{bHMSU580Ay0Bb%k*rqi%)#y z$Zz?Wg|aT)(MFE zMKw})(N{uubVkvno>vFAhKBl7q)7$7r}L9$P1~G`_27edg)zuZg!|7_#b^_^#dL6M zuaqLn)D^exGFG}J$Gx!Bc{A?BqqhQHi^LrZZkWCFlX&fwQP$RZ02WGk93z?O2QJ0D zlxvqC0QNF|k=gmQ1wRmCTCOTcoRr1{df-#v;Ma576;VnVsoYVZ#283hVQqP6%N&DG(l9mo#v zBL^pRfAB|Ce6e{eLX_DZ1aOL^)*~0v&aCr*a89+Y7V*W98tUGTrOPb*6V9lW*3C>U ze`c0oV9AP)CM%g{N-U9Z9R-S0vR3K4x^IZ!SSL5YwYo4Vu6Azh&h` zXr!5R(44sYtIb|j$yyeW`mGT^;{clRsJs`snEz*9j&?;iqRbl;N<;ZuQ&tdU?MCD_ zu-N5Tnj8gjx!jVFK_hgJg8?@nNRMFHCj~DjhndA{2V$-N;9JkOk_a3WYvUVnA zzb+|iF843*kHPNe{lpNl!MasJS&r}WO~h`kKShY*7kAF@14(P4XbkC>;}R}1 zk2n`qEY|(giPG;&1%~)JkTIC=Tm`O7b)}0i-Rr`BrjO8llyja%;KBTUdIG==aVU&Y zwgt61*PET$njY9_4c&A!2x?v0`u_Uqq#@>?P%jS}c(Fq1m=A#hUy z(O9SF2qLk(vb=Jxprrqd75-}6_Kz}9kIEedG86uk$O7@tI%(kSNj#=*FHegM2K?p% zqU{RPoc-!J@jm$V{CvjK!HY10M6o-x%r`wiv13MTz`IsvA@y?c5WPMN} ztk&ThMP6~|&OlOnn1I0KTd+Mj&pvl>G^>zJl-xRdVDyVt1=X70~ZE4 z0fAVVFH1RfxPKy1K&|U@)OC|X_h4)FSnr1j;2|CeSUiaE4{T7>NiR$!oGz_PNF3*O z^85@UAPccsoOsdqwOy^WGj+6oyF85Xw@eF0GHH663qJgf?p z%pC)5%4WHc@;5}p{t+CM)YXg$PCsA$ckEiuz@_i-<$-~Ds1?xBT*r*KTV#qZGf)#! zjjdp$=#vftKdRO!$iaQ9X;3ZgWHJyO178$1qpP_A*)C9BQQs%M4x|I!nI_=pw8)Ek zUA_^wK5qq#5OwuK+mk(DCYSh}IipVS5!UW&@6VBP_M?=F(imNQl$?tV+M3qHG{8S{ zdU}=SbB-d>qWIwj_Xc%f-9RVo2!iSb1ZfeAeR0 z6(pD=RkfR*OMt=!e&RCi+!flj3z9nYferDqlQ3gth>y^PjaJwnk+lyan4 zsac76-`k7yOSRZ&N$3oTL*zbBLGhI0#bk~eWZs?2e=+lhJwba2WAVkM+r@GKLy5FP z98yQc@EiLnvY+nV^Of7smBKkFsyr3MKqB~$oRrTpiyKGAHg5;25N*twRdE9}8pciM@i;ZyM}0C@ z277qNAiC-6CPs*Or5yhBGres!Tp)?hc@2_;i3~an0y|-2zW2KtR}T2$3DLLIitHv` zASnw^3g5BgN2worCU^O+xv*021r3MW`pBF2ek&b4IDIpLQ%18fD?aXw;-^bgKxN~N zw|P?r(=yjv=5s(28XPG`##$oXYqX*aCLk)tpg2JF`N{DimAB#P&4`GM-j7%6i|0Ff|Bf$2o(H~r z?@K+zP`sL{i4ntGGdf|`wDz`qK}>4W5+VzSSU19oOTo4lJYZugai>3S7Zvdh0kqDMl6MBA1R9_*Mf|H%o zXXhr&!u%=rRSGd(w7til)`1%z2bGr+o}*BfQ83lp7IvM+Y4!cxojwjoc{p8IK_%$kU z2bM5V)}g&03E{HKN4fhwBS_>9c{|e_M8K;EY{cjGc?pv6zYtj%c!Zrb+qrB}SK5x0 zyooy5*Ax8!{4y}?<*eVg>gI z=uc%D-Ha;}gH~pV-)K0&b{waNs4LpMHXp;C{Rq*HF6=JbtVt~C)pYR&3^l})(fm`5 z83~+tUsP7XVoEmF3tS_(g*0`Te?VFa62H6jZHB_%A?^F_nO~@y3?yr0kZf4lPy}Ka zi>dy@c)pFmhdBA1T$5pH?m^F+2IbX?)wR4!W3!I6Gv>L*qOe}HTw@FA-H$(8yX*NFFF@&%$QOJ2D;bo<=iaO_AFOQS z*pjy1Dm5W@GocUUD;c9jYfDDnY<3zDT=3bv`?aV=Lo7!ko@DGCw!uG5k58}iCCww6P zHxeRy7uM936vocwUU0(xS#cwsMu)N#IC^4r09NF zPwx9f2fnaeBiP&9{!c_e(gZ1x^h7SDgYH$z)*nW@4>IKNssL2wkxKQxEPY7j|B)Ii zR#?zO(pyJ+irZ-qIFTFVy|Fd~MbY_#!T9}Uy706Opl^oJq8E z2fRF*Qm#@#ZKoRKf4J&7aEI(szbHKbaPlgPn)ZaQDubRk&e}BB2T0A_0f`9U^iOwO zHzR+~29@h5Nt3u%3@*WJk=xm_XWg?)<-O~i{Pp+&qpZzC(ZA6h1$0 z9o?Ao*>#M|W>%WTv-@+6k5W0ri!o_#tRi~+rk}QPf(=7E>Ks4p>i~d_VpJxF1UGV+ zZZQ8*{&xoA$O&cTO}w7rtmk0DbOU8qH+umYP_etrwCy8>dSU-8OmUKXzhyCCv^i8y z;$h57pl7m{)-RDvU(E#0G`?-W{Mh4j2_VJTwgUn#M9IDC)tM0Yn`yxGv65TH{ zqC^ABPQ9I=wXjclWwl)mCJ0O%056xo;zZ>j%|4&(pRU9Nc_`X9^0`JM(nK#7%1lLu z4Lh6a=2zja=j{~lULb4>)RSK_Ld)Z=VUWE%* z`iH$@`$VE(WaRyMF_^!96&^o!~CP-6aIK;0__U2X}V{celZP&|w&0^Y2#eKJ4r6OIP(%SKq4cx_$5Y zPCvFhE!qb%`@l-A!^p1ow$IVO8R<9l-uj2&eoOU!q*X!YWw3vATC%I$g0rDFRe~!e z=Q`T3tS=a~Cuwq1ZSKEy@GTY+n;3P(ikkg)n|kRTT_%WK8$IOZ$HQx*u`Qy2J9bt?!!K0>Wd5nTNZk5-1*B6W>FaLd& zh4m!}2Tb!HmQNr8CPJ(wo2H$QP0yT#B^=sP1~LkJ?PqmH1iiAK*kS zGoIb@1>-TvTCRNDwAEhs;=(ukPSuU?a;SW}-6F3( z=fSS#zue~n!QqMecOnuf0}uxQ#%{J)&MLt~eWKaYtaetT^NbC$HfAO9PLfOWD;e*< zrl~Q?!&3!A)yTUB@iDoNqblvY4yHU?v!D-76oSdq_(Zr}`8p~Wxd3B;kk3`7=}kva zK?LWJH3m5xf8lUwmT#tHzG)Fnjxwl%X`j!Y#rf=(1LvPi=%hX8>|Wsa!K@Al>?}$t z@-%Z~Kf(RgGk+?$Etk6j`Eu{C0ab((<87*NV?@+2B#|xH!56Xj%x#$5Gn}Bd?nor; zrdS1&0q_qrg3HiI^Op>J2M0@OU+O;ER8421Oqj0Mf^b^AH0Xopn1#c>DpOjnW)Q|t zfy=LOlOJdfQhKS| zH1PMA&XFP5C{vg-ZQQUG09?}~>lq0(BCoolk1D_YQujR07lPRFog9At`}YPtROQTz z%Z)S-Yar3U0LTUE4-V~Upm`1d{$>0VM=$WGdv9y- z72XmmnYEO?4n1+%4SWw@$pZ+DnxNXC0ZI!Qy)R#~d(oqkK>w83=28&v-7M>lH*r-sH^>N{RC!avIm0CgV;BTE;Jd%qtqE zikyLuOq`U_$jSau^h`Y0B)qjTPxtRcyNz~JRC~_MO?zA7ZpeEf!_>w{{dD0fLa8HS z6-}o;bQN-_-8kVQj51pcDoS~H83zLi{p6BVojRU^9dVZ|-HjN1BEjq+U-wNIPm953 z@noZY1(8#>K~&jO9~@xyAaP(i`%FZPNFICd^_Mx^?(O-&wg4%|A5q;~OyBt>{2EU0 zEy+-#zvhvAj@Ur}a~u?Sa`dAMu%0m{=C-o+91j|#pTMn|lP&b= z$>Atv1;_X76_D7|+U`6hy4{_4QeF7oG9e;MaP|ITwN6MD)LGfWsPY2D|V62s3dd;Tgu=0gN`5)Vo+Vr-pEVQQ+GW>9;= z$OIh87R8jl9*H4;Pb(y27H_$&RqCVhU5YA&iyUY|G~SDWl{bfCk_*m|%2b|>7s+~r z>ONR?C!J~567ul~jeN}dPRfgF(5|AFCL>PG7grK3HokeoU8tErePIP|3);eROqGQ$ZJz>xT-H4uZ6|ZH-ji_Dm#~qFg!r`dH^-X6 zerzme?`*f5JZ|U7IAF;n^>OPh`^S1p=V+>)t7IF1;#xtj_ni)qYy0}n1kvz&k6;c{NGkI(2bLc-blPU%@;*yy(4jVq!r+knuD+ovM$ zsMpEosk5yZio0azzvQ6%dMYnOr9;&TIdSC|#^U3WgZ}lFI|GLZ_FaR$uQ>fGVqBjKV zA3+(|rKuz=(e^3arS?~a=lH?i7M;$gAK8i;B3>^dPwu*u$GK%}yI{sYa7J{84DI)g zWAN;pZS18BvOJKzuUSlI+bh|Bn^4=)~G$#lhEW1tJWHgVaNC`3P8IZIYTr!3pAdWV>9-7rl%>u;)057&*l zoR@@p@|~B*&i`f1+jB8*JIA|AY*&=*teqx5?sW-xKm8EL6uI^x?0)R_J`erU%IDZf zP2x)P-Q&@4#0z+~GFM@gz`Gpva{*}E>6lg%FDp1=x#h!oRSp~gyR{C;B@g7HCOl-t zShW45;`S%VJM(eFOg6kFIR|6X*}(qs>T%l!IcM4dfQYT8T%6l-ct;>o84MngurgOB7-tpX~U1~>f{5N!B?|6mx zeRcBpI5Bte*~_=WJ>$KZVoxprrV9!PlF z=Y`CeH(4B#h&`=xrwN)J0u?W|u=($ay(#Ut>UY!#c1SDkL-#3V-}qjx zgi3jLA)k+c*q-3D)Dh|V^WAhss*-CvVUg^psi2o|CabBNv;ev>X>Gi+vF?_SGC0%A zlyq^6q0R3~S5f2b4zew@(>u_z4OlK3(F7Js#@KyIKxULaT(kYN8E%27)V1bGiNfx0 zcSv_bW5BP=^4H3>?QD0_DoINEy@svHUSS}Ed-+U2n%e-O|J1`8CzWBl^ZoVsUbaxl z(*0ywqh7;F$p6L`Gyd_4sM2fJOsIW|t)^{h%Q`>cVbx2| zF)f?4H8bS@->}_Hx&rgW40ucu^H{%WWD*0dA?oBiA8h$uuCT5PrC+$ZH&Tea+N|c; z2X$Z{TXpuZaXS~4HJ#Tuq+j?Evk6!C_<6M$c0Vo^ZVIJ6ZavmaR=zEx1`1)Fx4_^{ z9y^R)T^^lkyRP>i^o?2#1fR|~Q@;be+@GtbHyqPsDX-E%g`1A6ALd80K|I?+IEYx- zO5t)HK-Tzk;f*}|L-dx|T@2&FXxn?S6M|$M9j%{2$d#_KF8;bVqr8wGd(2a%zVjEJ zb+X4K3|unz+SCK&!e%*Ddzc32zlxPEy6=vja>|y9;D28K#!_>mDoY-x8Rjsj#%-oV z!cLq5W5+awP{LbJxSv=XNR%YhvX=^Csd~=kUV1L`u3z}2vFL2K2?;zMfGXDk#x+LI zv&k1@(~jMbgjbHq+v{^h zy78+Um+Aj*GCv6&b{!StpyP7CC+97>*bo{;%W=-v52vK2`QfHdNrdqUn$pl^75Cw99yme}y2>@V z_)~Ma0Z5T~MI9MhL$liZ;e|@7-hA>Rv8Zb7VC}@!($BXdw0E61o&BX_Gve~OG+jj{ zaKB})&xGSO_Cidm>$$$RvWc}5J{HB?rkcuug#3__>P<3Lyw%C=vkoL$^gCkzZp!g?V(uaf_ zQ4i;Tf_*J%CeF{6E>T;ASY7rJCe_fD4o7TLcErVgf@yi#Q)-Kgyl*GS(`Ei?wix`? zY!{D&k2f~PMbSXgOHERxuoZnO?_v-P|G5H&J#&lga*Q;!LYpq~S)9dPXKc1&R4tmxd`ci}Zs1 zUcMFbT=MY$rd-SWI**_J`b_;hTr#?kZq)~W{gnNkmIHf(cK)GH9YLt3wuV>fgqni5 za5FOJuTw?KIi_{%CHU)iwPX*-#;Gf_XR(dLKkRUywRp^Oq$($rttQvv&-(Oq5BDt! zE0Gn37bB=(Gl%0Rk9kI$0YJ958^T(AT5XnTr!U`sLb7+hPlw5Lw7mgeVd=)*7yIOC z`s@LHV+TN>*2Ud9%$bboi0nX&?~ZixLC!=I`utW; z$0~sjaJs8?yeKVAW@zE@ur} zf90HBgRkkU;LO#;uN>_-mFr8CQfIbgGPj&X0jkXbAP|ts~bp(fes(SuBu$TkIy;b&W3D8)P1p%UH0D% ziApHKnv%cJ6uS~Kb^(a)Kz@v%la&O zLfCE947GohWlQnqI74_?LapY}yt|!-?z64fIo0!Ls+0nX&*V%+T7jtNx9S)hPfd)cFNERc**))@UM&fbD z)Q91JBd=gT2jiR;0Ju^uSp5?-WcRKmB(m}S>=gDJ=?X>CGp*C+jc~n9W!8M0m5&wp zD2i$Hgzh>zCksQ(*^slhKNP#E@~W4fO^KyR{fcRopb^uUpgS35LencV77*bmIrsj_ zg?2t)qMCs_owAov{Rv{aty)o)DKZ`El-jRj?Lm0vPb9d(h=d;R5%MgBqH`5=&BR+; z8uTzH<~M{(@<`s9`2`Ypf47_d+BUN3`*;y_aoh|H+K0@6XC>SDeGQMs4&a%u2Bm;H z9PH*HngAC@3=xNDmBOK~!~|cfH6q^dm--P{CDI#DQ*`?O#H<;bH`?k{vUh`2Gp^x( zjDzLHfU}b?i0PL6h@AY)$zQ*tF_@PbM2hz(*`AAV+KlioxLBacC{IbvrxA*W5ni6l z@EaqtPOO#kls^u4NAz=sl3=iA&${rY51vWjImi8q+_2-;$b$FTRnOD{O_6=s%EKzp zc|RNb!SX4g)>+!CMEksjBA@l&#o{~tFn}9)dr)|g%~$I`^kVG{kd}yhER^%6T9o8? z3RhVXK2h8DJ-HMzc8xO+B6`+UP1c%5sbKwbl@douu%=KdDx5rl`5e5o$=JQsJDtt^ z9QM}RJwO@rqbW00cqCVKqU#H7vk1gTxDP?0lc0;((v>v8f2vi$x-Sm=mm6ZETA4vN^sNf07vCpZi>2pifasYQ zoXG!ugGTRc=bDm(&Z~<^+r6oG7h8YnYd>G#aWM9r2~u-8W*E@*J_NP=$k(!uC@ku~3046T(!<;Sd}X!>3w(L3MY_0Mm!rJNxBfNYh($WI(0Jp~OBLs@u>zF?mzHDyOh>)uR8PT0k5~7&^e|2Cs{k%x)4J7J&S18A;`v|d%v;QEvu{V- zp^X;f9%1w`huA_7>Fy3Y1%8)rEgfE4kFt)>vu`KW5rJZ4FOX*&=iJ@aH{92Q+HU_7 zh<-eWq3?#|a!pzwW#zMcPBLTEGpNHFz3BJ@n{cuwX*xn>^GP!@D!IxqeaA5zC(%cP zixwiPz>iu+%vluX`iFM7+fzw~?wch);>^@kI+1ULgykL2kwIpPq$7X1#@aJSa&zCl zR83G2gBzzssswL^cZS>;?4`d!*D~$wV^_`3e@&>6zfwK6L5CB(oWuuO+a8e+bf;w; zKstb#us@pBXD)(=yol?ppJnT++$4pD1c-3!JL|YO1b(BU?l@!P#l(zFe`ag-_qT0> zhTy&}TyFWjjk48350{Bg3tK3;{O_1!qH^CX8elIVefDja3Vk=dVEG&-3C`uDY0SkX zc{wb4_nV)^NI^LEig&#{L7tBLZqI+kJl`iyyR;+n7n@hwZK7*?00jrI6WWhUavhi# z;MT3Mw6A7TJ4BxBXLuvc_*@qRFk!wF5LZXC90N_hRWyR`wd{`xD&*lQNy6FI#%g#L zgUf=7lZ9&+$DhJ?z#AoW`U9<`SiMT%WZaQ=%9VR@$_XQ#4s@%ce?Xsn9Y9%0IrGEB z&1ti|YIP2+2f9DreS&;410E|n`rUJ!o%L5firu9hj5pP`!E$T8<0JsZN{$L|%(H}( zp~g>Qf#o%~;HjX9p1tp$D8flvZGTG`OOg(s>|h3|4pl65m$qg)whZ-uFJi)ee^S8- zh@Z=&w?kSJ#A#`=P2%M^IY*0vN`59orqmz3|3W$Xk5vmZytCy)-cj&+asL84)XLdZ!Dv5c;eo^Mfw_{*2}NXE0O_2&|1#GCuh zF-2bbiB0eNYUyRFMIRBExgvi``#Cphxi3f(=Y5*lJa1QPwd`9LZnb?NNOWW}V&8Om z=H?Lyy6T&LNuK`h1xP(D421JO_Ii2D?`-pjdBv+JQ+aumUOn>st#&-1M5nR0X4VQX zFS_5!C2=TQ73qqZBYsb1WVADgnKM=Pj8Y=QJgsy;8O?jsV

jQhlmvPhu_I=1a!UV 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

t^v)V#)a#@a00qc^7 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 TPon5&QoYcyiN;0>#>-AaHihM;DIJ!KW4O19 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¥Fv*B5h_fCYAkwl>Zv7b#P5f7%e8kT5p|vwTu^jsvKg+6izUs7N z01I(VEU26CkL<{$?|dN{p0ql!2+9Zjh{Z3zclYk^{aU}j4-NF!1wXvIKl#P`u)4ke z=-}H7*4bm{0fWlr>N8iL%-HCrL9X_ct^V%ScmBn@7hm=3Pq3#Z98g&>_#6OlA3W%7 z09u`dt2T&L^1u+|VxI{yQ1k}NgRBa`)_&nSvf=T=Y?f8Is`)8)O|s>nE$ZE`8fbEN zmQw@;HeJ~YpsK!YABoroPPGxZSb+B84nU6Rw#Tdi}v&oJ1&ZoecT9;T+k`C z=y>rveiM)n)L#A2kDcEC-?#lIrqX$hd2BL#<&ZjH-@(lIeLi7XacfJ9T9d`Zo=kJS zd*q4c1wZkC$A(r|^%$n=WB>4(N5@nvE!BVy=`F0--^%t`PXKn}Y%Rf5n<6GYN)Ab# zV07XFFOGvQ^G63|3noAcXLX5v=>RTl4*rp2Hgl1$GSGC;wr$V^#`tRMwjW$hOH2~8=!%`5b2keaoZ#pKe)sxh|HuB|*WKOg6ZKhM zy;!0{Ep|r+AG+h@V1M|I_t;h!POFlGmy#-kK-3B@>d$aoGS{gtSZ$$U&U7NTTL6YS=dGB+7H~ z&K+0FLBF^TBZ}VC+CtXve`@Th6Va(Z zop`?bfgd@&|G!T{6AQ-+KUUnvhy{04+uy-6fb|3@m{AkBMPPdJQC#D{<5gBI>WYs@ zT{F=yTAKG)GGsde=+(wNvI&OWTE+%_l<~8k0K_oL)H`!~H^B?R>UWU^?472A7<^>7 zFheLBR5(U60MJnjj3hSf8qf2NNPW#A&RRBoXk$xk3p{3_7Jc-VrvWu)ddA7V$+m`~ zhkds>du>w7Y=1a7r09@;-9fMwcn(M#kA2Lc@jhSxrXMy%S9~-MJ*$P@`Y&+vH;nO_ za}jG;ewOaY9C{6miD`oV=k^ zlt5480G(#uOje%*aX`^~a3Tl2+Pm^0zi<3qe&`BU$9Gyp$#X(e2cAef`Z@Rl(gB#o z|JC>XUmW#Kl!fu&kU!YphG3T)UGz0<`)L|@2n%-roDV~5^An$(&jE0}$fC0-9uKv^ z-0O1y_-pmcF8nQZZD{MZkRmR5jA_E#7tOJIY!{rd^MqgIR&7`BsXm)#;sPzUG@qHZ z$$ZlV=YHgrz5`ZN`($?VA@jzr_;nI`=%qGk*ulOUa?C`d=WRpwWngpcfVmq!)aeL( zik0f<-GA+$3)=my#s5@$qI&qgA3S(?|6eDGmb*eQ1Z_Wae87gpIldUDNgdBL=p2BL z$k2wDJ!baTMrP5jib85N=dgoiQrU2-o19Fv!pI4~d*MRHJa)yW%tPz`Ep49l1R!T_ zUi>cxJIHw5-Z_w>+0K#>T5uF0_yopCZE~RSf*}HPMJ_}HxS*N{L9rMvc_|2FXIxZJ zxRn*=JZMA@95t~M;?R2MYI#|(Pe~3l_O@>GYJ6?+7hN8srODv+kgEa?*bu2%ruy(2 z*qe!0&!NcN-NFxO zje2pG5T7W$^ZS42-13JY2iy2WfHw|?*4tG}Y}WUcaQ(eT*b~;F!sh|^y)QuM<*P$TGUGM6eLl>T{K4U%yaQ~HGp6Tq{16YIjc&`V| z-19boKC~$D9doplHds8{_IBMYaS#=6F&rq{eX+V}Lo|78|Cl>&O)&VSH!z99>}u9^ z#0Xp)HYdc}l0C+Ln!WBE6+{x+9_LTvw3!2wZDtEneSCmpY}7cm2X8q!VdSkSUHy|s zazxD?8Z~)Md->g;xQ%TC$Z7qnZ=pV}YMk1#uP19IGCmWB;Fd#Gt0gmw|HKBcl^mvJ zEegbO@Nq}P$dx^=IkYLRi5Idx?_-qr3xr@-*G9W)o{Xc1wG{Dht3BHZfDP3deog}B zcqPjT9*$AbIf%sh*Ex1w91@%am&i=8NjQPGz}hmu)yxkqJZe{}%wr>TPy!!)J#S6I zMfXC8&&3C02#qcc(Ib!>dZIIZY`oOti9R&pN#A1K?UR7e2S4)^WZTi!o9t?9c%SNT z*#K;X;lzp!6+=g?bP7fTla{L>B$>l9MF%M_ks60Dnp>yjct>m@=xS2q59wmTjui*9 zXbz24v`sDQu<=;Z-G4?2ir2Ms=S`|71(M4+N30$?biIWRrh`~n%eX^Z2u z^#{QEa{v)}BDhRAk$Lg8|HR#^|MRc->wutB6CO*S?Pp@!=rmw_ZA+o630`QNr)FOB zsg1=~az(pxJ9HLR#J6e+(T`eU--yKAz3j{JbMqOuZ4xs2LKmA78`qzZu*K6;{u>8B zJmBaf2YA}6|L-$*?|#GAcW zW0*Lw&vZ@z`|ZFd><52pl7T$OP~+&0XtX+x9#pJrPJTp{6NB)EQGBP9Waj-+=LUXG z5(0dS{j;3_Y#V(GrvW~Av+xr}5|EPsjeP~X7zy~TGp$^$rU=bqSxD_h(WyBnJq~^3 z@>R1Ws=1EBZh#ZiLQNY&65BomL~niBjZ70LxCm-OP--d0njPEFTkk3w#FU<*wNzm7 zx%)_hKe!D`JS=+CT=<7~>4=F#5=`Ts`@fy7WV&)?FOv zaw6b51h8680#3L88-LF+y5k23ACrrlSHsX|e<=n_a=m#}o17#%W-_~^YNpyujNBtm zEYLaj%=lh@bkJMpAw2x06HX)`rz9Pef;q5-Z=$k+{ZD_^yU>aEmhn->&w2vD4!#%v z80wHXaDk{-ApG2D1#>$|d^jMdAaigIC$xrLq7(4j^hDJfoRkh`D%4u)##&BPq7fdjS`5V>(| zc({BJ@Zl%*Ie;Ji*%$XbhXCI$Jk5IQ_&VVI-~WYocmMCFd{PlDzPKnav_0(D%vWEG zLudRk+rb;zTgL0g*q@re(z*67z|cpJ=lIYj?0B5&4tv9d%-A3K!sXauvbHNM@o@GB z@u3SmxD97c{nOh%=tStxbm+4ve&z%?YEpLW+7Z|?een_gJ~7She?I`I-~Z1dpFEXC zVzhE(l?Tahot(OIQRN1%_HLNOQ=LV??zuL5@i$>_T9pXTxdiSL6vv`09KK<~#!?tN zJTFk42#1L{I*$ZXz;dnf*5k8hVf^qt`{1**e87hU@`^Ja$~g)ue}*}T6%`jA!O)B5 zSZJJsE-)9+q6u9|C?4yO9;9^qY8)cLp`oQHT+j_SKevw3T2SnEu?bchjvn$VhiHue z!P}=mvU9`^WV6#)6<15$kY|(;ZEw@X&!N5Q5CShEEjust(aRX3#9?`D)OE9Sqegvy+Tae2OSac)TvPJ8@ZSl8i^bCKlu>2U`Ui8_Cq1$rNb86gP{g&^z zd+$H|@BN&0Fa9EM2&I^l^}NED16StmlZy_%>rycG1;3g&bp*bAmlF0RUYx|>cl&jc zuZe#bK>a>}2~Fa{9KX4nL3>BPX}|{|uWjcITlPFT+dR;?^%WSiT2wZbY?K?P#vI~i z2e);3ES&fErn!|bY}$O#SQj<2WF!C7h-d#9uKBbK8-}Tt+XOLv=rzEGVbeUvj7=N+ zsgqg!|Cz74d+6VEAXo7vxu!>k5Nwa8J5twA{~t(=M4lJ|kKEAMcWk!ZBDNlDGKp<$ zkANN+3v-N%HxjFlkD)gWwzvJU!~QkV9MebN;VX8~QU}<5szb{bL9-lLFxy+-Jl`FF zceG=6kZd)66SvwpLBL(yEOZlLQ=4wC1_g2=B+rGbegZEXh312Fv3g7VLbJeD)3LpY zhc-!PoLfaOA}>2=XbJ|C@RrU{6a2ydQD$K#+*kIN$Y&+YCBNiLnq%d&r-Gm>Psj_+ z?h~60+v7;44{gjl(P4IoH$vkb)5w6taggmBPYgUq)=~s4^zbH@jA`ZxeeB2_Jkjbn zWoJw5Eg&*XTLLVzk(YYx z!k0m^f#>*Z@qdR;`m0T8$Odfj*}TS<)mK%}J@7vNAJgrAEs%UrE@%C(^gh3>7mv6k z1L{k=1#6Oy%7sBwjdQO!&0@Hb7up3BIe}D7e)yXL6u1`S*zeUkB7x z<0<|Sr`dp-IIq}J-k)nxM%u5Ki2dyk+~8av0wexZE8&3tE1&eQ4BQLFK_r zzvrv{HbCy)#1>m5c`jw~#2;#rM+CppU8!k(_0QaR@wr)uM)3<^U_GulioiU~I&Q@8 z5|_#fB8n#UN{`ssj_5RC&O>Ag!JK9;Galb?5w-pfBf|^K`eALf?Erf5_uv-o_8p}k zu|3-ffOkw8HwP&Ul_znMX#$X-=9ZmFJHR^$84tY(Q=s$-Tw>(Fb%4WzY#O-Gg_n7g zL*ix|88z{dzyeq<=p;>JU|E==XbM4!BLlH+Tz&Z#EJz@fhd79V=5^r2VvVJzMxM?l zGSN-Td4%}j!)nVHXzBf{m6^V)@5I}Q5w&29XpsW-we$=dYj+y$0o5 zL6FB2Ba<_-#AjFT`3A~&ed6@pe>k|kzH4>jO&l&UC=I30W0^`!@!fm`C$HIz0ns}y$tQf!HQWPj$PRl%*=FIh4#eKg=e)opeSu>S|Fko_jIVW~@NZ-Lcu#p7 zQZZfuO7fiO_KF~lQu$2A%Cyk6av%*W5XYe&}e4Yx;sd zvBiznzkQuMGKDs?Jx=Z~;$q(h$FBHcnp^xUCdo&ewZ)ysI_UBqH@E%wv+}+8i<13} zeabaX4!HW~`~3g;|5%GZS5&;Z-SagqJ6M1^Sr?z|%`0)DueuWhJmEtR$kk3WlZRMD zAp2GF7B!y(SoR^3uLIKXB{4OiH}Tapx`HGYkKP7|&XXCX*R{0-rp5p*a!tzGMLO{a zPkI?@GV^OZaAcBq{ns6kE$w~A5@|Wa8^Op~jU1dIR%2LvOuyJX%s!r2VtuK&N)%!* z{+f~#_Gh1n>(0`<`WkA!iDKb5Ht0#SF6vZ`ub)oqtA7@MT5?YxKO7GP&<>HFhMviz z`t1BThT^~CEnwN`Ida5j{V0oG!DG*X7g8jF!|w|cbVWC?Hu^*-y@4mAmTQ4Xs+PE9 z{!#l`i$AKMTl@(O2dM3AXyf2GOmq`q1u}(si#IaB>mu28#k>2Z()pABj($>lQ?JwgXkU%3wUrp{QWw19ZlrPlN6A`}vVLY&yn)BR9BM zhkRi1-@1Df+JT8l)HPjKUvo9yghLZ0z~Pmyw3A;gv#b!E7Q#?QHt`&}E$@+i5iS$A z{DAYMyIcUfHiV>x@GA8LefZI_;ov#~3~x5@k8G1efhP{>M+jf_)2IEj;L~Runw)HB z@qh0>{*!mFe(*=<)A6S3Px$+`J9nHq_K9Ck1fa!p+hO};q5LJ%jyArV2F}h%oEOe5 zb0z~qYW)F@UdGqPV}akajqiCXAUom>{UfhUerjF|?O+|JxH(2Y zLMMFw9_2oaqNz$3FMKU?-%$wZ^w4a5u<<82wVTEWm|r~(Zu(sL6I->^@x!mYclVCo z_vdG&X}4WR^AK0zsy&4d4L@|V~prjCnP z-G^og!e-unO?xs=A{eUJ6(;rBrvN2}gKqn8?iV*?4c|u?Jlk6U*yP1=6&kt8f%}%t z1g8>^v>a5%Em_IwK~bX|yc1mL@<~l70x#-H2(TnHeemuHz8bdMr&S<1_!y7%QkY$J z+l+|V4Bq<_^2{i|v4H@ktzke=hi1zIzwO>WCLWOyzAXN+3I3?uu<)hdvPUNjVs>tO z$Hvn7m(_jHu#V2LlX&hhg`%nL* zAw0_0CrvtV6assb#W{PBra%R?0D zOv_Ke^1c3dbQgm=9*IwAQoEVcI}W@Jpa;-A{M@I{LPNM>9neWRa%^Q=q;BtXn4cCn zJ^zE!z%>e?_i6RxYaQR>5AiU+X`y4FCO&uCm=-uPtf%S>TBrce1q6Ho6gLv8MW1Iv z?mA13p+QaRy;*N}#uJBKs0>>kJHh>yPi|duk(E?CL}2nMyKw zKF8Z^bF%U_g+^9P#c>C)3p@Dq1kB~vL303uT)m5wLZ!%C=Cy~Mc<+P6PGn>(u^w(Y z0GQ9tu#)LwBoWqfmJ&UzIT=F%?e*%w`Ywf$;)VW}d~3t+r4~7~-J=`-uQ7;C8*V!! zBe;kLj(J;d#-VxC!_Yo$9I{XHZ^X}d@jCbF?bUWv0@?Afs!qa8JZh{1>*va9a9-?Tx>U9IAjn*dK&oQ(R{;Az@~v)R&blwr0C~^p}+hqcklcGJ=eb% z|KOh7X}|x6?>_VI|M7^mjj1E9`geQZPyDyN*!IX~aGX?t!&d!Q#S#EtJPXZl{fc^i{5y4MLznC12|p)M2$adD^-!bcJ^3s-o?qmJ&k+!AY7Bb$ zZa;|u^|l*2fB{A(FU@n}fL#92=|AY;S$~mfcIhyQ$WnV6S}c89i9yP_!L##o!lpi{ zNBs=*JKyy6bIU*RKhduVc=DJRpHcbDfAzl}zxvnj{kt{^ll{P>V;pPU%kYz(aauib zdej7}$cS%yz2%@6#%7-aztJ`CeeB1t%N0Wmf5;;fq1gNb&O84S1z#Ed$WzZ z81oUQ&wc`6`)(MXB&2sB<#Y}_1gG;*A~a9qKo}f!!K8=IG@|oC)5R3->L<_(jI6!W z1Z7%HMw8^ZVXvSmtQfUn+lxJp?CB9{pAo88`#U;^@EBlMTKw5CaMw5jOKcdQT8o2+ zmY%WY9_nfiVl|5YmXFApGkJ2+x8>`Eqi~2J_rs3~S9wdn=^c4^%HEO(rtD2DjFs%{ zoTjtvqBB49I_)lLFqzTfym=IlMnz93T|=34?*psG7Z(ny99-3Nn~zVl=jw`sC-Iwu zv&P;}#BE#_|6D=W5yt-7x8zkBqOY9SW^cA`YyvKE27y+yEH6j8&_buPF z7k+|)R$TXO-}PZY-U@j6J)fvs`)7$y&b$ceEdv&(oB%x{#;w+C9CNk2EyCC_V?dku zU}O=4Pc0Ca$$!%dIkkp}178H@mki*+Stsb0VVbrL8Q6TXn`zqv`l&|Eq%n%l92b2o z03M?E>G_H1`;UL=EdG$c-y$-L{*wLjkN%mvyPx`0ZW0fMT5#>fZ2F8ntciWUWi zR}0OSf_x602P_9tn793j0r(UKBmUEnV!t=Ak=D=!zWv-WiBHjMSsjb$pa;JF3?H~< z4%2CoIRFiSaRO&Q8j&%5b{y~6eE2Nw2$z3kh?s;=196rWz;{DJaB{*Z+pgjE0mRIq ztAFzjzf|nJS(rAtf-}EpZut!^@;4rfcKmj3HaxnnJ@4J~zW;7pe&9Z^{y#r`_ue1= zw)(JN-#%qA<2~B&;GgZpFtu6idJf&nN*#b9Y-7WV9LG)Yd@wZ!%pyO$8`{=E?EQj} z#=Oa|`86K&&wTn7cQ(AR2iQHnUt3`OHo(1}M?Vccdf9LK7?QDUd30hl%&;Ore9(8j z1r5$|Zg|yjJKfzJ+rngFiY5DGY!EDZhHUM#p&i_HApOvoPPn~a8-AtNyv-X-cSe#Q|{s(Xf zaNP+^Rr7+!;}u7!9SefO7^2mDY=aM;3l5Q|*W!C>Xj{p-_V2y+{T+9`MonkPjgagcx+`v!DVaH zm(@k$Lb4aJ>)1Ji<3PkX{BU~V!TB2vyLjJR+sp@>$GS!JIs_1MI2aFZAGDmFCmd1WrbBjo{ftPCm20=VTMsJx)AZ^C8SL%Z~<6ZZoGy{D|^)vtOe|s$c4Ntvt3`y+~pEP2N zr)i#Jcf%(3gth-RfB4exS|6DFwSHt0TQ%FFG4ip%FmFL-aP+2Hb_X-DCZ1`BJ5wV@ zaT{c>drQ&YMZq3m@6i0Qjv!A~(!EXZn{W3uHp93A)7Vs;xy5 z{qmj>abp%+w(SC%b&IJiiEqUhx#quNlk-XpLesg3KKk9zb&By;zHpm{TmM{P<>zp- zpl!K#pV99C^wlq4_4C^WT=`>H-{PmwRX%qDh~eHJbFdw`ZChyYrQ&k-2_0z{j8H-b zpB_04B;JEZ2m;IkMKM_aE!9%ul`4U7r=Kz_CeQbt+-^N zTDI7}Egk#X#sO28#3Y4*bcY-@fdcFJa{7dsyKk#10klFg6pQTOX-BPb} z!rI9F%zr?R8NA3ucFRdz506>^YA(*eH*^;}tfT24M`6jdSV__To|s?*axGhOs}7oH zb+MH-Jb7N|^RUBIz81a5Yw<{KD&A-Iw=sLR6M(0Tb79^{4B?$d4QR3hm^m2 zJ1)R3^R1Jy7joSK^`lfZ&(+kXn**tUJmK%v&wu*vzHggeSK zlpX$aFCF4%E+gGLe{>pxd(nLgtY^3-bPgpHOwBu@6bP_sSxFUI871no=gu$=ZEy2$l$ z2?qRk0Z2~sdLLNFriyxPA$23HqzvyY!)%6qm(hE6$Knt8#>t>p-(Sl5KlKc#G|8I>>E0( zH<3uy53A%TVgBp5A|iZ^8C{OC3C{9Vm9PK+KmbWZK~(wx5xc}Yu?#G|Sy=#n;cwo( z^SghWzxSRWxY_nYm-g@-f8p+>9+YFB#LLNpY1@a;xfcxFmPO{TwI~7zOXh_>@+g1rAj>tYdI_yJV7Id<6WJm~n4c9Vm?jAHO`pnxr zwj~PFql;SUc)|Y2Ywd*4Ou>iOwIdC}*)Gku9gHil#lSetW7voTXz^+ibk($Gw_-d* z4D@3vJisPTQ}e+CS+^Pw`uP*z>94w3Z|kcFYYX3IJb4w#nc0#^(Q~fiT%*=>UOO==+b=AzyvmoCmiDhJ{2rF92e=@f3Q~! zVJ_LlCn{*Mt>N^D_kP+on;#(Tz`~Cemgf zSCKpU&<8m`^UdFuY2DrF#ee4`auSm^vm!{$+ONl7DpFa4v*&ln`+mw2d6^88{5{B?qg z)sUhofc+Nq6)_#s9zclB)?3pS#6<1MlK^=5boDoUjp?!>SIIZ99B|+JBi|kp44)U7 z=0#(tcFvZ0z=LO;mV+&^aHDHK?DO&oyfu`XiCy@-Pl`Wwapt%eV162A);$Bsq5{T z^@z{)B|5Kyh{_*2TDEf${isLoo@?UR3hX96&s+ZiE&z7J&dUdS4N@oUGqziu7i+z3 zl4noks^78J;(x2q@a z2!cyL@Ef*yhhoQ7ODw!|gf_kLVEC(@^AWFTqm$n8w$m0ryv%Y3AP0uJG6KQBbihx1 z`Nuxwc*@}1O4DH|d@q=C^>BbI_B|i_L}oo_k>^?O-F!&z#c%%jyvMqUb`Ch;avSyG zM}FE@|Dj9nePF`3-QsV74i4K6IV>3^(7oeXfY=ffWVU>AWxwAkq%G5R4j`5{nQF6Z zeStkpp04n>Ie*sQ0(f6T9nWKDo?weHLF^p4csWooXAyHwRV=d;G%S29d|B*#B?4{p zp6nv2Yw?FaGNH?S!%qB}$grI-#y-Fb?!xIST*GY1;iGp<>7_*<LuO^oamYV-NnKidWkQb9}U4jxW%| zH`8k#I`bk@v~nuwLnJaR07W8UQp1{e9CkdGIhJWcZIEO-e;Ytw<>SFK`oNe!JockM z`{G{jM_as3h(;;b?Y0q{Y2!njjW5O$V}+q<4KmY= zhCgyD0>V$IscKDnew0JQMe37>&WXu-Tz4tMh)j(;_J;+>=+-`ZVBdcfAKstx!&5xl z34nJ_UER_+6S^6UPNvFLe-+qqnxNAz$%ZN@8&PrFvr$}5#uUY)VFtRST zfVbrhj~y{^e6kK%vW>wBUK@r$wa8(S&bSfo*kC)XImt1s;f#Urg&r7fpBOQ}p?(hF zj*n>KKlGUFlZPBkg5OC&sOGctX_|KOr-3{FiCuVSXN-X15my^;`?kK;KPMonVdAQBEhk=S1o?@h<3JcXr*r$;vN96iMxvCyS4;A){>1LID1GIZ`>Bc#pZtY3PSTg^8Fw6so^@0@mp;vEf$N$4$@JB5i^d~t2TaNhWgg|_Vul#uAacbO0RPGwC_?fEb#23?& z6Na>^2@Q0|iMN~{E&S^$-V7gbvulWEZF1^vGB9$20xOy*f1S&3?5O}af>fOH}C?*K2+>{ zF@L{yYt8&S>O+9r!F|BWZIJ|rU}vF($BFDBFijW04j>Cq_@gIy7QN&qeQ4;z3qL%0 zf!i?VG+=pc+QJ@q3IW?~hZOs&zYQoKQ$gZf!{{8NBN(!a@38}t;1>A|Z{dK!e{Hv9 zVtZ(B$wG$t7~o6%LHuJChOt|@2~RwRPD3*0161du2;YC}9kgRa?9;H>It1_mCMak% z&W?TJ5hA`~D=$}4o9!|31U)rP_hMAM#SI;Cirf>QCj6iZSN|+nS+w>z^c+z6K0H5| zv-hRo?MM4)e}yPJ%GUUqFnGRV6qgdS$fb|$)Wi12enseFs=O$#of}XYr&nOi5nFmP zb?13+as~PF&wjrjezb9KGWHJyaW^1OnoF$K=T49_K3-Eytd=hdimQWor7i|9T;!bM zDVaI;oooi)`pB6Zy7Zd7H5eRFbfF`9aL41de^UpXEYS1vH$`+8iLdqXoqoU1|5d*p zk#4EK%y0ER@xOsME&h6&YA^oDU9SA^^w27w+_7I^Z!QuU<4Cs*>GYm^du+lJNa!=)JrY*hOaCIc#T&hgY=(6} z)6{3B_yMH+Ttw|4WTMC|VY4YB6ehkczjI=fIM4&zZToWFSxqVWaOCbl{Ck~G=txY7 z)zC^|Lwb^kHMB$8~CE7c%VOQF;1*m41+@~ z4Kvdmdg2JZ2{yK**X#%)}Ji-}(5L-f{Ks11LY;+q99J{^hrR zk8k~F-@#{Wa8q|ncX&^|2#fQ~F|vf+ZTz&3;_)|92$XCE;(hhZ=KKxFM2FOp(tHQ_ z(a1Hj-Ufic&jk3sAAG^LC9YTc;IFVOYWMnqp!PV)BRgM4HlNNs9|NXu8( z*{%G9K|Ih=Pxdj6T3T(QZ-G1<`K{f)3u8F?k=VxV+K-&j#}xQz^n9?76G#7e4=0v6oR>xyvW{*tknj@s>cgv32j5@qLSdAKI~dBQ@Ue(bqKACVtw+!Be@D{kTvh z2%8~r(@D3lLQHr=-@#Eb`v7gF7G1iCXhwr;+scTtBXo1p5n`h&-qFv1h9Q`(O~tx;BMpzY(7_DDxw+;Ac07b4LTNK% zAaUbmo0@n#n~7`N1;X@- zp>5RfAoxdn^#1<4{5-Nwfc$9bUifdtX@tMQz_Fsfaw4HS3%o_e*EOGFx39$HVl(uv zQF7fl`&Z%c@icH~cU%~o)e%Ah##WOTp-^DT)-o@^B80 zh7qHdQRteYw*cyJer6dF!nG2$y9?xp_KhnZDtUMdBpH#HRg4Mtn$oEK~UM zC)(geXl1g+$29F0{a&>YH)}pI5x(RUE9qvRu{`bq{H+mLPmm*=%M9#=@j)T`6?(zWPdZCi8*aX+D_7qJ{ z5Yyk{eJy2U~M=(Jy_E!m;$ps&10hIVo!8js3)8&^Lz z%eKg}oou>=f751c9Aep#kekMka5x6FyBM`eLcje_fSQgxMps1NN^RJ(qn>o>V2jV# z$B9E~%kMSrBW)}2nZ1o4jhf+1)uwIgxsPzzDitrB^NvH;LU~RQDjdXr;$;6Dp(`}r zmrk2~1)ZYvV!;9S`+xX!^&k42ym*eOvguUCsIk84!EHZmQ`@}ZiSjazNkp1^>jM$&Y#2Hd2V|$w6Us0wfVH@?pT2-}=4&MLNf67-2ix6Fa>KauyzjRPR(JH{Y&hGBtHDP(cz2!s#b4NRDJMPrfWWaXsrvLz|^XV*g9|)y=9B(01p%%Z z=!l2;=;3P4KJj2~`i6VboBQAJ4;79pH!b!)5k)3)`U=axgm>d>f05hbx7bHx?^lB2 zm75$XTenAx4YEer8+JN_q*Y@_0;ju~TycmV2LyUxj!*av3|rd}RibHn7XMu{92|83 zg+O}0y*hjhxYhj5H~y{{5Bi=zSE4NZ#1FHx$oRxqbHn-ym=9d~1OOql!;PA2o16nE ziGwQoj!kemNN2%^dXE>ky||v+fPckF>u&m*9?ibXM!ffrIK3C5-(8?{2u5`V7J1NZ=aPNs-sS{8qP)aTWWTmGkoUrZ0W@{hmNEY*`n=v{}%?|O3V zH9mA#-gi!hCxX(=9cT`K>Mk3&R$R&A|gl_-f3y zjDpL?hH3KR80qUnOeYXt*4Z><7lmfqZoJhXFO4y8wPN;Me_=b+<(H`y_z6 z!-gzg#Mz6NV$Oqs@?3y@jIzzTJ&n&kAa{%mk4+dSBc{nTFVegXc%x+j4+^<7+eY8M zKRRLHRzA-^5Dn3Kp}QCNAJ=Vt7JN<|yLM(l77guMPX+yuTiF(`qQ~;T{r|Xo@4xvD ze;{*9J}v&rS3TvgD5$^GRCtI1dB#7t&e;h@{yRn?n_i5`jraeSG$*1EB3vR8M}v^p zx|tU*BQ@3BoO}*1+(n0N(FrVmQKB?THsgXE+bsOl#I-rj3x12{%Ja77&vpWE%RmB_ zMnQDW0y_1kG>JA!SU6KS12}QjWGCs0_Q9or&_&*%0d-ZJkeOb}EmM6K55Je?@>vJi zrR;Rz#g8uJ96YP^+pE#1xhjYS(1l2k4SMCQI4Z`*#0UgqF}P5fQm`YTcR}e&%kJm9 z7Y%;&9$m`}4}EMm3&wTx&Zqf~CA9oq?~p)aI5=#MjHX34)<0-}nb;Z9hDPa)-hS_x zZWJht-0>v|P4Bqdp_a-~jicMeoyf$Vl3F%Fm$+7P zW2xZe#Y(MMbVb>k>IjOiv^4~jh336K^<8%#(}w{q>&BICY9?aR=b=jvf9)sl9{4^y zzZj^yEZkO4^8vZzhILs8gQt((j~4GmWE=vQy0ySFe_eas$-?XSJ`C=&b^<^k*~M}w*asOWr>)QJ z+d3#!;#@3B%w7a1k*j#1+sQ%XwP1p`Ol2(kQfzI8<%~Qn8t8*JSMn4}$*$tqg<`!f zj7Y=pDlQkAjV~C!12c%7qWv*_;1aX%1Yn1Cx17?aZ5Q**HL#rYO`jM*M}y|Yy$(#o zLx1D9el5vTr| zwe&J6lPb>U1{Pq(k}Fz!C6r|gg*F-|eCFxmuocSDy^mD&WI)sX%%@-J(a8QvAS~WL z`t20(Pc~gWzw=FBUuZ3A4}bNi?jH1{z=xmx^j%I0_Rk1oJ$b0R5QkL0(zabTX8rPD zC?Eb--?m2vjkk2Z;B)U@e9`CCBL5G3;oTjJ{TF@i-Q5@J#oHQ}wikNF_V+?_YnnSC zw{Wk6QG@u6F2>id_if%=i~k%Cn$WrKeC8P9eT>>@co&6hC-Qx))WqKm0r)834KuEf zPt?>DB8Y0asB)ccIcvYlejk2lO)nAI7m!xRir%lPZ0CLZ()NbiZO3BScK}{@g1J~e z^zhkE0DQLFW)aW|qnrkZ3GTN3R3JjkfiH=rGSwXSI+C18dS8TQXeF76(#bCeH*9C6bR@J(Qga=c0;|2aMJhm}y8zVDwVXEn1 zn%n;Xa$xuFu;uBK)ek@U^LO|C{(s3X;r2zku)zm4D`}frjpHGBomh%ktXO|-_6S_N z5*Ko?w4cVKOFCpDF?AfACvhln;YWpiw;)ffa&!ys1z){kkm%*J7*2`MKZ+Jq+ibmmog*(n*2ar+(?~;gi2~cc&8r z+QZL$%Du-8V;sF)vF~=z!cNCDej2=;cLec(3s4Bfho z(e3`v=uXRnPPWdJHS^-16C2(3XW??{g?3)mQybZbw=LAk)Kk|IRh&RnMJu}XKXS*8 z1Pp*^Tz5TohkWG1sa^yvgE16G8(j@by=FfG?0w;2DSZ-^wioTd(&vhv`5t2zBG{*Q zfHzI#KG%uBvz-9M`SeZ@4)0sA2_OmK#aw-*io6NXaK`&8*mA+CP4YGN0TRA$Gm3oR zOJ<5N^h4IN1E)u}{>#orkizn<>~-Ob=qMtMIC$E*=0!%!KiR(Qw)mcp53Lj4A=NlK zQ&8Y%2V8nBhYh;jGe|qMWQQHPknCOt3`fKaK!gn&D&g?8Y%M}|Fz2h~*5ZG}4sdot z`m~}rWEejBdO+97a_l`8tZj5InLtM;!>aC%K03{hxG{q7~iFnXt<^i^B|T8lRW1K99_X(GR-21SiN=Yr1y^aV9S9`w~S zJtgr4U#IlEuUK!z_X}naN zUy4U(c+}JOqFn%On=gyF>G~weH0*B&*~oxC^3t3B88**)0x$(ez&q$pjHgc7;SzW! zjF4#3-SCkZV6>(Uu7JH&@MtbD4jd^s2|U@;G$&fFHo^;iD{zsx0Kq~~-QE_bOf#0^=hu& zplf+=VZ<^j%P5B=Ig3 zR9Mi#K+;WT`5?)=dd}Fi80TzK#I!nSy;MR zKEk`%Z_qw|A9U0nEw!2Xzz)(WIb>TNHMn#yvu;iJCprC=g}ED6 zlG-QH6p!Ye%-r%n*%O_%0cE0>uJRdcMBsmIy7_=wq_uOv(>kC6#fxzk1bR2@(jdic zTTBQ&9)z%HPPu6jbS(7Ixxl`Cnt3Fd&OQ|;abb3f1#=tRbZse}c~}_Ka&XNeR#B=t zDi4Z|vHjKtTeA3j!6u)`#bcj^%6rqP2iGm}NgNDz3|p&sOfOlnAaSHG#loNqvEq%- z(|@tvC(bSADDFO2Vh=y`;|1yb4VhJFV@29#IB=nWxjX!VeB#jcKJy}fv`uX3{Xw!8 zS8n006j^V@1_ALgHW(&;$AXFdF%z?r(fO#X8T@Edv8}io*>3}I8#)gLdhfsf({~^H z=5KUtvoJprbOS)Wrp7iJ8E?Qz`laFoeN2+ z_hHTQ9Vhg~pAq=k9X#6!fDbYkU#hI}TCZeV2PH4En&*HVH0%UD1u(%{NeK>GT9P!h zfho}5v7Cuo=tqnOWpB}7N9Go~HdzD!9S0|k;L)q#?jCJwS8>@^3`CKbh`Z<;?Fn|Z zl=#)t>BdiXHf#{aBG`;bY4P50#AgI#&~KfXU$VlE-kpQ7qw`|@rE&a6Z|rh^>q@U} zL;d*9M2gYAVbmhlM{3C%mL}5D|vi$kl=kb)Dbre>W&yTY;v7oh~!w%njYn8 z#7g6eb=zg*^nSihBzZ(DxBh*rziVY!d(rWC)H&ELou;v|#Ol!JIG|>XiZs}T?brZy z91hl!-{d>q@p)oQEQm>HJ2sYWxs7RillO{*2%VZ;(Z^zEJ`Ss&cx4zD|<5`;tHWQ_?>IIwPhQ79to+s#22{7(5a2?xo|VsGHu&+zYrUrV~O0Rb{8EZvs4%dEFWIo zJg*yDo{m{_T-uqy4j0R9TOxO#Pz3h2`)4}=s2zGmQI>lRY7ph>16PWs`y&Ch(*xeg z8GJ{=i6%i^6gxmpTrUXbqaf96TN6f}&5CW3l>=bplfNt`(KWm+*FHJX(qRdKv~#U< z*MjY7TB9$dqd&YidWsC6+Waay=90DjVsb71TrFRHvR#S;^y4RS(aXAilHjZcQ-yXWeCb=^$4E&K9^e_SUC933tVTiWOGFDen3akJTG!E6~Z zXx~96CUt8a8VS(9_s7UVCM|P@m$+Mp`4$m8Z6`xFbR!Qk`ivP0mRJwp7PrTU@QGV3 zai_ZgfB4((KK94|ZBg{%Yn}3b`EBIdO#EL%v&e4_BcDZ$=B>sf`zF`>1MQ*g%EBH! zo6~jIwntxCAv@NG`gmLE16}Q-rWBh zd=&%HN~Y@=Qsn#pQalOHp~9mb+dVc?Jda=({$0-}l>IEF`#8crSrvSu{@XHnwiAF` z-sEh^2P3XH6(A?B$4P7gPUtd5Uhs?!C%>Jb9u7_Ta;RyVmTIH_CN~{4f~jff5U<^p*EWz6J%fdDq z@^T`C7Otk%W&vnC_AQq3y=}A}iz?g9&m1{0LDzD@(YE~s5lp)3gry;EqG!mkpy)Up z%L3*oNk%D-RP_Qm&PV_sSwh{kqiwbX#K8kCaq3nltOW<;oCRqevPS);gC=(1i)*Rl z!uS7?&MoF`(;Tez6}0~e}}5W2HV+5vV7^E*t8F&q+*$q24saEy!GR^WZ6bw zjF8uIpqa4+9CpMuXz(eR>$up2ZG@>cmKMhicv%2V$qKE7!gp;xL8=b}<|Kfxs_|Ta zJ3SrHx4WN;v1GmOZGtO~1vrcA)r>uV6Piu`LB_;3@r5soN9C?0rG`4p{w#?X{XhSg z{cVY&y1kvNeb8&+=UE4SJ63sd!-yVN9WpH(^F1zxW(%%qsmYu$oqq>_s^}(~w8%~^ zrg!a0X6mQ*!*D5POGa!~D?}HXjb{wsNe5mM3;!?XmbX9o ze;R9@9-kL?n&0; z_{T(-h7U=ifyZNEN#=e+T_smoY+Ulp(>9jB+u$w<8`;GQuUMt=r8i6)pHxL` zbkhrFJHf5RA3kD{g@FYR&&*)tv9bNiL!J)UrkY@n1Hd+T!k1WZVi3Ds7sP2bYynQg z8u&UoMWwmnHS;#~=^xpT7+l!{Tmw3qnru#1`KL@R93$ z62K4B2=d}VCjnUy(UF$-_L+BpZR@rtv~Q!o`Wl(oc0JJuj^4JZCx1DX0ZZM)w+;_1 zcRZgz4>fw-`}##(+gl4izuad%tdNzOChl(6WF?p)8Pf52(}}fgt;8~(m_yh5J_VB; zmo`c^UI$V={3D}LzFX3=kb_lo+=#7B>qBChV@Odhz=a;$T+5f3q&8dZ$tL5pBbX0@ z?JIM~7z;1^1p8{Y{WiecUi?w>f<-AB8lD(N5svKg4mdJpltwhqJOb=C zJgO1S`jE?iE&Yy5+u$7*xuUaV zVkTV0&Oi{xS=Pk{V6SV5TAR3|XEsM(!J(&d5>VMFgKbOO+C~dh6Ejv@ayibKN-9iUX~S9y>b*$;0l*Y5Q-XW3Cb>I=Je?Gf7_^WqUpyQBMyH(jgua*F*JWLLOWEkrsS}X3=M?w^RJJTXNY~qy(6p6K53>i9GFt z$t!4ZZ_X0UAd%r|(Iea1 z8^#>ni8pe<`|Nqeg@q9+=e!RTsm;j^yp~(K4QD7`H{rIPbDWCW_Q($}RK+#hn~Q)> z6#Qc

Bys$vGg)YyIeRU=kPb^06WVwOYqAxhx=d7gMO?Q*^cDke_Umzo@tQ80V{) z{KPPgV^de+e2vbCH`wrPk9_s+mB!?HzXkE6h`zEvE%tgW^iyBeds2=Nrz?F3v+(za zOnm}1C*ksc)fIJJ`=_c*@YDrjOy>kMdwp{{H|-v~Ca3|u^hYhyk3P{(eF{zsU9@5s zud!WYO(2|BQU&j3`qm=_)zUA%(5FW?^!zk4v7=$Nbt{g3uFD7%&N8*>xJFL#l`P9_ zowf^}MQ?lvmXD^SW9_^`Vqe{Hk2HX~i;I=TgR| z{y4LUI{)IEego#(ax3_BBEYZGwc)im9$X{4L4%i~ZF4wM+cCs70kRDPKaD)hW;hBnJQA7nEp6X@zs>SU)DTmH+f_ek5ln>wiu|^zr)8her@M|8E?8ut0z>qVKk>`G97d1# zmF7deo93p;1xkZ0SGbDX!8EZO8ynZ1Wtpq=MQ7x-c$8vlyDa8g-x3<)4>tJhoB*`- zI2fmiK43QjGLr)NeMLS1(n&iG>P48u74>=R05+&&eHA|pF6g!opzWh5{AY`Y)`JLy z)+ssAlW^nOPH22RLIO2zeivC6#?|>mW=;@dUx}O|$EncFPDZ@i{$=W6WRGGi{Atn4 z+_nZ0n_Y+lJFIaD&&y^4LJO?(8_;eK$Hy@0G^4bh=Ys7@IYUbCNlrtbf^ck zYhi#HI8?T*WIA^3yTIYKKhTPb<}GZ+Y8Z>LYf{)#?2LVS6CIok@QZYHPw_Fm>+~^r zAbmU=o3npZ>}9$0oM@IGVGgb5_$2hE03TNak3Cm-WTMx!9hX}8i#qX)Q0q_JoP(B% zfYfIcw*u<7YF>cLw*q2A-V(@s_uw9WK)2KNx;CFk=t+z(zUcGyD}A45bbjn-e&pxM z)bspkkbhlJi@MvVb?aU~JN&hCQ*mhoKa1K3yl*0+b5ps_2u?;?veRXs}J?-}bQ|yLO{bbDL|~!f}!e ztaTo81hlFqRL3_Nz}tJ*SJ7DEVWy_VlNcfY+EVxE*e$#=x_oL`rfXC1nU;%1lL%MO z1ZaQ3cU+-`r`gvC%-d<7of80TmG22a`ncf4LZaX$zzzb*Qe&K}cLTHuEy00`IW2IH z4Y|EJg4%f4Nk$ixCzgEhZ`nY*{7tZZvJWayijJ1zFirHpEyoQroe=uB--gD*k^!z3 zdkZve*~1XSwe1)v2tcZhKhW_v7+)Pjdu`AFt*v?vEs{eE%wizIF7yeVMaG0B!1~~+ zSP0naiHUtTacv4x7$DdYeHo+cO&h#h4>Py!Y%lUOHywr8&3bxzlF@O5RZikrW;%`1^8rnJ-i%7B~m!3u3^(lC0F)RD# zhCvG)7?FTKwiuc>r2}CLjxC0pEP(EeDckio{@6-`ZB&(K5Z?w3+amcklC} zv)q<(XuC1OHvCcBJe$?Nx4x5v@ZlHh1TrG03Af#Vtb?}_*^h7h>~ynz;O^N@0CIrD zqH=NBCXXjBB#I<fiZ0-)cRW1(pUj(Ky*Z>gNqQ9(6H%7Gu?ty z=Cm$%k8Mrl7;^YONfkZ^*@+E25*M44X@3O8^Q`z82Rd2sPI4%XA3h-=*4(LS`Qc5h!j%{~KBjIAJ}+MEi(dOHyMP7mG5Lw$ zR=B?}d$M8CMaJOK)$$YfignrGMP2;1k5p@n3>sVe0_^{%?rlOf>)QOTQ&rFVwoO`u z*Pw~55{x0FMG_KXkR+rVT2Qp{Gien?9B5+%2O1DDAcDipB@>+m9IGRR2;x9QQ3oAG zB8}o#20e)olf2JU{(t}L`d|Cn>)fZRo~k-;)9bFi*IL)d|N2^c?S0?pKBv|uBBFAS z@M_0Lc5GBx%S&p8{$Bt!b8TeF@YDyZ7%t;p4_1Oq-N{S0%m*Vm!#|vCd1FSW@r^@W z)p8?eoErgS@Dqb=Xzm<3pGd4`+hr;Fu9z-goHE=e7QM1gK&f!A*o@qm9_w^H1ZeV& ze%{iXq;#)$#>nw&mU_4RCGW&z^K19*5XU!==YG2A#yDpeSs~)#R$)KkSX{QB}X*T;(NY0 zR(RmIKa!PMJ*pS=*}1cfXrx=|euJcL}JC4ZFb4+o?&pVLZ*jvRNa?wo^NqTTY6X4KLOktu6ZQh~v!a=AC&W2dy z*~maMNRU7fDTZrWG|5KKae?0+*#928=qnzSxg7G1ZsxO)1+jgvt-5l73-$^Z$t8+v zxyjpyA{AZd(pNA0v@Bib(PtDo#LRs|>kBMCSrROiafY}&0KHnpBC7b84SU`?5ri+g z7gr>Ux7S)r-|~{^?W0t=nU0PO+nVUc7Xb1c-e9@$w{u7)6XoGGHwbRE>-IxqzdO~> zfz$k81Kw(UL5!a>fA_1ucGbB>lCR6R^3x9l2TmpNo|~9Som`t12{tmRS^8R&*t#0L ztcAHw9_Y?Bsu-NJtbrv9@Yn(uGs~F6*WA}k^Ctftcd}@oG{Gk<&h^SvH%C4p+Mf&n z$Bzp3jrO%42DUBC-{$)!4|BKmk$$e-Vv`*Gd+Fv+F8k)=HOsscw%5Y^u^rV2k!j=- zd}^n0s}9?Pt^;HCybGbFm(s;+2#mp}r!lroNgnj`LBu{;ybPrNfuT>!n$0JbUPE$r z@6odk$C+rWkAF=1oqek_KLeVO7l@aI8 zdcp@58=ZfvU#85%jk6g8Q@%olT4E7thr&nK{5;#6I{C$4V``9mKtsRz0AP;>9pBPSmniKf)AGf+^OEOQvk2o{ z4-m?w>so?_9&+$}ET7;zXI5mU*5lF&NyN~Tqc|eG8m~gbw z^gtlVx3_oOe3yV5bAPAcLjNA4= zzjN%2LWDkf?|OC}gME{+MwQHUqsju#k0D)k=E9h3N&Ct89$V(i0W%gq{)nGFeXq0l zf?D|P(!_zhKbiD}hewhY9y)}~HC@+a2tl~_S#{2e6mxA87Y%-IarFkR^BG%~KvoBG z&a?bTv#tu9Yc_NEUat}Siv6oS0QgN>Kbf*6ku)bbm9l&0^(1GVR8uA*Zb&D>)br7>)a`qWitd$nxjn{4Oj z_`npMLHp|E(8a|mnt6m<1ne3^V!k+}!#~(!*&cJQk-C9uz?d6yxA?n15eTi`1+^@l zalq?ECX#(Gf|6GILmb9{Dmu<%tNAA8B>Hz^qhbE&mH<8wtF>7p z$of<|i7jwWl;x>=QQO^`_~E5+{MUZ_YE2C+By-?~R-VZdUxBhq4muX+D6+UoYr8dx zAApLL&5mCMQ1JG5 zT(TaCJ9V=@`EXq&uNVJly^LWNfpg~*`QTWmB{JUlSE0u6e^2zz)+8GPrSz0(y(E(Q-*cy&3Vl?qgLoBh0 zTrrqAe2Wtkcxv(87~ny7-vS7!S8RZ$fLqd34By0v(}_q< zD)49W63Ydi4BS%}YV}#Dcj~9j@DqJ}$YPK0St>@7t1gTjVL6uP;%IO!Po~9zHa@d} zSb(E+`Gzgy!qXkgH&ce_sCmJ*o*xRDZg3!zYsM&Wvbnm@A$cxFJYrOO=9aD2Lq9%M zERk(LJ18&;S|eiX7J7JGU>WB@Vav1{9$|Q79fuX$ZVPhJAcz0#6}RSj;f&l3SJGnp z#?ZRt#BqWt_P%(*o_e;;4jYz+rR_-(jQ>_Q1OFPB{&Xw9W1iptAGr+lBLFvTc+>U} zo0{X(uD!=nn02aL%sPOMlZ}&Fx)*OYsTf38b_8G*t_4&hpX)HTP}?%dk|X!hr8bO< zAYO_jmU9_Cdf37?4LmNLqcM7JS$h!^=y#^!%g{iEyxDB zpLw%e3~>(1GWlUFg6q|Q_)vUC8bcT6vxY6P#vc;fV$ngL7Q3FH=fQQd|80ZuV7AD^ zy$8@n9zP)gz6?5+Wze=H6rFfOENSGBw!a{3%HS?Q3+g12Z83kPc0K-nWdkQLW#=F$ znv+IS5;rUg*@-Ht>+DTt@m6jxp&qeG(ULrLInrLT5q^5xL@hpQSW(m>2Is{%0daV} zFtG1`LQtPYg4k}}AhFjreKW;}KCquQ4+Y5!_B757*vlUAZ4a4#fy2e>3pN-gaw?PV z<-2jE=bVD67Mkl?q?I42l_zxSW#g7~9QKRAR>L3Z(Khaqs5R1Rxv=r&VMA`!Uf?zF zde~I^H#I?q%?EmPp{ZHMK8k=z=*`0y9(*hAiXR!i)lcKEyU}ic7{-)3z4={#n?G`X zt3L+hgIc z&X;{|-*YXNY^{PBW5-9?2uAqya>_Dj#@BruP8%KMx7uH74=8;THq^Rf`Yf zx44y?lIK@LQGkE8O@q7VksZ5DXzRwrhv->XCQV`e=rwq4dV2tG_+U?XMJFx}WD;Y1 za=+rSwFQ2|Z6({oJM`L?S?zQA4Xa-8>;vM#P7f2D9L&=q6aScRdy)If{dxcTs_-W` zH>}*8Fq8r%VcWQQDXtikAC32>lA;+yg@43@w&~l>IR@vM?D)|a-l??aY*XH<&H{D- zr_q%W1RAs`CP=X znjMT*uMYS6FkiCh52n?HM(!=A6F*1h5B+q@LqO*4_36NypZ?)R>UvE+gy$FixL-7s z_L?wACSLbNe(^lHV*To68(qKggLxyI zbwI{BNDTBDFRREn%E)(o;D$Hre2f8gVtKY2kdOIf6|Y+ zeFJ~lcoDm0s(w{RvWh(B0^=B0ZVrA78cM-v(#{&))MJ?CDif{DL_m4Vm){X*B99>{9#P!*stuznAywh^dR!sBm z>t^W6QOM)3d~jZfM~C(CD|R&W0uap+o7X4=pJC_PxX94N2OC=5oQ0ryjuJwwUZrw@ zqRDZ#qF!>=TR9l{T!1ZV|HZVA(3*}vw3|JVkju*geZpIsd-Bpa!7~p`=*^1`^NjH) zF3OPK*?*`1$Yr! zR(R-C121_9PeQ{-&JWFPA1&*spKq9JTt280QV1ThU{%)^Gl$r|_mJ+g+Yb9$TDC2; zPN3rV+?*C~hQyd0H!gbw!}e-!9+R(@4?kGOIf-JvM33H;(NI?3KG~s=vC;JMG>q<- z^X5PB$&$n|v8X;8r*4xIOtnnnUJsrpFbgC|anSPt zL$%RGmUPWVL( zw*wqwU0#6`TaiT<$C-n`KN`AF(!7|FV1c)cd74JfxnSP5M_2PSKEAV@2zlrVZ~7LG zKjb`zH`KOy+O@uTxa~L@Hx|)kjNGI6q1z^dqs3R}P}*CcGC~h6H#M8|#fBqlbaq3M zs}`|Y`t7hpU4v^XRV0xLp219uZOo)0=RAmT_I@y;mNdQxkUtHpX>R)F>BN5?!>0oL zXEka5gFxpRX;0+1Yipl~Q*@erz@**20voyZXU7-6rECmxGKVP!u8~Aa3fe_4yCSs7 zk->laCt2~vuBXJHK{wxY*4$+9bYwlBY-GG&;Ja+OF-|P-`zYo$uXQeuY=f$oer_s_ zR`E8TV+l5~E&Rj@jb>OouStmXEareC*v&c2WcTP4*hP)Lp)$GHGm>saMy}(8xD4Sn z>B=ueKr6P;Hy(Pe<~}$6$gr>G;R%{CgvTo}1~>LyuQL{m#888SF%3KVO*3qP2X=5T z8QE_g%iu%5MuvE4#3hX2AX$gb5?SxpDo0XrOjg!-``Va$y(5Mt`vV_;6JvDa3vwE> zR9@{SEgP?f{Sq)YuKXxq7K0MO?`UQ&znm!gG#5n^CvYVx{1^MK+OvhxSm0){+J>;b zP$&*#%jv9t_{~F&V=zHu=9Z!_oqpypJv@n7k6w%kuoh6;A`h@-C%NEVdp#K7iFhpo z2IHBVdz{*5@OE=lWlEk4pI8x~Z~Dk%#}3P7;dt)~9!i0h9J$FRv-52m#I*9n-{)o? z-}X4Vrbz}#8n#1gsHzqI_@B7#TYQcj`o1BTpw9Jj{>Wq7h`zaC$uwiy)LrAy=u>YQ zFC5f_xf?&9eXsx3jo$<~TWmsq^Lze|r}yjE&RCo6n$^Zn$hOyO63f)D<3||T`02bg z&%QC%fqZm4Mo&1lYl}LyZ1qi)jD3N(TZaubhuIy6{lQQB-#((F!}gWTn;y!@qT?~b z)oh<3*xQfz3#&wZ27@^`zG>NzQA6n~K$7AA(kMKbT zd>I%%VBv+2>1{r9%-t9$KW?4DoBZe+ZyOMX{7f#Kf9F-?_#}}G<2yaM4c&-3?R_OQ zCBy*e_!zDBACLewY5Zkw{YxM|;4ACb`+6|c=M&V}0~=Z5EV!;wL;Cu{2915V$585s zAQ=2B95aPE^JN35+Rt&n^}Dlr`+^*eJ!`*_F6!4izxrGM1^WP!ElI3Rp3VsFBQ2&xEx(Kn{%8WuhSPt>O& zyV~TYY2|fIxESDfCd-ak^S~fJ3!MkYn;x;_kBt|W&xh^B@z9>5*L9j+G{Q)WoD5V` zp9|OPL&nVlCo(N+U5&>#`)X&E-;xI-_3`%r^qT-r>wlXAYWwr_JN}mYe;s_TVSHE$ z&TA#;qL&NfFA=b|;!FI-HZdh$&x_-6U)qIyiKBYQ-1Li)DcEgIKd> zZoU~|lspSR`LQk{O~*k$`5=Jwv(2pJ90+bym>ozZk#3RbL>R-rr&a= z@u>u>Ev<(vR>#6}B&L>O>i(Z_9 zEq?iJ$-={yHm^Z$`{)dIW7-w*T@&>5vfTE|1d|7I%UL7&U$Rph(c(*?)O;|+ou)qq z^ZqaV@Ba8d*N5MUN`MWF9}952`JF#op8}|!Yy>$5=fQxC*pwFnV3%F;ayI9+Wl~=> z80Q^a5W)weYN{Sc@~8v8Wx!*uF7cxSFKfYkxQHej^SeSJxn955$C92IcsU=JI9;rp6fPI{3q zDY7UOWNBij1xLnAa=zoQ`tZ|>ApHhl|L)=ZMn`~OseiQx0Q_Zh&j#;#C(MONf|5k! z!|#QU+-}z*uRMiMpK&gZ+rEb=iHOHO3&c3c zM-KapIW+bnV!Gly$70_uNaktD8}pcmBf%MeurZ1?NX++qNNV|qj306um<3J?+FNdB zos%1mYR>$!kqnjXId^`%>4T-08*9e@YQC{YcdaokG7@>Q#?P_~u44foImk;sioSsD z`-SKD3dXfayagrHpZex0Uj*d;i_b@Uvyotm?*aVc?|*vIe;@niul+dYt{&HMt^GDb zHXV1$5eC<52w*09QGWwUch9OPO*hqKosKc|oNM`B+So^^G8@kB$2mi(QItPp6 zV{JL;HLbaG+;&j6UqBm^0dC7;$8MP-I-czpSW`0z=HU#T)4j-z9c0>uX;xZV_}VP=48kl3L6hOYk1&7m?=Or3aO?1s$X zF;Qb3Of4kO*z>OK#RFe>jL5TD%Y`o0F zZ}rpX8=G8&(0y!JkDNkxuaPise6hCQ4dEJDR@%gz=1+LY$=SedIop*f>fF{X?c5!@ z@bQNWt#X^OW_Gu2(P5+YlYjqgjf<`M?SeWBkC(yP`q*wG ziQ&?7?8NpL|Bt6PzyALYLL}k~4F5fV{Pnp0%X5B5;4sR8{a{UazLDaCCLG4;{CEOy zt&fr@&iLIjkxy?uazI>kI&80L#9$2LBXG@cxdRZNz~NPjlP7-)lAImBTNU8#__9m> zoI`B43^vLyw66vq9?dJdb-Df@?g3FHZasYR#k0J74$E#6k6Klr)>F-ET=(?Kj|Fwg z+HH42?%ebge;?)J0c`+!K>S|U?c3%j2hhS!t+D5azTl`QH5-;`( zcJm3DQ?7(KPjj_9KSS)nzR383-~0dN|GbI##;1JG-v-ck1m6Ap@4NpEdVD_c;}&s~ zCmI=bersB>ww`@NR=ngdIm?_LTClUJXHG76KC3=4*1d!$A8k8`kuNXtMK5Ih5AecB z-Q5R6Y+7`B>Us2vZ?QSyuWcxg} zE$57tMd!7Ro3AlzCiLZwrS^In8;MQ*#NC)@H#!1lkF>86iXG#(ePlVHr7^;W{gaY8 ztE}OlKSA&XB@2ETcu^PP`&ud;2_3M zdNbOOjh9-I5&PKw1;`NyyOy}4X(+4{RPi7>7mZ`Ou#(romI`AZ7hG*ZEB54I^3k%P z%NPa=2ji>RvyOwDSGDQ8#eU}WFrm}BkH++}S5Zf>HmoQ8jQ_j;{J)#;0gR7@Hs`m$ z@Ox{_uMg@k1MZeO*){o|IWno2eRSA9*_Vn=TA^?GHsBogcz3oXJ@+SL&9R>xa7{Vy zi|-*g?RT&Gn}pZOrhwBeJZvO)9{1e-vo`vLny+nY%n_`fCoaoDqp?ZZEaUG;YG&Y_dGP~Kr>pM7{{l` zLCd(_SZ$3<**2J_E=zBub}a%UAH3c)PkwG|Uwk=GJ}_2uE*fiUH%8WA`*Perm(*tu z?>oz9!#r06*iX6%m@?&~GsG247DNh`Icy4+qA}ks@-OkXEaMY5`tXcVHDT)F`vUh> zp*enb4CbGw!_TO30dU-x=iQe;#XNn+9*{3#aNy@2M>lULK|d_D{tZ`3bqSP@=LHnstRuLmq7S)Sg{7I$)r4pwe3CZoB0Hqf&1MmO{P7=Z5q|^mY8#*s=w64fmStK*tBtfFsVOaz&5)a?W$n z7RfT>7t+e5G0_LEg)95pp&ExypT?%_2WnEFv3;pw*=tek>>EUj8;LQE9=yS6hW&)w zH(q3ErngP5AN)T=N-$?ZObm@3R(j{-;O#GsS^wZ~UF(aGS~whhqZZvB*$9u`arHXK zRQ5Y~72jyF2IgXRr1E5Z5c=B*Eb;FO{{>(|dF8 zghDfYK&~P{^1@Ibx%3f#sSjorNaUE;0+R#O5g#ewdPqSwda)m|@aVk&)TRD~m6;`; zor0G>0)FsVIoUaS=1Y{+?|cLkKTLOY0%f79!FOIvvQ*Kj!MZRlD;o9Vh>gfVJzMPE z=!ogCT@RQhNV(-&loi;)2gBoKCcKamYx>w_Uaz9$Lat~zgIzu$0bYE+WNHN8t@FqD z#)&gcFTUd0xzvsS{eShVu-u;A{G0ynm;YG(?{izXd33JZnulh89R9DgrfZ-ty58P3R-j4LjR+eDQ-p)3F=>P{E(%vLWV^mZ@>;;2gSV z3ng}$?}LYw(L4OZ_q@P$c61u~XU5u^17nhGpZh_(VcdkW2#OdV9$`Z3&@F5ksVBy{iAdKq_voW?U({FlJzby|2A~EfGZX^gUed|eHhCTE} z>d|pxZP2ZiS{PnG2U11s$=rjAgN`(NaxxhgL5&^vc}wbvq;JM zfqn6|PqUU)b7L4E1JBzw>H0|o8q#rz8%NsQ%tf$Ve2;+XWs8jSO05uKWtLBj3zr`c zc*PzrZoLKn!C~_0e6Z&Iulllo#j^imXh4$#T;ZUBmHZ~RiLE61OfJ-RzKqxT%clgs z5hEB^#5b<=$w_RaN0b(S7$i6GEBrt*p9d2rtA?`;~tT@M;eL0KW*H%o6w>D@k5#*{pj(S&W~Bt)GWwBY{WTMJUSs zy!nhX-;0}ivLI;TGmT92W&ulXFF^G2lsjW&nDk-@E_%U(O|u=$cTP?j+XAffgiab* zBQGKl_LEol!GKy= zP7eoJ#OZAS(|*O0sdMtfqGaqTeo8DCf9mY5L3W%2d_=DM_?mv`&1desgGy5$|M^C*4$D}|tM*Nr1WZY;?ubjMwyqx%GZrJV19$QF)Sr53d z^|JRsryWrgaVv=8DK$tVc1@w|T*JQkqIvR&?6ys<_MXhO%aOW!+@3o(@cJcC%erQE z0?Q)t0;k&^L)vp>r{>Mu{Dyee@s59g3Xrw&Oz;Z{ulAP!;}^kZk#K#p1d|YwlLq}m z&5JilS6z75xiym1ECl+GH5N)R&}VZnDn!&dS|E5KC9W-ly~r^hd?|S$Jn$)gk8T`- zpcekdh0Z)a&_~W=@v$8_F?|t4Cv+|h(=4fzY?BdVN&h>2RQ;r1LwobHzxCEa}J_Y!>-{J2KK=PVuA6&1_$&iw-^1$}d%k`5Sr3UcQ z@DVzP*U!qSWg6SI+HS=t^eXY#BX0iMU2;~Vcu9Ur!G38R+t`V(U|0-W*lzW`dB?A! zwH!ptGq;3K;7-fBYyMoT(sQjkR_80RO8J&Ibb;){vN6EhdSTR_hZ_~?_KO@qpTIOS zzG(qkz3f>d{-Bx`UoyZnc8h1~w-0KH#gxhxA1)T=byFcBCz<(;F7B{+X{ceh?eYdyp6w zX`1Oel?1bH!H{pXGY+q<7CpQ3rtkf;h2C>-;tjwvgD1-Oo4@Jt@5NdU&ia3|-p9l*s)HCE>$g-qPR-mm9XU768HQ}y5q}BxIW-Qk6^(K78V3>h zw3g*cxpEubG>H0+F3_gvf`!ju72OxLr?!9USz&zzpluy z39EH`z)f-}EdSKs7E>|OX2JN+S#er)F= zd1kkDkWJgRLW^I)i@)KgCx3oo*3}*yc-PX3Wc9{n*cVddg1_xG>*E#3u)koJo_5oz9(+!&$ zG2^fKiyKM$lB;?$L7tjKMkRFPCPZy%zW62Mx$HW(-A8f!lN>LaN9ZAojmAq(EwoPY zFB?{iUmxx1(-J-fpgsHfzi9bX;Qfz&=IPx({zvD}1KURjE=2q|a%Vg+@x2ddZb?k$ z>Z4DN5=;7s8Ap@QxtXLTKjby9@OGac;TdcDmc&TdwNW2T#|lWxxGtVojeS#-FH?)< zmz*v2u2-oQQ1%$dF1hfM3(s|;cB!9*bc+A{`0G);gWb+E2Y2)gUbJm*@D+n)cP!Wi zN3R=>=-}tY+T0D<-SWU4diJl#Kqubq*T^*V=38#b2bUhb%t=LR6kF*{M^-QXIY!B6 zj;`Z6VncsNhppJ7$G3L5Oo?Wl$UVR_AaYxA$r&^A#xrI-*?M9e9?Z>uf4py}K607| z1Mv0c;cK<8hje&!0<(0zd`aH3BwfYwg4$J;>@F;3ETcX}-liYX;6s0@1ut^5;G@v0 z$i}_-1IiQ5jl)#KsQ-f97BIud%)V%YS8lwrM^$VzuNiF^V7` z>tz9aZ>OQ!$Ap957Sbs(GM7AZ9JkHKVxwUzh#Qk$dbWYSUm|9`1b^mewyt>AE7J3G zbPrB0=J!8T4*#EDfAPBjsPSOn;}8E_{dr)2I(7iAY4QpwxtKWdhgivV$CG|Rc!*Bq zB13O1QYoCP2+Sd@me|SZ8b<6l8K}Ie_D1=goU#uO`Mhf;%~Br_4v!?#5Ga zOC$R9wD`0A*kjAIPv`ze49-DcyNr!Gws68mu5a!G*v)pz)yhNkgme19d}5UjxZ}Gx zc^HEgKihQ7OEc>kw} z?TwlGL6`9G+CH6Slt(d5<-Fr7YUwieTqoxp4JXG+8fJoEYc_T;xuD_Y1%#V@ES=z_ z58s&R8Z&x=>!0V5GyX6TEjRm?a`&hPW-PZOAAd7H`sV9$xw_V>)(RFN@3xv^3w)BsX88gwei4hCVl$ zO=IjFJdjU}@yYpEa$X0=Lojan)4k=u_7!{RS(mZo{T~hC2d|aahBlt{I%fNGg+Exx zq-~tg=tCd-^2HBZ!n@_q!dvqguRH`ZD!Asj`coBaE_vp@?#;MPTjZsEK7Jb`*P#^c z1M}pMxH?~BVR%JBC&%E8uzXxK9Nh4?v!Qi=J`ilSYd_zo+MF*R|HK{ueB)4+ojj6E z#nZHElQ?ht3ijF4^+(?M1Zo!>TG^v8HICe)-ic;Ge4w59TNGO#YMXz;?(6xXQyZZu#<^#;gswXVsJn_&?{P;ohrqa4a(fO<+cKU@(#{BPf zj0Jk-Jvjvveq)_>ZA-kjdJymzDf>49CU1ui`_Bgl(-_z}+Kn$>ehh;c(hq%ssrHbU zyNdX%HDqo!G#{D__!>4^;SH~8={i;Al!(R@F+Z-!oD}0Yxn{#Moq2LArt1*kE_ZN<-&m4}9AGu~hgDr`Mr{ zj#J{edaUSK~qtrs&=mG>^;Ips=>@jGYev!J+HJb7&uFANmAf+a8}kg7>NH z+dBMIrB9VhQ0dWQq5Hz2Amm&TIaxc({c#66JRl^A=rEW5KDJLOc6xYhT-gLPfiNn; z8A1L?9G}73I4YIzw{42z5C-o@1J9Hi{d4pmVLL9GUdFuo4q;j#=~_--(qaW<4;FuSB(BX>|Sy(`U<#W0(TieGky#FtIwH z<70S>#n5Y#UklWa0>1sT|MAn49>nHT0DTu@#*+1?1o;gMObN5Pvg`aPp7h`+7rAyO zZkTG}*X;4x*Gx9>B?6Xfh97-+(Md)eONMTr_{h9ee3={_eB+;RL(6!2 zZ1W=42Q56f@qtuO*R2692ke@(U3@UH>FVq=G-BouSbXr_V3LR^S_FHSMaIN{j$g

OZECL5cXFaebItDH|nLoR6ct+xSx&iJrqe@HY3#bQ|}FCU0Bv3iQRT`LZi<@ za>7)A6lKd)J%q&t)E80YcfWNqh7Ts2HsiFdH}4FVO!T%t(E)?VK(k!W)!7#P*}$O% zgL&+3ti%f6hjcRL%S&$kj{KWH`Afb@@0)lD)EQ}P_-3#f9~k>#fjp55KITUDm;Sz| zAN;%jg1*F}|MQ*)k9-neTV!X&L4EqjhH{#K28klO91q;58AU~tfA*Ju_^1%Ov^slObNp#usP-LM(VaK!_uT%YqiZ30k++aedFTrt`j)w2 zI!*G55Owp3oO_4;z&7^d(~5}L#KCb)8Z&YNzGy9D*=3^y(2H#O!z^(l!+85iyg8uX z;x=64p_>~qK;77{RM<>!JwDpc$PzpLtIb2D^oQp0Wv|t$L#>_;CV!isnBsrt^c=*P zarBs<^6iVY_E7k^6Nj_o&&jcI)_ zRHM)4Ke_`wYVm^P4w2;b<3jbI0Zo)+6IMqAaFNxu2l|EZ^^{xv^8 z4T_goOY8H`@z4ImN7?%3&-}{MkN>?t_w-(W495=xGLfHYR-k1QFDH9)xaOjCE*yhI zCugFQ&e)CT(d($CUN7d^5F+co^Xgv<(t7g^68r6p^Kaj-n}>6oTBa_NxwY^`Y^bu& zn*ZV3r*HqK|Ly6$e%1^7Q6!{OJe3S+me9aOVgr4^vK4 z+svA9u5yDSSBBtQU)VJa5^^g z@J;L5*G?zIF^En`5{|ot0JK`JE$6EXKcDzopadV}plavylMsLPPH* z58>elZR4AkI6Jez#we7yNX3DAO=j7Y|;Uh;U|5>dMb@)3)u;WYR|4=xl z=wpk$C1c~QIN_VObZw($$XdLJ?ImjeD%E8RUTiUTti(ag!NdvdqP^(I)7DC}K6q*6 zTW0F5U-L63_T9SWn{h5ED^Z`Ui}>okP0UzpJGEqgW1O3R;-w#W{8}wCi!{SqykteX z&dGMQ zaUdoBV5GNgO&qU1k*q#?fAd>@>gnx2`A00q$IUD-WV{JPS^c9rF80IWOTOec3;d%7 zz89e1IN;X~{pCPnX=nYTfsPvkxd`ZQ*jI2)m1R5M)?rQbfgXo9+t5Z!nyCpqH7;t? zR=%r}X4lE^%O_#@;%@wrT(OLuW-fSB_=2Qs!EXsr7v|uef|7Yj_9XHmLqWv9pCXIgzl*YdC7rHTE@<$Kr{D)5<-`#Rg7i&!H83+ zEPV1(;#C{x%9OjDW0B~Ade5n1pk=K!j5{*j19#+_7l7_XO<#gFFR9iILw&NCSfU&H z67|seTefceGpHN$`%gY+fB$4FY-Aj14os6P5w)y`?*+$F?j+JFU@nGlc(8`n1QLE zH^2Lp`nf;v{Le+OYqrPWoh`9!`i|p9m#s8E1ZcuT0Dp1t4PSu!Yx-V5e?Ngf7YaHa z6U=I?9$s$U)x}?Q#Iwo7&rS2X5~1>_b=UZw>!FaISYpFJroaxHM{gkhh*L6dO*2er zFdEk~h{7|jDYjF*qUQmq)<9#IsO^rwv7sIs%{-0cJ9c&+E!28m7lMbM2X4pP{9VT# z0vP0#gBIJKJUD%fPu}A<>bL8C<8@r|CB7OT49Qae_?_Bk9vkUpbfa4jS?h@h*2EY| za)TQ7I(M)p$9KqR%^G64W<6O?bN3yajMf}EY;_BMbm_gXAR?Po8QP_Bzo{pV>{02H zi|}I)+h9S@L1?CLzdDD|!3sZP=P9uPZeQ{DiVs=#pu}jg*s!2@3xhHKIz=87nMt^;Vv33TTB`iNb4XNz9^0+VLCur;{nuGl^kcKnHaa9o3q zcWB`1K<{Q3TF0TG`N*V2HuJ<6I=ZynABzcG^_1O07AEo$rzhn3q3}R zIW2fSDI8dWOnsTAHmCogr#DWYHVr>2jLXsZmOi?cjo;LxoS9t32jusd`k}jjoarC; zB4-puE&guE(?ZQ0+6^1Rqo#0tr3JUc!{$Kk}DmRhAP}HWYXYGUr9-y>`)MN_f4N%WdUp~ z=xO0Ij~koD1;B;>sBO6upLz5)51rTvCj4N{_!N?`bHkd`C~n15-!1-Z+Na$7x4iL1NFFxt(bzWii2y$qpxqt>(YzI*(1bL})D|LybW zX+H5FqQj|?LF>C_njq)#6}$O10K52H$H;{jzoF$Cw+-w>7F>)8C(&;5!2$S2LQw=>S^@+`c`wM{kH1(4BK z>F3S)S3&y(kZj~{$#)k&YLjb{oFqjCdC260#Y6vzjf7?~$0is0ZkUVA$%`B~9FN+m z6FYhLhfZkG_q^i_ZF)jnwz8P=D}3>V{>0t1-28b0`;IUE{fVESU-`?&@MBYh&s`8? zCHdQU8NYyLR13E47rHG)h9phja!?Lu|N{dHyq(a z-}bS`qO5ak{lrvIdp>K>zsKJIaiCmdi)&u_J4T*%1VyM-6|*oj`S>BCF!r^u#9&NuGVIdQNM z`mo`n__jriT0B_;xp2t^G;Gp#pE2~{`&~Z-z0>B4e{b@?_d$Em$oZpKABf&a@$W`l z6L4Q@JOt!HfQJD7D&dnJR(3q6?vrv_^cP@6n>vF3sq zD9@46>-5!YHG=AW;WHQhjG5|noAa(`y<@P-Ap;HD+n;Se^ztu(p@e4XdffBqMo+*9 zmGC+v7uiLAamY2qLGF|PR&pm@nXWMp)tZJ(fRUi`uLNAWrT zV9D0B9$yy3feis3@;YKCwSbper7r0Q`@sMl#|$h+YzJF4AN=UT*FkKShmP#=31P%P zw@>arSN}yl3drH6Zf-=aQQN|$o}@Zh3PDpJdJ*ajEpl0W>9-tXJm6x>f;KIFDY+*d z`79`8X;}m(y|~4HCPT5vfu8^!h;cW@_Ic~)9RfM(40`b-7g^-^PO3Q0HrQG)9_veX z{Eu?9+-y&s%#r`xh4Hz1+b{oifF1(;^Kks-kWahif|-j$Ow+oymM_Ii4IVf;BAdKN zXz;9CcKs0Gg`s%_)jO~4*XV^W`CRZJ*>+Dpv}v3_20+D?w(A-lxfuM93>=bl(I@W2 zo*wl*egqf(zHT1LoqW=V?!=v1puFQ@yyp1IdQaZd$L6+^Uj>7gYb3eCKCR1GlgwoU z-_VI30>QS;WjL`6NDsDs=gG#TsD9Q^l!(c(c5R!E9%{7775?!OFS$X+ve3HWJ2YIy zckmMfBGzx&zwxDXt2_6n*dK7u|nK3k{Eom*F%^bw8XXv=1P4Krr-S7 zkN({FB^}-F>(O-4DMHs$%`$tW| zIo=eMGhcrGlKuLVt?M3mJq$Pzm6iacU@j&FA?Q9VLy{?ib6mXWDmP@--PTFL=}kur zoYhRflLiY}7BVu7eN)buxsEjRq1nlzGP8_)_QOz|#Mj0f3~0RDHwN-Ompup8hP)tJ zACkDYhOfKLoOl2FbH91_|0zZP&^z|n*qbFZa6d&a`j7fNobWw`H-GDo`e$>MYsass zSv?M(a1-(#i(I)Zz2w;<$`LRB>wHDkwbsP)^t`Bvj?}?7^@6?J2Y`kgUF-E^e2xF` zBWwAE?|8-`gO%;Jc|J&^KXqo(4RH?N6q;sO#cW>3^+>nHr;9s7(BQ27<}nUGxsN|L zxpTe3gvKHN;xFXjC2u>fQbW30Zho25pK+$H_~p8HK5jZMPA*1tp(NIed$)_M4+-Sv zpIYSfKa*^8d{ zWW^sFu17%|uj<2^t(P4V-*19>*KTw2Gd8GM#>kOdltSZyNR7yR|7;8szvAgr?ggJ^ z?zT~NQ0EOYzywL!(vn<~m4sG;G^Yqj-la0ymLr*cq+noY3~$=PFwHirAYny%^dN+Y zlE${Th>mB;?D#B?&_(g^002M$NklzPlGwA+YT+B}zqMMc)@&5<-PZW#qUyD=ki%{YEqGC?I$Z_(& z>xv%v?HrS(V0&GyyoohD-{3U|JGy5bX0f5D7JlSYYuB*r7#ZZlmjR~Fn)z8fc3dJY zn%Ge?=F&z5AI`cUvg4QHXn|qBmoG7iRE{W|Ha|uWpFV3f_~FI=<&`+?&r9}pjOf!M zTeHeVc;VX?vJliFTeE)WoXH1zY~cg_X(MWz{*Zd z@3V%`gBu-eKFTTd!DphzJozh1oxN@~1alv;6`bUJUmvy58daTr77h))iKVg4&?$Dp zhaMXKV8hM1Q>~Ic8$kFvYABgjv47H|zdf)O;+4a;;W+(CMhnmhi#K-`>G}<~?Fh)V zH62mn?l**}PTS)le%WB}yG(Q_BB7eEnpruZq~5H%ow zEt}$R_Df?Y8g<#-2lC8me&5UI1+Qa^_~Zyz&?9~w z@nhy9rot$3&#Rq@pOI~!2G1e9DZ;(zI-P@e>V8tzxt2q z`wOMjX($!fq%LrD$gx*h=P0=DrI?uM6SHk;4lVhc+Mo!gE@;;Qdo-M&F>jlez=zeW zBV1Y`;A=PhDb(uQ2m2@?$I~)$PQsFIEaUa~q?NT8soq!`K9gN|TO;zJWiv#dgBWt7 zNRRBsk1jp($#>+Taij%1b0vTzR31dcr|8Pih%pE@uV{6n zmREuj5A)P_#+@h6HI7a9U^bs?0`261Iql*dTKsfM&m{+Qu+~BfAMcr-&`J_?Ui_I`&O}uiyKBx8D7aY!7B*JsCgYx&2TM zX?aNS!wUbM@IUv9{?CI>m;6+IEtVTQb=bMs0@%YE-w&B5bj`Eow(N|xoaO>%-Ep`p zzlT?33guDNk+ouB+qLy#yPo)ITI1q}eHYz&lJ&z#*Z4BgAV4>--LeR@(4#~DC@D| zdG@EVi&^DtSGI{}7vSybPbAxKb6KEPFzs@J3uj+E3e-N{76UZ3{iP%3l>tWGSTsNT zTj$+BfAtTXoBhcC2KKq()1M2Hhk&>GUj+Go3-X%*u00tgcb$tz?!()8i_GY3-A*2L z${LLPB-W*H!LD`Vsd7qQty#5ky#?TP)`-yTlSF1Mi#GUeFubw92I$`o@g|<=%NI7r zb91y_%iv}7=-JTA^Ab3329bS~Piy2yQ%9d0#Up=$we5lheQ1fd+?w!?1{eHo*}>I)GN774CsP^9R27(TlBFZn`g_7K?wk4*gT|0I`-0I(fBPNdX(YO zPCB&k7^k`J%Qp1HihTTl?lH1Durk+;=b{`jn%}g^4?r~03#D;@6&bl=Z_6glDgF&X z13%QOC+@`VH;LE}lzQjSmP$K5q<0<41q4I9NCU(XfF)n!>-fU z{uJQcZ(nfb`;-07n1$fQP-z*I6krG%B#G+16x7$pN_XL1&|M4{N-)m3u>hzF%!Oq$ z*Me}7kUa`@woIarAvhAac^ptBINppC^MR>R)d zY1)mi|8n=;FZ@2sS{^?;jv;==;7b?H*56}X$-}^Q>%G3j$gc>qfjnv}kXLCk& z$tSd!1P>Yq{zcEe$u4wgen4J)4XY0ATjEcQ@!S3$`LjI)p-bO7jF;TxuxZwbEsU?0 zJQ_#zk!|^%Z}TG4{n(AI=q(%1hLRb((!*co$-^F_OCAza^iE9tkAC`7hPn0QBe)8R z1Nz1`h7}5B^JK_*DmGcE22NV?$sDZ2uzdqF^uc#2dyi~9bbQTf&RCB6@HHuXR$9if z?>_q1)l+OIt73-WXj+!Qz{|@DJD5Uvd~UhZm#_UPz`6fEcIGpwk2do_ktin%0txGY zv!ObN6etOEp{_&+hu$JxNYh2|yJ*o!AXvKySvXz{jcYyRH0Q7k1zejJKwez&sg+E_ z2y}WiFRmK;T+zxG$(V<%Hp}f7g0{;d#gDw&$K=L<>ttDEx*4B$4gGunyo>fhKEFcy zP+dO-KKfpK%kNW{4L>&mc>E87{F&f4dI<3U6`&Z?$Q6xxyXFrF+VVs-m6`5!c>`z_#X78J8z`7q=t{-HVJT|tSsE39=b~1E;QuA_gZvODW+<6;W z(P`UGY)t$juxHL3dA)M=q8l9n!R$kH&!M%143Bh^upgb+sOpGL9K;#SCCd<8KP*|m zlxHXGjm>A=uS0m%2Y|Rp5q$F^DFm#ZMzS(@(czPDe&s=}7RN|=o(lkealn==n6`7# zk#Pds9;R)6Es7}|#ZqG)8sfJ(MT6fVWbyItycUchJTBjBu^Cr}iPdMv2>NO=AG-n| zh-s5M=%~SKest)und7~F@1Ory{&Sn-`CR4ip|>~hM{2IO9$?(~&47RHpLlxutAE{J z44mJW?^Gtkt~sgdnkFZSY3FR)WIZ|ePP0&$PNEx6ynNz=uGcGl-`qAHx_+(NsK~tv z#KFI=BYN@EV{F5gOpc9xzYaF%8V4_NTL+t~F$={T5^GGY&Kl$fPM@(Q>fI!m-k_V$ zd}uBAu?|}t__1#y;Lyy|m|0>nUB1S5<}bD20 zz^7rKk@q}Ph%p)!BkM5oso#m+s_yJ3m^$J!eQZS*+V*>L0UiDJ5&7UmH#~BYI@xQ@ z<8O43wZ9dzvB0rEZ6mVAh>@OH7u?kbv+0Qi5%ZcJKTU@wA57D@_U?9SVLuyg5kdIj zgnrpZCvzt+b99l_>*fn}Pk!k$m+Z5)XwFmL6Q9X_A>k+T0Koe+zgJUGlYn--E(Im4 zlLmRqOoE|nlg&jO`o3_in@SWAXBWd48#Z*H6Mbyip6h|Yy|u~^qX%L9OtD&c(}^!J z%4{|DzPYd+zVunKMCXmLdNQg z=_RpjPVo5zgT`+&_|FCFA>j7MF_a?TjLB^hgZ#3QadLjv)&`Co&tIX`4K6o(%T-B? zf2VtO*$3#ncFR0H?9?w0dLw^V9Xa@$m1|=3UKk*^V-7}cQq1w?hL;DM^pc@2sm+$< zzb>AZhlS{6o_+*nQ)3RAZxA)~IX)}V<5%*90Q~L4<}H{R+K&8JpkfRBPK`W$euAmPH);*H zExX!4(N zS#Rv17nl74OdpUe^Vm+-0J8QC@jg)9$QhqKa?_d_+*ub0$i~CA=;YEmEtuSd9(`nj z{V4y)?KqetPcvOM=|kHXwz>GorB>+El7nYP+Gux`Q?PpN3|%+F)VC1J7yL-fi4kMi zi?8O#ft9K1iO69^84lyDfmywSLpli!Sut zvU$gs%{#|D804k|FHu`Ap<;d(x+eZtKE(&;tm86{bc2OVa?VHO?|%7@&HuFbIX7GI z^AN!JRocq`_r>@30i?tq3G&7M2!QmF$F{=-J54mFp58e{w(@NTOdWoxi&C?hg2~1d z?9|AjlJmN5?9k4QFuLXklRC;Dn*Czj^HU$0z44~@R8Z=@b`qt}7W zePrFAnM{)!0k&WaieC8a{g?={j36>8r9YgCO zi!LqL-WyH1pYRl{gmZ)5_*J}i$SQTA*w!&mfzsnL zmQSB4Z{C##C%tW>r^7L08lM`B#o^^4-yxZ!VbQfA2B`n=&y)U$4()S3{tp!Wx@B(i z7mvr^?}?O80sNN&_4fhzD~E61>5G9n(ws|jqg?T#?OMQ(%+?uth^=QF7A!T{jXJQ_ zx>>)4{J_^w?c)cvQ`#p&uLR!(*QD_B%EcqgO)XNx|2dX%g@?*6#Sa>Tx=7@dT{k zVl#Fv2euCQIYvK&2Yqm!^^(Ou$F%Fwh^WE?l?Y$1d-#Qtmj|_T_IWhDcxlXZ8O?*Af%| zx53*#_`9Fp>k|)dOk``Xubt=A;%0)Jljm+;M?GJeH#ciBvS&ZKu3d5C%f%~O(1+GQ znbW7?LvDD`;OupFNnSUmM>kh=Y2GAjIOo`fkny%*!QAjQVY43_JP;@r(=517=;Ubf zj%7)D7~4!-$L^V*df{W&Hy?!5@Eh42rkQipc`ppf*S=8s4QuBXh|FWdJ}g_V+p3*) zdX8*-rQcY{r6mWEXU>=w9%FnMde2|NM4pzKrr$IRoG01XHeoZTB^Ji`al?eDHvUNB z8eg);f}w*k7_ql|V<%Vx7h1Ad{IX$8WF$X6*+GaVk1g91ExuXc!D(=iC4PL>y5Dr{ z;SVp#KC;sWjnGptJ2yDDo*mGxf(q`tskKKm2pX zfX2o88tr2X=j7X$uiyx{9C^6$e;ULW9?4H?Ej~G#T67MQuW^I=sUhdQ_Jz&d8@2ji zcr7n^!QWaiB2T}Y=}kb@_Nb+fmvm13+gy{e(Kl$Y&K8>OG!xJ41N{RXaBp-TjMLV` z03@%kgJ#jClNK+ktV_M<0U@3h3abChxXj=zq-^*8Nf^6`7? zH;jGF`0OJ;y#(-=`L;g`v2%IiWF8zThvcM#HN56NxbB1%b)&i9&nkb$38E& z2R=`27H{u6@bgB{4+wFp5bb1a%^M_LFOKJwjm{VJ=RW-fP zMad?(%H;=6>XMC|^)z+D-pA(lNih(QWB`IdeZS8Jog2Zv89g~cxqS5+;iev_5_%S&K+a79# zC{y|<{4IYn7rg%F|HNZzo$SL4F?j5ekUtPa zyU7&6bPtu!{binP1n$>@ij9wL5ZRPtAbP{gfcTU#M)s9hR>ei;k#2jGhl2vr8Ao*-m(8$0cX00MpNsQBz5O6S0Fy9s z(3Nwsh2onn!K#tCee-4BtPkKKfGF(8}kjr75M^9roat6ZBrucW>V%vD)eW=Ap`ZRC+__uUV zl&q1&7aVMd7QEeq6Tj)VEaTWo%+PX#w%1;AkNxRWM=TOG8-E}?4a;pw$o z<+o_T-2AUGezkp?BnEu{2!QL`JjIm&z4VBagM{dCsyk@}>YF)K;U-bnM>~|D>ELS9 z0ozFfYj0RfAA7cqNspz6eYMbQrgQtnd>4ap>{tdI+isu%=t0Pmqd6AQvR^azOSC+o zSTK4_G$s!|B+a}3_j>oc|MDL`eBbt=hlUUF9@#nN&-o*rN3^V=N4(Jd5Wrsv{FDFa z?SBma&mZNEZ0-_(G5AbgXu-1WWiR3zBeL`vM@~EO3>jo&W}Hnu zW9FfOe_|yL$0YtszJWI_3lu*x9_BGXCjLrx`x?po(V*OjkcnTBp?{&Zv z$er})U?01&joge|Z}ZUukLDaBFM@4A)O^|=5AhnLF6m}`j^Bj6;7b_xP)PKtU`q>7u>F-YfHW-&0$G(9w+e!0BJjk%%EXlH|EF>vVc%?zV zCBMHPu=Oo17~4j%H?_fTOwU)+BOjl^w7vGpAOARn&jSGF;Z#8gf@yC;v z_xL>V@&7OU-V)>6|Gi*dwZNwjpD_PE{&=%`fSFC|Wbx?--+|z}0j|jlH>yefkx%M? zUN+9jq1Rqn+bXWx;e_qxVik{h>K?nn^?bKdwm&%Qw5{Iy)O9@Fd6!pQZqRff6aU5rn39`$ zX_VUXCo<{xVz8|c+T?<PA&J4M@J8_a?3=Om zDgA5V-ja`z9NVc}=DEo^pvne80%Fgy_o#au;gRY3Gay)T^H5`Sax?I zV=EZ+^~6tQnN(LpU7VRxTl{m4)oYwMBl57{c+z1X1>;eh1s$7U0m$1eKMaQoq7V@TmP z-9AMhiQxI>zha0oeC+2sOuzAvjkR5q$OLQm@W73pF*;7h@ezU8gw7b9XeL+NN958r zKCy7@W<1%Az2q60(Z%MWh~AEMV?sZ~H~aaxYZV_5$av$NvEsw&{|1LQIK5w$M$h}8ce(rnvAwDmU}Rc+~~Hw zte1_yF}$xY*OCk4bbSURa_OIeejeeaz4!Cb4y}OiO+P;laC#LCJ9M{}q&3mr3*V=!c z_j$j}``&xbIp!GG7<0_I=9p{kwby=K9UNgC-3*u94)Xr|#ghqpI)B0OK-P84G$38( z!0hs=tF<#+{E4A0Hc_1UA()hGQxkCwCoGdD1u}j`QIk9bb@~6>e_Iz=TO>LIFdUeb z@Zhq2FjI5H4j;G`$24tzViWX+8zFf>5LbR8;nkY7u(CiDdgZ`>el6r`WuxqcV8)_h zFAI`wC!Z~l##oFg6SPcHW0==Dx@P@wJ9*WK-Cj7ISKbBxByh*7I3pdNR_RD#qd34ejI?V z?ay@wIqH5Qf%4I~r3V#$t#Y4Sx6^s|b^`n4D|A8{9^!^GRa?*qm5?2vRN6}qDM}$e$$glSuNY| zwm$}f!DwRyW0on({?uc9vBVqwseOW86Z~Vlb;E~#`v*R^)w0`ZWrJ zvOARdtIO7d_mI^e_$len^o>b7vY|yrV|NHj&RTdDh;Yf_OCxNrPXx_Rv-kwos7Q7_ zW-K`xotm|+ZaV9bwC6EFndDshXtQnBeTF@cfh60T{bjk*bW?G*e%}N*3ZuZU^o4eO zZ76X^D`^tMz&WWU1{@hCgC;~Yc1NV+j3aDlOp3^HxL;??HVnc@c}*c>p$`b>i6I)e zr7p&Jt5~KnNSY9lvrhBUZkLG(cGQ)~vn)H@f6Rzi_dx)764NV^{lML;3U=l4zj?(t zd0QKFCT|U-7Oni0D^D$(ztTp2^Bm2`MQl?(pADd&4aSe*bN^|6;OF!^t|k!x>{I8h z<@G!MtqUaOh0pWgm)_8gs?mNHM8B%OG^+(ytM(*vz2w%LQ${V&Os~1i*5^98%eE69 zYysEpmh0+m7kToz%{Z<*H@)fu_Z7AUN^XA@l8Wv-*v6+}YIpE>?6Ep}-uRN(Xo78H zOwp616m73_>+0|O`%atP{oM&ep$6ny9@tbx zugAdc9#7Y|@=N$zva}tQ*Uf)%10*>&1nRzs|L-&H7ZG!qR~=NwqD}|%&&hJhCV40zGBGz)b4yBX#+z&|q=0@8G%Q zQ~Sp<)Sb?y)=$%w(>ORoL%)#+317tPqOKE&n!C!YSdGrh8R zBFCiAfe^lde~-)r=4v@(?a^C(JKg~K_R~MwKL*f2*%yH}Cga$8j9C<2wk-^e7goyf zXr2TnWiO=S&tOg&8ThYGHTW9@TG3&$bNi&`?hm=`HvgC}`Vw=s`!4;bdJ0(wv>tTe zOJ3MG=3KA3ISx1!{DOBap~wD*NBxQ1db@7GBFV0hQ7X1z2Y*jWhZ0ydK4tf7)F^sd+vG6tk*JQZavMq<(!6Q zI%6ig3xL`oX0m@2EO=tqbYeP7d_I^1w)1Kf*Spq`WrPNUtfs3E@knCEHvR!P8~B`*^XUYA-Q9^ zFIq9of!@JHeZ%>@CP~gHjcqU(&nAGQNYy!}6|siQc*3s1ZDoBv1R>~lbYpJs(4@ib z)+dn48Ra%#L>vc|HYQ9@0`0YS<`&h?Gq%GDtw7iZG}R;@f6Z5&o_^PF?d0y0PB~t< zCFmd5cAEtAVN87^kT(SCk-<5hv0}D^P9xA{XBc3b?%YGA;mLz{>hC;bezgDw=3=pP zAYTjS&^gnuX{^__EPGC3w{5R%Zm+ai89qE@433c3-Y$}i1uuM^vT9W|-qa4DKwFc? zC29_0Y7k4=RFrnx1E1_kz4ny)9c`)yezRMgh$z9Mu?zC~ScJ35k;}}+-eq)kC3zs) zmYzfN3mq_(B%M(=j|%nywo;B4?UePizsYYH=(f`|=g5so{s>!`d)ABm=x@fy_WC5n z^%0Z0`>ZzXj>NlOSr?JFz;?enP2{u13%qCpr_lP^j#}468T-NEIF5gfNcKr($n+`w=Mm|0LcibJ!!`h; ziS9_Hd*JAtJ50Mi^AFeHYX*XLIl#g>;I2v*X1-FDj>E|wM9MvtvNtKU&3f7n7|=+$ zKr=uZ@Mahauugw<(3t3B>bB58fAyDm#ilM75DQ(sp%u{g5?^(D{Yg*~so+4o=Onr(7H0a{2=+NS8{kxt9XAxN@z-AzSVA+H6L!Nv7Kt z(dcKkyKa8fkt;mamRh!~Y^D;4cJTzhMi(5>a$J+{xdxB>A?U@A65M)ST@WN!eyg5@G$Md<4(~aTraY z(S)EM*_T%nD4J@*~0Z!YH7NdSJ^g4dQulJQ+~0l=|C81u@_ZGJP0c z47Mf;{6iZ!v}zKunPA|Prb51{pVk{}wSP5O%eO4}{P<6N2JYLvW)ZIUJ7n&T2n9bi2)KkwijE*r1sLc&NHJGVb9+M zV!_v3lqrn^J)Jjw1)O|Ta9!+ub6+Aitp@UnpVwd~ZZJ~{q?8B4sXNQHn zH&Tr!Sn&((g(qrlE(VU~H`g-TUZ*11u_m)`PdD%$>L|(3|&p6x*U0T-Lw=6|2oXyJ{gyKUW2LKER!ut~~9*DqE(xz_^hZ zJ7ulaXJBf}w@T|zx?@iJRhegH&wukfhqsm;{7oa@O6E_V+7Hk7E>3@QzLEP_(dJWv z2FYX2b2i#^Ovk&AH!q;D*yrTDtk<~%76HM%p{>4bW3vfE8J5tJ3$t+@>>0CZN?U82 z^n_+(^|r)7ThrKDq$`SO;N`Y4_!$=|_r-Qe7W-+8dY9X!z1? zwAX|z?Z}X9gKCY`=-EyMd$hKe_8jHzFXO>)dz3?nq&`iIg2!XuI8$Hf?7qa7sC(U1 z(2u%s^%iCELSG1DsDr1tL_L%IvFc%SEpzL)pM(K6cIR;{ ze?^@?%+O|L33IpFc~!g{WCOzJaUma7*cqA>ZVW%j`LltEHao|TLrgUC#GpP-RPlwQ zEjP<85AXS~0=F5%fGiS&56&Y5peN$Ch<; z*&yhcivHk+wlqa)+;9Y7YlDwJ5n4H9Srd$7Y~iJkee7VD_|oU*t+rBDKRiD88h&gH zS{{8t;Babxl(DKOiKLC-U?Vt*0gh}+noz7yU?EfBg1J{E*A)TTa807l%ku`Evt%5} z2ZM8Xku^@01xfTugcdLBleW^AD$@dMbS;R|Pao-OjJHqDdN2$g*0D}c%+W6K1WZ5P zqU^MEVXMLCmwmAC5q%E;UZiH{8OeTf4x>gV1&paYfjoN1xV@q0yD{vy#PV@4_E}Os z4)IaQ$+yv9yU=O93tjNUpAK=#e7yaDs{m_j_-M;jbw7y>C5u-$PM2WpNYInEP?Qd~ zV|!3PkF(g-4g~bl)^~A`^I(xcd8-4BI@Jq?4gx^c7+K(GF-_YFR4;mvA;7O84*tbJ zyYI%y+?x&C-ln;LYS(m0`#GNe=SSc!4vucgeF|#_uDUp{fV@c5$M|Nn2@=B;vBgu>pJbWrw3IqCj26s85Z^Cwf~ab<`3xEY4|1XerJERCI$_* zS(2^UF!t5<+qaC5acD!Q?Q~vsbf08@?Js!54<2ptr^K;IC!9*zJmj*=hM2eJ1b_Ts zUqoB89h-~Y1>fRLG&UT(a<#PeHHeYwai`$Vkr92>F*+E^A|+quaoFc>$aqeVersA% z4|wqsx{qZT@U61fImZ-9$JQ_=#zIP5QP8oIc*ylLR?7yX`}{8ELQU4C<2LN8snV>= z!Oncm`c{KyT*$4LHv7)9YLnkh!A~vC_|IGoOZ|pO-;3>%4Kg=-ny#^eHyW*@_b&+^ zNA{EQi_T1=VgckMQSE(UOihcNvil4VysTf)d$PUckt~0%oE0{NGbTLN?Pp>@V_i0{ z>ckQ{;uK2y95;f48dOh1P7T&HcQ8qD35u2 z=mucZj9>?a8x=pN+s2-;0VoG&?(4m9+^nN(67pz$7^VRLOH03{QeK zH4={jZbwdQA28Ifpp9LB)n6DQkm&1wai;h#HUP6!7xY>C$4SGsMFgbR38{pJ1+e6Fe^j?V7^xFJJLio37K>w)-!hzV2&I&)@L1 z;(Gd~_*lj%X;k~Y8lRfnvk{w<-RZNxwq*UxFZcS(FPzJ#fAWiG`u`gbLyG##FMPJq ze({&%3ep!&FZiJJPxFoq3Y&-U3v-^-+!AvdhIjGNlc=xXH1F%>FIWn*@N$8xb9!-% z6j*q)u}vBMa1v`k{I%mW#;O^oW3pnl0Sqt=WCNw0>ioD@gBXqD9Dd8lM{r?B`bR?b zE$ww+g;!-{^0Vg}@QR5-k-jJ`K0-=->z}q2n<~CUu{GPluukKN2Rr+KH9u1q55A*4 z`>eSjNNAF7?J721t(fJIv%j3vP8o5HbJnd#u&H-xnHbfdC<#L{!Nh#9Z=h9O@PcDM zP?U1y%$xQIPYma`S3ac)SymJQ2VCZo3M?8lU)7nYMGjATRbo7PIGZIlzOhcWMQd!i zz5ehGz?R|N>GXr0C~m+LI4E~#bs)yUL=4)<<2qxp<=9m`fHN^P4Ax67Y{sV9@>3dJ z2?N@qhaEwXL1_^g^+?iC>P%oTM||pH`{EZ*Pv89Yr_+VN@yLnOEN>HYZy>h}`Cb|3 zPvsr!74u@5s{lXpJttiS`0C&AeEM7r!B;kP1@(_&BNajYIRg4q1n(ZqDRZ+W!TW;G zb6woc))E_v*hKWr0~>|hFles*{hvSS zev{9|M-gSSpeq^WDuX}nlbYOAX+scmR_Q6OwxkU{UYO6683EdSj5xS^{v#&dJS`G# zM^kGYXHXrq1eV8Jl=C=qyEitWTsoms4kb+wWyCVKokO z4C#yZ&Vv5Z4`kFf(4fn{$h{GO)^SQZ^qTBn(EEw@`JX`oRP%|zU`&5mNvgJqn z$NC_)TCGbb>N+vQc8*khQ7~eTEN79H3KKF#YT(Gg~ z;29DpUS;#ZRMo5E)Qv*fB)r51;U(rHzq|1A%fI&SDK=J5c@^O)HUfRxbIfO=#C-7a zO8?u!+KxCmI|a$ipW-cZJI67X?3KF-2_21X-X)0UVFgb>+do|m3K^|; z7S9}A1U_M;Ex^l$g@1wroS?yLGh|!w%lYa~+D)7EV?nc{70&a9wN|ZFUocfUbn-u@ zvv0Dc=xU%r4>{SLMoUJzLN9s6P5CyL>1X31mrnxD8wJV9@PJ5ctgtR&yB@i)zO=E4 zNBGM0unho%qd;E_!#Q$ldM<}Ag4fv*>TDU%t-77(+zu=%RUGN&uYBSOyReC=+7AMl zaNy2e~dCr{441*Cs%?Zd3XetJYW`|K=tE)S@73_f0Mra zer*8SLau=t+mGs~@>_an+lx)We2z2u=Rfwtf|O5dkV{1l+!ECvbswD{ z0xQhMC1={4BEKflSJHF?!IckQxlv&H>Sh1M2I1xJ{Xfrq^x!K9eB|&hev6_0D&x~v z#>Xd~&&&(vK6PnczV$f|Pw?vKMCmLgKpwfTICNa01uRnHQS>oqebjl8Oj9M^L`PS( zqwBWzmP1A!)`}U`Mh_ioFS;sU!gf`*#kMU>Ku_X;zi6xY%^FzB5>vU=)Ec8GOdC>! z6wH#<8ROGj-0)i83w9K%ja@G7qs@IJD0H<6p^ZrDkgTCYu5L0quDuXI1x?LhTipCT zuMJf;%JxU?2Oj}6+w4=V`mg$GnQ99&B`<7=f1D54B%j1bNB#J`<7wT)HUQn3rNBbx zP89OlQQ`O&JevdIsm@Y8Ci+^kgV)+k_auX#S9Q8)UB{~Gm>5h5kM=Ny7)(zqe}sWf z;RmlKDTI==td)sx+km!RraAAQ{|C0kG><=<7Uah zv+AtnBiL1r@*dkqnGd0U$tP1AV{^a{Fe$|bghRy}sZ-k=oY)+^`ri$45an@?*_kO%d zEHSOyY(e%KMNSZOy|73Jb)T_&-7yb&Uvmo4{Mdp8)S1Jk7uMbn<5c@QA3p7sbHOJk zO}4Cw?qB4z>4YVidmo|EXW5s?MM38~t4iJ=gp6&aSr|c6(pa^k-bK&3;5OvZP2IF8 zTBmba7fV^gf^y zzB&MHio|ehRw3Pz21|COy^&#;CWcDpj653p4ml?SQaWf;yxW?*^|PjeqzRz@r$6E)ltO_%X;f<8uws|6(He;CDk zB3Le-j((H#GSW+3%$%9tbar)p` z1H_nw5yWOwYsD^)mF=OWwyRkS|qk)=OVtdK_H`$M&{w*4=Ma zeAot{cHZWCH{UweIP_5gj$|Tb(3L~8j34dzib+`mz{J{-DzD2(F7Ojdb{1HMN-3D1 zqgV${wwX5;90mU1b3g~5B)-gBUjrrk?Ry@~L%U_#*UQ3!;MFAH&h5BKZI;>wV_)!} zE!Z>6EgIu`1jqAW3-eOj5q+1iYCFDXtY!;uWIn_ucdFh)uJ; zLi7BeV#PzOo_Iy0Y=DWY1%LhToHqq|r9fA`Vnd(>upIL`E|Al4na52A|9%A^ni*ZL z+-a0UA)L0&Ulr4F4;1(fsvo04ZnEUzt6*w;G(>94RbMHD4TOWWed=Qz8JC%J=5yI~ z_@XhcW;DAjBl_gc^62B$`J)^aI??TxPv|K0OpVt1BdVICU*N$55v-ErQ^@gWeXn$z zKXf&SMWn<_Jbo{3?`XqZ! zNGRXbw-As|YzI1#iM;s$!ZN8(!GmrmSzK7bB-PwLwah*9hiw3Qz*@1kJ0bep9IELf zZcUMNrp3h*lgGK~N1TIe>w!g?0T0l$A(W=xHkH~yX|gbE@Rh-n4S+Vbe%4=Ys^vbY zgB`+&H}?%0%4hy&f%xL==y~Pmf7KU*UC3t(_63n?GdS`oe#~?Y_TIpL4fYppsf~ddc{sP~87*`7X@xkfm|7nAZ zZy|7_A*s#_pZPl%SOE6la*wH+hit>){XBi67!~SbI&-$8za5*1^mU3#q%IO=j$tJ0 zF%1vNL7KoGl~-$0)al!aMK&(cC0++B~x@rIM*=QjK1V;ngN6+&i01xBuH1<=%A@N4?nLE?KYoDUA_Uf zcVPyW_#vfyD+1K`R8JdCl&!Asmvz()r!u_4g&4Ezj(0>%{s`%4RK z9_KoK3!jd;IF`L0`B?VBt#zgxgNPFN>apy#p}@AaF8b_sYT=&@?3I5J^l8scfY%7) zSjIlpIu=CNtEpASe3lOZEnlMZmgA!^mCV9Yn-siSpVU;=VfgV2Il3(>9|zGCwMR{U zE1zv^Qx{(w6+c&1@gH`jJs`85As>yrXIaJehTpPHId){d#qek!yhiw4bfOVHN z!?UBB6wPO?2l%-4UxO`QW!+uWWQdAcJpiV|Cx_#y;lQdqhSj8sNkP)W05a8>c*t9( z4OQcjjZGw2qi?a>>#{-u0$JLkOdlC!%BjnFvMWnSm94kQwU!oS+OF}LJF(;Y=(B~h zVvc3wj`UwI^)~1BeLR9s@VNuWmXG%ERQbKQx5`xcJ>{eQsF+)Qs`GU39Mop@qqy83 zN*plJQ8xv;;qZIDwO=KOZy=odhT!M^aWjt%!B@1qr@>(kOl@FM$b+yR&+P#(A?L>6 zL0)r-<2lN%+fEdl=Ca`(#K~>PtM$NZm)EhZd%Xiw(Exh#xL?LXUrlbe9p-~awq{OU zeWhzu!p@%PZ}wTVS`M~WZT!Y}-Z8R(kDa8y1b^syRCWMrBfwQ49;3}ipESl*ALf+$ zgdDnyysrAd82%9teV~3;2})Gkxc1;Ls#z^dpIbv_9kY%x9PPxFLj0o(hh##N5^eem z2F!{&ZJrC_$?=wt5Q{DfN|+7ip6?Ia0B{iFTox8=_YS7z&HcS2Fs!9$lY?+xVCq0F zyssu%I?yb2pzLV;T9YxHYrF=V7&u?vu`D!Fv_xC$?};0an0&24?S2E_HmEu3HK*L$ zY0Co;AEN7t?}Ek;0C7gNwdjI#B;6AWTR+64d2qn6OZ~XkXP0niCSD^m!+8`9`v_1q z2;QPgW0mvYewGi`u6X$-!`X)5fB8R7FQ55m?Rfl+UwL{H7`{PQXOS5oxVNV z?BY4@ym(OyyL^m#y{L1`K^_zmNDkg(ZX7XYFz;m*F1(es&bF*c*lzP#_eh`0m$k_4 zI=RV;f>pYVb;_ygfJR$e`I`?3-0h||OGF-lo|`~VF;-9C`Yr9_&hkV}f0N|h|M`zj zPkcPYCeMkFgaWf~0-&GG5f>}7NmxU2fVRD&#k{~b_D>y0+M#=_#Z$*3ducr3KnoE0 z3V)^%#7L?|9QdX?xZ#t{+Eu9Ou5S<90MIe*~Kv8Ip91o#EJ;cS03erz@h24 z0WqufAewV@c@J!5^MFCqpwWlIAbW$t-@Lf!|yan%oG){@(V8+eh+ zpK);L2Y}cW=PuZrtTm`T^h*K8i|`(-EuJGdM|ek_YqAPd$$Y8LBj#8?BEM$Oh*|5S z40Vo)dj#1LyXxIj9_3Y!d4JF3WBrn!>lFs{>+CQ$1Y9L}{>dNeHx}w1g1`5F#{-Lg zwLMBZ9_iekKpKK<+)L7i-p66k$76P^Bia`F0s<{w9y8W~DhGd0H3HN!4Bmj4PQJ3% zSyvM~<2zVcAG)!HRPBH!dbIOLnvHB<8EHJLS(a%kebhz@{sP#4LxYD&MKI_7rsp60 z-R&{wTU=#aZvfO}#*PWzy-AlA;Zffh zAZTAMucO54`lxe;`C6(`M%l_&+q$nS!%#k=7ttAi)j7Z~s#SKBq5jPLze5_ak3d!% zkI2nk|QZ_asGzVBFl)ONjDREDt0i}Cy@ z?uYuqpZl-R51R@*ybgQK*6P|7W;fDac)d<%OShFe_NqO~Gym2xl^*-SU@Lj^fSH@q z?4yN9uxR)p23y}SEtsyaJZ=M!2L}iFvYYh7lxDaGidu7M3`zFIEylx1*DfrTBJ)@{?8tJaPeQ_MH_GD5&t>#SDiQ0Ih z%y~r%#$1!NJ{pg8J+5TF#BWP*w*Tf2{~M=MjE6RAxoOkWO|5(-@8cP=AN~11a$*y} zhC}0)y10i=S57Buve#_jH7>alvx<+&<2<(5Z{wI+r{#1n37|Dbg+Ke|DHV=58scKi zy@Nl{58D8+AYkyj7!)ra1W3}j0Z`o&OdQBIf(C~mtjhv1SPei0+B_(Bp7|3-gIjq( zb=?C5ujR&e>J2-kf>7l&;hAtjANvL0Y}bfBl68WN9v^_z-4?~JkH@I$2Wf(K&4LTo zp5*a@_fd=VB|GlbKb^fz7GvwT&t3m=&TsYU)?ChhkV1}P24NM&4lm%hyU&L^0WW&^pc-3>$pq7dS%mCHOF3(?Pl#@l}d6ymc>*9 zI)t%a14b=NIF8M@Ij)H{opm!?HxsNajUxq1)_oOrA|rdty4Bl}F-YX%)0*6fS_e_& z6u%ltQc#uw;RkR(^DC$4-}7y!w-NYX;+q|6%D{h$8$U_57aijmGj1MB=R8Z|?VtR& zPhb2?|Ly6C&08e-CQS3y3b1NoOhki+KMxRW@zMq%{ehg@)$c;@ZTKX{sA*|FA>Jt_ zOvX-f*pL#`XQ>`nx}p*>^`7Zr8-QI#;-@k$az;3}edLj@j7i2Hsl7+eE;0txgX+N% zQ@%lSyJVwI@&Hn1a@PX|noUw(Crvh3S07wta>B$cY4RawJ}_;EyV@*iU9bAY3x5+5 z22aicm0x6g{`&Y;y11Ol6fhR7d(wCyt;y`=TazE@6|a`J_-NZR&A0Ht(Aee}j(C@3 zkss4FG9$)VzXkKC?C68hma$&(N7>po${nqb*j3)gPNh*s|1HVE!x-8SU8Gl^r|LD*DgaZF5)-!ypv;DkUGaKuU z8N8%%+@R@+lQCv*;@*LGfBkREpYF2)FieqfV=Tt9lRmj$YRvvnrl)NsZ~nj!P4v93 zS3gPKZ^0Y?iQS?^6HW$zB7xugEAU1i-|u;xjo8%R-v%H%uK7~S#VQ>vI8X;yFv*j_ zfJ0;S>@erX>h;88a7begtxsSoLt+jfY4hCN?PAcPk$}%{IAfo5U>|KMFq<5WuN_3@ z^CY!R?uTp$eCoXqvD@bffIc8>+unR8H=A5DzP&nTe8!vUs{H1wv`giSGT}dXX2Ig3 z>_quBG832u=@FO<1*LLn3<|4?TefYrjW~~}-&_AQuy7M0fhB^^i#G#58=Ha8{^}`y z_3(6JGeCeVHxT+5cAs_Qn=3W{Ewc8Z2+pl473J))-%(Z-!({mvPD~Uq#X{cc%sk;(l{lrvM$#FOO;*1 zxC=}sJi$07hUglsm?n{{$Xfy#&pOCWTVQsLU9?kd0|fQaEqnuDP3{5#6JKKADpUm2799=27y zYs!zbVGnly9fL4l!G?`MYzX2fi_V*YxMzSLG}30k2RcW5{Ie{0V;{vu@>;YYQgNW4 zobgv)RC$f*I#R~;%J1^H2>{2-7rE^+=D22YESLv9NY^!ruTs9F@3Ap@iqH5-Z-45C zb+aC2{79ho@#y(V8@7-@oh1kSS8ZWa7$vec8vpWd)4d$}Ma=YPJM98{iLU+(v_)Wk zrR+69K1}_zY9oUh%rbV=?Wa!jSR*753g_f0;A31Lr9^X$t7q=(OAp%sRFiZ`NH=H% zRpXTthmMnQSdI-2lCy9c%*o@4*F`+&IVmOYMuCPXlVeggZI*k|r#a1rtQe16_Sm=p za$BRH4^ZQw%W~s^2#@vo;On+A6G+^t%d$9KK z%(P!C2UcC^_uy21+wzFu5B45=Y{Suf){i3?kF;&e4$oEeAGP7uK96=u|JrdDn*m)J z$a@C9^p}77^pX!Bdg7h}4(77<=-P8#@abP1^Zi(Nn~!xH6ZQHzztPjM`3 zme(#rHwEz?a%0+B>-t>5W_%H21p+q}zWWbP0?v$jaK2rvj{%! zK1rfzp_8=0$#!SP>^z919HZcN(P+a5=>~>Va>=LpV&FUG58D8+V|g{mVYuhI`amb6 zgYe-Dl59rUpFz&=rGM$a`=9mT zA?_E@4;b+jJJvRWsDpH_Xq$ zQr{R8zTpw~8l3nMsUdl7hV1xZ7S-_#&WU7R`xBadrM^a=Jr3rN)AQglMy~hxQpUj* zsm=lEqCFVk!QYfK$*XD#FApCzndjEr{<{@tDsONe&cJ|avfvQ)GrC6}l^*n?PLiWN z`9z4fkn=YKAbDVLmt2;zwCza@-r3bnr{rP)fbv0Av8w|(AzuhC+h$K3a$Xlr zXVUPXi+BVuNsCXxuSNU3(rWoqd*z*#uU$OBn#p3%%qH{B+HBWWn|Zc!>)7Hs+Ey{$ zw~;o(tUR=9(H|H`o}pRQHJ&y44CCXVRr_lBk}bE+flK&n?KYD=Aka*RkbtUPw zjkU;it#eV=T+4M2Ipa#d?Qvd|7zdKh`oH71pWgnbpVp=b8Xu$FD|J5?K-pRR;0?o@ zKm7grXoim>SX^aix~JMKDCp)%Y?z+p12GG4rMfgtWsBu{knIR~dsP}6IE zWO1!>k1AiXXRCdy?NRm~!JXMRqaDF~U%V{1p9EX4vGJaLlm0Q#aTXfS8Tg90DUF{t z{?cFjTl&Ewo@1|j3s`sR2@Yk}!by;2yuChnz2WDJSvTt%0w2dd>quRXkXfu}1D`P~ zRxN}3D%oBd;;frKy_uZ#qZeDO;}y<8*cz9$ah}nie%Eh3z5M2{>gci~&NeyaYas_O zWwLYRxJHxD!fzzWHgfL^{*kXs-`dGxfbefj^6(XPis06<5p3#770XO=NQP{jL}TJ7 z<{p4LFdo=`r)P8}d>2k>e+qwewh`6g`lip7G;_9PH?F~*lUQ_oVr%cj(PLXuuWqviUq^fd2q^B;RP@2vvup< zn7F#3hxx>3{c9&D$U0zlWmyBM;O&hTeD)xG{>De6H~s}rSKie28x9jhz6~H`l&svjbP)0G=BdYh3&75#Bbdtp z+lHY9xfc7_lf|E%8~yfYOJa{V;WSYyCilE;8bXkNH`{^9CC7G&d7(Q!n#~V3L*Y7- zww2sv=p;{!R+CA669CNtzT>fB@oqXd1KfCH?9d#)gcYA_@d)7f3ZN!)?6~oz4#vpQ zxhHwSL^lWTwaGX~<*${idX8f3<-gzIRIR;Fd2b)?qwS1ReNxM3j#k1xlJiTvTkQ>L z`F@mtDSfWhAhh?GIZIzx^6`o^550Mc*GIFf0&%IjuQkETGUl~J$7@;lymqigxF23u zq(^V%_>LlYeQl&Y6I{ryC3HAxou#7BqT_3J5&YlCqN<9?FQlI7qXRiQ&;F&)xtM!A;g26X$aCHFRhyJ@SVAy?q2fOco@&do zFTIF27}-qt0LMT$zd%Zuo*BE0$GNSS+5yse%K-t4CxPtAPMOwvP=KOnoKDT4uT4NF zHe6vwZ+Vg;SWo$jSX^$yms}MY7sst&A(@cFA7h`l4ya<5ilff4{YZVw-Wkpw-Fq=F z@m*@K_O02)TzEC+Rd7dn4SR>ESdR1q0r*LK_3q_6YDcr;e(!4w`K=l*^{WS@P^6zT z_PqjeRiHmx;D3#~*15+gp7hBQqh*1)gKLN26S*d9ID)g{Ob+B!<=O{;z+m zA1BfoYyL2i)k8K@i$`uFJ&R4$P$0dLI@f45m zMMDji34ix&uMYc{Urv4DBbPUE6(EdcHJ4u^K#)7RfAVAWUwXoK;Rtc{H9iF-8ALE0 zU%CuDDMt}ZMG^5gow9*OlEE3<)Ja^Bt%1{6%%tjF-ZOXEbMkEXRLTU19MeQ8>s zrNE2R@()hCK^U~*Fo@Z}0cd`09@2r<=^QMQ2fo$L@BS=@ffRh`@q}@H=5D1lVI(i= z=Fw#FMu*(5%I%E+KbjHWsnsuKMf=twQ^hH-@m|BXhP8JBRe27YaV$ogYQ5^HIF)Bs z9(l+5Os3K<@mIU5TrGpqFQ1l%8Fr=Z!7gyS$GXZ~Dsy~PJF0zavhefT!@GuYiGBpT z+Hc$$&24-0iox-`Wz#tRTov$3!76VW{OG^7!`pcL)fuf!)_BuNh$^G_NN! zo3~q=ypKV!R2~4)?HBgE`H}BAz4_CBsC~yi`k)&cL%G(oG@|-tW>=-{;a%fdqtg%U zBJnW*4{DMxqAxg%GHbX_@Pen>j?AKtkCBViLUH67Q|w>(yW6r2Uh_FO25rJj+%BDU z@quH75K8*Q+yuzPsRxB7S#*TU{S3h@i5^Y;DuDcuyy0a^cD4r4l_^+vtz?kZJf}gs(X$ zs?WFhY1`tjZM8gN9?@sn>MPT>@~UIYmMx4&v{k#d@R<*L6PWaIO!w^f5v;v^w7p09 zE%&H=X3vavRR0kSuLrYv;ExETzuYv)#{+)A5Dyp9+L!B;ZS@-Ey7f@^I_9=9cVOx> zv&>P}ZYMDLc7Wj@Ea;g_}X$&P+g9cGXqCZ@mAy&lkDsU^N{z4e+0m!AoT&DQ|j}% zcXdO6qqIW{5BV1W1%Q(zdk(zSNo`vZl5ytj4MeKSac_eFR^JH7;qv}@Jf!UWCM$za zIRlA&yd1=Q5IVOc?UmO0)pTppm~qGn{7>KbsR;bvuZL+ck#%4(p>rY)BE5=_j~s-T z4k82Z2Apu6v|dJOA~6rQHnp6&dQn z-WFe$(q7kDv*j217Rf%My|3r;mzvv~{g)(TQMK*Qj8|O2p^cZ{WlP$`0Ej146Z^zn z!0vtaj};Hw002&!O^OyqTl@H~iP}6Q2WS%jQyY(POt`L=)1Ag?gVzR5HyGxn^gfUb z7H}Tmkk_ObSdCPITYQXJ;3& zZdawv$}@k@__k>GSn!OGJ@>!^gEZ^kjMliX*Ih9^X2zW5NBg+OU-i!PtkGDUyeVDd zfqpGMvTHWec1yZ9?k#=1r60G-I`~(dx)IRN6#vZ6ot`3?ahC4&sgHZmYNCd3S>eW{ zIUNjI26HWBJuY7yUbur!}v(}G2vU_=~<5FKQ zweQvUD8Bb$pM6CiRF1P(C*5qv4-v)d*}qE2HwW_PiO(w|%V0Zm^m?YHF@qa;tRrsg z_@)u8z7!wb##j9KY2D|a{2{aS&-*)}6l5>5J5ufE4))zFb&RSdJQRp;SUks9Eq#p8 zfBV4eDs`8g!%s+e8+2Z-lk*yrDaLUwe$MR8fM6J_F;fsDMnF6>DDaoMHUT$n+Ji2LwELYuz?D z$G$Pi<{)~4She}UgH|^MZ9`KAO_)1OeOy87W%;I3z=T}TFcmij{x1LAk^L2?!w$JKbcxG+a&_-Lw`X0=!eyrhb$;@;f z+VI);`uUvc57|1R+Qnqens%@_}+uT19Uk5yW=Cgty z*JX$cpRjhvONrPJg*9v{c9UFnS|H)7Fn+5qkb9ylIAHqx!1#sjUMB_EogbpOyyE2LA<^(zZVQAvw&AKOs z+Csjc`I{M8aO3szi_721J`((ayENG^wS#f#fP&}Rw1({&aLK-YaD@TzuHjYt_V85x zTgpBIS}YC&;NHF>cF7+D^rHIces9^frF+(9tkE}PebK=Kl{#+@h>3NaXSQ_>r}nRw z;UDWG#-;W>7!{Ks4JPYIzgh6F{>>9VNqqXuKRfaD!9`*tkedKqDWEX|KO2U(fBwhY z{YMLd*OfheKngz6=-)=&|9)vab|J;DsJ_Hg7TXFQwnZH9F$_&1fi;@=P?O@tBw&~a znU2a5f8HpFB{88l&LzI6RYx@Y#{&Ele=xvcf5kZx6pl(*FpJ||CMV9-?AVE12>pn^A12H9%C`nf=NtYAj-a$`3 z=RLl4zC}1|Cy{}B@B0A8IW6Pw_4X8>Gx4g4plxS5NYmb&k7MFL9Hh-aROA@dgO(08 zo^V10Ua-iGr5yhF=OxTRUUUzHB$sctvs1yEq{=ti$meQ&M#r+Ic;nL)Rrx04v{nB zw6ZuIz*A0DVoWr)m=*=q8*Y<4zsrV@tuf`?djHa`{@t{Q-!LY56VO6*yyNI_0(GZ; zr+&JWgH6uHkvL3s21Lc?Aj;A11CzX6%8y03IuY@FwJk-y2W}g5ENM=MZ86B$8h_{k zLwvw-!%f0Q77F9%jM7fkKpwZ_{FgDh9|cMtNNfqAmg@5 zf1l-QcP($(fXy{+$!)b&49f8GGCuhc`C#v9OKxmCItHV>m5%hWZoXOWKD%wKn@+yf zM*FDuX#c>dX~e5-v+~ToJs2}N^j>U)G1Av|S zH9M{ZL?>I&4C%F+hHW_x7?;<0 zA4RWvYuWmIFwfe|b8VaD$=7_ag8 ziE+fXH5=CGqn&GfraK?yX7$Q@qz`qS@kZb!HUdw7^!J}Wif7}0@jw6X_0!^drhh1g zr>|QxLk8M-`M=sZuuiK-`eJ~Yjv3ATW3C$kc^*KfzK-{p&>Fl6FrRb$qc-?U>~L9M zHzyPVdjR}d1^pw@Vf@1ch??H*8s{S7 zpeV?BQ95&WH#RoEn`}jzL`xB&r8r1kXVyr!EWpNpjyO6YcVLwK~tQFTpuNq6CW}bC`v_jOgc}L z%+P`y|3wB2=>s=Yir z`7R$~jYo|tdqVcX0tbht^ZE{Lbf#Dc^zF|&WUl0cAx$U9lfb1^`^0zqd4iU{mJ>fh zA-)^W`~~u5^5#p7OuE^`WBg|HPeS*x+w<>}VDD(-QQI%^-(wunv8Lw|{#rZq`WpQ( zwyoh*xh)>g`O5$3vi+vDFO*U5R|fc4AnUBT*th0mF&=!Y{q^VP72+PcX1G5C&8qNxSj!TW+5Eii`KYLct4t8P*QdLsO@#fgzw#L_LEowA;-o@*fLPTlI z%6q&zv){e6HFs}T#^nktU?b4ZrC-A;h?a4|wQ?_xfu0UpNX}?%7@D8k&bB%e#E@w_ zzZU4n9T@o1yHIqlc+;YB%FPK#e5J;}n8l?wr+(0x@ar`99B}v776JIy+K?S_jCVA@ z7moE_vfF&0r2JMNZH?nKeyDhR{0;wBA80pbO~$%LzHT!1%F}Niy!Q0@c=~F@feaJpIFUe#3?;S@ghlhl!Gcw> z;!ZTATt+W(DBe^&G*gcke?76m$m$q_h*OW6V-LFrVD+g*bTazdHv>`ka45irW9)Q0 zBnZt0fzLaoX}KmHZu@RUTozTr*ugvHaBno z!XG*5MnArj%YkMEH{?Fp_Cg%tKf=3L{~Dis7Gqcs+&ZCcOQnGYon*RjN?natyczGP z=xEPT`5C|aK5P3pY1jH_ug%x?+hedt&uqSk#(GlL0n_UIT*J5Bw_NJh8EojUJ~nRE zd!(-mx#3u=ZM8hpS=+~QwU;*AH`2RY@$sn3KCk1@+v_lR{`Uj0Pp<6KqXS3 zQfJ(;aPt{`Tlbei+Bd4ht0&0DG$J$*poue!z)b+45g){OD6WVQG?Lp}E%^ovw{>iU z?wcOA0kD~x9Qkbk+**C$;7d-Mc0|F_a(T}%=R6!LN7Kx!Bn?{B<#XfVfBiKb-c0E1 zA6+qW(q|lK@yEXg4tfBBNxi8I8=&zl|JmpL!vX`uHEH$0S^+!4zs5Trs4J?bA4S~*aj;m^cpI%j;)X1p_6m9Oo!Y@V5&voW91jIk%TZv8M&5zxt@>lvh7UBrYW z_LIXFU@{g&V*~}o1;vi2N4qu?YKr&*KmQ6k4b1Jw+63R{IOG^R>4t~@JV!=KsxkF&PYE{#(_}{06eUc914j9E=hPQX*k5+1AXd( z+eriCN@Al=&+N}ZYt12JNb8E=&n2Zd`E!}|a{mI?_>=tBi9Lb5ndF1zDA0%4mwVVW zVvO~petMt$AGfWy>fbvTXL@F|_vQOh_gH^lcHFAxeb|i-uFQJPoQ*lOTW!%d0pk9L zm#>b80-=-m!kSKjwLg+#4eLAML~S%hA^v3_blCHL0AxtLt+BQ{^#d1KMK0q7uj5(A z6hgy*pFjRd;7W9{Q)zAYddC+JNpf-q|Ai8IRMO(+Kj_h}jx#Wsnp>JzJRowQ+#62n z1DGR;Y!+pgEQ#w8M;c^Wqx@nI3d4tgaIrHacKfX%y5;yzR;GZV%%ErmQ@Y61Y z^JTa-_G>WLuts{t9{V!#9<5hwWXHN`GrD!%Y7^KahU=qV)2(}!k1{i!N`rq(W~&XH znN62us{UKcJ}xie?6ql)Z`RKl?UEgr+Sl}3&h4Y_u|D#<{yuWsxJTE_?wS0IwuN7B z;zI}P?IjG`0$-Ki(lcv&{w?t_Kqlft3%ZdzR%BiSF)^vt6%cxSa-8Tf$-tTGSuw<% z@={0-l5@UgCi#$2>}kptNHqp68tzcJGA<|oS@>Lrsujw86Zml1+?d~w^{RI!KciKdu{^u&2rxe<`SJDkaPe#e^(LJro%3&n;>?5gn%`@A`D4mR?NS6xvQ z;4ML)cIgm}Am#AijN+cNd#0Hm?}^{B=mtsMu>%!=lNRk5(|1!DjOCzo5}N^DJe>(A zbzULoTk@E(+9m07bJo~^cuag=wIy=UDv&wS-Ja+lFP<6X1q z8r~{X^;(D9$z68aT5g;z{#o0Mwx;7AblVBmHM+TSb4hNieXDJ6|7suH4{)_^M?SX0 zqm4n>zP0W?ebc9OBOv@F;W;kkf?tEQ;#FWS0(pxLlCN|K3Dsj$k0rEaY?5GL&XP6* z(I6X$lf=Hlx_Wc{3~UGO9%D6Cvvv7TXX{AOF`SYPC6_~^-|BNXsW~t`kPh(x!HdIr z*I(4D!=-{<9y-L^fZ^!_$3Wyubm1WxDVmsQ>=^ZPnFRlMvR}XCA98CB9Cx8JzDlcQ z>E3u7dB^&i=^**GS*O8hJ+@kEk=R zZqa*-?5sbY=isjSindYj$UC;z`p7%8!!+}f8^gJI$Sa@AwH=w6T$QCf^31SiG-Ss# zV%7HAR?C%VR^Fp~`gLK)fMV<)7kDZHsaZ`fgHg z@CEVu%EN8~^mi>iaILf9S~^dP->c>(LD!x4;0{kXsk_roV}Nj8;>h_?Uh_-k1=4bq zNytIdgvh$I#-n`o6~DaXq95~#Cuw;ivG8r}G{-jlFecNSkL_#qk$R;>AUR>n+E63Kh zjM!t{e0%d-<@f3_U+-5C_UhitXC1fZjMLt+TGO@1<}DqU9k-)#v@?GN0UJX=D5=G)Mo^r|m_$4> zj(CV0xi%@L3^*Vt9=3qfHBi-ySX9@x(4%|iP+~=0>bAnnPq!haw9n|L3bLmP)s#!p zjwd>)a9o`bJaRNnwY=7SSa<6Rjz!U~!uO%5bxK<5EJa`;DYPH+Fxzkl*C z`JG}jK);ZwX`JM>ZBKbkevQ5d^9T=PG##fSAcc3ttJqryXn|NQE_`xf92UFz4nnRNvQ^RqR+-Nk{BlvjrV9J)QQcuQ{K!FPHKbTZj+&SI@xnl(of?lyfXmdiJeD<1UkSnPM{zC4%Kp$AZXb`yUAD4^xJ#GD@CPD=N9Hfe| zr~EJ{56Z96Ib^k2CrDF{P`n7Tu)3#LFp7I|5?7=Y)eHm>T z>*n3cXEHO|7REjNx`ltQwxf2!Ka-i!?qwr5qyNVfk0ZJsZR;glF6z&<{Sx2klS_KX zc6|)+bzh^ayJ}O+XaR&6MRGjR<#|Za?9l8W-7nosqPY%o#jO)~OPwajFq!t~tM+Z-)3$~OeXVT^e@*5n z{gT~B+t>6PXRA-NAJ zq;l#~ObdcH8(k&~rW5?zW}qFfpPVZIajAkq^+y2&et+%Kn}8bFYoLbSofQSQ;SBvn zJAXC7Z;Ek|qwG5TYz)RhgDxHXWagXmnLmMlPr5WA*V@+YApr%gow8X|!3t_tNO=jJNvW8egr=O>|X#E;~oB^*OJ$%*tDQZXISH_(&_P~^!9nx{fF-h?$-g=$0VaX8e$-8R<8Ur9oN#TzKXGS zU{{{KWpwzpSLP_+mVah_oY9WzJj%ai;~ExrYW^j|H5hByTl6(O*t6EQg?F@VOV8f1 z+rl_%&xgQ2_FHSz4Tjxg9P8YyreJHe*JbCG_fnaD)^v;Ad-Okj-SAC-RDKTHOM5sQ z*LoRo#h3moM=c8~2>6W`a)d6Q(T}lg+)Q{QAQ^88s&npKzQxy@rU1+)U`(1&cr0H% z5EU=F*xO(y(BnP+aV~{O!lO_o5iJhxG&zZu8xXfko-_Y1zB|4BbN}9n8~zCXRk!7j zX23)k5=tjT$f-eXUNaLj5^>D*G+Sz%|~J9C`lnPZyT@(O|Z z7V+rq{YL73NVYU{KC94=0Tnt9@aO+@Xwj+QbDi90=yoVq3%pyBEISAqc?|WFy2O%B zw45gf^;wb(kN1t>?w!!%WRXqfH&F(e&wMNXwc}APz-_w?RVI9F4dB^&!&-bA1 z_3u4&-3!C(^d%pe&w9Dh`&G)|4ZUvyB9J@OIDmHw@g|Zl?^n9kuhOmt(5`6Ngf#Qm}nKfoT=+a8*&u<84P@V zkivnzOhY8=ZME0$I>Y?j7fx?~>W7!Xzw9VqOKnpwIrCEcHu`m~_nRv7P0AMjcox6K zTYI;~x7B8hJ${B~51%nVuKbf=<0E`C8)wtrhflV2Y_(nM3-|3>`BBa`Iv?9-^(`B= z+GbeqgLaKge7vjsyS_F)1|Z;dk&5_32Gns z;S+BWuFhY`7M_9$ z!>-YJjm(Pr1sOZnu-E7dfE^WZ_F&C;B%6!Z4poO(X8HiwGroru4b14>(lNHJ**U<< z$9s8avLkJ)ZX2Di@m_1s8eXN(ut%Ef!~>TrhRa)R=DF0@+Wy3|0P->KmUnR0**C%2 z0lKVXiYHNbOzQy~9`hrrpq}xbfS!%w&vt0&(qG6*FKww0UN~N*$;TJ3cy-j9-V95mFr?BwCH4X*uJB?oH@!Fbzy-a8gWW}Y3G!DINgr-Hw^4>Sr?aJ z*pLwXpTFa`pPqm6hh*bZ>o+h_W=_>Z*VgX)J`zK7n5XS?JpR}SVg zn`Sx=!ai)?V%yB-kwzU};=$Nf`(nOIyQTcZR{^<{PV6XGo5571g6>Q9QN}*Uti}`V zHD!EyMG85Bo8nnCwtLxNZb>mq8qwP5@NS;Oev&={u+zMgId|ADI6KD_%eYQ1_b>>p zqD??;*FZ-5Ff5BfW1uwv-}tH1n?L=B`VadsxjexZ8-|5~>nh;ZbdQ6!;!(E!BmI+D zAN_T&K2+Q~29=JkTCe3xc2vwuykk4EGx?Er#GX+{t=|i$V%G9ne=5)3^5}z^+#a4$ z4jt6Z$(6MGK{_ty!XD@wkKlbu%jg@WE&jM#b91$-MKy{o17!+7CuRA5t`x^xu-1zlT1Opkny0?Gk$6FiU04RwGX52!) zTM=jPoy1mp>i{thYhz&@zuxjAV=(%EtlM`-FfHR8*_r%|W|>FlYyI4#_mOtb?7CIo z9{HI<@cIA%KmbWZK~z0Fdwg?*XS|2zi0q?z@yES=aA)jHZPs?o*`fvEXZ!gv4t3#T`Kwms+v`jLnIH1r$aOMmtv ztxe|Hz|Cm*h|SNeZDzxVv{~3|^Q<37X)|5WM*olXqk29x{#n1V@!tJh)3fD|SwN_E z&Dyu{+_uM-Jv_#{XI}O4(aO=|vC3Dw*65Yzp5;1DdvW)Elyr~2=wU!tH{LgNkog#Eyl5E0ufras_%I zN5|L%^j!_jLGu|p{jb4Wola-8;6LEG%7E~)<6MUnPDY<5<1hW{>FrPa;K>jA8H4{3 zM;zhBO!y^J2c3qEbV6+21uCY8~ zj_qUpDE}ybYyFY>N8i=@mfqU7Hy|AKL$zg1b`Q^*>`1SD8r#Nt)r(wT9|y)Y%qm|+ zps&ePzI&D*sqe1X{;WS^NRLqwbcsWY4MEgtS8O4^)HR)N0Fb~T4n-mY1ye8U>H;;e z9n@QMh%{QDi@(1mmg+-O=ee?QS3iz6j1K}@^eZ|=NBtFtKG916H(t9WleN5o5I^8B zVMaxvJ{&6lq?6PR-{<0{zu)t1r&HeXKkM|`fnytLeuUR@mYL}`k8^m;UuiDinpYcY zx$3OtTlKw9dF-xzs^x0OJ^P0aUPm#H@UQiw_UULD{YPv#ioeExNoVcr7Vo1r2y1p< zORs&XeY=E*T$Qc-YxK%9mS-|Y>>2T>&oIq5YeU~Xbk4A?mt1-x9#maD;KER!2ADSi zx}8{}c+!GQlohN9IC>F}#H+YNe9wSGGnkxb96FD7Z6>58q>#;xLB~Z8 zIs|IK`{dO>_d&dX;!Y9p7dt6C*`37^rN?a(5RE}s@~$fL^X>_j<|*Z0if8s6Ol=KY2vQgh1|Vty4Hp@+dgjk=$G2RTAtZC%FcK$(a>>8 z&n1jY?G+!J{kkM~kM=bmaS48{?OMIJ`10PiT-%2&xjK$peDAC6ecN-(_#W|z^h9i^ z{d=|QmxlrQWU=bd32G1=hY0$5nJEEhahayZ8>NglieYRJ@`^)`h2Sb`MH?Z`F)ih+ z?h*NWIc9t%z&RcFWtQrAciavmce_6xXq5IY2jb~S4*Kd)y)lDl{D1mKPcNVPP5R8= z6QfD3zt@Sg&b6lPbsk+lU|_V2+*eh;7ZeMtGN@9(uQ z2j}%8eMi5mKkn^^d*hA1ID)^$yLMm1mdt3&k@}->ZNtOx<8i<*U-4FIbT*#~dr;1R zOn7k&vP}eh)^z0XcZMJ}cln_A^4bm|0eur8QIS6`4v@b}Rne=)>j)3?Zw2;`0y)Rj zMF(R?)ViK~u5d2N{U!LDf03I(c&*~gC&<6VrGJ0!uRA{>9!#|#wR{lfA?$U*dx(M^ zpx4@PPhYOtutp!dHS+DTZ4b|=XI8%k2YW}GjlD*9`!)F*<{JGP9k^)djBU-c0my2w9!^BS2ism$nTVzxd`rPZXhtJ`xd;S5GO^>o*pz_2osi zvw>R(;R|;x9MJbs*>N`^(;Y~KfuJ$|Eo0}??4*bNeCO{tJ^k=^i!|Q!XTC8XFKb@x z#|ZF%`rbj>%l7~iuY!D(Z$A`2*be7cIq6CvbbcIquQc~2vD(jCz6taO;iEQe9S3Vz zGnqBs8@h5)bhh?t4b63Yw5eX9sBM(%YfCYG7w?LWDqD5DikVi0EwQCp=KwBe_A>CHAwstVSK*B=l**G zKX};eotw%KOR5v4kPjDU*jpfb_4>fr%fF@b9&KCwytj?kMSg4AW7`p)HQsyJTKj4r zKwHyU`EMy#U6*i<@Ls}ld&PW2Un{j{P(g4%$ll>N`GJFE&a94YbbV&zF2Fk zd}F_7{S$x2f+}m?OBl1bPy;+?>Z%ii9FCHfRjXQT42Ve%`Y^bz9H2p1&kYc*HDowc z(691cE@NU>eq-U1pRR^yP#p9tNDuy%2ygU|SNnh6OP}rcT}6?vZSUA@#HIf?f4qI} ze{Bc3v~{g4rq!KpF;zu;Q?YA#gc?u-fQ~3w=YL+ zo!L@p_w4tjKDd${p3ahFj@i8rS-qS3Z~FrG1OXyfxp; zZ(EQZWolc+zNUN+tUVa~9soaEFP|y&I105Ou2|FJau4LaKY1gc>ZR<+MZo8^NMQNp zhQz1(ia%%t+7fro$8o$XIAFf<&$$(7cQth0m-e+0Kz0`GMK6L;x8un9%HNwG{N1P1 z_k64F`nSHLIaqrp(Hd^u>CSj6b}i3%Z>7~izE;;LS23|~tl!h-Jy@6gy$55)Gxodk z?CCQ+d-*E%-ZJ*>!QYGb2)>yQ?`iW4e@{QKp~_a9Dt(4^seLAMD-E5zu(R4)am`a{ zV|kSQIQoK3TPI1Q%}49R7xk-L!uJ}XZ-N;xpR4bfWAaMq>9OW~89OA(SuK$XJvH7CJfrM2eXh2=kMgxP*S?JyN9`Wv$NCmtwPVId z``&pplOJi-Hp;a=@?NUnB5UlsU_>x{R3gZd9Q0>;^n*Y{16O_*n}K3t4dgRyw-cjl zoQAF|)1|+@YVAw@gBkCwdTActWC)j6NDj~yOap{-i-Wr{II7SsXJ`3q6F$KDM-88g zNBwf?-@o~6!2@}>VUVfxS{~R|t9W822hIH$h1X<8KG)Z9+`c#8t9LKoUcGC4^-KYs zdlS5C{1tP=)7(trY|JYE8h+(@ALZJgd)Y+aFZKVLzKxjF_u35KD1VI&*T_`8d+hCB zYraO=$0$QTYhP{3NE_?sdsIH^8|&BVWh^=775Fpmv>KGxvB>K~J?2cq2G(IUzG|lmK+6bT1#iGN%F)pr8Dh#8;eW!mN|tDV@2(v3bcwZuW;H zzw)`$n;-d})6;kVPM!UuzYb89r>yoQ*7EGYW*{oPmPg#J`b-X5ojjxbOlIV9eYW7# zNo2k~`Ch%1ua0>w@6or#v$c!glG|znbB~SmZ^k#HZRxvb+f4UEe3VAd^xe{*I%mKc z>qoJ%Z;y?8c(!yOu@l}S{9Ag*wzYn&(MS1OpULdCz4mX0Q}wKskH}Sh;EnY)9V2~| z!v+QI4A$)$KskqxVqHgT+1Su3y-16$w%{TDo6mU!fFM_ihzU{E4T8|wgA5x9>%BEU z;{U63`Llmt4X7a90ZCl!nDB=-U%4kr2WWQ`ZMq7On+kNi{N}GZJ^$GE3dxuLSrn^| zx*=c^@UjEONg<$on+4X|ZVcyc8|$Xul5b(o`tYcUGwVauU&}MzHQHAH)?}bxBe&Pa zExv2?ZuLWbVWC(_=-aYqMQoFNZ6A>HYg56U8)yBW(MGj`%Gx8h9Y{n@`a0nWvYgTomlj8LcTEXejwacN&v*K%WlKC@`sSO>W_)%*aQbz8!V4WWpD-H0qe~+=F)1 z=A$|*el1_yziZ`Yb~6{NO*0+sVF%-1=RhsHe@FEm<+q-i zgHdrjS1YaZ-&!7Vy`DP1H-5z%byFY5rSfl;XX7XS_}IAW(N*P$IrLAj2t*1;8LNUQ zG4~(;JFc)|*A0SV8dzMhZej});TJm1=<||4@|wPozE5Spva>cLZOLAlTT3@cU3w$5kqpn)N1;66V%3e^$wp(?s`ATOzqq3cK z&DEW}_hP%%4}0tw=j2!)F}7^lY8!Em)>;3mZS^YWQLHWgSs(V&s(o8J_O#stYsAnM zWa0}ionp3kop`F=ETdgPJ=Sx@s31eL&()Tl=L(eN+6M&It!t#nV`u!EdKtRVeGKT50-|)2})%(+0 z_$BI`OKCQ?Xcc^=G_;HF}T8++#eC=(~q+-nYI>Hs8aCmvnHpNWYKt{V4w>8$Pu5_u=>2=Pi8- z{R--537w!4?1RMD`E;2-PD&laN_gr*Zi`C=oZRcxX}Kw&MXed^5jIUSae3TV=NuQy zzNG8ArEp{(9@fLoYP<^I4grQ^Ngfb(U2+I*;iPs+B`7#ijtu@71$fl{(c2T>`6uZh zDUK9z7CK`V(#U;WIyefc=qZ#!narFBYm`G z!%XiMKYgFc9P!Cf-Y-{uzC?S}A4mB2`i`{#-o5+Ks#&4J25qKOlJH)%4rIVt! z*;$<62mko$0lT25PyMF;OMhzyjXEn|wa;bemG46+SG#JTpi{53YHyXR<;t^GUc+4D zH~m_944MWf8laYwKnQjOff?q&47~6AJbU;0T=)9k`}$pbt#z;a@Y~Nj%6gvP zUTasrVATsF%QK2nw*$?QXd}QTKpTM*Fa5DM{*gHP-;!eIP+9pF zc{QHwcRcM?qhAYKvNz&mxuqxD+oBVEExq`b@)28?WQ^dmo?IW1aU>1;^^;uH$uAXe z=^{rDN%>Se{_m(SqjWV36}ya6EVs(u%2%@^!w_+qXz z42l;EZ7b!|>gaJk7TwyyTVPtpbcTSO&4H(&PBu&k6A7*?^uC!0i#{^^pNYwQ;y*H9 z6Vugt^8lO!D@inp0_8>fgX3x}VhLPzE(ch;`q+>3H~x7g{kf<^Ntde9xwOFJYOPVl zFL^nCsDZghPtC9F$FJA-MPP0Ftfk*)-DSA(VQE}#)x0%6Zqrt!8~HRxXfE7p`;vO; zS)w}%KcY`Gqdv#Eh};AQ}zL~!jHAO z(6Ph=euM^Dzn;b-(2M6tylYx|GHdj4T-ESfV6pE$3;s1aiPu(p*Yd8_v$VL@{ERwu zra00?Jn&qPw6!vGQB1M_wC3_!Uv@br%I7v;@~KRVJ@U^s2wpFG(H|E1Xp8#`T5^)D z_s$3yGn<69VR=zyg?|I&z*#8|K;dkU;FDhcNA_u zBasC5f=8zv?YOanCKYGZp1u}t)CLq~+AK5I&y2H5ZrhBH^{gx6D5ERuTjBvfLWBHY zPva5D?I6yPc#AgXTlu04$}PJx-P-!4`!~?TDhn+vlJ7(SL+JL^=8q^|j!U=EySoXs}kJ z2^aGv{ViGWW&d)0Np=h0(k)Tlt7RE#5WohtjjgrX|{kYKOJ4fj;(+5&jVx z;t}P>_>A&Lo>qBO)+k*|kKmF+uCj13@8KfdXgSJLTxCPdqrAuy;jz4C;}Q9KW$)`O zz+s^mJ&QI;i@Gn&WwGV}(GLo}B*+zcRU_?bb${Ivjgm+#8 zMtm%1deuj0MBB^P#15Tn=zDsPz#SEXBWxLwu_ty*yi52I`?k=mZG%id(x)=dT049E zYvb0F73~_O6Hkj?aB`K!6Z0MpA8UTrV3JvrCmV7uo~68}ug5DsT}I-c4uGcvVetI-HIpT$tFqpw5o)qRzP1m&Tvkpvsnw&}5!H^|kue((keD zrNaBHeP(TjLFshu3I2A0m5c(Aah zV_iIOgErU5*7#B5@4=p>-+Ohh;lC7ZFSeJ)U9w;7wfzX&j*vmSuW9?OejlM@kH1m> zlAK7}@)sE+bdi58edHZ2XB{{6tUE7lhJob)legK@-MDgv-v%h=go$(Vm1r3Dnl6=cFk!7cGUO{vUt@1qg|An71 zfy#6L&1*mwu&8s=biZK*-Kgy;hom=s;wPvSq$Od1d*{Zk3#~jzvfh}7PU~0Mq%a=%7`e%9- zEMKA(=_uVA`q(z&Q{Kagrj>h)f8kv-`s@hX)}45J8cP6UQ;tzQSR;&?b4K-nVq0zUslo84=TnX6EFhK2k$tNo z{tUELSIw^)>`d80rN-ZjZ5CJK&ooEXd%AkKp8hqyMmbs_B1~m$iFozbt-5Bp%%g*8 zudYEgLu9?S>ERHLA4@cDL|B`+(_cTwb^drev7ocEHR20g>s_Sr7u%KfjOA(zO>{=np!#fgWRRGRZo_i zb@gHtZOn37^*wgVwkPQkJevdz5d;kt}qz^kx0IE*Tkq zsk-d0>CHZf7I<8YwE(m5^Yz-CSg+u{cO+gGaKR<>tY3A8P3PwPlO^KPkcnKYO5)H>)A5BRNo zQT7%YiZgOzIm)G+W#@XNueDi^9$s|!^n+WHft+6I$?x)6|6P-|=o#@1xi#7F)?&V7 zb4}I~PsSf1Bg@S7cym_9#d^<{rTDh+QD!W+=%YQECf6e^vZMTnqbyvM5zCprRbSH6 z;>)^ZPmBIYxO8XR1+VFXR`}bWf2Wgh1W&rR_-g?+y!#b~JDY->w1xp^y_@T0Z8N~k zf~Z-Xfv58XGi0WtfE;Zy&JYJw6pI5yor}5k#*sGFbVL}svfa5}v!@1&a(4dfR9%_Hk1 z-vn5W2*0ThU`W9I`!;di=b)mc6W$&!$Q~^o*&g?Fo&^%@nWN^5HH!ZNCqbKky!hv{ zKKbmmzY2I@SyjYg{c5su=RkNXNSQX*W!T7uLbPjh*+xF9r)B4eZs{ALJ(32!E!}JV zS)yOUFZq@2T#}dRqx>E&(v6mpmu0qKOEU3m$v0#~npj?ve>6V(E%{3}wfMx(;wqXY zSxb0i)@W)l=_i+d%FyPyZFFvce@%*dq7AW}<@Ioxc1u0RO1h$*HJQS*3AlY^T(;11 zl&^o{p&nCQ_<R)fSOXr9k#O+!56X|8&0?TG}mY?fcFt$MxZOk-B)*nhw zwi&(8N_VzpslL|E9(@nDWJe8;JbtlX2^Q(M>Wy-vyk}XKO`M&v~sVma~xZ{Z@{p0Z+sj1gUapvrnwZakkCbzrs}^P6P?D+_hDQHwlq z%KkIn40k@06O(TY{NUfV7-a_#^_;DhPe#0o(%a?PuQthc%)K)JCW>nv{GT!ltpzG3 z!i`Z^cJzP!i63)1z3LTy{oi-ljlf5K$_&WfCctSj(_lS^ekp(%pX*tzgG}1KGACY^p8ty*?OqVo$ay^7e3% zu2znEGJcKRHMCjJ-g--ShUa=qZ!ad1=lXYly1!jkFkghrvwoCWn~>#~tP7FWE9K(# zdRd>m92W7jg*>cb4!4cKyaKYhU=vboh&t9@YLQ(_`Djd+2 zg2MAJp04fgegv(0!L5-g+t=_!Tf`q>z4BH)J(&@QJ<+DD z4_t;t{w-zXMV(7B*3i~up^v$IiytlB8J_DkJJ!H>vh496|B}>QnMqoTp zQxFM^g5z+|+(f~%|G)jW`1OBI|E~$eEDxm%X{l4K4bWQp1@mTf%}!vW`bOzyg_T0h z2FQ+(T~XHrwd}|Yd+dUDNzW2K+q0*>M%NnJnoT48BQ#O=T6%c5@I;-X^T1v`QRhe* zee|!`KLs1%AEAjpMLi?DTWHY793Ay;k@Ju|{$v@OfUka&HHIxrc0HcA&T^o_VmvSA z#VD=m=(yqJcf$80ZUqk?H)bhn95eADDQRJ6@X$r#|M-L-dU^OQ+WwPN7ur zlyj$~N6CUEw=5EUDJ02 zjCk&`3*N15vc~>BHje12`H*4MGi*fmv!GdHr{)#eIl?X?c>BKANS$xIJ!dw>JvLz74H#^MzasJoB9{Zi} zs?&h<(!g)W9LU>Dr=b#uBfu93sum1H7KyZGk z7#)eHnrE3NV9S7ua%(n3`8E6~tYzDh{!#gBcK{L3~k(F?z%yC%2Ali|58ompnCLsx@| z|EL@tEgO%}fqd3mHUX?V?vv%SPTK@L`U78Z;*;|yJ~+qXU($43ZxsWb-Ha!^$sze) ztIpEwSRcrz9JKzOQwftTh6SwQ#h{xoGtzepydki)hB$^D!*yS zOE@!kkQLTohdRLuwkqis{ZWu&(vu^a$oGLXglUWoG`RSV14{$o#c>wl&w`sljTpMqxD@kJgcGLH@k9ltozw zih9EA%04#t_)Fh-I@tl}m7NV>!S~AoUYI0?^8)*hE?X>zfJF?{C7@=Adi@ zwD`lR154#~Y5SqIw%9MVNQlC<@Jl$35#P8G5blcC{@RX=hVj!h540PS{xx~@G4ic# zxRxBrT*5;KO{NB+k^ZZtkM zy6kI>re%MPwgzkQkHGiXHzL19Q}ZL*0-m~lEjcxKlvBg6f%Rn7d{`rUD=m6i^gT~P z=1suoe(LG^^&f9%0qk->crCE6Q?^jEaCGQsgT5KCwN3QoD&Nsh zC2d{=KPvCF(@{EHjymLgI>n%De4@i%=4`=leck->A2r&DG0&=FW!qYhY$yhqo_f!A z_m0EIoJS|xf-jW+>a(S=LEN`SP zJC%>{^k|Nf6LpNna^^p% z5dA+$WL*rQ%zO1D`QqD;G9c5q3?xWx>JLMy}%28(x1|E4WT;v}qA0=l* zmuOn?5Wbf8c<`Zxe-{4L>^~aT@;Add?8e}Wx7Ii+mRR}n zKQ;ln`Ok?8?NpvBte0mgRnKr6Di>>S0vm(No+dk^h0-?$77X6~@9D!{?U{CD6R`hx zts~HjM|Mbq;HqO)h6%SNkFs2L=`1~2k*7u1qG`dSoFyMeWJGz;w|Hvs8Z62?vK)2A zvUC6|JyD=$K6-hgDPRH7vTuRy5OhuVh+z0yad8~@j-VDUwn;2YR1F3Wc`clHNAtCE z*3ysK(W6_^*}@+gr=ERFxK`h3@u|O%tK6bpf-5$>BjCCJ`b($#0kkd^W}T6Yn*k2y zRgY%}Y|)$G0+xI_#&1{yk24lLOJiC1{So;F+ZOQNcr|s_1W#K4MPfR&;Ya?qIhaqI z^ZH+V{$EK!RlSv~;ad0%hn`=}&YG+m44INcc_h{7)uAGPwyUK-^0a7L@W@NKN7uu_ zn+GlQ{A%r2lOsi>Gdu)E7@ke!-XxE-HKJk-XJs*3xB^yS0 z^TJ+}3k?IKMoatE;59kGj*hpWyCh2%&#}+KUCZ~`|OvY?WVv-pZ96g zQvs&jmt*^bjoD6zm4#k7hv8E>gs1LD_by8p1tzbk`5Jk?=DSiJrN2aq@xO$8m+41= zVOKK&Cv|a$!|4s(0622`U!CrfPGs3j^)*36R+fiP@(Wq9Og*L^F4EP?BfPX*Znml{ z>(nG2_1*KS;A^~>hK=}{?cHKiOHY&?%jlqy z$qwZ;`Uo4Pi#(&{XxAvu9=a|1MtRn3*!TLNHmRK{+#~x4UadjbU-mivy5Q;k04CX{ zJ1y|i6Oa6T(qtpR{OJq6QL$-Y5ic7A$7yzD`gGhKUS|cEf&C!a1sHy0LN>C|G}VOLDg0rMtyj8yngLa8sa{pFSept-D*# z?-oAY@TbLHn*d)QSq8&WC|cytm&YYkN)O1FVzOBUM*A*FcP|u4ewxD^@qm@s%tr9o z#Yenmv(8aQ=~JHh+-?KbI%KVrWPaq1&`EyO*Rm<=c*we*$WUI>txb&Mm_%wszYg0J zfwS1IkdXTlb-#wGqmUrWaiOl_o_5T3qa)iSJ!|3OJ0frCmh2_G=yE>7H$s!;ZmEyz zsV)AayfwQrFa0UQ;1jIX--NH}$gnlEXS|Jr9TtASDe$ZR!|;-zvRapgn?;=dt}_A5 z=}sYhlI`~qNO=)O)fawv%i4Ihv49}ZQr(+2$7)Hj7B$}r1*1Js)Qf}2nQ83{fG0Zx zz(PaH6Nm%TpXfj=C$h7uI+^k_q*Z!y7$7}7vXEOFNZ@L`akBt#rpa~WTq0?KSNzdY z(;ew3Lql`QkCyMqYb9jVbhYS#uchzF%JQP@SYDE`2Vd*&=$^+%5k6{9>`U;dzh`-f z6|Sh*ogpvlnU7>D(p&ml@GN39CiGGDQ_1M*7?q2xEGO1ma-eD9WZy&ON88bp7h_ZF z_t9tYksi7FMY^SOlu@It;iboY`LvJo`vK-R1)7lQw0@Bm);Wiig}iJ99K<~7OP|(1 zHsdNo;Z-&gPCozPJo+Wl<4XvYOf~GSdBc%EX%0rsAHD$ip|2FXPaqz6gVY)a!U&IL z34uSuYcSE1XZna6!DsqdugT5}W~RyYC~s{!`yLnjDC_Dcf9`bqj`vQn_=R`fq1d3& zM)sygCATl_aD`JI_0li$;;_ZXH9Y7i^>`wVvTTlU7t;76dZBY&@R&y$ViN1n)pQG% zX`@_~MXOx!XOZJyZP;(ICHp?=Umj~qx_ff74Lw;sIXzrW<{G`?&pK){pvhyiMkkqz zIg4AG7kn{Kuq8RuHSjyC?8_ZyDlOrmViP#h7Zek0@B<&aAK>OkfA)0yFW>3Pe4&mD zI8f{4zTN?+!xIM*k@& z!RH>&NK7*St`0q-a0d;xDP_9_UzE?%j`gHqKZ?}(P z*x4A8U6%2r`cymdx@KZqmJgnVIIp3RuVrhbSzGQc{&{oYHnWCtDZtKnazDV6|KczD-saXEWfPzU z-#wvTZrf*J*PO>1ID1NH%xUMd;)HR)bZ}HccgC~U$pQL{Z2-_68+&rRcvTF;IsVV- zxkq!6f&zC2+*!FqXWD7f%#LjgoFkGtar9SZA8^)@ebCv$fAcs0!RhH+e{g!<^*6rU zAI0>?G0l*h$A#-=QNo z609}hEb)Pt|0A?fPb{yMGomZgwEBIvBlBINehHl|nQPhyUt4l=93&U|RvMLc1pkOG z+1%2%7Cxdg(~Q{AqN%}q@@w=hAG4emU4*aUiJJrQX%oQx09Q80`IiH~;X6*Z%!_Oe zxY=DB9-9DFW-Kesi@yPUjGrxjEQQm0s7=A87k})+zXwTND^W0XgM%w3K8n$+?@s@N zQxKi*Uz0x!ggh7&w>2S%ojCLPsVsK0f9D^cp8TP|e0utpf8r!`?5pR(LN7)-w%SQ; zRdD`dig&&D^yII-<@Drh{?h68AOF-OtK7#S5z3c3+!9`LE}g3lGyz;fpPz}T2v)y; zDa1l?sF-;M8nyMPcy5t{FD*NJxHCsLR9ZSWh-%uuBx|G{M`&t#M*TZVeos!#7GOP| znok+VSk<`nrvbPhfU}?W>UtJ#Kd35;eVni15uYFM2Ei=!@Sd%Q;L9A#h9UC^7sTu; z*5b8GpqOPsY`O+ghV!t*G<DGL)*rCap$Hfi{APj@4B zYjm%n&9Us(8@6Shdp>1?P;c4Vf(O=W5x$=O9?HeK2vvsQ$6tN&5nsEiT1 z?9UR<5luJ3;(>ypkwl@Z3O7J_H!O~x@;h9 zK-|3T$4^g8hc*FpqE!YJYzDcCiJ*9b*kC|Egw(cK|53Rk{Mqjgsp%UNzkF2FwM}2F`Bf8hf%d|P(@}sA>hl}zhE5dr^we&q%!Y#>UUh$t8EMMY*lo;qo zy_QDg)cAlc$%aOAC*@|co7VkOfX#7nPK4eUd4MeD{Igj2T3F^vmr1$It<=vp0fzHW z14DD6G}_lztJe{wPASLTjK{>hCM-GWwDDfU)WBQh?CDt9pInzc=#U=4GVPwa7IZzn z;)}l>mHXZOcDnz@JEdu`&ZUkEPi!aj&u43lh|WnzCUpmgeb7mpfT!R1-A<3Mk}coy z8#$_bZZ?pQq!GFrjpALyNpBA)nvuLFM=<3hazwM1N4_dYPD=(fqHEE}jx~8rmh>Q} zh6f&LsJGH6U5&q`yGFYewpK@`-{W7;X5=0vTe6OlM-24q`LGqYBpW}NuX_G)pvq6L z3cj*~U3m0ks??cpE$o^V zBqs7M(H?=1_O7*cuYUQeyeBW(*`urB*7`MS&xqU+Sv8xNV0-7EmVA6#WAhr?md+X+ zxl#5=tZQG%A=>6Z5_GU(dFxgTzB*?f_S{tKF*-?|bG@;aZE)6>*UJLzds}IDhWnT-vRafTAo71E8t?dIx z>COJ-`jTCju;UUkvtL<9u4j7cx`bN8MHus|r*bLaWbaGy8{or%PNbj9%%^q`Mbf&Np;ly^xNL*&*$gGf8LqK#oZ?IJn?x4J$G$(N3VDFy=rVP z=*yzOI|ZJ8+uxsgD8&A7hS*SvF@ZhTH;6oAQ9GRh52{>JcFV-9^N4s|a--d{uLU3V zyG7T6FX_v1ZOK^+U(&l)-d6fGb`T5wdis!gMBIAwNwgbXvEH(+)}Q5%a`9>tpr-=3 zp*^R3)>Zb-b8VSlfooo#pQMYz;#?i!8_P$;zN^|DuNckoBBzw+Qyg6>6LQJe`(Ymt zdJqW>nL7>kv}~&3mi*gdDyQE$_0M9=hGAaES*)!B#5>(o0(1sL=hRM4=_bsV0w$#6 zy(3S|P6Kt^pxpl0&z?>{@DtNDGh~{6^j^YeH{ifOtu~Yje9%v@nD=l?vZI_@c}x8E z>coyMdTaJy8W!z5!tb8FYvo7VdU78Uca%-U!sB6Md@1F)VZ&NG=7n@YS=JTFmvqiP z6(+Z{%wfDGko4#)-!#2YtIkE%sBc&XpH* z=<jK!p|{&!7#_2OBRu8AS{Ptgjm z`bb{0w+53=a5emht}Qg^eW;ZEiuG8o*|W#aEIZewuLT#rmG7~wC9?$|wee`1@oh!>5t>-{RJEDwC2aW|rJH1Go z%PpBMBNJO4|Me4%j;j6U6t25-^iL`aG4xuar{!CEoQPVV%o zgATH8e&lCPSMU54gSJyGer@V;OfR-s%AS6;Mo-oo>&VHm=*iZlm?-0ja<<_JnaEfp zH~XFIqc*M4=`tG!C|O^$C+avGJDKT$>(RysR=ruBY+kz|k8RLWj_eV-C7L)_=u*I= zuloE+?cFqbZidZ|{=PNK{V2a+Df5rSyjkr(6S{Eh&zAh#tRddS;N@aCj=6IDo z5OR-*bI-n&Cfy^ClRL-}U6Ceke(wptAYf};xAGn7O3jAc4omuC8!XW-;j^roeG%rD z0_>IQkH7Sdr>pnc+fXa_14t(ezScvP!OiQU2+1|SM%V%6X!`-;&P8Z~;E2&YwZD@W z{Ka7<(NX8fH5)}e!^Hbi%5!IVXxkVlbRI3Re74eG^J|98lM#zQby|Qk6XhQ;zaOJ4 z>Vltj#;M_)1A3C^rW$hZC)aJ80PNziz*agcetYHYhpV{mH0DV=*MSLKJmX&<=_=P#)ottlE*} zDE~tgQY_dtm_^kM-n#(v1GFlUHZNJ=rv>&B`)WP=_esv&r8Bt0m5$*r zjww7eijHQZ7~e41pqK-1a%v8!QP*~!s7vLVeBmygN8Ouzq4U|1x5vCPm-r;*OMF;9C4u z2d9KC{EWT|w>2joy_jUVHQHXhGe33w$O*ZUGh$OsMwS`t#H?mJG$Z<=EXuMq!-cEi zIn&8+z9Eno1Sx5)525isV{pxz;Lq*i?FF2I(Gm67Zlw|6on%ZBoLGP8!QtA!z`i5KHfw8d8|&=ZH|)?vI`f4> zj7tFDE4JuraJ84)WRhw$D9E-(r9E7vd#RP>`&zriw-htcwd5<-OLXwlBl(F3SWZiF zwzgC5lewRA?+v#3_~QoLcf9v>YnQzIQk;D zJ|p1Qk!`%$fMn#YO4+7W%!N3{k1|Q|Rz1R;p!^YD^bwbNS~QtwP5s^)QJAAoOY+vx zN){W%*srv~d-TPh=^sZs{f6iK0KxBvvtFXPSp3sedABzDd<>PzRXU<=B}@6mHULuK zIlZ{}p+ST*ByTVl>hOwS;NU%=Wf9-nS+??F!`c*e#qy|}UdQX0JRZ_aECeV)RmpIw3&{YW|@dA?Hysn2oc#g`b2~s-SMMr*`xL% z2mew{HQRf(=7|Z~nv5E(E7y=QW<6Tb^=Mi+^(9TNb@aLW@%f~D{4#^@_>_VKf)JfUN6=;W2z*Pr+?r)$11Z;z1?FF!M2yuFm|gEre1 z`A6G9bc(avQ~VY#%8h04;*0QSU0$*~+P8*QeCnU7`zDaeZ~Ms8qtE-a)6=*9;OWNu z!d)9p2Ug;ka@ue1FKN!~we7i-@!G>|0K)o$X}-P$NAu|pA85gcEe#UbUou7f>iH6J zOSE#z2b*;ibwEFQ;4AxJEW&$vcP+;PUr$@y9tN* zNv6@62&BxLHjw$~zwm>KY3}gWA6n_?jvwP((+Pew{x$wf!M50>ej%G`GM3_zd2(H{ zFOg^a(XnLj624_;^sBbGs$W<~_l4GP+$z`cdEu77@|sIrwxC}( zyinH*y2$x%#>p-fJo$$2I6e8Z-+Feh%zT!i2)So_+Cb0aTuRZE%CR0$UQ{fUXMC%^ zMBmd{lev~&GBod~KALCyTl!o5BkP$T3;d$bJYD_Ti&OuZXH4r0WesyaTMMZ(51+ve z8RzM&V^6*1-yWIH8y7GM^L>nVIa*i@xztz~nFE4MH9~GQaJ3|7nFMO~^?1O=$s^KA zZiMYED<;T}{?%-Vu@SAls{n4g&VOf-!(zuPDDPxtaSwg;j?qfer5vQ>McZf_`Jf5Y z1@TnE!o40&*ZCtKJ0xc)VyNF4!Yas8KLQ6`^bHsZy3%#MzVrt&0 zIn`j9X0*Q7hb{cFUpcl?j&<2zg9)cR^2WAaV_WPKdd~*0wtn$X{?+Men^(N-bY+XU ze{EBjGdT-Z7G`adu}QMxc#*Aa8gA{Z0FTSQ_@Dm;|A>}w%HdnGo&J(z6L~1dx@6b# zEN3eXd|9WrO=T{s=>XQVCE{WkzN{BmPLYQ)?Iga46Q9~qb+u=PgZIY*+2AoR`W$P2 zOg`ngUiymUk<*UIW4)@$jkH_JHNUpVxyKVHEHB$wFc;40a6X?ZKBt&!1fUX}8mSsV zxL8+X#>Gi=%5y-(13pe3)YTx7Cc-IadBBt+F3W%>;=OKhpaVspC|6@+N7+}ee8u!x z$+EMQy-v>yD$0sBonWG4BHuKXpLe`FB21V%BP69`N~R;h>lfsJV-w(=_`_dw?{p@9 zMxUS`O*uxH4?M9|e0zF4mc*cu@EMw#&J2^jC<>Y!uZXAIvoF$O3n8xQslg)eNO{zb z5m{?#WWRFp=Q=iKm}p~rK+~d|>1o$S+XJWndi!ysAE)oX>mT`%I*Y#Ebb3?xy^S>x zP++`D(&4!-5ikeZkHy~;^lX&vK7kki&|f*-+Sfdlu;EUcp!Hln$5_-U`%uY&D$7zG z8kPIf&ap1L#8w#O)MRCtVktgkX?#VSg^zib1upaWJQ}sAS&6hr&^1{9(gnGl3?HYSi|EoDStsJ@)OutpOsV! znO2QbA+3UPC#Vt?pc*ODM0iggxSn6&!*v7x3%>=6gAU%z9*FF&UM=!G5k6AR zx^q2FQX}#%O{4fmJvmlqIIG{8l>Zg?*Vxg%c&9br zMv!K8A0(1=-d{`+o!)|Bz3R$=kz!r#HDRW|y)hAOkempU9AI(Y6?`;@KgYo5kl*=L8Z6SZ$~9VKMR}381&=hBD%Wg{ zx?(2~e2GVN*Y=3fNke^}2g!V~mNmK=yQOIn~wia)*|VT0YYpJO z`#<@nzvDO8(tf$Ueeo~-LjEz%7Ipw68t7!HUVGvEq+e`eUKFC&tx_# zxqTaCyTRzuIE%tsZ866+5Vvft!Zj8Hf3KMPLro^I>_d&Nr6=>xBa+S zRQBJ`EWG^j^vttSkH%ILo!U>jM{?(>_zOJhXHe-B8#sL8u5KRZ;-)N5_U!hf&-&!) zS{_GYwjn?Uzh08;n*FV~_T+Jd;LTL=tIJoA(eDv@(Rf^llL6Ew!jVgE&Cf_DTJoM8 z$MYv8=@D4*Z|QBp#lMuRU-%vQwi$fkFaDpW6PMO37E1Huv<8~W6K*~eV1%^w%oE0m zWXySKUudV(!4~s!aP;&Wzx(v)k9^Tg`VfVD21Gh@I|$y(_qIh(cE+{r^Tl}M*vUH9 z)X(N*ZWzS|`jBi1{b`Ndy|fxXd*$5YpF6uS3;<>>jT>^6MYAR+VD`JGXF#vdOV4(_s^+;fW^g%Nuk=B(k;o^ z%8%U4%UD%kjyBZFnK#pn;GyB?ZEU*rM8G57_h9p(_YWG{8++0~_whN&=wnhmBkYLs zk{?Itm}C%qe8Sn{98NKCa#G+jJl1veFxsGzcTKt1K_4!7Yb-Weiz~LVK=5*r+h2WQ zS}g3wzpL_E%6Z|p2}WmMh^4m&xwVxKeoa!P&14B}r%BWKj4mg?K?*+F-QG!X{aL?b zT0k;kr7qbO)Fx1DE0t+m^&^B5uP1-zTTZt>{d4{-H!$6p<++`T}|MV^Y#INCA+Xi+{q2H@7XpUoyXq=FHDuYe5jGFr+XO@0e;IhEezx-YI zKB+y+KBGRqQqx0!t0nv+(FQFVCs_DXYr_SW8J&4$(@fAQWZq0Vf`=y3smwg+Z$wqY zYXUa<9Anu`4!`Qzo~`vgZ8NTuzE6kXSH=O%I5QthDi}p7yR{|b7_`t3v>do1#_>yX zM5}z1UJWX~wRulZ57*Pf!bvD)8*di7vX_f+N3s7p%!;9M--*`fLx%;8KJm;QR5X@z z^v~O{P9O7OzKF@TnrOimCu9*0=F&-ARTe9CUtBr5`k|k`(H9gQZ+(oef3({P zI)5T>hpdXK#Z{9$?`^k7C$D(~i}(mgT~vK#fbJ~V`7VvKJ|R{VXr=MlyhsZ zL1kRiUUA}D)7JFAv&l2qy|y1fKbp?;ntxA!{AW+s&)psQU#TNm|7Qfva}TjMo8!2N zW$B+|V6?T=S`awz@i9OG8h<@WTKs!7dvOfl z+92L4m!R(reC%|RrTm^(Y@o%G_J_kG9xm#TPX;I_8&bBUhPC!0kKU(^QB5 zqHPi-1oVm{{IMN);fy~DX7Jk&`5=E$40ng~!k8;AOV}rtxh%m(CY}k1Dv%l5CE{XP zc0^b%%QugcJreigAN)qYKY|Sazo+(odw+iYfBCy^6Ya$~9nqg@s(r8+l>5PmkC`X? z7V92Q&iHV|t*2=qD4Slljc(!tshkzCJ`+>6pL&GN)CO1b_-dmqpmH`Gd_#bb4Vi8D zj8AG4kzF#+ZFGdLER+7Pw#vAgKGD{28McO2yo$5xubo)n^$mqhm@NK}f8S@HPWE1N6w4OrS({JB zujLx2zxvQu`QukC@cbB(8kXer1?~m7dGYT0w$2_%3;x&ILHJ(ov1WlFhL$7SZ%VZ1 z!2RuTaS_TBhTWvdr2$g5si!mIw26ytUW>Wp-`WE2&%@gp6VCErqi;;?aVIt^H{bum zvrVmyNz_kyP3*F5@b>t7xNJwPv#DXDMe_EywjoA7F_#HVV}@LDlnm(f0|txwmD3oW z6#JV4@*gc0X3Nd@{g7jc>qDolam3EtW`bpz9@~~<7UR7s>#Nxnb!7U8TT_m6pg&b) z_hd2Gkm9(*fBKUDN^iNu!S0gZYkX_cTKB)h;h5F@dTMS+JJpucsDZr(8kG@sOCF7) za>U27@Z?)`MOl>B%IFT3Ae={t{M`U7EEaopGIISy?s!#{v*_q2Sj-EKj(TJ7y?*qD zPd=UA^zS%@Vid_Kuk|(Zw|HCd$lEN3J~`jS7>K>^@)pHL8f}XP`wX5JSv&gVh9R$O z0hp=I3pfAw|2w}wUi_kPi-T`@k%BHxKxcebp+Ar$_v$Bq&ToqI_t!h7{GksRtxsy4 zuC_}QoWa6rG z3ElVFhIZ$t@nst>&Q_@{oN>dSrTA_T=`20DwKl84qV9~RpWcg+G4%9Cd9f@xSx2O4 zmAB|amNo%Arp=}~CSPxuk0a2!#abguMxdvf`jg(t)YQaZj$_tlaz|i2Sv_1!w)2E>(4rfG$2J+EkM{QBHzJG2v>$)^zvX8X z&&E;lO>>X;mC`i?JN?po{^f{{C7S5Z674FyUXB5ih7C7V>82_i@xhXYlqL!-O0@>k zpcyf!=U@xhf{*BH(abV6$XkRf;mDnT4cXv{t6sl*^`HI+zChn{#>I{<{VsQADf`iG z>SXk}UaWmV)CR(6Pj+gYYo(9B=J(7;{&Ra8I?Gc`s%?zClo)X0NvPwhc6Nlph^>l) z_f`6<1?bvxzd=wh2r8PhUAaDDpz~N8ma*~;Az@zV= zMC)-c@pK<)`-#BY$mg%n9zm=Zkt12&$E|LFnT!%}pWq7=`g^ei7d8dp*}!x2WlXu< zB5qC0h)0V)#|oXmBD}^M@muW@AM$HDz_;{>cL~2l7xnhyEjg`xo)_)SsCqQpX;c63 z{y-nGpI=&~zlDS4mi$rpXgo&c-KPt)nFhq&5SYm4vjySk#Tz*UBN|#;Fpv~!=6Yv< z#>Sj1nf?MtmVUxT`57mgRxX)w0%*~$fqR^c!SfKWC_}o*=ZCe+ODfd=OeclE=itj|#&Ctij2o;Bo8pJC~dO zz~}L>INkCpVitXSS%J8<5HXg#e|5kyA zAAPC1pcRHzyGULw&ph(6MhgtzdNk2@$(UiplQt+ZU^?^m4LLl5Q6{DJOfPW>LXq=P4;}`#SpfHIf`R)pU|!9{Z$N1znV)57wEF9Q(rpE-_dkUi16#eFG5Pa9a+2YzrOYK`@i#ouMOvI5U_KTW)!DzGD z?=p|wTP(CVi>c%@U#QRRXt0bwW7oK2(N|eHuu4+Dsi1a)V!XW1tdyABYbAS^JlZD0 ztS{F^r~Hz!la6kl-|b)eTIBJ$MdO|gsro}C+EBje2EZpBGs?THITZ(h29i4#CKv`C z?^!1~jhuGKv-`8uiIyyj9Ti!zzQo(YN1I}~M&HBpvY^NIn zPdo8k|Hpll-%#k^8?Z%iZ_>n4v$F=1zO6X;;f;Or#2Ek*K+Ut7`1N)dCor^q*ML}Y zpMw(pH7+!Qllz9t`nE4BrGH1fb@pikVKh!}-y-0xgSz`1n9Ht=hxsVnCX%Q2x>!aJ zzHzxii+633^Vk)dmfg~;@k*WKkD{4Azcf*^X(IE#{&Yq0xz^6H4UZTXLG`@@32;## zbz;=?Uws0?S}F^&<}pZWv37Xmx0n%A#T6d9oNor9D{?t|qPxT+dn|iQdp<f zF=kb5vul}-qAmXPa zV^4ng2H=A~__e?C!i(?u7dBtM&O0$qg;X0TK491d+3ApYgwz^HLEOFKQfI!$a4?|O z83l`~@<6(z-7VQM)_#O)NAN6YZZD^|!zsqu_Sd}9k9y~NDMVQrH&V~>Xz{dsY0+nR z=GVkaFTMKhzhxq)&rbh+Qf6XyQ0dD$3&2L{nmBP5zy|P=549M@O@p@q7T8AY#lP`Z zd-2TO@Vs{#z1YYYo9_U(KuEt2u%iOsVDOeg+t9h)<;=w7So?2x5i~a9Lo_dug1&d&aDW{-8eN6Vi;a!p-T&Vs&eIPrqsEy#DalyptV^cWUxNNgWp% zK-@s)snDGsSuo0OXr2fQ-%=(gF4e)7^hZ3MQ3FzTc?UJ$dueSamh76$j2n^7WH(QE z2<52WHU#&6;b@=J%;Jj^V%A@?aSbfW8}X$lyT*@v_nn2@Ue19pO!)LyFI!GIr<-4T z*NG3rkhVULtpPswdD~Dt`HS|xce|;NF~Y)sw)kU5w1rcFEd1q^AZHA?oHHjIYcb7w z3ebJGGE_AmBd>hC{n3B!&-?qLh~8q;d;_To!!N~}ODpJgU4>SDNBNZDiR~@CWU1cD zN9kJp5suAl3a_{%q-6fmCz!+Z)6*gHNwHKs={YyLNmFt{F1i3ZFEUqZzAP9&GZe-m zwya6D7_Z@9ON+iE+h&XYwLC}D(?947G0m6K)O^;r=DAcbW6^`Krs0R$XxNSx*EWRK^a;N96SgDifUX;ymf6m>G*h#)%l+*lyd@iCZ`z;(=s&s@(;J$%g<=MS#{rtLM6ds~< zudB9V9CeOL?8Q_vTKa^q<;We8E#4mOi2feGbmV+3Ph1BWOY;{;!@yQst6!m0=ZE51 zglx-}`da%Jb@-i$c88*F5M##D9EM|ZPJ-l+!|l;3fXWfy0sjPy^PM~~)X)2RsY9#2?tF3a*R^%V4m!f`On+9Wx@}zI zSL4ugrb~2|Bab-Ea;+Z68KjZ_*6vN<%ttFRQTf>mY_{Yt3gS#sO0IXLZzaB5&3z)* zm*_KIzAN`nYL~@UXJY(_-~7V|{3Y?%f96vy3GX=ifEBdZ&atmBg1d+3z5u)NRvYpP zTNN}i9yHHY1q=d5^o)q2EtlFe%2`7j`BdJ*pLysvv@0=KjDsCGT)o%6qfzegcRSRt z7(U)ld_>KgOyu7@dhY$tzw9(!hm-Le^FV3J8oP!Ae`Jb2O0Vao(@9sP6HIxwGuNS`O^El%gaE%} z$R_Ek<)Y2FC@15#)LVL@ta~)JSY3ViYvxn$>T~2Oi=Uy%;N_X@7ysm6^`mZZOw25r zJkEErpJM4U1S=a|r?+eNPrl|aO;2I4_#3~?B0d55O2NQU@PV+H&T!Mu^f48F@d&4A9;J_z5G4dJ)DlH{2kr) zZUbLrS;%by)(n2HFIxxai2ybNe6WYl^y(%#>EbkeJc<^aoBg1F^5(xjz1YRB-}}*k z>qBSKma4OyRPfW(W)F`9>iYnZME!G~tW1w4EyktJ%E2=)csfI2pLQNfJT+pAroVdH z7;2kO5C2Ek&C= z?y0@8f*+Ir+$Jpkwm}{Jw03Qbo?CpGn%Yr0T3WV@(8$V2D_AdY>Fn`<%X)IX#t)79 zP7SYdH{*B*fQ|V%(O>M}yrDFNwY;N!qe;oy|N0e={{=VOJ7VXbaCgg2Nr9cy6OV76 zd%qpW{HD@_XDpLW9uz(h%y~Y;nx6!i87l=Eccft|tTSkoKhyPe*E&Iuel})LAx$28 zM2+$ilZ*Q`YRo}&zTnw}fAyW;?+X}W>9f8tmx)`drY8f-5M|u*K^%MQ9cKfWq`9Bq ziZ^|I=ybO>eB40C!t8JO)2DsQtA1UFgfkvUw5uC1RVSGJbr`rpSI~Z%+IX}`@nt^l zH1^L0bCV-y9&BNw-H@HzkFn@DR6tAO$luN;=+Qra2ZZU>JqPr6tneEVZGyV7&4-<+ z^vTU16J-;{S&f%Ksx7pOW?9_W$lSc`$J2y8ajFIUMnBra;z7H_1-_+2bD_@}g-`P4 zdC>rV1^}710ek$#Z37 z2wrsc^m~8s#@ZHBrna)S*HPWPSW5uyS0-;Y>l~wUN5AUuLgR5i#b>iX)(uK(8lw*VsUA{&7DT>v+?eyb`zC=hZE{y#tRaA(U= z17PYJY@-th^h>m{0b|ECb@x)F1)1yj7!0r^6P^(o$&R#p^hY_8y*7{fV|lF{=xg>! zX2#|E7JV%qbTS_KeDd}8{owWOPusmVI!#AEHk|j(mVf}ip;%1lLerXB0uO`o=Z3R9EGzG)7O#U{oTqw*%-j-Y*9Yhi=AHlW8dsYve&jS z^LQIKxRJC#Q}#ul4T4>sxV0A@J^hFO)NNx^;|snXM+*xTe4gY36}t!F`gec2Z#J}0 zVN>*OO*_&q&?~8LsNT!h=*e>Cc-Xxmd`_FQ0Jkr)E#UCa6FIV= zw|^|@zGSN7b6s7@Hpe;z4+y=e@`Np>!xtok83MacwqbCdVk_9NfL}(jH!Zrn5Et>B-MB?i3(eWQSNe%~yzQ;xb071Z5SFvRJFm&Pu~Yxoum5;I z`iBG?)ZWOTEoy$&VDKNE9!gKo?mafNcynKx>ly>WAjbvxJf;hc+L)@}>&7d6`5dtN z1nnI%m+ck2X2V{c$Q+4pOE>yk_JXS= z*J+5J%pRX`S#Pd89etLwPqqnt{I&n_blNQ~xK0-q0pHA)e#{__16eb*Zvsp8c2C(fEW~p>KiEE&c8Xmxlhtm!4fh zIkTPiS0A9AOSvUq@NAd$K1VS(lDrStJte*YvASZa1qK^hKD&U%ANdy1HCV(SQGN&= zvU5aNFLoKHI(e2YoOI`$_R_e$wflhBkbBZRVt8!<<9wp|L-LM#1Q2=I2kF?IKmYjj zeYc#hkaHD@z9X`M!cKQK#I9ZPq7ilS3fpebtZPuxq4Z@?52_v}i=A(+y~9clB#ics zh&{@utT)%AE%ZVEPQsUe?&-u+0V!$m=H@S-s`(QJ@w~H#k2&4O0*i9o1X9*SK27@& zr^JrmP8a&(|Mg$#mnR;*;gkJ~gnmOFeTt(9`?)O22Afotqez3z_S&X`@>A&T6y)tI z?X8bYMw}tAQ`=65jF_NB55JhKI&D&>%?e*heDsxXnveeT;ucv)%Dwg(;g9`BafWZi zE_7%8+(YsB_kFfMvh1Hb4zbsUDB4wP(`d&Tt@VX_c3C@d8RzjI{}PX>_erDMn}|#H z4Vg7qO;?O-#`kbp#}aLpO??f|s6T7vLcc~f^d#DkA1}QluO^0A7-rrlXmWeG-s2Dc z4gc3sPXM5MO)Q}wPHyA}zv3q&L#yLMBb?t!{EP28ag9W~r6@yX-}UCnNXSk;>~I)l zH1sf^(QfG&9g|n1M)j}pX|)jtFhu*j4MLkpe54VKyakUmt+I}&kl_=q-4DP~_bqqr zm!!qi7ewr`zDOVD=#76?n-+aBSk4%Zi$C=0_kqm08T9j!znv=N6~&Kj@%L9WBZqG` z@PvU~7rwI7!#tuk)0g!j%poow-Lu_5*qoOQx?h@bs_Bo%z!o2EECR~?Q(Zc5x%>a! z@An)3PM`L1^W;|ZPrPgLJ?#l^=GW16&4x@X8t)6-A7M8svk5qvzcKb6ixTvy{ilYA zF(9tgebZ=pEzx`YKaX6p`2W!_@%uHjU_^N}{v{axjm{BS7QC(aWZIGXT08dGE_+(` z`y3;pO5WlV>AlUH5Fuwx^XvcRZJz-(7rpPyH7juQCy5&X5Bd!7B=Fd_luXaL4}ixN zI6BZILx30nBs#Hw4}hxo_vuK;2Z&8(dLKaE1jGr{1+Dx;C`&lIoU&6y@GNxz(XP!e zC3ne=kvJ^LxFkN?m)iv0nMU>i9~qFMujP_GZvyOc07tl7hn)m+{nkZM3Sxznm*S#L8q}+ z#@oHi6VQ~6z}Y6ie1g`;DZKW>*U2w06zL^1JN-KfwlT?L_MC8AO>MD-8{W)My*2JK zZPckv05^&HxoZ+`XZilaY{x^?7T2CEP0c~l#tm8p{ABr%VHvba)bnE25jmlW7& zy!O?n=f3_cPVtk&*r74OhJdpIXPbaY)vQx`YOzRW&f`xTb{B$ZL3LT1oNe@(}IA#z&g~#RXo?XOul| zrLC=E?PJlW&)83GMBL$L&h$-RIqeUBOtm?r28%pv%E;nZiwpB_)|Knh)q)F8Uegir zy)yEbbkt}vY>y4HZ*D^YI=Mgp&pU6F=m(a3e3dnE?k^6dw^-Z{d;2x!HbQ1z1<)rY2B+Piq(rDnj0E~uD$gCV=iE|<@9|y=NBcZW zyIQ(|hhbKAe`G^>CZ8Jv?P&Zl_XUv3(I2OObviuuYe-lK@?t=T<_s7UzIe%R?UX*t z1D71obH#J}$cPKSL&_g*0?HzUzlaF+Uf~gME7vB#Zxk*4fdd!c z3|X7{6B@Rl_vS5cD~qvju#CrFh5m6L<(o|3^jX`^dRm5D5MGO$)G6jUuELMzk~2!b zM7PDBQ5$NqGpyH7y)A8gvjN~t4}FiekG;e2C9$m*m*M z_Ltz;9q_*3b1Ii3I({75Qzy3pZ+G8$?h`-!Jo;*v6|U@NyvJYi@0}if#qT;@{hJ>> zg#%r-sG86)VhmO6a3Xs~|N3+6*?v0`zxk&>V~_aRdj~*hVKbG}B!djcObfj;LuSzO zo7f5+r1raZ{z8oX*o-e`H%F?E{(#-|XX%oQm1&z3ZG~wOCS(l;4tR^tapr&KUF;LN zKYZ4|eY&=@Hn+d{?$hnN-)EacJ40l#@-WiAR`3a*7;+hje(-*KUGi^u+3ET-KI!!M z%l`e-qc8Y(?Rx(!y{{;qzA2M~Az!pF(B|SOZ;nZpvxLv|m#)|P#i*}Kw$)^3|22;8 z!MXNtW9IgIf7stB=}+R&A?O_%d-MzMEmJ>XohuZl)35v*zps1lyZ^)wE!nvSkNk_K z7rx+6eeLZlUiNi#X6=sr+JDrVtWX%PZ8!Yrk9?6+YA{ftL2c+9nzGDXU!!ad?;6@Q zHe}jM=s_l_)fQQ2u20+#^OwHYJKAU9>Zni__3YL4Jkj#*iW_@zj(^hH-U6t%?fISJ z5V2^IbiJN3Zv(XJyu32_ny(O+cVB=asn27FUI`j)yk`SUW{1DIkDH7*&B*4VBo?3P zc*Fjhze=$@%eO%YHH&{t-0bH^xpwb?-|cTV{Jj)XudSkeJ-Qw)%Aj29FKQ1zTVpqj zavu|qd+FT@l5zE+uk!aVTz$lArjPnqyVKUB2JLCYQDx{L z-8J7--}|>ES3W7P>1pxSXh&d@C)%TZmE4@Sd~CttuPy#P`m9s+7LNuubn`^coAlmc z8v#D}0sQCRX=k(SBQ7L+`Hp`dz>f6tjeRU0ilruqiD~*PT!g8NeSCO`#Akb-e&_f5 zy##(nfDH~uAZ1eF(K3yQvoC^@ogZT_tFueG;E zj}N3UA|^oRuOIZX@n&4woWP64Ztc>}bdIfzv9JL^6q|G&`HSiH{m;McWzW6pGr74w z!uOUh@+QD&)N`+S?LT|r#rM4J`u6tq!0&>0zW2~%6nQE89h3Ih4Fg7lC(_g=_lVEs zJNi8+0_jW<2g@KxUigYZ*=>YtWu~Nc5@%vrscBi^;1nj*U zH=GgR>_d62F?7E0o2_TbFIgBwn+0x0EN3eGD~Ws=&n7TZZ9F<6{E0D>k3uVEkw&}H*n#X_T4^HHHvU2E4`_E{{8>M?FZ!c+2 zO}1dlFX`tcbs(FRj{qm=-QTzv2zsPlnsg+aqYvJ&$xF6))8Fm8>>4$R4FQiF+VyOm z9pG}swY{SB_Cr79{_l~#F3D1xXaa9p8#mic8bd6}Y4I+>^P(92 z(vjqd?#Q>Kb1S~3qXn0qh^L&>h<}-9Z#~+yMV7aXG3EmewWr47ldt&m(+i|*P4lGP zy)B0^7Pj}c`vvYIVV978`Rex38;(Bxj|~?;1Ar2qUA(<||9^G*h}Zlk>Lh>0cq;l) z4qE~R%)vy#5|2Z16vN9m<{*`wl@mO`pEVsM)7~}XWN60AeCS#@(L7YX){Yu|4JP|@ zOn?ifT(n#BwfZ;fNqup)e{i!J?VNeH8WZ@m!O-Nx0?rwU$97i06E`is^S#q$ion;p zG$E3Bi9)coIX)kmmg1K2(x2fio6aV9Yi~PrZISMR=Of}X`%LFqy`yQAt|Ov}Qh#mf zXu%~g;wi6f3&}Y$k2YrXPcRzNcaGni#1%$PZ(P8Sy(8n9b`2-g1CFd8hs6RkMV+lp8hql z>zU|sb}j}zJA1lEaoPx|56ovKIE~M>$Z(mWCKLV`uQFt7q{Fc6bCf?UWRP&pILt?I zQW4>?Ec}|IbMb7+bDWNt@9xPo!*csXTYI=jx2D{ZnQdDmvqpQgJn4w*mSD=+fU{|S z@@;SR`)AV0eP8$D>3{gnZUD3c;H&q)@b#~H%bR~uGLOuO$;Feu74YinJN;FLXtSex zU-TC=b5?+KYu7K?K|NGj6BHdCMBCukO;i!SrQ8}EEqF}_v?KIu{Fa_AJmSsyk+LMa zCX-my;5C@!)bf^`8tqZAXk#od$yXm>L0|g$UF?Mp9V2~bNk90Sy=&#yWb|~`#^F)^ zNH3d$qh(5V%+Yb?zv(@zN8$^W*-))XJ^OqE(-C%wn6938I z^SgW4P211U25?58Aa5Y!F<<*V{k11=x6burv$BH=z4kd&)vx{9^ZzTaGW5&;B?!}< zko;vsaCdBxckSGH{M2?(K5?<5GFon!QQ+rAILO#z{F;teJ0fuYfq^pHy~o%Uyi1L} z#&4Scd2NzY4L7oAM=pTJ6??x8$|r2)rdL z^2BmW#}YisXz5&%A$;UVZj>#WoJSsTwFL$m{WAYjy~UUHB4@=ibOf~zw6*E_WZMjQ6cg=YrMTU!FN(-9oI26D9F`umO4cZ@4>#9$CKci z-b)8w^5aHitDBOwmHz{Y4`21e?$~y~=`X+{Ty&#xVpBFsi|nmwL`RP%`#53;@+5N$ ze~YIEuj#4Ljo1y1Y~LfJr>iA*WDb&T$ZyHXx)q-cAFabbqHBakI``UyoDqG{v~-Qa zdwMcXGL_3;)-2w`q2z6C>shtU%c7^X&2RpD+36xT|H+msF!!k%?~-jXX5x>!B7U^ohZ5ZzRzy{voq%r zesp;$hKK6oRK}G#C0NwoHOgK%!28HKV zjDhoo-ChjD7qf1@`R`3{1`L-1z{kbkWyU{)gIp}N4!$N>nZH(-yo{3#%0;KT^32zw z6aCtJkGv(`h{umyU$QmQ_h?7ydNOLbHFDR`)^shwdh&B0$+DL4nSM>ZW*0EQm9LR0 z+Sq51)AA$hsL{4~rR&%8DFU@vW>_s&BQ!O>5!jx#lU?L0kLc{t=;)s{%0KCCN3;G4 z?#lY(H?nKBL+RdG06*fl)BE|b-=uMBEN=p?p0j%a z-fC|%;(2FP8fo%&0}X{vk_CR6DhdLBR%CPFXLjpMVIAfx{Qx9 zYvoLjybP=H)nM6{NCPh8b3M}6%A@qq;loxxYPx|TH}=gIooH+LJvQy(&9il^D?=n`9I!gfxnd(H5R2@or#2ryy zlDmY@Hq_{W)jCNHmgSAqkCM~VRr70&UTACd8qircw0S|P@n^o#Iq=yg4&ctc}oIBt31=^wj{1c$0O^^^_Fd!Zf_kuJze-+^Btbtuh-hr(tA`t zUTaT}z7>ZSk6yCH*Er|YI4Jce+pLc%7yYdr*gpQC=l(I9p3o`JKrBlhg5 zHTagvjyNvaC0)vEcBwsTxE8D?uLg_!SzOQ9HWTeeUrPgEt<2UL*bS>K4uSe~Iu4bF~ z_s++XQ_Hj7T(9v6){2Ah$|aN6*5Cg0&rL`F1?g*=HUeDwR@v~J1<;uQ$RG0PAAPc$ z@`EOw1$e|R$TY_epxXfBQFb=&>J=}WZV0p;6K4T%WOR|ujk*+sC46kaY^Un6(FLpJ zHC?>{S(5>*77Ju&-=Xa-A}t>0Ysdpfk}l;FbpKz;i?I?+(j!i=oM)blSG`78gNc4m z9_6-txb)Fdt?%sdDe|^_iZm~kGGlhs-aRpkZNAs{nk-;7_^5AtZ4!UY2H})n!Y1@a zxzUaYmkr7zonWopkN#~fdh*xaaytFSm)FU{g60dqt$nfIaP9vgkNz>T=YQRagP?vE z;Jq*WCpYhY|G7jzEn*;obB?)RvsVf~Etdj7Dya7fCrE4J>@_q2iuhW$J*ESp@vZD;jOFSdCWSL9#QJFp4z>Uc1$-vGf{E{zQ@z~zd9XHE0`6GVU z_ynu<9pQ4mR+oHQ%a`@O)atFd>RI`T-e;w|);6QIsDIRC?18o9w%}`Z@%n77MYrW^ zfBv{Ti@ttE=zPFvX9LjhXPS(Lo8uqq+Q0l8Z5PoWn)6o;4JR%LgUBbUSw@Bya_(;Hx zd`Y+Pd;009dD1mX{}6O*`a#Ve;)V>?ubc1sM}AqF7uI>QB}gB>6E7QUK5{$V@ir~| zzQ(bR+0j3b$UoGzf7!G*29mQW=PUqk2YmXD_t^&z&X<4hl>Xs>JJI>b?HF|rr$gatko!&N`*ocn2rzg^l zl#i5!zMhZSo>p6Ax?bJm76^{^Cx~8E?Ag(aQJgnx3R$>sUwqeM001Nh zNklT^w&?0JZMkKMA*<5nuj%drt%E#Intgm1(S zg%_VB_VCG|NjVtYAk1awy5mUr5e-_v*h>f0~<&VSjK^XzxVKSCJhh87Yfjw7D+$(fM6#pG)rP7mi%zbMEEZ8om~7%l4l3maGi#^|hY-9xm&EmVQ@* zM>&o+x}^y*@=(t4@l8Y~x==mp9vNG;7_9ZT=JQeR)$Bud3%*uwjMrM;BWzfsKkAEo z5x&L;_>d!6qjL1fAJ_f$!XF*L&VA07C4b((+|9<(zkaig?|a^F{Nq4)hCN_m)ZdGm z#C4A=yCLxQ7v5zb3c9~&YcR5rVn@YfLQbF+>|5Ie-2BMT_*()oRt==x*oXskf>uCe zLzbfsl;KC!TlOGd9ao)K`C8qXk9tqZRY@$;j92+R7UvjqN}n=M}R(f@U^Jm+J8Em9eB_3IEXg_v&N()!GKR5B2_prI==XoHMriuf_jlH~q2Jh^8Z>Cx6?3l`-7i}hF*z49Ke zWy>hMMJL@$d2K9VZ?rk)bKS}O)qw?jRCU#_47aQ6Vpw9w$|u%^nQa{P{jNHPj*e?o>4zCU#uS?Bg$J--V=*#cS{!V zc@P)lXq&a0fRZ_*fp%Q=?guQN!0@HQ5;!{meMkT{esVH+;v*ANSK!YMeCY=j&C; zErb4^JyfA0E!=x6>-^Sw0U_dTXd0{l{d?Tq+juuherj|td+Ht}lFXCvU7foWgh z@%M?^9u4G8flvO&zvZtBrV((IpXAJqV>0eFlF{|j2))~3LPltNy1-=_xxU1IG+wc6 z$$iL9H4+Q8May2nYqI1^+;q`2alnCb}N4 zXj(bEQNHMEdW2ib_t>)}dk_90?7D;wwb35m*YNh*esmnK(I?uG*j@eP&-t3-i@&W$ z_mVaOme=MBx8J4=-Sz)0PyNgGUc5!~;Q7n`_Mdt)J0vy%F?j&(6pDgN?mw1(=HfUZ z+wSb@<(Iob zfv@!j`)_{!oxl9(=H_dj^{M}2;q0I4FPYr$2e3ON6-06ZICla99Rkl~0uq1EPUG{U zAln4+rY2t3$UOmZB2xpydqhV^PAi?+RfuY^$RF*C@V#Z^;%`p(I(H^0^~f8`k^EA& zM;)zl%?~DlY-=wLd+nF5xzDzYt-#&>(7!7r_xkHzduuu}Y>O{h#+LdX-)g*Sn6YZ2;p2gdA!_zT`Xg-U$&h~KQTI`0`pt;^o}GJaS(<0FY}txEe&lb9zppnoCa0BV z;_&kT=9?XEW)X88tgm18&H%se|CTrZjT!lj3-a?Bn{i(~_XE7}{0pCV^Na6&J4Vuh z&VC-z*cA6=IE3powh4Iht>1gL2|$)6*|=EH;H{vrfPib^vjwAcnJ?Cl8W7o*mR#a; zRLVNC4bszs3%@0=`H?4qp57Y&k{vC1!1Y^_Aw2rCy~4#j#yFNE%}cS|idD4rjAmdn z6nS&`Ojbsg`r0;Xh*<>3c4F>{^+o0l1U^4T5??FzVFR=?8%H{kBt-CLPEq5KUpLaG7HL)Hz@M_FrA8-rcz zI1BCnVcCFsabd-as$H?%R^A``g^Q~pUH;j)G2kp2|IYt{y>n)FkaK|-pD5#Vex&`D zf1=^a-#=})@BPopbAQ;#uooKu82iC5{q7g-+sZ%aYyz;$N*?$?P8|+u1a>~=TbkY8 zS$ynTbKy6hYnmT;%-;>@TQIg6KqWG;iT1g%>5s~4T6yM3LpQ?MASGWPK5H%Aze9F! zqpz#|WG)W!q54ls=P7gO$ZO71OX@4($!p&aYReuv;C=sIE5bxa@)I&DJvJ$%?1HK_ld@x7lDGxc|R9aV$nwFfi zV=mooC8v2wA+MWq^*`#GtM3fe5$hby zr#X3uucW>bo?IP6az6i?5Eg#S_kQ8`!oN00_O+N}ski*$2mfnDXdnxn#b5Z2UVHn@ zFaB=7d$C7f4TLi8(#U`vA0G*Zk9BW*3t8=nI<@R>rO$#5Q+(Ywd)2pS zJMs+tvA9NV`8RfMjQu#zaNxYgU6=7=Rl%hlHx~PFjAm)IUWAya=WJVv6py=JWSAw~>!tYX9M)44zV)qrSep>$XCy&3q>eZIr$0 z=Ue{h#|T{DnKu&brXJ@>f1HL&Occ-jkKcHAFK+v*4_qIWwm0d{Ccu7~Nml#T2cMwY z+;GCb#kUTC#>6qi2T%V{`?(+_^=<$)5Foud{OSa<#*{%z))YH z-4@f+mfOWgw0GyK|F6H{hr;;}@?5&G8TYc_$3BAkHRxsl=>yvY_}l(C;`a={@TWd7 z-Sv#rJ&Ju3aP-b^wVw#$=N9ReHC${wbq$S|?%*i7-39tazz%Wn!-M3p0FD$!=vy?V zcG$iFG*wU6iTQGU_RUwr>1!(8xa?xddB7?9Kh_6_d>)gHb>;HAwp*zULw@A!n#!Nk z@nO^E@FAz8w9Rs|IZR61Fv@b__X{^W(eLwT4-NZCkKWUByGZx1UAj>0NM2eHdzgWN+i7{j`o#p!S6n*5~2LGYH$uii;(aHX` zOUDx1sjsA1vB+US|G}r5U6s>%`sm6N)44X=Mi2BxAGEG*b*c`gb2g(aN8hF1oIcir zSldU(0m>M#@cV+#SQdUiHzwV+C4V-~-373|$Q4At9$Vq(>W^1{@&2t_UpQ;W`(}OX zA=0;=;{7B0ZwFkx@2iXZEoVE+BZcnd_ia@keL^j5?f$D2N~@skvlCO^rg;oU%`ZU;mW6P zqMd4&>zq;F)cPZT-$JGIu`!^$)KBcR~)oMxME1`m1%4hC1Ab(1y{k zQd?9nWTuSo$P0TB&{sL{;xHdx@$7C^x{Erdl$eiD34yf z{>p{R7ylGL?1LYz6SaGndUJq$v8(ZAnUK7aiMa)WUCN@#F z#Fr^DtY=Jb&gL8qdO*)D>RiyZuA%x_rv8woI#_2;e~yovHRvtrV^K%U=k!EuG_PsZ z)#n>{6a$r+l5Q7#2-&y%tAF}tbNu4J`7{3Z!Vmo&jTcur&i_`#V%W<7>=zKVc#X%p z1kd~VrvJr<9{u{hFaGdq<2AiMCox|RT)uPbcNW(keAr+7P;aHdqs!>j8U_a=It#uU zu$Kkfy8_RCx>?wPLhWZKrfjYa!O*}`n{(-11|A1Gm&*Ub?5-SEOc)SateRZP(xb*ZeTRsUEz+KD_u?Axj*+M#80rFFsQt$tkXFJJuk z=J+4~!+&yjanWAxv*2xRZ8j8Izmk8E7kw2zd&jlH)jPJySTuY4vS0L%`R+N$$r zM*^$nX+JqozY;J%(Gl1ghTM)5NsjlxI?M_$3|d$``Dk}Q(AkJ!n@_Jc!hrxn4t zDTo6p<|zs~f=)_n%H}V88k%r7oSRhNq@lVry^$PGMctb3^K(qA>KNli`B*(s-xOcL zi=dc8-`K(jnOHAq3`1q)>9aFOAHx7TRW8S0;+N{6oux5k5fn@0K_6O(z{4k z?+d@(nqT5V3?VN57=&S^ga5qz_p}Rc{%`}}Z~1@k`dbHAFaPoT@8ACFK0oi@c(+Lf zST+UcDb=|f@Yq8S!#DhxP}?cegaf)y0@(e9Nyzt;=vd^{Gz3#Pz!wPoM1-4x^uEBt z9u+J;`9yolAZ1G8xLGKX(SfOH$YPMGPLv~XQ$hY5J@UDr$TH73r-Su?hjo#kG3ks+ zN1UT+c}#;JLF-*pHh&`z^J8)-&)JisZED-3hWKcM+Re6OOrDL#wV$M`Y_2Zo;is+C zK^f`*oiy^~bfGNLW1jNlWuE$n>LjnmqD=*W;j(LsM-l#h#M;_IIH0}UmjHy1* zBR}kRbQkZ}Bg``M-WX z3%(~BxZ{CqIef=&n+QDU;kplT z{){hP^gEAN{R+`NwE=+Ryc=+I>&|Z;e)NGS7wH5+ycvyKLi$pm1En2H7Jl3*h(T|+ zb;@u@AhG-V0w|OX0kR7_TcCO9s&5SJEQ4n8@oP6sUnKDgpLc09bM0C|_HxaO@COS!k9xxfz&8Q-oZBb* z^#(vc?L+zTt8ct@@X@PZUtPcTWA-F<;&cYz=8 zd+XNy3vSi@aHp!PPfwqk4?R;er{_H9d6F0A|EW!i#=ZYC_R(Z-8N(kl1B0-6Tg7ZH z-m7EB9}`4r^l_NRM&URk&JZpgiaS^4#b4%=Z#7+!nlzZGenn0g>t}Qu;gM+1w$c;A zz0A1XNeoCc)>KJK%0au9epREzMM8{G(6;4=6FiUz=VQ(0NUqRF z{%$Xcp)(z$^Qpdvez!cySx?0m1TAhHJClKQrx>*~Ja{DV%9>mQfZsMA9d6Kg?Ih8G z)@@vGUf6XHIqRhYNr1wT?vqvHETu!l$}Yh#Gvldw3*|qFDck~I#_Lr`?>Q7WVZs6U zwbbSstwGveoIl-KVyo%ji65l$uGY_!9e?|wfY0IEtcQyF{*-uQVV}ACRpe99lfx2A zf1)gn>?^{sXfjtDnr$Gjtmtp@^Dm{n2O3#}B`#$Oo=FnkB6SethU*@hKsw!iK3sI@jTxt63bCFgcziD?+#5c4LYLK7`B&cjp7Lgk?!$5~hu$w8Fx>d& zHr=jhf)zU43YDcS+Sv3%&xz`QIp;ofzU5i(YI(6_Ty#z?6<-c%DrlwZ(a#+LO=3gsw! zov|NR-LqOZ{uF_E%j&C7==H4i3!;}0EoaONyT{yBKznAJA8eNCo}A>jGNNIMpw(~u zarDr7`$wY3{XclUpiujQC}h!fI*ILSfn;fdkBNka;I>$D)lZRy7kvi$m1-R!MDTU_*6W7g~(G0T*Jkx z=>7nh`fs7q0c_#D^HHsd4o3T_QA|RL13AjvKWu}gj9nl#>1XNHd)b2FaKkb z%yQfBl}i%(*Q2mLmCCX(6ih%FIQUlbN6Xq^JF#$u)}ftt*j4P$9xwmg7>a2*-&(o8 zjFf>!`F0W~p+azvT&&l~d|eUZPxEI^gCDnbTq(5UDI~(*v0Y8mEZ&&=9M`J;Bjdj{ zVE*O>RS^ED_!zc-+%YMiww_HHiUdG|0B9{iJE#Mv*e)|ilh9SY!FZeUWOT>)$c^9i z**$?eM|pws10grtcxL|?eOrwZ^S3RJpYJ^0&WX%_#`U3Ia>RN|Y%=J~^92m^-pY+S zz#Fl($nCC`@=V$(%_M)6){V9rUsOyts4djAqe$kB*&c?l(3NBaGIFDX+D$oS)r@kz zUR5MGSdfH*}=b;3GRAtj-Xp5L=lL$06C5fn%p^IHSQi=k;8@3se~;qleFwpP$Mt~Cb-+M_SjinHGR zt(N_YM@iE~hacw=#ltbdNaXIZH~1eHfAUQB2QT|G)KK6J2+2>CGm6>_aMAW(K{Xxp z=Oh|S6OlZ@v&j6+)rR;C&wsaz)^GS-`Gx`N&X7Mk0v3%E$+h8 z-Y}D;*l~{#PU{Yr#q%lhug7(=ukWhRsXhS?1O{S?zF#hebgNKT?fgslZmSq4{Bq)5 z7)KRwA(~-)tXxCh%H~GPf+%lJi{1ItI|(QwoA0YTpGleT3`p~YX{<3h4$D>3>E1co zg%!nrcX$F?02B{5Jz0N^9e?yT+ah_DVsSZP9kblxwB5v;-!7we0uSPpeFHBOQ|Tt- zYSgVvE;NS-v0>3K%rc58k2P&LVKwX!Qit(Af@QEjam97T(nEts$-}kpi2K36)wet0 zT<==%cX5E>?t9`8#40iI^+h$mC)Ldsh_kSF4RN zKT%=y)@C|2g-2q?M!xj?r6x|R01^pK9(Vdf!8OiJ$0T08Wt>&yy#1%nM^EyZE;B`8 zuXz1VCG{6o@HYL7-b}q=`t|}25Qo?5VDNR_T|v)N6`97$GV;s_bq+|9{!mkmzakC+Fh?L8ujm8GNQTIHsbPX8i4hTTm?>=T%L zvJ|mAJJRt~D+rTE)v`xCVde_1KhAPiVZ4()5>?8}y?cSUdIsINXt-VVt!P_lm@H}c zJzvOsaHWMEyN6obGt|I`(?_dBmPi|2ro>u2&Mqv@!`9XiFf;TD#tLA~_=w~gQ>0MX z+_|EK#CotAj7a@y9~0xnRga_7Wq|2$JdtOGs zvyed&2@%JdQ==~$lGC_X4RygfH}n54GNW<$DiR6g|0PnrT7FuT^|b`dHNvxPkv7pk z8@|qPH|`Q@m6!|s{M#;B4k_O(`R(1}EbZNoA!UE5*oVrp*}}8^a=JDksD{(8fI25I&TSWl|SH^Wtoz18-zA5|7h&hN){e<&;J|z#aGO_`dTFaQCNJ%a;|G8eIl-|C3LN3*_f@Ggy&>BeT__YVlx4H zo|sd*JyMUihf#PmhfRFOU3C^Q;0Wa;wWG>=`IYn1CbLNJeB(tOquRH+RScc^Z}%5x z8)XEIZGM|G1XoIufm+EGRQ^1f5lt>KV`A55yhEq$o+E-dZU;H6RWe)-=s$k4zkp#E z;RDY$J52L4GiT_4^GiY<%4xzTkT(;u()uCrlC1bxiNCZHZ!30wf%Z+TAagcY>-bR} z*_gFODI}tvFD4}}cq+td3WO~d_qsQKx34vb2RFnLyG>gEz~;~eMX%sy{n7uu9uTf` z@#z)GmJ;19BRQ6)u-zy$%9)oitM9mv*Zd@E=^XAS zEUuKNHc_jZX72m=5#tIu9xL<>HsiMQG-H#a9dYlaK)wceT~{<>c9^KbQSb{|GOxW$ zR&*D^>PBF!1yZ|Q%&TT)MZ2~z-{RC(SQt-nA3M0m6!NjdbpE0s zjZqGx-hUj(n&~K-tEysrc(k8lV1`;@Jo%5yW21a*7#P?&m>AfPJ?4MTEA^!5iAlP( zhM{4KrlBj$vtjg9GJ92LG}HU|Ci6t1@L+16|5|jHe6tA+8S?1ObS`VV_BuVWJ5CPJ z$F-wqG+X=s#{Zo~AU4N6kxc#GCYk4TZVg5$oF7Gk(8GfJ>m{Hzlv>|DWcd!=g%)V~ zUw1&Fh{D71X4k?JbbGc8a=%wyg&%`V(MQBSt10=*NOwW(20VXvL5S3*{HdyhAfmZ= z6?$ED+K#%O1@}fmZ|Bw95f>SfB%K!aZRb@AJD?@sONNDLhoKI3?1tw)FL=+6P83GJ zw|U&>&gP1v_E`On=P$MIPg?-?cekOO;BC&;=`Jkdr()zww9jC=sPGYcVhqWuR+`$@ z_0f#G$c7a(&eC4fz^WfQZ`JGOAT$}qI(kBzE#A@sg+jeOJQ`d4&W%l^{QkZNEazS( zp&5`FW`#CTeO1XL=5ju74`(qm-d@24&HDk+mlWO$^>dCd)^N2deKo)1Q3v&rLb1oE z*!^3dD|ZYlSWShioAi{jUF3SOz%RT^MxtW-CLAqhWnpX(Zi{l>7$FZIy^Xs1J0g>l zGQdA?T^re(Z(eBIrwOZS&vM-LIbf~0KQW}kFI@Um0GYE8xwMj*cZOaSEEn3my?k8} zU>;?>8S3^ZP0VA_hKOmaXnh$U%ISNn;30I@xwd{;@$iBehYq>b@X)h}9iWRozj|DI zI#7E(eAn-a7|myoAf|gbyP>nYrt@M1zdLK#4ioiVB?TM&t zeKQ={_lE-6nktm-6!WVPl9~uZq@#6r?zkF&ZoaKUYE87ene9X7?N#e5dE&y(>Vc)R zH$1u)8IlzGo&jCkN6cPr((VQa1@;s-L72oi?yK-|XtyYi*bATSUoDbu-l1z*6-(PO zmoQiGppw@l)bDuu6nuVm*5(dEUyQarY+0`&&(^h18i}S3Z$+AaD4f&2a{F-frFi*{ zgA<$h`7GD2dH4D}ll=q*Yh)Cl_28878_Vs%CLYg$a-m;Z+xwdW6w3Ws%%`^!> zCKxzO#tZJ|>*4by*60g^7Hc@KYF5Kx&)1V@YIfbgMBrQILk&sW%yZ`0C34izh3v|- zj2(^D+BVs(Cfjg_gT!t z?Ist)^NC6Y(F9b7n?3fxr!^|dV@nYhM58W|pV$ni0!V2+d@ zbNPr*M9_-x_4@`##nswbPi7fD^tAhKB=;|AU7@0^Yboa&N;?}uz|^QXR zj8fkgPC|lRpR6)i+uPH>t6LqZgqpA_i zy|gz$6Tq(TD5E}PykB$Nj$u(r@LaME$hF}-?)GzV zc{p*S9Ix@vt<{N(?!{ZtS*h)lkqjMr3mR)#Nt3Lqn)ab3rGp_;Ot}XAnYVvdWUPO2 z2tE7#o6rhA$-J{o0O{~tyZiz|4tkLz=0d@{UAxc=nTFBlaXHiF`CnGl2ePRi6eKPV z?AO@u7mOh7C9Auum)|*fzu}~6>)Le`3^7@GPiiK;ZgK@qUdma3Sa4%Dyn{@6K@K%_ zg`L%Dk)0hLPUp{-2xJ6=nnfG)>+C-g{*EJ!z5G;M zS>B56u#Azcu6oql<~F`<9PcXk?H0dF?;3~W_Ow5$i~JuI(%Z0gE{pT)TwGz>q?dBR z>a|)LVcXHkLQ6u|+TP+CXsEAtKvmQ+)5eaXs(U4hGSh_q-nliOjt+)M1P^BLr&0FG z4>aQ{TpoW#S&pcuDG0{a_&sg7n^S%2c!H%V!W-+HMQ-16+$VQF_%x-E+Sx>mi=%nY zW6{^JS&1=wl9ouX@G#Jzfa>*HvH?$_V0 zS~x~~3V&mXxw3YJ(E8ZtOnlYoz8|)TvElF^fxit_^LTEfItM6WePm`?v32;>Vo&2U zGgEu{G$D1~zg81%P`ivaeJ!)&ct`+<-0hk3y2RmD}~P zFovfc3Ccc;vynB6%v+%+3=ivd#JYt9Ka)Ac-L~vAcFM`SV0mI2@?<+$yDX>@mm6e6 zbVsdV-v@VySX^SU_w{ai46c$}2w)t>y}8C3NjDjD=}Z?li4Va&32)h8k4H5e%!d0N zI^UkH-KDf67bW2AtM_<*fA2{D6wuS!(_D^;?>%ET(I)Q?Bs4 z%E%;+U`wfH-RkgU++B8U6V3$SQtR2NC)JmV!p^vmdFP;AoFQ?Sk24mYXy2IKRn~Fp4LW1*Ym4nLlv=yk z3-pVU?dsW!oqZ_iRy99+h`5gdIJ*2D`P>!yl$R?qBUPHf@w~8YuT+SbTY%_jIcL7W z(Q28_At&oT!yRU*jF)~?7fS+j9Gla z2z#yDZM&mV1LS_wdt4KW{CsIkDOo=Mv0$t0ru4Mv=Wt@lQ{Z(XaiMKVW9Wi;dSjkG zu=Y3_0&g&M`jnwK?&zT^*Z^x#BYc(7o>XRvUGq|}utP;MVr*;?N)M(uz)r&Nr}iH= zZrIs!?h`@m(i3B$y>gtcnEn1rJk9*#p*D`^4sJhRg(8&#M^3L>#+GE?-~ZOtF=xE2 zTF*WGZ9aiBdNwxZ$U7wjh|w6Ci~&^oYQFabumgw%493ya|2oxFliKQInNCalqVIji z)7`dOm_Z?=w6~g1O}aq>mI@ca*;H=4HMyg=KMS#?^a{6dA?+{p$Wm>qg1W$X0MRVAvYl|qqWgwo>Wv(r9xWiFIf!vjhfTjg}A z2078;CtG<2d_JiGYrDCfg}+9NjSIHqn!@K>`<*YOWTY=mYOF+|!;4=-f<>)wx29i5 zGf%>)8`2OEgoSv_T)ir8@4{el73{F{rdMRFd^_JUAvW>OFl8YSglW3`sy{-kNxQ&i zSa{1Iwm4&CKA7Z~2kQ{O!HG6T~T8H?i^xBuyWVQ#qQJ)%{ z!Rx9hSHO~|oXxVs;o%6Q+Kq!HLz(iH*S;5ON2-Ae)g_(`YwsidV>W~CvU5zZEnxYR z(Ba-B(O8n9o^E3*ZvuO0IH~hE!c^6l}V-0QrQ zG{=wMZR}EGGijVCbk(~2E?fHnRK_!e^Ik++Eji5HbYce^QPZi5q3hZ@NU{B1OP_0& zTK13u_epbDiXfM~rn=|?&D)*RBUa}{`u2b6b*D(y7F3N4&Ta)RI;x|@503XRoc!QV z#G#A!#}(>A4ptxnKrRpLbg1@F!Bw;?F$7@=9{m9EN(F%SYCKwVbmGQY86!re)m6V~ z6zV9LQ^343a44y53>S;+p5J1Vh!-rFt6lCKM^jQdj!-?Tb>?;3-wB zG^&>K=!lt`tEHZr6UCDY>rD0m_&9N!Ua+XElav%R83}lDbAruPzrJUZLAMm-mER59 z!;$~Upb;2EJX;oFZ1hH9TW5HOk}9Iyv1zLdoTH$9X5TLhvfAWBj1}F z^LYmnyOStQJIu8kDf3HojKLWv&{F**+kj1zt|)S8eP1fkDtfp=DLG}eZ~agyKq!95 z++-&jYy1<|oY7Do5Eh%-rhAMge8|Nyf6}`B@B4d>D`;Odw8}ag&tGb_I$8!Vx&X)& zS$P4XHY0Lcv-|#!DQsGYj*{>4G(GxrLzL&&o!0E95*KZo&ptuio+I5H_Z@vSQrts zEd3KoVk#w7nQ$zsnO0AYvu_?&mRvRV$i}D@=XcT~bTuAHYP?IZ;29Hj!jpZ>HsrH9 zJZj%S)Z=+BwwE-V7MflgL}w$gQQdTlJCgC!`t^8`A&372N94OVjsWo8<%SEp-=|S3 zG=^PTB$yfGLR2%(x8USj8R8k8kz<6)_<<11qHI%d^-d*2?My}@6^;hasg z3w!u1y(Jc6D;MHx4LWFtHjxu{_dywB^4k7=?e+1Td4rnaJT{A~lM%6T{6hv%r_zYL||}4d=IyH~(H;GIPQBKiR)}11m?9pR4KthDj2(_iC&4sHTU}Zpjia4;h)G;~) z@Ta5@H3|Z=pGo-&8kKELpGlI-0dpT@mC!3TZ*Ge+Z>fTO^A*gWm!7YcXfv3&*XB_^0a zD}Ua*mub_|ioohDGhy+QoW1J&+tU^P8c`k`*_#uXw3D8~_+@{l6+(C9eq3zFFZ7q% zK?*set{@(5jW(h^lCjf(7k36|1JtWHruu65L6E5Jhdg4KCSW`GE=`R#TDY0=P3-hA zCz8)Od7zHOt5m&iuJnDn1vI%sPT-Q2*~#b?-S;PrXUaQks82`+IwdmxjOP7An&`G0 zVJmvH(Nk$M+ zCd$pkv02alsvwSA@?O-^DB@i-o4U`L6z;HH;EVT=j;actzR(ry8*fI~ zapdXo?BZdV1ja^|W$)+R{szK+!ZG~>jmjq(&xkwvSFfN~NArI0L3LDjp$_5>Q?i$I za|QiP=snNI$+_nHu%+9Zd_$f2cWwC8`f#$}cPngNv|Lpnn0$rbC;;S@zhfTe!`<{* z{736x&aaB4Kfls886ED#wxi5C*-IN*&JP!5`|=cL4;5u3oESLui%AoxIO0gqG%+1-Xh=iXNf&MdjSV*lN><1h3)JSde(>(BxM2S-BY z%A2YpHwoW6Ki20~podvYgA8^nMa9FSgu^9S4mf3Y;oVL@cxXKh;^nD(9p{Uhcl>0*cGC~?<7Zv)~(ES(Bb-cxfHr%AWyE5F&P z_X-IA75KJ_jNH)~Q;1^#@4kiP0@2nSvP$rP7mFAgdrFLW(?+$Xm-+9ZH+zTVo(?r< zzv+H=vwCqZ!v$=mrxfF_>dR{+ieT&(#dPd6fR=}`y1`|LI?}J7DxyB-SMfQI zygKzxz0Ebz^>`1Fm*BC8#e=zOI+vjMyPboz!zRML{!WNU+N{-}R9-mqsIb^Y0q3wq z7oFby@Yij64A?tf$xSQeroE|wCC{!-t@b*nH+qrW6mrks0+eQ~qtEi#jGi3PaBt{w zcx}&xpp$|mxRaD69T+sbr<9)4zPouhmo5X6^!R9*QoAei`-mJ`F}*6_KuNwWHR>^- zYoeeEQX^MspcY|Bd6QZZrR;E#=wz9%FsGZ>pEOe`IBS#+qUDHiOSSc)ZaI^&Prn%C z8Eqa#b54K5Mocz^8(f;7V2PVzUCBzg4@X~C!w|&xCc_0Ir9ZpB*{94~@m&z$HKP93 z2tHf`z=+$&l=61a#WE(ZYTk3Yy z@Pth?c3YbYRh+7WSeJnzWduNVCgp{?NPICsaY*)wn+#_MPOv%#1 zz-1~Dj~&|QJAAhY!TqxWbkvB>@5PwkqMQ?Zg{0*IZmOHSB-T;cxbl`GoCymOTslx^ z!}`;F$c%tD*;3D5%{5VXbug&WY5UimqRVY;z{2bb+orxFoPy?#Nh`-t@{)o=ZTcUm zt83oXL9)Ztrk%)zXtTfqHef1;%GO>1rlo*y$b|TJkTsL6WgJ zk^2pT?}kHGybPq8D4Q9*iwrmR$hYX9eX(YrqHplz@7S5bj^zIo?M_U~4%Rwt<}e~3 z0&;H&j#&&7+)V#ultBV>3}CYhp~4}ISCQL<+Z$Cq(iQzxfdM5UWZ+8#)cawj?rfSd$K zz8cRBq5_n;!8#^G9oxf{^w#Bhi>D#=NV!J&-E?&Wv)F=%s_ELPk3DrJr{p!b`FWr- z;qFx(W#=ran(m3WL5X>3muufZrTbIV2|1X>vHiiRVp^E}YOF~Jx~KX!i}v=l zhzTxt4a@uCYwsZI3RXYGyjsj=E#f+H1+K>Z}>6h`CmyIGKf~ z?9OT=_A%d!AepYTZtb2dtRFo|hNy}?&ML*ta>tVbMiIqm)zxXK8L>sY8Xq}%6-&)k z@|?-Bd{xgK+98j=IWZ?O+oB-$%@w|PhoH0i3yy>3)P$f4;@jE$);^Hgc=VP{mw}4&A4XlC5%&ydNL5fT(ip38I*ThqDI3xpe<+>U)g*>lh7qxtzcqqM4?^5d7pCdYpm)<_S zFfL#D;xCim$c*m&PS%~ma3*07&1O#1>^qv79yDHgZ6DTti5Hxg>GI{h2LB(c-4~-7 z_RHd}_rD}PK_FUqG_ncfGV>$!rTTq1sO{js%v~(aF#Kr(fuPABmNF8uw0eSXxV6Sr z15U;7g-y#hejN$K-e*sxYVlRFnG#qpvb}U*zUHS3!dW7f%ucw$%_~3Wk=z^>_6o@b z83(JWTqP1tubF?lowLl&O7+H!$Z6$f;k}dc8?SCzd~PV;a?&hJe3YmlN|0D%ef+bx)OB*Uy&JY|)DJl@hC8>9-%B zr2gQteupS78N^f)huwdmJG1ZO{$Xw0jYn87BU(O4K!^$$wV9G#FRaVt(v;lXVen4Y z#W+-$YFnbftKR}XQ>&QOm)I5DrgR>vOMJoojtcpw)koVFYC4m| z&Xd;zu_qR#HUk5T@R?sk5+)h#URlDYnm4IF@TtDOV|S{#^E%G>3hrv}EcN@hCSnU2 z=Oul<>t)cZTRQOlPNW~2>YTo#_2sFc!6MzY_tkAh%VRzFhgHMZYfdQ>a6N9hIhXcmw=l{>gxJ*)W|K`gR;92MxCn&% zSFpG0y;<{_xlhcp<$x3;BUQBOOpN(ThkiXP5gkzzyRb6`xbSaXtl`CS#rHC%EofCm06zUS zFbS~SP?g*j;`yNNmr|Vx@3kNvM;Z;wbe7(7q}90vN7E+u%h6**xlIZ_-HLGEFs@+i zUV6s=5zou`*vGW|bDvpx;CRTzn;>vdDyLa6%Nai#n)yoEc-M$^1GLX{y?OOEEDLbN zBxyADl8PknIK#UYLvY2VQnZ5kxyb@zQY-p0rVwGPNw<*)elvL+%xCS1BpwxR(^-I^ zW`r14HCmX~`Yn9y&dcsrz|pt5+P7$ASw~~cHhqo+HR6EowsY05*H3bEw5ko_D~?Fc zco;>-JZ!8JfRGApw+vj7fx=WgI1!$QQY(ESCL zR^iJXwA8esr{5$cQdFkTGN=@3YO+svZE1(Zx6XDtXI~v&ooMXt&lF3e^Z5oy7fz}` z*D~(Zfl&)?aT?m96LG%G*Gx>!qR@QFyVxwA1Zpx)x45eEjQV{VR209GYkFh6JubJ3 zs49>@&^ER7p^}usa@pKhxE{ho9-(z8@hj9;naa$&e0#7v!phh{Y~mnaom_=Lb9GAf zen`F?yMN7Mh&$i#?~f5@yR5VR*!vGyW$CjuG=N$WxVxg^PIvm%4>9ytw*~(bJgjAz z#T8t3)h(m1$_B%o)b|ng$2KTNL1*Y^j#-AoI~5C#Q-59fF~KHJ>tROKaH4^#qXgI@ z(+XrUMIS~zIeOV+3&2L>TEGB+=vBMg@;coklf(Y#e-D4)#yY|9E8(GN@Ke8WLMQO zo56aB=_nkT7|Kp11gj^`RWwYPw+VOf+=DQ2%Q*FYnUf$_R0hTsL#(_{i!j(kg(17u zF2#vk1&|HrD9X#AkzO9VJB8j%;AD^mN4WLw-}5PoNn7ouM3ny5CDo#U`+XE_TSwGt z&s7O4H%nz@=beP{r25$z0hKXx1Aj$FV7fCe=a=vNP`JML42H@s+gQ}5%BWA;VuyY^ z+CSe<#)vq+Qf=_FPpn!+ZlH1IIbXDD)s#AIH=F<5cc86jawolnYbo8XM#M&SQS{uq ze`%KqUC($nx_>O__(){dWstqTdCy1c#*NNQx0&$@u0H${zz_iU;!Oa9mSyxQ&PM~&9LKr0nwoK~ z9{X;<@!UN)i-vM zN~^a=GDn*4T>e7Y0#JSA4m(t+JM%n}kR1-Y1y&IK(>a3BYj=zu$n3d-5M@4B=m9j!2}yW` zv9@@BvxJsx1@EeB)x>R*Hp|81oYq$Km_}cJIl)oPtpUFlCIAso+$L1IZ=T%F0E2Rp zX?uRTY*phL{T}VYihdZ*FN1}$`|3%v0!?1JT}Bw82mAA4(P4x~_3b9wF|?&M z&kxh6lgsNuOOMl`XGzPc44{Jog`9h08Y$u#rmj*@#=RnOrN^Mu(k)j8wBb0>gE|8@l#`V89|2as zIf=wF^1@k9^OwtAxGw7D$+?$0)w?N)Y(k zM{LjtDE&m3h; zt%d3`Z!>{sR0HZyr^pvn`g#3cM5UZ}6IQ78voe4TcK9y5dX~>>Ely-g6J4ru)g^x^ zOJ0;656s4fY%aD`GXI(YEha=Qv(QXYCF16*G;68TqDyi!XUoi7{un;r z$LKKnP=#2YghBcFRU$tY74+(x8Bz+MPxn?!BxA)3mbX%CP2<$No;_zh{p=*VIJVT~ zYRfuG zQ!Py-67zB9J;?ZL{fHsgk7cJkKxvqVAP-{hg@1h_?be)adwZ^QOoFy2Kjb)*!NC&f z0BfG8_}-tWa-XCoI^E46XZ< zjAv|5@Mj$-!aA8&z&T6@$k4E4A`_dnt~b4_zv-d5`b0olrjkO6#rj1!ulY}iknQB6 z4*mg~Kyz%y2e{gI2)D%6lp=hf*Ln@6x2hG6`6Ir_jb2G}roFEe+Asja9fhZbdh4R(>~t&6-h-Y{JZ$ zG(Q?%F3knJcqxHs+y*|b~P$rot6BMJqxXGRqq5U0u zA%vEL!3NyYi(e1DZ5kZO$XOK{H5RP zlNCZ+4UL?x<=6H1`;%KGkP&`kEc-^cYNW?s-=ruiqxQ&_$I+rdVdY(k8 zK;dH?wcJYeX?qtP|S@sx+t&GPxOlfZC>L zXv~q+E*oCY7*Bb!P+zMZ4rYVb=;F#dd;em>1sVqWB`5G6-W9RGW@e>(Wyfa*=Wn8H ze^kBqkByc$wiQ(e2F>{OO~3#6vJRf9)$+CbpYoCI=y@;AVa|PCyK63}bnOJ``HoJw zRYz1kBfQLmx5C2kEd-r5SySpfGvz8CTUX51CQzBTvZa906wiAbp3kJ%Bx_2%SvN7D z_T7R1Ey+Ny?#s3dY~N%29|nYz{h+ZRHRx9mMq7Hbp>5cNUettDZJUetXZvJ-!mq&J`?6>E7g2tP znL1}}M^tMyECXLt-}0OWZ(B!BzwEmI(Dug1sN;&7S2A_)c3tXw5Idvo)+TyC#XBd^qY;>W`+6VY@KTp}#Ar&Lf%bN6 znzYetBg=D8?;{;I(fRXug;+9SUwfZJd&N>Q%PxuL%yISsMjV$II)17oHn-(w7nKUORU7@w&-?5z~^Pt82W~4NVssq`do{U^n@Yp zpNOCttj&oxySPluNwXI{7wLRbW3CF95<1k7cN?W7YedCfd3BWRDq435sTf}_>zw_*AxzPn%=PvB)<9ul*|t6- zsS02MOUL+d7&n5LjA{XM9@f!7)2j-XHd%igSk^g{$3w^%DW_2NvwxEtKjB!)-K+Tm%{|iR25o2FD1aE zX%%&amw=2RO;md@EphNcXquOO-On^$birwf&7$m6I^2WsdEMn$_*$Aeo5QIuvjKKn ze*t*eCf*JJr_BxZak#}|Adnqw=Cb`$g$BLnckPC{|AejCX^=Ki;Di*~pV-^92#?0y#U4Dh=M2f;-}S*iz*CEDYa?A4lFXK^hAxc6Xw}F z9u|R_roMa0_ThE?*G9+gRxc_@FT%123#OH0S7h$eMEu%%{oZt2EO0DUobLJFVsvP} zQdfRgswET&3t2xawk|9ynUot1wB2zuqNge5CvYQ$v7FdMRrU@JT1e3lS6bJ{XUNHQ znghMaoOWpN9KwUa)Dt}>YBGciHXG6z7YqqTU9-u(FrEIf?)cbj6T|?G=lG{9>+Oh; zkAea+7f;6XLgwF%>tc8^2fJ%l=JEKd&h#X%2VeF>CW3m?8G2HK%pr~}07@|vTdf&I z8WXqjVTbKF0D^=Qc}yZErlj_x8}aJpl1Ud+D4>mWL{7dvNkM{4Cf!pbnRa!FVRAQ! zsC`x{vdyDyCQwBoS&VuArLw7PA2HAT&`TX3*g_3w&NmWU)`T^;$m4Vdb%`x%U)Jv~ zaVDu&%F=YIEeDG!n3?)QWzY>EeFUVja}v1UZ9d|z7EzWJ9jTWhd0NR}g(*avKqCHcP&C8ufe9wwFE{eA|A+s{lzp979|ly~r0WUP%w) z>&puGv=JQ2vycnne-SM4eu(cLP~??6ZM6^F_-W4(m&Wdi*+rN2A$P5Q@*q@;XK#5H zQhq6QeKYC)r1g{*!$SnwuK>Iwbk#8MJ5=YcdNaMvq!F&-8PcB2G$7$i@r)19iPOsF z2nc$PWc#5A$5PY!ppKQX{qCm{k&m%Ko5E9VH6&Tn#%{i(Suz~7SFx^zRVQ1( z{%6Lv&5vyTPaZ`*KPFSB5qob(xli;Y^veBo)cc;LzvWO%S#8XtT~P<9;A%CJ8YQgy zzG*!U@1%W1{05ZKnB|OTazfMVn-wg$O^5fUAm?-&AMt}P*oAgNeBxu#`kR?lV%|-! zuQV510EJu1KPn6fIW}JjG#p^kJs6avHw=VzY{n%!F7jxZO$fsBsP{piXZMekohGi| za{GLVp0h08+t(rZe0+#QoRG%9OOBd%Gaje)rKy_cJH|4%xu)z#PXBE7o_eMrVt1-l zJ)r+`tE&s5E2H0zo5L{FiSVH|jW0H-r$D8J8aU|N4~oc#R?WWM#3w`w6*%vFw!iMu z<>3-Pa>SQ73oMZ6zuzFJcsTT_KaQB1&4jmmV|Z*jLvCqbx!jyqk&d(-9?dw+HL(U-j9mec!;6QbTWp8VPJ)?wCns_}%K+DbVzYCf39>L>cAm zQd3j8nD+cqmdWW5_nsXuG|m z`)|074yVDgy|EwvD7P5s7d@~2{CGWV6GA{JG%6G55ct+eHTTNmI!o_&yEj|onz64>EyoGY;#Q1 z@9lNJra*3)>@H2s!wsKq<3S_-jTY?R2;=RAq;$TzmiOwNrSk=TI1Dru8?ziNswv`> z)OVhBX?+@*BYsrGJPLdWKQOR3%ZUt9D?F$Si8r>d)Vg{k&n->sC@W%1J$K(*V-A)0 zZod=eX{1w=pnS+(BjD(&&apmpn^_{SU*FRn#$4YWMB`(ZcFcPDlHhOr_6CZ(Lm)LDou`vg^qwf(Ig;+hd5OrK&L3rQu26Lu^BIjWZQ2>>(?nOZC5t;JcR}Az zwCRI7NU~DQMx%uUN#oD6{Mr&<(u{8@n?T^Q}GIZGby2lRR!7i zVc}6u7u`CDp??cia{IwM@itVUGjMS>RFs9d1(4I>Rt)3RBk1Ak$7u$hf9usl9te@$ zHH+5LstI8y_1d@_)*u?KWu2!H~ee<&e%z!_HiE{sV%`}&#QYm*2#** zEYbK=tnl5+51FrFwa4wo@$flS-2mO-7fc_6ob|Hpp8et`VJ29H#W-V|hZ`=wn;YOS z&A((Fa%TDRP^hW#oLAotDQ8S@n@rix!hadLa_t-lCtn%vqlTOpcx@bxzTJ~(E@2{* zlN=*qB{xeHxHbN`8<>Yq3Q*|U?SCWK+d_19NW-@k5623PXq@PP;PDKPE4K#6<464% z3)F8TAG;znT%=Ah?(fX>e>MvK9{`L%bH8A;BRG`<6zxwH#?v2p%c`?uZ+__Yf5%*+ z$qRk)q0=~TdaYtsl6)24c*8H+o-e8BFO0?lw|_$Kd5mo7HCQXM^1m>aFOU3fUmnFx z{<`TGQ*#D4zI2=|dZ^PaJFuYX12(lQjdk~-SP!t%ncMi+ayb$~;>@Wk)+Z$+}=kaj`Q&-6{!|8n&1?$r<7eZ9XVXhm@G`Rg*u z-}~1WCFE}<`z`-Rj+Cbqi$#;_C?Ve&$U6eeyAbDMKf-E43TrT*N(RNeUhyqqiyL{z z!a&2e1!QsUg>OL%Ts?44FO$TW*1`L9J9CtE zn7*Mjn{zDA?c*!vOJ~-_c|PU9j>#S%o5seiraMi| zJfd|XAMnF#y=m96_U+$xtz)^CynZ!COI=*4fr(=+U6uDCa;{U5*9FiRA!V&bEE6~U z)>D>#$^>g9EBQ>qhGGq~$IYKPht4{;sD7E>ozLjEP<6^*D8~``yOyi-esg`|v$(@I zQMVr>PX}IqCS<;Q3E#Tc&ZMco_t;`?9c7inhvce!h`-Ebc0coC4irIFzGL%Y{Kkon zKGUNYTYIqh$dyyMhRz3PJzJV$MX*=$cM!=foG&-1&bJVC&iIZ+zW;74UU`2f(-fpMT_)Pmw$v@?dVWSa#1Q>%=4D zgFwbrZ11GG7dFK!5a4F7q=@-t5hTK_~V)8PB7eHtW?bvNcs^UjPJCkiYC8#*tX?r+ z)>{9nIEK&~Zr&qK5vDxjF{jQvRMoecXJbKTdWkIFJi;g%I{xgvHI)JDMozUs?#}HH zaodzepM%_=8hM!q>~pRT^HC?rZp0Jz*~ghPbrL`8eR_do?b~CAwlNLAY?_y|EH$=| zGU$JEjvvukkoyen0$aG7vtl#{_zutYoN=kLX+=bJntJ-P%%4x^uDcoN2$6mYO8f|oR8RmTqVvpw-%0&`@I3;U7rcm}p@=0RHgGfz## zUvsp5Ef{Xt$Y*m4fGiQBR7Y~cm}%j%zOHGA2t=-hLM%OS>(Iv|w}nu}*Ku4pJ$mXw z@JKLjgu3xAsoHVof`GjQwgj^B(%R9*UNKxT6`#*^FTUw{?8^NFI%NLq8mZAxvEOi}3;HeY2FP<@7i)*Ul<0F}_ z6EE`MIaYUBIo2FJ>LsmxwRXKZ$GIhuvp9p4;)}h40&nIGdG}0i=#>`#PkJ!4zi-Ax z%No%a9%DR|HuGd16+1ZQWzykUXVP8g#JZF@FW-qF7DKw6IzQQ8C%$$s!fT{G_>Qqj zn{h1Z$7gdhIcJ>7bG&?9v$YzW6IfylzuY*-xofyovBR;vEduNMkq_N{z5XiT>-qqY zFZ`)Lhq(XbYv0PJ{rS+JEycv5?o2I?MGxKE=1P3cx(LMhiS}%aT+FM-d^XGpdoF5R zY(Fh7dWsROtF2#Mz>EJSYip=XDVE?g-$GUP$J49pWb%pBd>#F{k6N`lAuoZHK4 zEe&L{z56juxh&a(FEvNRH5Lk=ke$9cj{LOI81jQL*SR0I`b z3kSJ7*Nl6AIf<{e2a!u4jTDEDsIEh5bh3uu)@i;au)hChihV7{mpks)!rIBkt8d=@ z^1Cm7`RDqO9u$eW+-l>IN zwCI7oOlJ;Fyo2A$gEa~g*;%+?GnZy>v7k9$yGsaLIa+JK^sR&vsY*t!rF zVfu)i4Sn+r9gYY7!>fcwJbdTy>ZevZJo=THbw+3M5Hg?ZfuIO&C^m*qnFsZ}f@ zu*uEXd#!Si^Jc&0OGiIKd$X=g95xND|w66Vrp-B@fhX8Up#!6*3RL|q}~7kKmbWZK~!@Q^woKKayT0~?iUfU zQ?KwJzWV0f-79rtuTN^Heak1(-GAF_Q}gcO?|lC5?sH!V?cpnLESdTfFW%8g&R&ue zgsuk9;a&hIenBaO+TX0Cw-l?Io2<8qu*ybI+Nx#D_EWHxhC3hy_nCq~gvnh~?xH?UD!|ZngN>&;+yfM!F#CD%1edgs% z57vl7j45YsCe5{&`iV_|>Mgll8RSWR*01K+W-aLJpT2In&QI1FotM;(erjT0%zAC5 z+wXd80vAMS_R7+`zFs)>RT*{gHH{!@65rQw`!5!9#;RU;<%@cexiE`fd8_0mujE4j zY}CBXE^_A#+JQdog1_OVvmVgFztV`6i*MSezWwgmFZfw^_rK`7?(V;>Zu&p_r(e-{ zg7o<>-#z@rzjk-`w?23G)*t)RcMqTW+}(ZA_4Bsy(It4W9fIFf}3CS=AZrLfBI|d_dJ2uQ!`s5 zujWa5dpECnCYy30b+n8};f44PPb`|4s#p-tO_C);F?-WOCPmQwx#+|=_ZG_|`XFF; z+iW_%UfrDMhX8a(;adFQ;p5~vZWEiAdC3-mD&PIQ89plR=`=pnToS#xj?IFVFa4Si z@Q|M}`aEe@j=4sRtt>+sb(ysVR z{^C#ZG|zn60%|tnMIMV&FLKmHHD0mv3jhA+eCqDmuloD$?tk9Tyt{ktqjwLlJkK|^ zMd*nWGBm!??c65s;ZObLyN5sXSMFZ?um9KG!)L#khXLgC4X3zKv6%lej&t+q+@Q!C zwRq0s%pv#@DY0C)I=|*rOxFwgxsmIqe2i~k*I};dmA|G7O)J_{4m@eo5BM~29cxp1 z1fDplW1Na54`8?R&h^2XUDivh{RqExI;GVuDeHO89cMjC%xegK)GEe4kB8g`zn0$+ z_;q;);QqB&zx(-%hfl3^k%Ef9ZvM1>My^bxyh978R+|TbB&(>Fkn{ZB48W3e@uGa? zG>fcN9!c%uss+yj$&m1-CCQ?CS<$;ulreVfq%|RSiJ{5%Jx%dX+K#24k}OzqU>+hP zK=r)cMuYFh@TT63*jF#Eb>U|NlpgKbVq`5+;7!jO6hHRcuV;191uvNG8#*(VfYwPF zdKx~SFJ8tLfu~OM$?++fqQ_2|@*RpTioZ2E;I8E9tK|gk^lYf#h?5O>%pV`W0?5Z4m9AdEX1$xrhV-;`VCDXOK= zI;`W&D`Mt$KwolPzCBx)3yPHli+t#zx+dVwbzw}CPjAhIF?g=;lvjq{wS7CetuuVa zuKMy1^Kz5i#9QL*#4PXCIQFUg!d-bT9%I^2n)!yCdGQfh>(S?a5&ZC@pZl7<3((%; z@znS$464m>3br?Bu*=?lz-gbB zR?+K+QcWY*Q`?L55Bg_%z^w#Yq6^x@`gnu>IJ75 z^C2Lzj$)-~$XTQQ4fx@r)41ja-?YY3q8#2S#)P<~{TV(o1KIgoJrtOegPC7Kl;4`*wh{KHsT4F%2F6a++ zfqnkV{@%N1zx)^9@z(zN=A9eAK;4tKr}A|Yy*l+B0&F?H`>TKJ?!_PbKkweEhXMX~ zxVwHgVFR2W0%}>!|GHIN_Ti+fpKHj-Z**9orp>%Dp7HHE@!Dc9cwknYao+MxcYP3_ zHGp~BSx@@x^=aZuv(}tX88P66-u^2qAKtEw&cXCiIePq+m&na+>6ugH_S%GwUZgMd z8c9Q*mVA|%zTj^D(&TIO{{(!^-vQ{~{eNl|X(ypx{riwfU%xG@pGqf*WSpJ^_ju*C z(^)czo!TtK&ARsVdZoax71YM4fyILx(=>~ST@Dx2@$^UD#GD_L^X8v0J3O(cPVL-1 z74vQF;2TcGNhz%F}kc+^2P}sEeBVgAhY+a$p=j!#T)icMxxfwAnWFXvJ1v6vb zSU57;jCYWq93rp1DFpqzIpK$nPdGZ74{FepFXBz$okoIbz0lD$w(1irw39#97A%^z zX#D!K^_SS;tj>wJzd=y?lmkDQqmNwASALeJ>q17RA7Yp(@Uzy1agNh`8W+gH?bUIW zsIkaYPZK*6=JI$N;#^~gn9 z#k|)mxnFs6tLcXFBs8 zXER-+sk6uJKYpO6Y^$at2IY-s@@GtY4}(`h5m%xtq12nvF|Qx^k1!!tOB_9B&aEiz z`SZIszxbtJ`S9lJKd1P2n*_PkiZhrC~OY2SlDv%!g5TF<5g$^B$O<)Zu$J{(e zAxF<_o(fp(NBv2jMFVFp9%d~R^eHCi%IPz6TC*`-W#3Qoog}&1t#V6_LIq@fcFxHrC;hyyvZ|<(v*`v=Z?>^ z($D=h+sL3F-jy{H!`v`WeoChXt6hn`IZt-_JmbncDWtON0%f2vV-0$7bB@eAX-~y| z&>is{{HhwYZNH145AlgQCMVq-U$Z;05myEsEP;~o2@}~g_i;_~JN}>X$-7tnmHPiy z#)tW?-IUj_8L!_U8u=&O`JgcYd^aEuAb;tv-@WzU{LgoH|Ih!oJ|H-*S*-icLBB!Ct-dTsnQb8d7kE!`|@ixd`~6l$cdk=M?+2vwa*k^-CV|8-FNkhI&0; zBoUK-;EgMd{Aq{rVb1z%2+$tKN5&_4L<}T6A~^{t4p|pX$PoZuqT_2U%ld zFW0O4SN_>warer9@Gspx{M_%1W^Vi+dg4t_YOSQ)v)X#6y*Ko;{I#2ZWXg}+{CPK` z9!{SBo4@w%*{}JB_q!9wBsLSroX<&hP0f86yq4eMoi5}U?);JKIj^xXTPNo^bZI5N zTbtN(*JKmMHMrfr_|ZaV49M1{J+`NP=)?mJo3jSyc>7TU>5qIYo*43YLFrfw^h3j5 z2ebR)k<`3fm3%XAzbgCa)+sNxpwrg66uoVE4Yub$3VQR4KlUrX_{GoETUPH~TOVb8 z@9;M=ul}EZ^uxdN?vtqM_CHl`0-vxwq`R&s6%Wu_0(b_`e`=58rU zNHu4C`B?(YXS?uu6p*+2g;6+eCITqpwms~EDII?irAb?}o;P#y=2%&JV~6h;Iu{Ui zc-5Zk|hL5szo>6RYE+#M{ikzt*nM<8R3(p7NU; zJD*Xw`vrg3-SfZayY626+5h*hz98-Hjz1eLfB0m0k_|qoU#D1G87F_s>&zLl5b2_@ zCd>oA> zDYv*kj`Q$iYmIjSwll@F*q*XyQHjGwaHB}u{jB059xzGEN=7o? zZP3kAu7##vaJ7X(jdKxE2V89RkC?k3zWP~!-0TnDZ0S7+X4{Al#lbydqb25yE;M!M zv4O(=aAC{ltGb9Qg1e zH3~noj?h5Cb*x(1rtLiT92#*!=eQOlbQ^r2=eodCUpaeg0KIG2@TEIG%Q{z1f9$n& ztyhO!$M25C`qn>m=Q#7F5ZKf@x`mTwUz2rBh{W2Fc-{T`n%TqU~s+> zOasc$S9{7Ai%!*D2meF?b?_*632e>v}KPikFG;XizMfWuA88U?M|SX@nc*Y z2W}vSG=BCpH{mvxhk?q^_OasuA}+LT{Dn2xh#cCIxRCM?P*ZQ#+eXpkAuU~d&T+ul zkc;g)*)xc2#}%wFi_T`o_gTBfP<ey;yJvOa`&FeeK z&s-aF;972*J$O4T{8a{D%%?Rx@uuvgW1E(~uf*C2CF zUB~B3W}{;q%d1kYfmq_xA7U&St^2UXKJ(y3b6z}8fOm2H#4p6&MGFPR?=k96|9)lt z>0jO=wIoT-AsKC!C!kzxYM!(s*U2`ydU#D=-l3;JxKI>!3TEEmXYojH`HgXwnfw#a z!c-R@Erj$nPphs%dudZwF>%gu6BjaP`p&TcZEck>3iYp?SQqdTI?2JG7|Z{Q%i--3 zZhD8uEb%&;xkRI^#$8_k1qo=_FGpZ{FoQNW*@p8M^W`hfjI}uJTlgXvgcY1-5N)}{hvM; zQT6qX2dEbkfhOOJc46cqS+OT?acsjx^h7%3=_0vugWn*huJs4(7L4EcL9V}AH~-)A zYaabg{~2@6pBjUYUGQBaWPK3ep+X-foiWXajy1cSiD&9DUjKVv=Wo;u7}(=shW6GE z{crWBg0E(rwXc!;_o43^i(aSzUzQ{~^AtaZtuv1~anIPTAz_pIsaRuiXN+09;wf*9 z>7scCN*=Q4G1o49WTA<_b-nhEnl1oTR_p6Dy80oDK9YlHjPST7Eyo`9(j*u4Mg-}0^X#X#Rz?0-S|ce6h~h`yWH8;cMBJ$vho zkA*-^NM|R5q;Ysr&o)UCI^m6?oS-Io(iuP5Mp4Y-j{M+leP|XJMCKmO6_!pvnm!(> z)Wl;y53gJs1AS!0nPbgRSaYL5M;@CwD7aGtM<0+B{~%UsXlirnP@!8N{5B!Y3ZA&Z z0k4hF$It0&^9~)@jSsytvh)fdzUN%Habv}jSK%Gw!G{5??S;8a#0)s?(-7aonFr&I z4FhXQT@Ds?(FAn77`Sfw5}eZ4hI1gLF;4wViqGxY<&9NBRiXCjVJ?A27efw1m#^5VUZ&SVm za6WRzAVy^SntaeLOk}_aV&O7YFJtDN;N0l7U3#;DtDf{oAlJCk&;42Nz}HMY4Dw;X z_v+UG@A*3by#4>?i`T#V?)m+@3ma1C)smxt5E9Z%jTZ+LxN6|xxgpZx{-Gw zJmY(~8GCbYdT3`wd1t!wGlul;^~nt}*~FIupiUf@0PAih9E)SYWK$q#Q4U~aprdxd_ zs!84%C@?u(=GN0SSrCw5E;k(U;o@t0uyh=nwyHO-BJZ*Mp@QZ5xloYV@o8uD;9YB8 z;NV@*m|QNp;niGOY|Z+cV`OJDp2S^y4=d~4At)o4)E#-@lyUQ?Q)L&PapK38Mdyj8 z53$%^SEfyuuPp z-Fn(E9l)x(ny?PYA%3!_PN3Q^X|u;ya<47M=5nveRH*TJ?X-QSi;cRN$RIy$p5QZ8 ze)@36S5N!Q$wjA$q$Ees9<}o0(0cAQAvi^wHj_(erKlaQM_G@eLwjVaeb5(_`KDFk zd;XDEKK1njoH*N3+%Y_y8pFU*$lzC-n~(Wim$@=}ZDc^UFI}Y%{NVticQ8!{gHGP~nf|JN) zkM`c=%YAs|f9)InrY<++c~hM{#@=KZD|=tx+5hmr{JFaq^&S3i)!*jd|LE&?4`2B5 zzJK3;;-in=Ieqv<{ocMlxW`uv-~WTZFb@R51DM9!`W<`LxJV-_5#gi{67=u>ANwVD z&;Q0}@80_3e>zy`k~dx=<=@DCfK79Z)Tv}4Ws05pVXQATOv?veOIA5-SWooOg?8Oq z_0Y*X-=MXLurl`gVxCe%Wz+dIpw6Vd-S~iGED5M1GKUp&8aXo&kf56Y`yw;ccl6e{ z@+K_AozYCziQLX9V`;>lX~|S=J$vPq@2(fqfAHBK_`Z4?efQdX{s6$+{|^sud@O(J zg5RB#-5X2MNymQ0kw1;O{Ju9yB{{uF7KiHR{?Wzc%FX&keR4R3B#(;=&H0*L9w5Rx zzM@j(seW#N={xewEs~n70)s&=MyymxenrrJ6)R1=Ryop2XcqjGUD|a9=f19GDYgb} z^5J2uXem_W3fJ7~BiPeciSKn{Y*XI(xtD*&+VoN&U-2~Q>Utm{<%2wYHJZ@kJj%@h z!z4nkgSK_XIE*@h<3(r3I}BaTugc(+JuaVXTSI7Cd|HCSZTz86&hgJ&oMq9Odd4y! z!bi_IChyufHGo33+&%!U`OazQy#+3bgrI|AUQP{Be1Vsz=5NM%(dtp}&xbea=3gK9 zJAaS=Bl#j+yZM_#ZnnGeKl#;7KmUv04}WHT_>TW52m$(6KeTT8^pSnB|1-ZVKE{W(bw3^iw(S~tPZAi`12p3lTffwud4&eQy^P5D0yp)=6l?Ot&@W}^ z(Nh*VZCtC*sW)|k(?$*+cW%RHTuomxsgpi|my4~0!9!NxuGIm2_BP+{$M^a3Rl2!> z^MyLDbA;ah#GRbC`qS#h|Kh*<6ZxPWuk!QizW)2(+~fz;`Oon7LqIvbd45_SJwCl( z?Y_Qx`1$w#(mP(k)3}*qe_jW}c*!a2MV++IZ~hy<=kCSd`OyNycqMbbV5w;y*09xi zv2L;_uv2u`fu%6-i^XiU=oh}%HmJ&4^DVk-3LO#9Q_h-GK3MG^^7x^j%1kV1Vu-nE z^ZGr`pDpya#s#ljTL9Do_TMc#CCYV6T39M_b5 z99q2#b@#=u{^D2v=nuZDUk$vTY7ds)y$Nsszj*7-U+_?W&q#n2{agSnLHC0(`-x|| zK!(g_4beYiO!32Jm>WYOXcWmTlxuRzkKV##@mL@a3v>FFfT^Xo>Nw_VL%F*)5|q8LWTFgiU)<$&Kg(rzHDF7LnPWBfJ1yV~B<2Y~(M zKO-9io_j-w!Pp!Beh|ndrou)~-R@`N%mg$ReDav@A{a##<0@Y3r|jT^buM%lZQ4ae zS^2`Skmn;8#fmLYitveJ{6seufXvECbdak<8*3}BbOi$*KUKWJGmqG)4WVBAX)J6F zv7JxjvyV^67WR{EY&>PuUp}o|m{XDH$=~!#9yrn&?XhS;OtW^9YR7&N;Nw z$wSx7MaQR2{GYxEWejaLWMggU_o9hVbJb)e{*b3&maaFQ*c`rSd&h??N6Dz`E9U3F>7U3=kQ*V_4fwVh zlQjByk;@IU{x?2u$Z!6hKNK-<{JDAiMNS%?Kt1L`V87~z)=p&o_>gfo$f14YLw65< z^Rstv{@{Og_twAv-`?HTyAIyCb0gx#ZS=<*LRb=^Q-9eI3sd(!e@ z?qY&D={4E&YdGM|nw|6gf(v5%j-SbC>-AD2vDf`TTb!Kn&6>|T);!lD))QW&R!$fv zzhw{G=SIErVw!i=&%B?^{8Fty8I&hllau74$k5LC&~{EL1{*f>1u75*J<}GtNG}4po|~b?-f`!qGcVcvrJXgTuS0y| z@Iq0;OAJhC=A{Jp&AoF*Y#gDt98dOO8k4qX!d!=qU&1>_Z4>{%2u_VNue{`gQ-T-Y zI8Ynp9V>L2Ia#w+Gsv4G=ra$Aq1VfJ#J5Lhy^?RvUwiVtZkl|zKcBYeX32#MU${PCcg7LDKIs!W zd1&BO|M&lAcMtrYzg|^w)Az=oxAmto;|}Re4r|z&yvSJ_iKFsEUd)Z@QilFK&UFPk zxUFEXEH-y5n+RX!l@Pf+39##rIWz zm?K8!E}ILDm-x)W1W+2EO?dCfKy<_1m7j*d~^Ic|YDh zJ`}e2=a9J`}_ z@Q?4{&jU%MJC7~HTAlIt+O<3#Ux)kb(|`Bj_vlPOI--^XO7TW`7?ezvJ+q`mr;=J6TE2G$cU^=PT@EHNJ@js19We6y|1ZD!Gf&I5WeiTjO4FKU%pz3sX#0V{ z>{s9E-Gz>%>{^BBJvu@6!C{VD(1KP^`^*{VILSVx^MnVN!YYBhdRZZ2AhF~I7K3f5 zy2AwoV2(q;OEx(Xr-Zy^BD(X1au?LnYTeE?F#=DsIA@GACrc`V2=D1r19k1iMbnL= z?(%uf;BB22K-VLr&Z9SR`tXaXE^^b$R*Cw~d&?}{k{W~|xw&EU);}$;fZGSIEmyBEjWcily!FmE>hX3zzoVyK^CE=0 z)uC88ojRPC$>f~_F}lCRR6hH|uNQd7fxjxKKPBX0WIhC78Fh8%Lr%QvT#*ABe+9vV zkq-gnAN}b{Y6rb&6t@03A30YB7ULXO_})nEk*6-0A8M#*#3KD%yHg*G^ITkd+QeRz z>*uHBavi01+ONb{`LM3hOL|OT!DlUIf8FFG--Jm1TW5}i0bkkFKl!d>=6~mf&jGyl zvF}#*-RR%52LOKmzrX#@1f1S7+W~Y2NQLhV*dw!3)LB_cTS^ufay!A?1)=}R~pdIB=`01WID?Lvu6v(#^Rv|onie9nZq}tLpn%cC#hPF3F zGublnk5Eqv2VVydxkSuKC1URQ!6Z-dtz^zVy1s!7&;k$CScgTuqT?P6(Z_Geqid}m&m5zAw*2XD{vp5gp9>#2k9a_F%Z*HrO|Q6;XjWwhr3z@3;xT|ExdXz>phliR*OX zli#A;bgRtPBsmX{-s`CeJMKpYb59wpLd%Juy>;FT44&bBR0O<9(U*nf75{UYJu%td+8U=YJnX$K>2=qJgPKoo>sp$g2 z&*IRaEO(y;=Eaf=R-9-b(FbuCFvSWjK3JCGXbTM(^sVEA&&JEXhX?GZ%;WEl($J&&fUhgU^-&HZt)9 zI$P%H(I4MNsW6%55J_(U>7?1Nq@dUj4Tz0aDHEY-$RblrbZVR)I^)PpEer%sNit15 z#_FA8S>wS$Pa8V+S3NEK#1DS9@T6cbnV0o8pe|VS$s;yskNn#-bmCKV#wOTUEvV~4 zTIm{krq*k_*fyP5x{*hqutT4`@;-OILKoTh>GP8MdGSZLvUKHY)(wW)XmZVTJ5%%F zSwOqUc0U)@j_YP8IO^vnfM;}iMS>3_@(qDBF8r=(=PPEyr_aq0KZG&oY2_-J>YK`4GCV9Xq$!`<=00PRnWy?3r^B!3U(sRvFF!{Tq7(N7IW ztm6*9FjSispz~02$!lz);}e1UoSb$pww%-7vF(!@7`v4f?XhQ@;-c5~Q@b0s_&ElM zIpg5QUVMa~@y<~&EiW0`oJ2IwdXCP`PRC|nHLf_Was+#Nz4y;o-niK1f|m<>{C?C& z=FRW>&+C~#AND)WMOzPFWAjG84%#An{cH)1XARtsRr280U8e4(!%cv11o(!4zApH! z-~Asy@e71BzqR4~)UlU3nAy9;$n^C;_V=1AAQt?m8Oe!5sRo zV}~4Z_wXa*wGn$Qb=tToACq7O?_j@k`p7pFvL4e1ggO4cvkC)pGIyuyFE`n z5?|(yK_N%hJqtrEP?pUkhO%&a@kD{sa~fFKbKKFNh;ZyDJ-MyF$#>#F|7`C%KJ1?qz(BY1=;`9`@95*X zVBK(9tupSx8``PdMgY^mjd?oPA8G1+a z&5?d=h+IACUn0}U`S`!$m}BR_jSslU82#NsgHIbSc8X-3Q?FUKZtA?Kp`pJqUK%ge z8G;f2LuB1U&#?oYyqt6EHhp3XNcY>9&6+%K;<~Pc92=R-5&JP~uo)G@s$bK649mF> z{XxKwasz9hEwqoX{mn-2OT(-NK|9u+y@jL2_zZ1_*h2`>4<*QlA# zj+P*5R(M-b^u#i~?C2xyr5oTwC$RM8SK$PMJg{Y5CSJ?8FS{Fh+i@oK zW#S(Fu21HHf=Ao_h4)gA&1so{BOe@lIb{*EMSs?shIQvC7gf#q+TVVE`Tc+_As62>JkQ z{$}}5CB8f~766j9sM^@vWVC(cO9(;!L|J~B-SMCs{9u=|RWmF&=_Eh<0D&1geHQ;s z`H)G?kWWsXn~u-Agwdgxr}}Lf{3b`v(`k(PiW!(p2P4?6i!W~Rq4}Y^ho+4&8`F(D zvWNx8ptU16$VV(BfM#kzqWt_}RWr z@RDyIh15%Q#OmaHk*1 zA5|IIVB@Ic9*;`~3|jh{%|5SqnsBM!aa>C462& zdM`CNUqp`-bD&`{<;Pe4BfAoMLa+XCG(i?6`uJ9)5H?Vo0~z+wb^VT>EP~GLBw!?K3oP z4!`&7zwAT(Akas-0U(xq1Hcz|FDw#9{Ol^)T!;C@Kc`SO)`x>-hOC;u5zsnLTB&<1)IxWsd{EZBATIe|`hHgwt zT)W5uh;|{!ev2drOvmr$?JhPBSIf^sH+JF=CmQU<=DX}{o9N{v75kY#lCzE2O5P*b zfrAr2a)K;Bo7%@-@>H?(Ogx^!p3nZj%t?x8&~JbIC;X#3^FDig+0)*Vy}9u!NI~)cDI{0H2Pr@ifn(f+T$MNlq@`>Yy}!c8y_d za?%gjd8VH+J^Y*q3CoDs*F(-AGL0Fy9IVJDUmU0zBOEQq(mJM}c$(W!@D2>{N5{rA zORrq|#xZyur(M1jFF(j*)8l`kS3J;&A-8DeMX%U5otwMpCJ#BGL5CK7P{J#~ z+D9MW=tMxiZw$wRoamEl$wr1y*}?Y2EniO7oh#4bsp<29R z){yhqZiCyVay~<5{AnMPhv=ja9`n$mi%&o|d_?d4x}J~|+i3~rrfJ?;6sb$bTK1sd`CmJ#6@78+m zMFl_aNeU}If%#f~_-;NqLAD!w;w6V}V04=s0nHu4(nG`-NjQO%9Ud$!NU005ZsBMC zpwGdPMV@&?!Nr%*Cr+@nrR_1aEyvh6=;Xk%=cRA&9Y)17xoP<5$CmN?BE4yh@q>+c zGPl0w;pGHifweo=k>$lNq)QOIk!>>ea2MWgu}Nr{P>O-q~t$hy!GLK z@S`6I`s{cACnj^VlN$hjK!LxW$Z_<8f&Q)WGs(OS@C%2Z7x3rylp?6%BK8@}g(Ji3qc zV=rEB8OPsxn-1Q#8w}_Q$eje-pL$Ti^-qg0A6b7nj=dHyhm6A%EXTT%nOD~>^c8p4 zmEVACU3Qu~1A3i;vyxoAll2n}vL29iLgvROgeRg~EeC&aLB5*p^mJmuKgmnWefd^GTigD%OhXWXa~EFd;F%VK zfU8Yzdwx53EMPJ3w(X1h@+iJg%gGJ-RLk6B?Q$a6ba=E!c8O)Lk&>)ESnwX@0DM;( zKLBoj(2Ng-a_!NvUfYaat4r*me@#FAzy_%<)1|rf!?#W3RvhqX?7eNpFzD4LcK)3% zzmnI_WO?=XLM07GlXC$Tuoq+U4_;S10e@W|65z#Rn z|Fqd35gY?WSS<&VNsE^_!EtnbLK=UFZ^d}g%T6*wKP`CxG~^Ql-`oF)M0TGzom&L7 z5pC?-o?|BcGBh@wfaMBKh%J4?;D=h`W{d;}n(GW^V`WaiPZa3TB3HBC1CI;X(cj3l zT^kD@m2xkkH%RFlLzMIumv61&e)FOX-~O&RHV>b+9xi9jIBjB<@9;UH#CBwMpIC2e zVGC%y@nbW3hbT^&gw1Uqzi7d_&G8X_%UDjd*kT@g^ueYNJwd|t30sDIBOHrd{PijJ zPk!UyKEL|6FHqzu$D3`)4)*;jfi8TX|NH+%z4Wg!cDJugO%%i4CCJb%(7VkOiM-oq zj}B(^ZE%BVylJT6uO9rR;s4~2SZ+$XIsW567E$MMayeA3$sQ+HulVZZ&;9%x{!5@+ z*Q{LFP8HknEt!V9c`|-2-yKKhT?=%v;JP$s@Xmh6^4zPE-gF-)jp?jYtunQMl9<|8kP=Z*0UA6Y_M@tiT($OqbG{d^#} zr>Fc%Q4UssCzuY31(k%r=g@%0Vnh#K?vj%SlC~3z-dAn5(~IiFihqt{RRBME@E3BO z)N3|AHgo3z8ztK)$QX+Fg9b^D&I9`7g$A7wbMrN)fq7|5u4Af`X64_nrXb}52ZW{3 z!`L*ac5`rPObxNdHZe90^=ulpSB%t_nR6PPU^4fXJ89!z0K{($`0&UP15JZ<<;J3A z{(>ld>}Z5eP9|WvHamBcfsPzKEp{?4s#zn$EHwc?^T-<0)Lsw54t|r%FHKCxcD5FX z-;t-sw`|O@$A&L#VphIH!>ib|KkaAa!NGvEduYdU0EzSE79U3>YY&m(o`ZGWA^yYv3@u#=?Q5~A)ka5l+Tw5@ZbK8M6DQn7l zP1_hDjT{8wU@zOqD_Qn~HQTo8#-~uWKYPso3V8G1{7*6Ec$s)TVP`n^!K=TizkRKn zDZiZ{HXknZ2Ml$AdZn>9VepJO&zk)Nq4OKW*rXrN)1wQW zjk%A*21Ad{$CG%^TAUOkm}DmzvVPaaw%W$VaeWpca?tIQcsYjR&l0ctLdbcmnO+MT z8$YhMF9dzsMGe1mu4R53x9_jM@%3M-#s5M6DbVp|874nmn}Q^GL5Eg1OI1Ce&#O{4 z&SLb90D7XQ5I%`D#-rp}`OywzU7RdH#Dd2dy!ghPwrQLKIUUxiP;q!%a@I>>i&R)0 zcP;T|bZ0ztI#w?TY>4Ku=rrum=7z}RqM4!H_8T_ykyq@)Z|sR=!Q_Aev)xww%jtzjBa`?IV!_rw4z_Zl1QIVf@X1!>&>Ft8 z+O|Vfj~uo&s9rSB)o1Or8Ta z<_kT3NIo~z95>eDZG!bo*>y8|=Z9oS)fS11sq;|dNl44VcU~FLM6W#8G&B{kRPoCv zu$F6B_@*18h_)+o+L;&Ve0cGd&&v8BBfkvDhXJ{$6T3EYk#&f?*H_}=tpa@x;Pv0| zeXp(CuL8Vj|M;zeX`Eyyd*q z)fpLNtxrDHvJM>|JmOc+aTDC4Al(hK)P;Ps(5a6ktk9Y7IzVT$J#dUw^YEe*f0)yP zv#~R_ua{ePBJx(oF6Jr=y*I~WI9KFF{}B6+as$Ap6hHZ{c8`TNfi=w}!Xbrf3Z6!B zNea(aU8EGVc^29`u-Aov1H&iQgML>jTcPD(3KfrLEWFI?bgoeFwcQRzdVKc<-H}Pi zvQ@ZQ2w(yMTbps|ZS=w(dzMgM;9IE{N*pse4yd04%y_5>JZ+83UKm%8G^+TU+}Li5 zKrdG+H_@E<+Of>dm)&?_f7%GtSEnNaAKfU~ppc^1aacdqBG=tq2j&;a9Ot{qV?!Lr zDNJagr{9>2A6>YyH+fEe5N>}Nf)|+++oFekXf(?5}v&94Em1% z06+jqL_t)O3VcV7ar6+&9KF0>>MFO-j9tO52i?xY*jx1A!^;?3%%8P>`TtYB{C|Gn z4OuSqdA7gtc=`WHpZxz^&y@Wi{uIhW{dfPZPjCN^ zpQ&F5{QmEGdiBjd8Gj=u8Ce;^3OiK`O81^^y=q+zJ&QHW&Vkg znsdi134Y+awza-3!)~?Ied3n97DHtVUCDNetQC1CjN$J!2c2~lUd>B(18p4ineX+8 z9@uWwOwG9TVL->4Wxr{_T!G z$_+rAA%I*EL!-~asZ-I#nZ&IG-ICLyNzAL`XhMps#>F`o4ziYtfxcED6VGFS?+_c5BcNlKIP4ZVH?iPkO;SuL}+A*g;>7j(GM0-RX*%928dS9d0$u zkdt!=JC?{z?ZkhC>C8G_X|z%>_j(A;h6C;i=@I~;HUP3|*GM9Z9=>}CA-8=E|541t z%Rv}|`uG{W_(GqHEXJ^fOx9-ffQk-dTKvihCccJ8AAMxgEU$TZq0@trX8cUL4?ngc zGrrxINr;@=V%xo}7m$5z-5u8+mn2x=3vtEJFaN*hLigw<0NuLzF3YTC+x)NpL_Jgf zE`0)Cn3e5VLa7DaJ>XkJLXn$_zt_yL(X)TI5C#+!msqkNg}R z1k^mYPU}K!p1bzdSdv2?@!Bz1jYTSX!-nfwxM%j@J0L`FaOu`&3XkOwsy~$3ll$&YI+Wcdy33EcX{?7dh+18 zi`pEGxd^FnXgjx7f( z`}>@SPVk~1e&!RZ2B&O}4>1OJ>ji6$8D25#UjuyohyVG0i6$CfuK#GS091hq`)WII zoi@CJ^DJQVE{$GLUHl~KlzoZL$?;qqbHZFk&_6N5b_DRf4CRjqEW43Mpw?qlp3SjI zi!bS+#dnJDwy?0}G=*0Zwmo{#nqR85M>X+BmLB@HAK!q19$DsT(1x zunjX8cyia;=@GCEI4f5F_5sg`O2EF?M0=4tJwsw(KbIu)#ORySmIig(YVxx#u_JGs zuH_@!C?mg{{W)lxAN);YeA1~6_JiJ8=}g{Zu@BhnnmzI%{v*pcwNFk`o9G%-a?B$W zTKeSFn8J;2k6-qVD9BDb%jo zBzY#n&>sg|=;$zR-P^@qbaGQ!ivqF{*0`U=UviDL#6>B5(Tt_MhIBciHTRQ0Q?GFJ zGjt2RuuER{D<~Xyz6nUqHQ%|k&CYxBzx9J@llbs={moCG{sz7J%-1gU*^AGA0qV8$QdwdT<2vp=h1&V3CyRA{VSTXSBkZg#bkai0TFM;0X%RfUfsS7UDs@zS6b zw~Z96Y$i1*`}ja9V($=wr;VH-=eF`gZJ{S;;IQp=i9SAU zzhdu3lbq>bM2Bmh#%Yme>;JW}#Jkx6`b z>A~4N=#LsQk@G>-LMdeVQ^X`3`R#jfZ#3LxQAx=ceFojU=+|2g4%2a$K4 z1nu7Ax1SYbdo{b$^WZMFhmKi=xGmn`$UmAFC~mta$XOhVq3zOCAd21v{^>iyo z#>-8U!HA4|iBlWf^;jCgkSr~BJ$Ye=Rc|^rjFol7vv+;vjjylmXPeh5djU{nzx>bp z(YSF2V$uWRf@6J+9?l)jRd@tJjvUQh0h(V(aG)0;I91V>zgPnR$+hNW**6jS-vDoa zsy?7M!WyJT=lXbb6R`Ej@y;{6=1w)sUqbVSOFKh|m&bY5y%DB(}SrNbkzUaZ(<11={r z4Sj$#fG=zsBNBgMZ9DNlGVw0*x#%G`vT>wwNB4-0T(Rw(Vm~dI@FSDODni66&Q3Gk z$Y>nBOSney)xjeN8BG4W>fg3;irfC=5=+HZM?c$5)jT<{+-U5j1#*L5Y zZ@Z@~7HyZc*T#IZR1o$>?7hc%}bm&wI^j-28d#oIuY7X)a#+fLdPu zkKWna=NL3dOI)UlKv=yVH-`O2mN@%iJ~uJJjUIJjtUsUFHLiID9GVLcY30(jieX{r z{{n>cq)+ru+#+sYQ}4}>-npJb-xvDO&K{Y_nb+&CT(6j*kDThqhY!EwU;k}SPv7@F zPfvg4TYl@|w$PJH@=0w4LtgT&FXtio1lxvdP_?v*sgAqXNAlsLN_hIKbB6jHKPX{q z>fl--sn%U77K#1XUn1dRlZ_mwXN*q#Ngo|}-8PTV2GPC*_;~CJ>4F@PQ|yU;ch_e`(wRltOl~g^#<%{*5d-+D*1d1b=IhaonwkNUc&~C{NneW_qiK zCSk8*=G{s^vtGO8uFqxRA4Z$ z1B3tOZ}+PKZ*`OQN^c81`yBz{_$XC~bzaas`GD73)(q0a$1Zy6T+2?QbGm$*izsqU z-+3mc(1EbcmTO4Q_SoyG{k-|pdOOdMZZgt~Juz^;Vz;1nVTw*8_G_)8%cFV=e4yQ2 zk3SIJJ7EZb=7RQ^3xN;Q4%~(4BqK`_cA>*}F~E{6i6?F0G0y>#qC_4{>U!p+y0g%m=_MleL_>u#XrrmTP_ULTLXN6D-FW zfw+#(sSni&o%kf10~0m7(fKnkSoBMesq@%z=b1nBw8>X1X3>W~ctf~M?6|@vACs>m zKgh&AkHrVWIGx+zoc!eZmh;J2#0J67yEUW=BDY2rIyiii4?2?m9cyd~ex zL2!e;=8tl?X?!oAw&TYZE$2B;jvW|TD+lZ0bYuCvhzA&<()bBHJ zlb3IMwB?D<>8^ZI61PgIYvQ?laK1G`FKY^3))m$99GW&?KuxLnj?Ja-{2{`;)=kMm zL;j{iy;h4qkjPJRgjDAE;rys)9Zk&4%Uv6^?{-=Y8Zq*~N7FgpN+Ep-e)at?+6_R3 zz`hc>6G#|&ww$1otjL{#!*_DKP%dCEloW1sNhrx&E#(Y7vWR%RNDVs3DWZ#XNrdrSy&$e}hlIV+kEyn%$Jl#w<=VXQDy- zStq&5;ou1fth6j>Y}=o9bPiDFjb|O}7ea2Da$rxMC7AURofx$r?L+*CZDirIVKxV3 z@`8N1bG?Lz&hXoJXu+{P2;C3~&Gv7xU0ze)$i)vJZaVT)LZV%ImT8*xEpo*K9}vHv z$#~gE-uTefVn2ORi@rjgwG|!X7)v!(Ph&m%KUBky|9Bn8H|4AJ9e>dVoH$A+}cdoKH z({BtLYC>5h4iVz$P57D&Th2L;>U|*ES$1mjjjifX8um*=$E-dFuXFss=-en6yk zmKc*i+fh!=HgaM_Ha)V|JCxvCrb8LI&N*?VxlYS2da-Fe?d0Waq-%> zjm>Iejt%e{!@Z`v#x~cu93RZ%0&>{OJo=`IPLqYJuc2$58&Q;zWFwx9XN1N9#5WTV z^Iql)zfi};WM5Eb%=`a;>8GFa)3&o#i9tVY>+7bCVy#;_X5XL??ThP7oV3ZxMUwIY zC+DkiyyP5|y1R}y9ddNFg=Qk>k1x}(_hnAx+(>1++1WmdvCpH(5eK#Nd0KJr^>xG9 z6MZ(|(|`51KE3|6zpB2`uz4N0^RwwaPF<5n@*bMcOOkVaQUlhp9&VlB? zmio>E<)Ts691)Cf7fmI}Ns&kv3I)vA`jW}Kp`R^Y8?vo3wTosiz!%>OtlaXLc`i)x z+dVGJk6LoEiheCp{Bc~^B!+Ieh%rS!EsJu~;Gr8@dg$lD32is{TOOW$T6A)N2gElX zith*UiI~`(2TM!#a{Ds5XcOb2MbNoux^1>$cac{g8}yygy1CFiVqh{R&cxPmGlz!^ zu_D8P{iykX*CGx7%pE%RNd_Fl0@*ee`8M1-#y;Z^ZgMRHr7NG)(0S%Rv=&Idm|BZE zYO`KIz}=Y0t~qvRO^9BZYvs!o3erZdT(D-)N9^WH96W60*}s3pXMF&$t-ZVBXKVj2 z{Z6i$>zXX!0w2n4UwP)AuL#~=1;8g{Tkp&PbjMB(db6=(XBo%Ls2=2imi5Niavs!Z z?6oYcG#_))p!r6L-{Q_`ug(=yxAmwy|yoa%7x#k^_3q%|1UT&JBjo z!lUKp%{OhT?B0{Qh3Z9-~qee^dW`T&71!{p3^3eKaY6^9gvx+x1z!?C>HN(RK>rP}Z zG;r;gB-GPf+FmfxN8RJV=pysXU+EgFalp#F)s_vo)w1|9Pfm;<;_4sSg`GthTXOKa z7+F9&;@11EvEho~uXBJ<<47*ArZ{%6R!*D?gu&}R9rp2s{>I~d-kjKN5{b6w(S2u} zCZ#scCp7#~mjgC@`zS_yP5v9k^!OAWJ@VNio8Bi1O~!`C$Yze5G2K^8c$dfU$+2?{ zrSOnO*V_v*k;t(XUt7l~Kk1zf`Q#wHs&y+~#EhoL@A4DFBI9>CanBJvp8f0M_Ue~? z!}_^!4A=_;Us(8ke0}|oPs`6SAk4YQgReF>0}9h)(d}OnS7IV&P`zs;8IE)0c}z`^ z?ai^F5G6%+=cb`qbK%dNp5G6k-EIOpT*tLSPp(WSm7RM7FkgA~ z1=AUiXO{8~$ZSI(_f5|lpI&rOX6s|hy3qSrA!cO2;TXz!1UVm547v?ZI*E(5LA%yd z^NMUKegv5PP93!!KY&4i-*Ml1rz9=b7d(y;{|s*LHA6AjCL91;^=`1YgiKuc|Q{el;t zfl6GPCPT7~G=5a?iRaR)zmV*xb1Xe{!Hr)C85^Gby5=p@H0bEWkF7tx>^kEihG^-5 z&v*|Hy75+6u?bY#t8aci!!PW)uyLW{_woIE=+47qwYeD(+ZX>OHgPJ5tZ&$p7stk~ zo!2mOBeUWn7vaZ@A$qd&kQ~rY?unzD&x>mgIsXud3-i8E?+bnO-E)!WTN3*Ep#M#> zeopx5XY^x2yqb`=U82D6Fz{On-}7C%(fIn)E4|wAqz`5KcNz5cOwQr4^M=o1tTi8q z)Ft!uUh|p|9XA2^&rQJFf8qB&J@J19WP563Au_JDsfTkT+T=T+JMUokoPR3D*nRSs zHSc)|`le1ppYIy8A!5OO>{2IOBNpWBW{;zgnx1pl7^39Svz!4U2@yUWgKkU4xgaNU z+}Bgx1k{_%5&mlZN4o*IUHs*v3#(-EWX>nMKFNem5@=5ImFc39KnmePlFt@4x8~++ zo)c{=j(&?yx6)lj5NDE&Et{yqX|>>3`6f9p4o6|{As81Q#y6=r&SePR#m8Ei#%EeC zCZ4R_Ev7{utVK&63b7cE;a0v`dd!Zc1!3ddNO% zwj+k*Y#XPT>Org3)#JuUEK;zZ=gq^mag1RI&xaO&!tZ(b74`BJJo_P!Y_ELtF=3%Fc5x)Sa3(kvAYWI@}#+3>6T+nDu*cbafw#a_gL@?6X`7M|rGb_Gh zYo;SAUmZ$Fhux`&W^h(9_%kBE0BR_cL-{KcWl65=3 za>3WzBK$P3KOCvgNBAugF6?S=zq3#CTs(Tng*SD7^DVt>&wu{QKl}O5{#$;W>FL$4 z`GKd`{6ZuDj#w|_zjUGBHzn9}trVk=HS*Hy0y!}FOu%QqP&3^Ftqql*Romb{*? zJU=WPI%#0GN!uMd1roz2(k8~g(kNoE-l{5ZbtF{MSU$ual?7@`DSJV_h*B zpm)htE`XbtaeQzraiE!4HhzTFBmYQtiFjzQg;)5RkvHbz`r<|(C5X%%Zxh>>OV&aX zDa`nlJkT3k>hZCWri)JcFh)-Va%KHyB5UmSAD@%gF({g2nSe@pUg^-(-Z{R?9}=-; z>Es4k+v*-BxqzmY*y6*@By!!n{>WA93|%-Af6HfXyU1%}ohEkr$bb{NeJKL_Y1D{D z%;2PlmRGT$V`#Xt0iN@OAPxUI7fdSy=+tDN#Q22!qs9r3i(LFCZ|M8ksGiNza#7h>07_!-xEbQDc_mPTls2V!~tb;kH3up&ZAc699E`u zJFVoC=BfOk#W$}7VS?LY<})8#(#i4Okd|SWn1-+h=$H?1`|az%l9?g)B_Erqm+i3= zM(z(@>BjB(8!vZ=a11W&|Ge`@(N_13NBa#&+(~I|th@bZ+>P zr^P?(n^HUH7#aQfZ6^lr@R8h2W866#oM!GiCtuM^?&8zv1{&D*IDK??@3qjrqfxn* zM0}KQr|pVAmK%+ioR7xkpl+I#Z{gieoLuzu%#mlcd6JPEfeo{73c`n$f9&(pO5m6# z6<*KVb`iIU5`=672`Lr+f+ZUn&hdQwlVsUv4^`e%(F4EpWA>FKlI`$y!vE<^1ra>uHM2JA^A1*QI&! zJu#UKpmaj>O+d?ksP3cP0OSCqf3dBMIRO;5n-3L5o`avuA?S;Qgh5%%bZ>5&yGTa)R_d#EL6EM=ta9(BpRE+_HP2LZ?M; zFSg)?7g^}RpE2>7T3X?EDjf5~E0Lkk*wxYkzw(37BimSj9)E}@b=|RrW_fn?G7=-V z@Pk8N*|_{0J3If**%g6f)y@$}Zgga2{O2b0;3WyMk5hY$Zm`nZ@!kd7Q@P{V?Tqvi z$Wj>W#L|7RgSS-;w=goiqQ89h`gPGHwLr)Vjmy?<<*cM(p{)fP8 zeV)UgQ{W~*ZTw^0YY$J#I}I9l^5VJZu!v6&_d5>@z&JW7=Twzij)?S92W&x~xvh}O z@un!5_w^v`F1@R4-^z-;{B&}V^~CZOmP z5B{ZZnci{n3ht&eh8})=JY{f&ZTp8z=ICXE9vot&fe+2{5b#}Hw#YF*8@$9_@WxIE*gl4-j_ zUEw}?&aCP!V^vi^*)~d}k-yLNe)IYu8lP~dan13DjCAqCCnv_@eLE+rx$xT`tX%Dk z8xCSwyODHl#W?Balcr9UtJC!`NxNc`l@fB^WhMDn@f@q;6&+i{ivygSxbS02{5Px% z{X?F;@vQ%C|Mx)Te6GU&_Pbn|^BA$xl84NpIp(&>+==nchN_?&0seW@XMgBN-h9S~ z`SgkmH*fY({^dAAt-NM*HEtGo8z65R@V3USk&|xDY3C)hoCiEdbjd?QC;2wdGM1KX z=f-kP>#k~36XK_x=i$(upJn%1&7`~Y3D)iU;Wd3pOq}+-Y?nVJLquNxV(quj!2PQ2 z+c~bjw9S1tekxbFu4K#!2NFppA8dVap`S$yonpJ-7X%{DFX+&O=OUul1$3f)Yh^Vc z-Jn~(FFg2}wsBl(`xGP&T+6$KM)&TCTHv1<8QWLMHGE#$qHlqO1>f&Uow|- zrGt-BMrVS=cc$&X5ltBF7P#g5Yv~?N{h$|HubuTaNLW z%NO;CiyV<>wo|bE37HTglejvph+KXD2$0{~Z)(EL zhu`ttXsKyp+)TzCvn@kTcXo2pe`aUfW6VW<;X22iYkcs$u@Orh;nA$y^2qwkgImjv z{X}*adEtVE7#rBeS6@U?J2pse+z9A%2Cu&N>#zA|OyBzFe$Ug}Kl2l2QLp&M4cw!a z3%~v3RRcZ`pnoUq-v@}TRt*+7lUE#6+i^HvQ}9=+d7y2{(Lr=0j`X*c*cA^D0WqA!~oYkD_|Una7{> zzy~|_p}lWQyr=w2Sdq7T>=Y{v`BNurrw&>P`;Q$J=K%mQtEa}I-`9g=T@O1?Gjao`@ljuFr4rZT0X66FT-|3}o@$nZexC>20^=sSnMx){8uO z=L3LjAF_Q|wB?zD_yT7WFlrRf1=N_u?!=Q&l0&C)662)z#S-0HLtc3dHYs%p8Gy$; z$?dV{ZO6L%LR>1P2eVuJG3g?gzmge#%O@T#PWE3upu@M)n={?UyXZyHMggJ0-I>IKlY%W9F#BSm~<9Ia3jo`pfY}gB~$IP{9 z=X#(QS@fo%M3Vrg;)gONQDWew8MJU&Uv-)&=Eh-+mi>ut_pU?p!}9Ozi?-XwtKZ)j z_b$RBf7Z@A-*R(Gw|-sh@8HCi8zeHFnw!?5Re^_Yl%<`!)AoDJ=Y~MvZQqS&`E0pB zZQhJsfSGv^f4xTTtV*qPI3MxQ!g%q9YmNypkmF%{NoXBgb8&7tn`|4&r)ku`zyUF@Y%oi z`{yPg7kM=I_(JLtws;jl?UR4>?|XXsKmYIXed^Pq)5ZP}t^qoZj~sL}j;1;Oas%%< zzDq90*B+zpUk6nVqU&D??K+$_vpHn-;14XCtFruwEqK9wy=)jE%IGiM0Y&)(wU2iL zz~brl@Zjtq3Aj#?BunGmeUZl;0K(acF!D-L?6_~UeK-fJ7(C|0 zOze!i-Te%;Y6IS^B``W?ta&!ad#Tt6xhCFy65JNC9h<>szGXJ#R~_g4Lchm1+?5b} zk~s0IeY@q>K~$VK98HKn>&{3f&1T zG9Y;8Jn;9$pn1@fTV9@i6`>(8AsGT^4fetXiwwYtfR(6pG^4KL+cTU*9Fhc3{}Fh zEm1Lq6uIQY(V>NQK8~Lz&>%ePRi|w&vYGq=JKgow< zvOf9fnQ&>5$(r$+Y@P+QtJRd*z27IItcCLevM(N6JLi|Y8nAgDJGmNlYVfsk=#fRd zp1p3Hhwp^8^LfNm*el=GuX+?H_>Hk~%T#4)85;jMXTa%yC~N_H$V;){qV@U4j1REe$K3~jyC8O;xhuc8$LM)ZTr8x5L#lVt;6Zb7I@y*L z7OC&txgaHjhR9vBjhE!cjo1uEPQIq+qNEF}aaB;$>1{SeyX*xoeY;?RT(C;|<^7J` z7r@w+3Kro9ACwulzgvHJts5Vpvmu)UBG_QIp`^v6z1z45-R3Qb?}M%JG;UPZ%?dtb zLneNO=K6^a*6@+jhRm&XOr|4XAp|ceuNiFWp)8aPZ0XuIG(C$rWAZa*@DOcX>leZF z8bZ~>j%&ALN4MvRZxrG6HnixTaY_LFtdBhg9~^k80eF^0PfT9~8TUh-TWQu<3XX%B z^V#HdH(J{a?_E8&QSohKoHqf3v1&K8HJFR?U0TIgKAI+< zk{kPukNB!aAKvt$GcJi5BO5+r8a1smM0;-+y%NUfn)P0oV4OZQn7i4<_z=ohrn8>E z;BED#`d-72b_3uf=h@LqcP-?Qpq<2uJ_+|T5LjI_7F|h<3ORL~f*{aB*6Xng5M_z@ zfTO42Sr`o#T5vEBdB!%H3l%!rd?1_I{5S?pV_)zzoLHcD+oC1~n+X^1Jr{{Easfpo zn2c%X92)Y@0TWtdA`6`p?;|~UH$AClj^8V#9tRH-kz*|Ik+sapM}}CBo0W|bY-pQ~ zYML)*;@{di$7yYK%BqtBV-8mV8{3Dtd`8YEI|ishOG6yJ1ApEwLLON#z@|;t14wN% z@x_8AE-|aK3!iapr-w!ZYuEhdcZtN6TTElFV{(o}Oy8>Rpvi&S!bqJvPvAMO@YTQT zLrgn1RJd^Li^q#M0}DTI1MmxaEc1B=jcuaFDYn%d3%+ZcV?D9q6!gx?=xXk~+$z$b z9e+%m`Nt1n?TYQR;8s+vxexKDE~4ZYMLoACi*>`=FT;#)IZ{51rWi0i9TQ zJZq5nhJd~%_~yrc{LR}x^4~ub@eQ7+TyzlMYZT$PdJEvS{tp3P6U_w=`q^&038M03 z|5yHZ{ezQyW=_9p8PjJxve@e_xw`Rs;&a2w(=%D*9;9)wg7ZS{PPuP){?Tp#Zbg!J z7&>VrCD*qsb}z6LuzMFlNnf>|@$KdLGYhSd18^@~ALw0xT^ttW6y_+9adtsAUwVGT zXYq0|*nZ>UUQ~N*_xmC^q2!`Cgc?MTJ{$gpzQsB4Ztb)o=GJ4pbqLd2#|9R2P@|W8 zI)Ct&tc0$5wXVANYJ5~Gfc)=)w?Fx_*E`4$@NdT2SMY7y znZvW1$DVn~&yIJ20fpAC18nD!fm(7p%R~x0HMDfoD-;GGk73E>inZ>8(8sPOvF{^3Gy& zIrbt!?vUMO@=JMVp}1RF7tcKN&jHge)dG^}UP$))3l?DXAN9CG4!~i1=|mabZ9B9t z>bK5AeEe?^{9%la7pxtWkD}opyoVT~f4dD^8xuP;>_dm2E%cJG*zB$-R*fX+i@NIT zi?jg>J15{^)~i#6j6E7FEUoxG6WMvvo=*{P|B*mHYdifwflW*x^f)$5>WT5iPu`hd z-i(HALr;#7ON+d*G?`;axxKA#gSWKhgn(~H8@%RCY=<2G#3n7d%sl-*SA_Pio|^#< z)9Wfybwr1_DxWVS;A_d2jPs?T{Zl-rpGYTO=Z%vFAzA%vd{9k=wH2>s0_Q;&1-L|0W7Bj^(ufbhKF5Z9`A4+RdgM{G#}x_rCp?J+|H#*S|>}XCK|Yc?tAL-rniY3#t8Kw>uJZ! zxO_jQulcP$B744l?eXbb5R5N7a?Z9?ScP47p#eBGLvA%@*rVSUsU`Ak_|BL27ruWv zke{V(+QW0pdZMun-RjsblS>{nk2O!Oc5NMVgIU8urPJhE`)J4C$#L7zeCuKs8)FyS zty4|}xq5r49r9^=9Gd&c1P@}X*$4Q{>4;^^Em?*ohad{wBtO0`vfl=f2tO3``5*ls z=2d{iY3<;s&ov^j{;z|7<6l=l_q={F68^(ic}7u#u8EI7$JK5;P!p_i0t)&@^CzmU+VhMw)WKjVwo z9ut?wZ1$fqc4Di0=HQP{D9C2XYOGeVASLA1i#^2Cf{{M-^k3$e{h~8{42tm5kEF!U z?K}GAIRJ9MIt`eZM1Zw1WxQHf&KTf6i;aAAPEeckgY#e7$#3)rBRGUCgZY=w`fr7_XE%8G^q}#X zk57L1*ZW^ZpX|Yh5f=iZd1~$0HIK*8(<(QTZNe>XU^|bIcaN|(j-Igr1DddnAAhWH z?UJj9xR=3y0J{-~u4n4Z~S5RCqq(q?}BpuqO=VF2ijeN5)cLCI=F(>hL zCn$;aEEyVH+A|vg=)P=}A7j4_7eWLzV7dHqDsGF<-8F zjGx|WjmBTE`_7krZ87lNQlrQ~?3m@Gy(&5HYlrP@8dBwoCyJNs5;HLp$N-B#biY4) z#&`U%&xLZ0qHl_RI{4LJ{hd#**KgnB7r6O~)SPofPj2J8bJT!!me_V26-p)&$F#2X zt})UN8?rpQHy&e&Ll?tpkr^KY7sK+y{>BLWy@jsc>!as+b#<{TrnYN(#`N0aL*k2% zC0j^0UjiPVnG1@Gv^VO*2Lkoae)h#549-8<0z=Qm-){r_;IAz5`(Eg_57KmAkJ=Y$uQ4$H(Q9F;+lV~3-!msogi;_ zZB6xJ>nN!O8;R?@UA^Q>wiHR!u5~V4K5_C)xS8mrJy)4Q`HDa@lB>w&0K`Vd@U~nQCVqyV8n`Yj zaQXHqj@&TqRK#{t5`5?$cY^$DcO7$h@E_U|*rwYT3<%4wLS<0^q~`bcn|H8^-wxX! zQG7{QdCy5d{OKDkYtec(D4XKVngA=CEnYig!;Amzi(#zO{@ANa&-^884t3E0%;cLn zw(Ya+gd_SImha_<{a!rJ4OBYCXWz$Wc-ZQewSz-*aSro6dd$y_gMK^SCk)Y&`xhY| z_+p)o$5lawTy%3hnS|W@5_Oeo%`3+bo8(a<3alfZ+#oCo8O+b!E`!VYP{3Dj@_>I#G^ce1(5)*Td;kSR4-m3Xu{@lC`;Ah{5 z?m6|hKDf#2&jFl#uy6Jdz(sA&lZTZfSe>rnnMAzZoIA!x{K0}1Y4TR`szZqIPhD3@ z0{HcyoyWko^+5b`Ak5>fk&wTg{-fOhkd#Lk|Lr8pIW1!sSa$?2a+vh1z#*r2E)4vJ z^8tqtg{Dv}{Kb#{^sP7ZJ?O>af`bEK(+~rGT4d{vWZr?pNs9~`h~#L#-? zqT4Y|Z^7CX`>KU&l$th<&BqtR;e}w?Lti{l?UQVtIYiE))>*zU|Im4+2dLVd%OYRo z*^f_9B$HZ>Kn4hAop!9Yx#D>9SM)Z(pZ*KCuX9HIQSVm^bd&e?yMM_u_Dk`cg6DBx z=Y#89xpPAZG^bKM=eyWRZ$5MeX`1-+E$WjW>c|S9McTEEPWQ7uH(hG>%bJA&JLe?d z0JBW`T(VWa%J`$*0C2YlICnb_0~YU5avlXkI+A4gEMU_f2$T4SxGZ)SGm5=Ye73Xs z;w2(tw44YK20steh5774*U!S^b53CUVhU~+*duyq(T&^8PoELpHSZ z(T@(;8He|%Cnw2Y@`f%x$6k8$%@d#Id~|TPaZWOpsvZ2qn;uTaqu<7ARj|jw3y4}; z4?Jxiw?Hy&EOV;A%E#J|h1+0aTK4Lg zkrsd8tnH?piR6eQ_Cr2}&SB)x4+hyk+uQee3*gjw_06wyHXpL>YdX)Yk{oFL z7~wTA517a=o`+q7kb{-ng&tYx#yWD4urZTdSO+<5w7g%jG6pz}g&;noD*X+`9d}4L+tOIYI@Bj zXx^1#9IlogJ40N$;h%l0e~9j*-2mX6-~eg+O%>ory(hAYqd!e!sV7MSrl zQAyV=24>3o)&x3Y>TOpx&?y_WN8dSwr`8arL5FINc9To&Mjrp;2LMs%-gXDH__i$l z^09k#yV+hQtLJ6jxBu&(UzGcBuh($=90nMsMANuFrfk5+)6mbgMoA3l9( z$ZmP)&TGdW)W}8FSmZ(i-jo$|@hLjUFi#qtdohu+5Vg=ot|JxORd+jT*}`eP0Tipp zsk!B-tN%*NYiIdt-?PJq+d63PL!FS(UJ$#wAT3tLi;9fWOXy}uI8IO1lG&l z=<>J{_cFr7PaJ8T6V7W;*+Xh_(OTZIU`jES-2ySab*u-F2Bs%BC0g|%CkQMzGWHYX zQ^#=eC_qZ}HThA^guw5t|q`uJ^*aQ7Trv6&v1(Ay`x$Qk3rhr0v)u3+<(59*(J{1gU-e^-Jv~a@9r5il}lKoh9acF;5ON zzU3Mz=cc@*=6lVEHLZBa^%`sksrdHMXYI$zqIesbsS`kHTn9G*f0M^eNF6Z>+~ffu|%%0TNzW| z@xPLP_7GmvB7>9B4-dPhiPs@k-}tO8>^y3NYY=<^%aaT)P?3?J*21xEy8VUn4tu@E za;%|uogW0cOc{n{tLe7q$XVv-~AVesTU>xcI=lIV{-zzc(#S}4_Y0w3BCoKj~Fh?mXq(3V`S8v!&ZCq zllm9Vs_}07hHuj|e#K4RZp)R#bZ1`z@Eu!XzF1b7sRjujos1D;%vQC4(lq4ORvhu# zW&}$Q%<=?!aul8ESN@Ozi^esysyZHz%7+@{xcg9e?}alzpwk&ANIRlA2R_>*fyB0) zh{ksTT%IzQ(vdtGbJNi66mA`;mg8ojv2COAVIz;!*tW)fR|mhYzjKF7F6@qF`4W0;(9TUS%s9v9`>8yozUtN7-;tUTL?^5m9; zd4G0=d0D8DYu|PzqwDKkJj=_k0dfv_2h>3O$AD(0LErRm(%2G_ETs)yIlJmlgyb>V zwUXCK@)UiERkMDN$)R+ezsppuGx9h}icfleYjpWE*vG)O@vJeyaZNGTb=%ms1U)`< z-Z!jemOt=}k=O^<468ci700%T0Ot1bZY4hZ6|7&-+coPKAL0A0oa1ePeOw#kT|07s z&-$D7NxXVI!XXC7o;WqfZ|L@O(M56_o@S-rjMbg>h$8iwwbt_G-Q7QYO??sLiD&#z zjji4_{r}B}0CDtvX2UbqrM{&@IA2x~nI*Ok7|MU4UE zlU!lWg&4)AgBxtfZlc8-7g4&qrHG;BV95OJ;m7vL6XO}_5h41gZDezE@a#Zs-b%+a z=zT#A78gBy#xvW~z!&Fd+E?z*95|<4N7Uf&JbNx$cphjMe;(!C(&*?fcekuI8hx%6 z$Yd3h$-71OpYuA?Vh^O&mKnL2?E;6_NSAGA} z+waoX0pkzM2jj_^*o=9kJ`Zf%WaeEe>gkVt+aZ5g|$9Xcy7m8Mc=obxBTJ8qwcL|bMM@p&NjQt9}VxdpSc z3s|}u#{@d@YrowXsa|cFe$YPW$PWq{umqKPdgvsM$37{T-V2K~JvzaIVR~01$#LR2 zyLW!U%7*;2KvM_xob>S{C;j;89{P=N%^xiYY|?U(%KXGeDl*82e`3O$s}|#T+ziAP zV!H)9G;G9ve8>s^j5P;0^x+YsCc%we`iyOhS|0U$6rb0CYoqAlf9?>lYX)w$U0^eA9#}bun^c`$sQ4koZ`Qo$_O2`> z_hzhJUBpG`FcO1Bnr2>nP%lFwAz9ezt&@P-MsmuOaqOV;&{pdq#|Q56p}UaV+jPC0gsyy$AdlmYrDiz&$!7-k|zE&9Oy^Bp|a_N zd*l+3Kvw>G7CG|ULiSVh$QR@J9y!iiu4m`ZQc@(YAjIDGbI`E6e8{mD*~G)TNyD%B z#&MQo4c*v8-fJ3sHX0b-YRd8S>hs&JKrwp%g*O2=>1$%&aW`9>H3=X1s|Q+%5%IGn z4>vmc9iMfaL-I|i%D|3!*+yXFU3Tx5b1tw=oo~qL*2|NS_+-o5P8c7ce|BB`0GKz3 zNf^{l&;(0RB)UtKG!4IzQVi>}L$DK4>8m~BB-07zLdjn*&d4>SyE<`Kv4I;qE#w8? z`tWBHZMo3Wjnnd+yf|=Te~(e{9cF9Bc7SrgWj;MItqau9*C1F~WcUeQPNv&##^|`Y z7FV1hQ=>Fw6B9Hz=4uz4(A}0^NcInn<{3}Zxd<*D>|{ef`0@8vFJovW4O8+xruI8+ z_CIoQ418+GU*BXQ!WO#`MgL|SM*A9>N{VLki{=h6wH)xVXCuXuoE|F&CQF$ZMqWhA zY`l$&H2#niz}ts5VH)&jF@W1b*h`DQCPm<4SCS%34-Yx@whxVV#wC)!381e6`upD& zs`dKfG0-xvfA9xxpAd&kJigpuTie46zhrpWeeD>hCGXBlh}~x$dhK)^Ti0TOO1x#m z32+`B#g2|~rFFwXjkUNMb7U+hJ5!%!6Y-V+z}h*ktj9rs@8m$+*x0sg2b|jOUWj%Q zYz1L7p0{T=i4xhhey-7!_4Ux(jh^hMPL|yM^w_a3VCR?^iF?sAbRL|R;u0gH$O1dL ziqfNarQnzCqp04#GLLV`d(Ss66{hhdBx6V(!zyl}1_PM;@F!rZyAAE8M% z_#Q0#@vm_3LnGOSzHMcWTrgr|;9%x}%2@OGg&e6&{b6u1^#yf6G_VnModetH_%lY_ zxokRf?8KJWbx$gKB*Ygs@L97pDO=cc{06Gyp_EYYsQ4P<2Q9rPdl&g&wycl7)-7ui z)Z9KHYk83x%l0AG#O=Hwq4?7(j#(`u8~&|Vevs!ui;eg&@8h=^tCwd^k0n6hk(pHCF9m-rM2!9*NJ`5Fp#LCo>)Y)=>J0!+IT0kI!n2<~2_~`KlMEIbCs(#xbH)L?s3Ac? z_gRA^dJ3}np>4h34N?zWq~J>^E`TCX;K)=TSMUIOk#&ef8Gw`c#T1W2GdSo!3#7Sm@Iavj+G;3%{r}w=G@8!=s<^ zaibKTd#`|7#N(%__ysO<3kTk=#UA6!p2yGhzHt(kZ!(1IjAMsXw<|^Mi4+sNF&u2! z@X6hCc1S!n&CC}MrHnD=ywM8CX5!~PWgSp#^$)Mtm{1u{U z#@xCZzxm()@|*Kh;)v{Z9Ti{v`9Ya)UKf<`(%Xgt!w0{uqgbEWRa{v=(WB4WAwQY- zfNWH2o+q8N!H-aK5}a0@oBLpNe&wqEq@=&B5&Yk{p#$aE)SG|A0FgxJnosPIh$-hj#}S-9KTO0LIM?K0h=rHAYiiKTkEyBXSf-T^yiyC4sKn5@jf@39 z@b1j}2ZhhcWWNjK;AG{NaLpr&16dS|eL!~NuahDAYHI-?M`C$c2MddjxRq9+Bzm)J znTQE53&+LjB5&UUNsmloNN=Bd@th~K%|kyeHa+iW{HM*xKI(7!4+KcbEib*uB6{PA zh`iH?oCg4O^)ydhQ=3H#Ch}?9Kj+nO;I#}kwC#LGC$!xMm;Tv-!Y7esI|q_iNY?`! zjum^f^`SoO;Z5wq-s8;i<5_z|L%->>MzKI%Y)kffje`g3AWP4_z)%~TFW5n5a>+!E z_hE_MWAz;LtSKM-TuimS{@(9-dd)LyePWyK?gSu>i!(rNrx>T*oJW*LHAbgzQR7qM z@AJ$MpDo7+0d-M1>k?zJgT=`i#^OI?Ip(}9|5?v$Au)EYdQG4vz>dG9V#~Y-JFnNK zGX^jC@!v9{(~_~tR}v`R|4ZGwhHAI2_g!RP(x`-Q;1-( z5o--mDM%?&Qb9zNpcTY3`Y9G|X+Wh|YJ(arX~_pe4z~FarDdm;#HKc+Ih$l}c9PQA z?Df9W|L=cY|7%=h%xAvqU2DB_m)>(eZ2(V?56@9yo`4!Ogff@aAuU&qMg_ ziX=+67rmB=CA^qA9@yOW2{bK!41n11ub!IscjLeOOJ1AXTXN^&LAUPmT$L`nsE?C8 zY|zVYLDzo7H)9sR{SZAlCeF<{?nU;sx6h`wCiA+shLd>J)k_5*LECtyPKbaG##vJqI~UL;F^H+~?!m$Dgo(&BP9iEQi4*h@ zAifAB+1LUte5-jb5QPAKw@O}#0e*`gSPml4WK2)92^nAHhgL@n(~7zJ%Qi6!c>Azz z&RK2k=!ZW@&5m$A0q~9>yX;#0rHMXpV;ece4{11Q78v@X z6Hd8Je&8pboVe_K*gxda+Q*t4Tk70-X<@dXlbh>@u~_84?-8phxDT{zgE8kf1~+C zrQwdrXX+_{?|l4A?_T}jkIuXQ`-@In>teley%%%t~bYxxte{cVQqypM9?K=_otjhjkqT$->??p>*{zddDa#$(?OHIHay(%R{sx zDKxH;j#Fg3?SAiDZ&h|3&^k2o4d;oC3zz`ZzjbD3U_#*hdcqn@>{$7#Nr0Ug@D)o1 z`IiX8zpoeDY;3v+-ehLJ67;wuAU*x7IBU{e)!#==|Yjn%D z@M9PCK(vjAmX=;a?^wGgFm9XPa$(yoJ^+jE;4_EDs+>!CBUxpKpbdk^hZE-U2fk$o zIk&7!duqX2&D&9NP>%~|6BX{rqoBzVyyZ^=Szq|jPU+Kl4 z1Az|)x~hKXo4)?;h2CQ~U+ONhR=}_$f4xUp-{caWZuVE>^ns`6XJcu%J|M#5u^mHDw&jw`imH)ivuwwew8Szo-gR@OkrM&Ax4#_;16sECeAF-(&&U=L`ZA~Y zpTr`|b}jQ<(*jzacuQW%Fm0RT*v2PpL`LJH!}j$Cm}AAZeG2=gV~iNbNWJv~w4dV8 zewROvRl)nIb-IH0=QSYb4pYYhUZ2dV=YlXih~@2|2c3xe7Xb~pv5{KG3cK6(TZ@18 zr;#Aq_z|D6WnL}I^s%*L2rPD{NB5vL2A-Gc!LFdj#>G|iP2`NASIujvBRd)lpPH}P zN7_Ey9RRGF0AI%n3I#)KDv2WKASJLXW{8@=Gfwa#A6bSGdC3wq94BvXkIIqF2=}G#YcY){W8Dz2kVb#cB@LlAx=8 z7I46*yVDc*Ui{{d-@Wq_9 z#zQ*`oM@%>FHA+dOF3YBOQC<~y&pyNNTtr6TgT&C%h{Ybdv`sI36)t0wWk z;r@&VZwK)hV15$#(x~bE%>wI72-|kYk@1B$(~}<^YunM|$sVz~+SfGuVl6(!_h=?# z*JDp?D19@Vf$60;@dR}WcdG(8D-Bs_D54y6O|Ll|fnN*5;vvu=xVdI~`*mD!~&I7~4MYRD!wY;E8;g)V2*7U+pZ) z61j1VQFV(CeW#4rpSBp=G>>hOpFVjTUFhi;hK4>@QMtlU;dq~T#rylZ;(u!4pZDD* zlcR0)agy+$XYKp*0H4#x;f^Ev@F#Kf+&(cXoMSFq-3ZM4w1=PnMLr1l;;9ddZFWvn zERs)_^9ggB^>@zu#G}BM*qay|k$@L{ySnSe>Cl)MJEzG{`?7s*QH%Dmqs?y8k`7yeUbm1}<0vWbj z2E+yzEHUl8hTb`V2wHZAZW*Nq%s4dmz3C&wP(6B@GQT_%hFR)B!jMn?;( zov!#XCX40JEnbG`v@HvM*s*Fs_C*HxYYtMIA}UGvM}#|;5l_r%=^0ZG<6_$b99ZZg zKmM@z-+kug-GhEo_1%B{pTB$YhyKO8eGrC!7Jepb$$8Cc%ZCE*)?<4&ZjbRMkN1u( z8~>)`H~HQ8#2@?l+yW;zGJayQqIIZEpc(@8!9g4+!lpon^^l)4YUbp7=dbGIEEi#6 zj|n4NxcFkat_{mA0NY+rJUrPvbVAwZOD&nBl~={br$cXSyZRwT)AsQw;Mk1~k-@%o zY9P4l^jm)83H-W=@9OA;H4lag?P<{m7^Uqq_HZnl*mf*8X=Kso*mW{FGQ%X96*nOu z)QdT_&dX#=PlM)I<@&-t!6bF^SB_T%_tA`@r>T@k?QJa5}f@%A>K)jI$#966IP z;>IrI6xJ1fbr&8VWEEhCiew4aaXphKNkI-zkpbTErQr7gyyUhX(PHD?DU25^jgx@z zZ@J*%h5lCCGBbXPHvG{Yy6wx>5`6fytJHAWGV~d=t&!=Sru{s;t%U<&u*JWtKJeh+ zqo+Y1y1h%Jb-9vvXkvQTdQ84peTSD$>u&h(+RGd-~! z`x-yXs&>BWv||7ZaZN0!oA8h9$ffUmAsL>uIb%H`@aSD(z-=%zug_LQyu?$vkx#k&_@_3KZtrzRXw zSupq<0B;{W=xqR6orJ44h*k2y5aVK>2{BOg2Fru23c%KW;X1P6@xyGERk^D9DRxb= z<)AI<-LD#Ga(9+f1O+x-*$SYlzHJ|g*al9u7De{GMC@(wv85Rg5+PP7d@PIh^bk8P zij#fZ2#{RRDYfW$@jHGKkPp;e{m_q{-v8gX{U@f`yiRJ}A@qovMR#^2Irs`w=@R>))R4XmjfDY*`tk~bm_E}E=cH(R; z!Bm?fCO%3INu6MH;sP&@gD&$&2W1N;KniDdiGAq+E^H3|kz+P zB`)yAkI0TZ;Et{MBLAv4zQL1b+SV1=2oNCBICy90q)(20=O#hHBORD>H)rH&ki6f5 zfy1`(p^4&SV#)lL91f;lT*i)$Z5$yc9n-`QImk;)60_)vot|?y3mKf?=mUQD`egsd z{@~Z$-Rl$eSzf(ZqC+iqM+P6dTA3U;fX3{_g%`dJ8~=^Mgy8#LvWg4u(mn z`oy6=*<6~)5N&dXy}r9pp928*@`rzXJ_mpeu^-&EfkWawp95Ge{#jec5w!J}31x#~ zR!x|WO-=$L!@joN&|*7s%!w=w?9Yf25V?}qsw&=^C$}XNlFD(}09%B#Oc2OHoV7@z z<}_;9HtQtHbMej{SIa@axDF$V-qhMe&Sh?L)JUi6w((eo{i#R5iCL38?v&sAFVnX= z`u2Zn?5PvcsXv`~zWRY5IlceCPeKz5#|uAJ+{TCncU0To!83sM1SptM6SzfSdh$_R z_g6AxI|1m`#yqkKhTU4m27Q$Avz`FNFv`?Bb9*m5aMl%4=Q45SDHtZVD^NvV;%^}WOHhpMgOKb}~W}y~+^p>Xq zHD-Fo$-c?9hN6dkw>f)lQp;?AI5?!}kbm7luoZX?NE?rR%%SlT13fnLQ@Bx zNIUvD_yW=an8p9q_x)cS^-Yw8@!*g@*x!a=mm6L5HEjE78h8i`cL1CZLu>OBpPbJD zaJ$Z?0E_sY;!rK?kv3qP6oU!wSU*uM8SMRAl zn`YtyEw(hDnYGD$(*@^#ge5n?Vk(U{jJ6SRC}U&_`V-JczFL`CyAE3LNEkvKXZJ*hQv9( z7^g`c&ot;9fRD(~hL=5N_SZ&c(XNU@YBlGugJn|LaH^Y}Otiwt3BP;cLdHCH#iz_e z>;5fmp7jJEXKr5nF9tivc-`JPkfPbnk`P*O6e0Ko#z<{)pzwkr0&_(!LL=PM_u@mCZdgp3+S+Gw@4m0+)Zu4q=eai@z)G;LkF+ z5=O%9hV3-OxG@dGHb7ukPhg=hE^h&W9+ZSy{s=E(>sQF|Zzc@kI?MHw1?&Y_B@7=4v zqVMCs)Wd=D;VbHj80%AA#Lsp*NSdjj6N%J~7bVxBAj@6v>Y76ro~=G(J_m6Bm0zCe z?ArragZOx_2hH5`Hh?~~DDfS0w3IejJlpnm-7Iks6>l*dDBFFpx@ki+d2IifJ8n%d z_@y^6iNfq^)^)@PTpKng#M_cR#(tW;?i>|F651Z;Pvf+i1CwoL3sQZ2fMaabIJO6G zIXPkEttegnlSgty%^eywc};uy-JiIPZ3D<@{i|=GKCWt<+On@FYb7#16NliILshFK zGmHPk2C$VJre!S(#BuO(N5sgLJ+3*lDXxhZvOVu(l=lmSU{}{hyK0_{qldK=@o%d= z+X;XT)fs+H0_J!n%LyKiQPDYw#QE1bc3m72oCKH1Ot48ffw#cgGQZW#4=p@uSE|fo zBXm##AALPXhG~r3#V%_bNfY1j&^Au#; z(bk*nYHN6(>TlTqY=z;(iVYP*N33)TMgxhbOP}p$V%z96V0>*$ zp{ogAXq=~JUh}Dq#aD7gyK*~p7FEQzY6{VhT4LXb#N55?%kgvb8Mkc`GWtRnn-Uw> zpOCP{(^LK%2R}UE=pzSs+N=NXGk5QP!`FD~#b30E2f(RsjX6P>lhMBYZ^p|e`%Qc! zhgE?kyX?mDYNlhDIIz!jP5}Guz$fene`=C}JjYPu=#6N!I*uMxtZPnwM3obR@P<)* zr;}vn{Zi)!eohhse2e|Fod9ebeG8`nK6tb66Gjq{lK_o<1-lpt_^mUoT&<=E&0<+d z?MBh5IVU|1edO|0vm~myj>2w$6VyUY8$uG>J_JN>ec6pn6DYU{YC=$IDaM)|+t6F@ zDjLL;o}#r>VDY*8NP<7O4NE*MdedC^hj;0SoNFIibXJ6F=h*P@0K*1aVw)b{y`weW z{FYhtj)8C-?4hHb>Zi$o=imX)jzx4qBLo5TaMxGtHezjnN0#L@?=iMA$vv2`losBM z4P2YC3mw3*&Am1yLhtwM3SN)zefQt{m;F^M-vZac^r_|pQD61@d-S^EfA5cc`?2un zia($FS6qGI(SdW@i+t}B^f6}nX#KJqGXck3;La!qLu9J++W`Gezkd}_-~E31=l=5D z$9~^G<%J>fh4*#s{@4AQ`8r^9`hbmg0k)lcJTx&DB@sYA&0CUVe1YTjwD>{G}64Bp|0G9h8DOu!V1;vVr|if7ZLuiT9TA zQO3`D0>BQw7ylUQkT`IGs8=BT+-L=JJ4t*vAg3U6a1JN5hLMD(9b69zQ*szP`2mJT zLC#gR<1L+IM-tFHN${MR1s}zciBnc60K>L1{1X7-=^P;;Bvoiyaj!m;+(XsJ~&pihQeafE?`|%_{ z4xXmJvG~uG6*>{75B1&Y7W;dBtN)(G{_}rzokU&Vj~6)+NecIYd;5Y|<|+<`YU<6z z9Ib%^wiOV$acp?Fd=T*AC-pgiAN|=E_dJIH-!44Odg}N(;QinKg?D%V@27lH5iP#B zC@-`&Dohn!nPy_AbECM~~ei8wls1XI9rt@766vu9!a@ICwBv$TA`hXnG9Gakx03Mzkw zIfxY%7aqaTi{@BpoP#bf7to>!T}db&>yRF#bo^=@BEg}dr6^p`4L3ixj?!9C>~^sU zRvL~T@+yaDjR3*hr$DlE#13S$(^wT(OWlxXlo4%j)5XuBz3LDGFCr~FFZ9vN7^1{s zd2Q5nvvcH_bsV7!&L#u5>4!vgUgJ=1pW#E(hR@teP{iPNJNYG&>pWbMiFMQzyQg#3ABC$t8H75%L`a^BiFJ;>%MLAw`%kZf3C3n7~fv>*@>as za?x{Y++O{b@3?#KKm70goOLh$B5(+$n3MIq!j}VA=I)b=4!-MBF!lw%nmBa?zI>Mw z_9b4N#Nc=Pb&{`%e-}XgK7k2M;=&xixtl?IN55&n2O+O*=M7u-JUQDu(75##7_(Yb zHkE9Y8>hw`;${c8b$KkD_xGl`l`m}Ce9%}IHL_$Q|I~*nO%)%N9Yi z99b~iTi-n29e{VVV|I{iHGUJf+BiYLUEC~m6Jb-EZmtFeav~(pg{yu7FC2yDgLJWa zOZ-B!z*f_-y@`i5NoSl}MKB^SJ7{PM29xlX&QKHl!T(WaVJ6&H_Lj(JCCnwiNsU*OYAKmGE7?nEVGf(y+k@);f?I!!CsKknFRRwEZ?fU|b2ltKTTN)i7sr zpfUOPi2`2-)K%jt{t&0xfSNe3*izn~Yf(npub7Da?GN1GTpt1>{!}aBfe;_zRYPCV zGTuZ+nx-;*w~KfG+@p{WC_8XW-c=x-*6GN{thF<3(WdqZM5wG zdhz$*7VY*Or5~|9+X;YoOc^%^DGZe-agu2Qkf7$4ok=^uI|&&Ny$DmF^a)&I-v2I*_`4%ilAe4tVh=JyH z;KX8$rKd)o&L%R^P0M@;Gzc#J#${Xw?wVOr>j#2N%-i}S%eYGnfR#TbPX>676#IuS zPuOdpn{9WA#l$zFtxJ+y-bwE^y2p-B;0rb&$+P{)i_F$#U$;>j&v6|)m@nT9&?X1W z%u~COM_dyR)8y`x`iIZdcl^Eh_iwAS@cA}t>%7{Dj935N|91EC&wjtfd4bfTf7&O> zrqe<%+sk~@+kcI)$yJAeGzST$%3}(`F^K;ntFI4=H}aoDr zxMo4&Ly){J@}Rc?_;$s$*#_HPn6e-SEh99X9Dr^EtMXfF8$30f04!eHiI?qrWLEu_ zocKZ?`Hr>chpzCFU#m_$n> zli%mBpbotTb>dANE-@$#rO#uTN=)(H zd;}-93T8jl4K4z}wQm8@J1)s5e9$%A18v9-dqml0;j<3J-p%K{z$1NuV-NqdGrWwi zb)xWZWBYhdc^gtOUI9w-oapw7AdOP_WQRqc0KBPs!qIU$v1~aGu!smG2VZz9jHM6$ z6gjke4i7IGR`R=#{Mda`Zg~Z9AuJ1-yR{LPU68rUP!{4>Xv^Bz2>xV$+k~vx)gn)G z`zG4)Nkms3G+IWh9-YjIL$^ks`fI3+hpX|*maiXuf69YWq zLl4N+PBW8-SVSQERq_@!p95I-A(F2H((olQHJ~@~)ik<-Bo>d}28hm+8Ku{?wFIWd z04;J&%GyOb@d!_P8EP`~Ydmmdl6U>r9gr>UeZ~@LIm8>m$XSgXoFP_YSbR*s*gVWW zo>*diskllMVlV!hk`wl4pNQ+u(!2T^YQBkL;WswuNwY5MRE@8nPV1|G7JpiDPai)V z4+PK-k)DR0$)o!0{5Xc43leliH?TJPL?^w0 zC!>~Yfk>*BxMcoO`&o-Ws-Ro^2@D6Q?QCe{;5bZl6JP~0g?WoNGQi~(7=_Ji7o3QK zb`NeR<%H}B9GGJ}HMs_h0%8Y&ZlS6?g4(Ue-U-i?c z{j=cHXB?WGY-jO*??3*Ncdvf%N9WV=rt44m`?foGoI3W2Urq#|#dF(X`(&Z~CDM*I zzMBTl&PbdW&Mk8$143&30ghhA*T!Rk-?WYIc`6_~;tl;HuT6exUJLDD9jCZC{X%E> zh))Q-C3e$1?Ri2ceEuHgK8&KNN*6DDEpy*d2Y4oIM?Pf#d(qVdmrHxygb1mdymG5|FeURK_h?$>~8+qa3^wTPiT(Bs6{S?g_pcw%eyw zAUXILkM&ZRU3J@xh}aC?`xElaD8I3R0Hv*AKv0Kf%LBjd-aaNCkrBQu{;>)EsNAsd zrQfngCk$eCZhObZ()yRxebBIu&avf1Q40s3nu(p!8+spPw>}Udre{iUMdO&}z*Q)K zr&s~rK6&1>#3%I_<_0M7*k zd;%0V5~@X?XF~2eOOByIP3pZ_Z+GO7x03XM)OYj@h8OzUX477+iY{P@-~G=R!qfl2e1n}`1AzK<<~)T0E1k;i0%@i)^e5-J*+tyLjmpe>cILgg^}Wg{+4`e!|$aQIkerQ z8~?8{h)o-AJ0v5xhz5>%TW-dodDO$uK5ZPbPx5cX&v@}V_v-D{c2ok{@vy2+!c07B ztOM)~*$BfdUWlwX59E^o7bf1=YyfY z{400w`~f}JzZd`Dp4@4_|A+5B^Y8!hh_#KWBd+>)d*4s|x4qc*$YyYyRDi=){a3{j z6a5L6SNujdu*CFgi+>kjy$!(c!N19`18zBtc^lwfSEW+sm+dG6s{oq8wrpmX6JCgK zw<>{xTu=b*H~HUm;TP-*HyN9z?=BofZ^qCho&%O&Uhpkq(H>kg+CHKubMkq5S=O+< z(ZD}5tq-K`#Xnd7=@FF0KPLv!YaAa=y}tL~cd23o^(Bw}0=DB}e+a>Pkq_qPYCpqA z@3?o+p&4EywL#X6Hv52N(h^Hg1PVU6;TUB)DNK#nHh67>(NAu?hM4jRgP!F4V2JZk z;e+wM5w#Oe2v$H6ltK0z($~=8p{XS`yMKc9BcH z0O3C^@f&+uX7KcohtBi1i*ee-u>A%enHeA6h3n{{wH|>cwu#X}_IQ-jeir|}y7J>x zbrr>qntZ^x?P1O*{onh~{s)G4e7c_(jW}J~>15zB+T_VcO-cej-I7-{U@9lIU$4Z# z*ZxDB_%hyb=DX!*VY7H_+;uz)&2RmRdVc&nb!bDE>*NVPCs7EL$)@#Cqvk#NEIFQE z5N>J=diicYi2?Pt8#;giMkX)KbK-zp{?O?^=-^p@k!g16Fp0=gdm36SeOie@ z%DKU_^K-(cKB-6j4D&nR^!0PgKk+}&uL*eam=~W>`OJUyzaGE(*YEwiHVKpcz@uXv zYu(H6lbvx|J#l)}1ggl0Z+pGvpclqwpS)d!**0=SUOOhydE~8xC3BY9x^jVdvcGkV zPP11ou-ALDjk_505vR|70$}@Y7@j1gcOd0-4m<>>^H3r*Pvbxs9CX2?ht4#j^FhY7SyGivO06$eA;Fa?!Wt>x83lh#~jGj|o?KOTXzId3eg+ zk_V>jO)QL+?ChMTv+SZXKl3{6E@?2C(c-*$6puzlPbghOnRM?1tHu`>4yqhn)pMJV zPqgRiii0Qdn}f5)-cQ7BTo(UaLDvz+{@S3oA_X45ra=H5SPh+(+WAYhKK`S1m>3v;K5lZ=$2ucwhbBBe6pKq+XMQk zM$M!#iq0GteJlVTqW9_fiRk-}f9Wj#kiXv|GK>C_{qm3gnY+86`c!Tb4~ANB?Zs^R zj6JN0eZOTi{M03WFZ@2XxRxc0YNut+bdC`#wuy80IWuW9dP>O13p{ytZo~AZeVJxz z?0df$zp2iK1nr^*oB)kLa=!&F`JM!H@dMwu zjNwgk8Pkvjzju%V%{E0>3(b~-d=8xlEC*7UxBZC$_!I^s{?m|Rzc;Xv*3bpM{oFB$ zPtj{x9gFCo2fqCbAGl=>(`k`801bd~0%t!OkuiOC9PikC_$=)Rmw#l4n1oLQah4Up zcSAyOa>6LvuHp6p#LS_qfAbE%RP4N2m^QhBGrwqV`3)}eHy(?2{B~|OJi4wu@7?ph z|884;;6AYaKRCz7 zlCf-gbYe8jup&Tw(09EB4bE|Hc-3$_-Q679!en8JCHrJ-5G;CzZ0)n59o%&w{m__B zxV>K+ex=vE%^OU3@HTP=$z3U)^7l3W_-XM6ziVgc*Wj8zKbJzc{)Z%={O`Lqz#Z4M z9oi=br@Y`;oKs)^2XF{*-3d%p^Mc3Y6-THY3xdKJqSbtCgAbky4w0wV;(KanTgkhR zr)OPH@q85cvz-9kG)jJ@u-q>vckz?ViCwN1yTCb^5|$)9J^D_3J>Li4(1Zuv#+x7c zy)#2kb5d$}Y-L2jWoy%y)kWe$vKO)I*g1paK*TuwaC+gv`5O(pc=2h}*cvi+UhKgi z$5x2Yhi3a47(Kku?slFPCh{U5DC6+j4>4YMhU^$Q5x{3R>Va&k-wO#iXkDCbNMwZ1 zz3FOn4H*m6wiBvNL*zvuSpAh0ZEgQy4+n4Ci;cv%>v8CNkQgwHo*YntZU2J5l@G44 z-ucFV_KwHf(yj+j^f<>XfVz$95B%w&Th|5bG{+}dWuGne*u-vdlNaM+W-%LSAXrX+z6X{ z^zm<#mY>Xm%$6YDn%)Xgd>VrAsUH79j1AR#KGym>? zdo2D9PrY&sN$nAzG-8XVX`W+u!zT8Gwf{DM_|orMADH~Leq<6`HQS;w^0B}$Z$V~o z^rl*N2Q#rIo@s|WQzJ%k8)UD0OVQp(BIcv?KE{3&0Z;MnMQzheAV}T{;3SeHPQYlA%#Vu>~#W&>APUTPccy}$ZDKp zvgJ1%9rza?O)uQl-e$GD*cG14>2Jj*bdjB0S+5-0_$|x$WM$Z^ak6=D%T+^GXat)47N~`rXiV zit$#yaGQo(|6F0^=Ww&2ZMk=!(eD8C)h}Q5^V1`kIe=J0z5(=*T__l(Bzzf;&liz5DO{OY>Df z@LtA{+4`WLrA_1AQm=Eu+Q|LPe?X2IyvRg$%Sl`hk6Hj~F3!L=bQe3Uqv;<u zNzwkEm|z2PEn9M{4w`3mv6VGEd0y!Au)|co7QM%7@knke-e>l=F?+TXfTxUeVctj# z;hjbeXtD#CIWS(g7H{}B93JM8lVC!Z20n0hj?EvM^pWG8BleQ$_QwU>6$veQv~2v* zoHk`d5iJ?zaA|_}SRl=0L}G`XvALv{9`lJeK7<}-aKKLsPTOtaO#@wI13%G+aP`nn z!{1%Jj2q3t-3NH)dPN5?zkR?Svsz-~9aUl?XOWzSju-w|e^G&c_jwK>BaB(O?Nz`+tl`1 zm^R*D$uuXi$h@4)hVH}mlY|c|;KTAc{3LAtDu7P<3OMppOj(Q)s05pSE{&hk{1umAu+07*naRQdoB zyTm)O3@p7_Spa_FZ{EH0yMLR%_nsfP+4e)1_V68l;qIj#lw+U7%gKXj+lSD(7Yy8% zMdq)yC;|ve=7m1;L_wq~R_Ky{X~~r5EoOiY4vl9VugK zLq4omy9Um)JtT={Yo{z3s&xxzBb2;2^f;+aW;XcnXAg zub?OS^r6i{wQ-RJO>zR+0K3n&~MPB6FRtY4I$UZze>_cA`bh2|~ zNC#GQB3*Tlv%(*2E za}-7Hiue#>usbQi$#+X-ytZ|fYPaa4W9)B~aQCqK-I{m}91cC9<;(tOdt1U8H( z9ODE&6)ZX&7wOu6uvZLWF4@H=Drm8-;q-|2e%dyhA0X_&!lORVp1AwsFSz@dp6mb4 zzxq!X`Hd$}?z>SY(qfG{+fy zg`~jOxuDK>_G$1E;cVvEK|Hn)@Wf^{;{i3V@i*)X%c;Hsg#98nsL%(WKKQrUAA8%| zlzNdBhXQuuI)LXL*tndHTRppum9z?t@?#0JyD>Ic;cbI$M14 z!H8l~o1HuqDaiCD(uRVma@4lG+OY^beRRiWd`}$GLxc%7^g79-bLvd_?ojdYa{<%M;0+=MPZp?nY{iavt}Ui6M{ z@?r*|dM+w7h!-*Zb%Ki3kfJGo{TB2UF&)w#K#0!PThkWAMD59w0C@Ox^*4Nt>9QeL z$v3bZaNqkQ-yRYSpBI_tMPsLS&X#$=gJ+zUgDtUeqia9x^YRJ2HI$l(UHH6Dia&Pa zv!xRAeld4`RUgKg`LbDG2Mp%n`+m?vRuQ}1b}@gduJd*Z$Dh2cR01mJ?Z_z6AVz`e zvFi?fmspe`qH8g!>+PBKh|l#UI?S_XTmJzr0CvO9 z%LjT5QYY*)wp*PSYrSofXHVp+-?7%>f2-vr>k&?J(FHFxd#`V)><|6rqz*nl&{lQ2 zoD{sGC0}u)?XYliu;R(-uFx z%yI`H2Zp*b0>Qs@z)yVn$3EnE%HZ5e(_tulFPL)maDXfJJs(*G{g{ZN3~$vCdW3k&=WVh=3}0?8e!y&EccjI&SDFEj3B~fa4Y1lk1>RmxIZ!ZX5pzye zEVC0dEPO0{S?qiz0&Vl2>>{aa@rOS$q04;3PW+k3u$?f*KEMj@!s#nq!)(dnqjyZ{ zr9~g(_}Szqe@v?lKEWBkf$tU>(E%;6ePWPU!iSu)q-00dmLHwQX%g9?wE)2nNyvh* zDZsVA1un$IPX?G*IpY_{9{+~=S$6F3?RrGn0hDN5qJ5%8o}<^g+R@5M*)}$qy<~tl zjiyya^v5kubGEGV89wpI{+o@OqjmqzH~jXRKe6?}lqaWg>;LcmRi?Ui;9z0&i2zPx z5B{QxSK9}3e6(MVFVMp`(`z0&^CD8Taw_OUBr+@jMIvER!<`Fv#J~e>B#)mwCN9|Q_zTEi>I@pc;&8{MP9l3^heX%1kp=(AKp(Bi83yNAHD6%l z$YX5Uw8(^6Z#lWwohROU;_j25IN4YGPhQ=_duf~2bsBsx{(OjY7JhK+*1z}dwh^0Y z<3pT{FUAsMg`sH;GSiHPKXNJp!cVBFYE626ltaTs>XV1giOG6gcPYb&OpQDChXu#z z);@Y*-+vSz-k|9INL)y-S^UE=vM#oOx8)6w9WiixvJP3Yjll_C8-_r&$YGJrxDoEyU^}ci$uX?q zjDhck9vE$(7%{)0eh%P{k7(jQ^qB0Eha60T-$_BJ=Cku@ns)N1fjj?+U3h0_jDX=0 zR~v8pw!YRsEW$x!dUBzKmjj6phw7=ZlMOHmZkTgW_>pIHS$JQ9>YyRGDW$8q&5}5J za@la@m7h_~qIEiuHoc#m*5m8Gg>sJ;fZc(4}@V4zCc0rpR^Jh`JiLhkJ>iX99fp58UPHh)*s4#O|~xedU+?sfrJu z{Dn47(wFKPcO0C*gvF$FY=O@8f)x0nS8r>_|HEJKM=c!mCpiLJj`-(aUO&7opAPZ3V zqbGP4z2qi+Xz0TWKRkJX+c4%dV0mrY!X9`E0o!ed6#J^b4JaQ|LE>D)=p3UX7_y7+ zu>+Fe7WoWs;ef$^ZMS4%duVUTLWcPm;7j~L{9_e{v0J$bPdtWBLo((AROh1z-+${J zv|~i<)3Dh(1n>bSC}=g#j(y@0BEDiPFIQ5V?J@EMJvB}DVpP1v4IOcc+!LQB{GbX~ z|14NpwDvgk98mc_JU^JT_od+NNBd}hg(y49*7%t)c)ns3mlCtcrH|~?!}iC1Md)Ix zyeO}o8&DaiS76K$TY55e=Xq~(1^M#Ne!m}nv~h1T_74PcHy}@%ORU!CPLMM`UQ5$K$nsQwN+Z(DU*) zMRXU5ul4bre!tKERlgpQZmGY_Z}mR$zkxU{{(75gFaF70uKe%x&?=wYv0q?sE)p5z zNVg2*qa)q^1qW$jv3VoIc!*d0j7yk>OOEJ??KEPRKaB_FVbcMpb$1=xHqlI8P4H%$ zTl@K&aegqWXS)MH@#Vm{$J;TIn;=u{&=E^^2**?#(-6c?KJF#y^qzZrY{C;r=ri9v z5?0zv|01}>8@-EchIK&G)Musm0i^s~MC~ACqR1^_vne7JCcZ7db7GS?&;#3T`*Ph` zO)2_t<&`MZ&*8bT5N5E!9HcID89-hwSVNKPFb%+@M?YpFp!IoGX z_@bqFpg(LePOMoBgF`G0Gt(S;;t0J7Hnymc<60I0p2p`b<1tt3=^RbW#1z}#`S_RK zarN&5C_mlXw2_ucu&0ui}TDevV`4j{IrhZ@i$Tklxzj! zef7-d{0+!Nht!hNdtSe*v9SJkDSoQ z6!>WLe6WubNB?*aC!4=k-!@rS`-xFxy{^9=Z=l_>cs9j*f9iX3*q>)PWbx<9-}`ne ziQQXgYzG&AtcSd<7GC=B1nn5*N**A*$w!ZocIAU~$CvSL?D=W4A041WM~Kur7-umH z`?r7iY$pJ?cI!4qTuyKkNaidOwfG+qBT2gyg1MEv$li

*%gU2LE;8Ore3xcx1|$ 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®ktT>E^F| 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%0Af`!0BvJKG}<% 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 literal 0 HcmV?d00001 From 46505623a378c5d83684eb0c776b3e2f705194d2 Mon Sep 17 00:00:00 2001 From: kmou424 Date: Mon, 14 Sep 2026 00:37:35 +0800 Subject: [PATCH 289/334] fix(macos): sign complete application bundles - Apply ad-hoc signing after application and Cubism resources are staged. - Add native bundle signature verification to CTest. Co-Authored-By: GPT-6 Astra --- CMakeLists.txt | 11 +++++++++++ cmake/Tests.cmake | 5 +++++ 2 files changed, 16 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 2f45b2cd..87e99362 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -136,6 +136,17 @@ endif() bongo_cat_stage_cubism_assets(bongo_cat) +if(APPLE) + # The linker signs the executable on its own before the bundle's resources are + # copied in, which leaves strict verification failing with "code has no resources + # but signature indicates they must be present". Re-sign once every resource and + # executable change has landed, so this must stay the last POST_BUILD step. + add_custom_command(TARGET bongo_cat POST_BUILD + COMMAND /usr/bin/codesign --force --sign - "$" + COMMENT "Ad-hoc signing the BongoCat bundle" + VERBATIM) +endif() + add_custom_target(check-lines ALL COMMAND ${CMAKE_COMMAND} -DROOT=${CMAKE_CURRENT_SOURCE_DIR} -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/CheckLines.cmake diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index 5d5acb2b..16978741 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -194,4 +194,9 @@ if(BUILD_TESTING) target_link_libraries(bongo_cat_ui_tests PRIVATE m) target_link_libraries(bongo_cat_app_state_tests PRIVATE m) endif() + + if(APPLE) + add_test(NAME macos-bundle-signature COMMAND /usr/bin/codesign + --verify --strict --deep --verbose=2 "$") + endif() endif() From f0b1cb7bc3a4b56f5a3a1d7b2d2578d8ac175d90 Mon Sep 17 00:00:00 2001 From: kmou424 Date: Mon, 14 Sep 2026 00:37:35 +0800 Subject: [PATCH 290/334] fix(macos): preserve click-through across mouse motion - Keep SDL window shapes consistent with native click-through state. - Cover mouse motion and repeated pass-through toggles in a macOS regression test. Co-Authored-By: GPT-6 Astra --- cmake/Tests.cmake | 10 ++ src/platform/macos/macos.m | 26 ++- tests/platform/test_macos_click_through.m | 207 ++++++++++++++++++++++ 3 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 tests/platform/test_macos_click_through.m diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index 5d5acb2b..fccfb3f3 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -157,6 +157,16 @@ if(BUILD_TESTING) add_test(NAME live2d-motion-state COMMAND bongo_cat_motion_state_tests) endif() + if(APPLE) + add_executable(bongo_cat_macos_click_through_tests + tests/platform/test_macos_click_through.m) + target_link_libraries(bongo_cat_macos_click_through_tests PRIVATE + bongo_cat_runtime bongo_cat_warnings) + add_test(NAME macos-click-through COMMAND bongo_cat_macos_click_through_tests) + set_tests_properties(macos-click-through PROPERTIES + TIMEOUT 60 SKIP_RETURN_CODE 77) + endif() + if(WIN32) add_executable(bongo_cat_windows_input_tests tests/platform/test_windows_input.c diff --git a/src/platform/macos/macos.m b/src/platform/macos/macos.m index 2745270b..cfe0f688 100644 --- a/src/platform/macos/macos.m +++ b/src/platform/macos/macos.m @@ -92,9 +92,33 @@ void bongo_cat_platform_shutdown(BongoCatPlatform *platform) { bongo_cat_macos_input_stop(platform); if (active_platform == platform) active_platform = NULL; } +/* SDL rewrites NSWindow.ignoresMouseEvents from the window shape whenever a mouse move reaches the + window, and derives "ignore the mouse" from a transparent shape pixel. Telling SDL the same state + through that shape keeps the two from disagreeing, which would otherwise hand the pet back to the + window server on the next move. The native property is written afterwards: SDL's answer depends on + where the pointer is, while this state has to hold wherever the pointer is. */ +static void apply_click_through_shape(BongoCatPlatform *platform, bool enabled) { + if (!(SDL_GetWindowFlags(platform->window) & SDL_WINDOW_TRANSPARENT)) return; + SDL_Surface *shape = SDL_CreateSurface(1, 1, SDL_PIXELFORMAT_ARGB32); + if (!shape) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Click-through window has no shape SDL can follow: %s", SDL_GetError()); + return; + } + if (!SDL_WriteSurfacePixel(shape, 0, 0, 0, 0, 0, + enabled ? SDL_ALPHA_TRANSPARENT : SDL_ALPHA_OPAQUE) || + !SDL_SetWindowShape(platform->window, shape)) + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Click-through state is not described to SDL: %s", SDL_GetError()); + SDL_DestroySurface(shape); +} + void bongo_cat_platform_set_click_through(BongoCatPlatform *platform, bool forced, bool pointer_transparent) { - [native_window(platform) setIgnoresMouseEvents:forced || pointer_transparent]; + if (!platform || !platform->window) return; + bool enabled = forced || pointer_transparent; + apply_click_through_shape(platform, enabled); + [native_window(platform) setIgnoresMouseEvents:enabled]; } bool bongo_cat_platform_set_opacity(BongoCatPlatform *platform, float opacity) { if (!platform || !platform->window) return false; diff --git a/tests/platform/test_macos_click_through.m b/tests/platform/test_macos_click_through.m new file mode 100644 index 00000000..8711e006 --- /dev/null +++ b/tests/platform/test_macos_click_through.m @@ -0,0 +1,207 @@ +// Regression check for macOS pass-through: once the platform is told the pet window must ignore the +// mouse, SDL's own mouse-motion handling must not take that back. SDL derives +// NSWindow.ignoresMouseEvents from the window shape on every mouse move, so a click-through window +// has to describe itself to SDL through that shape as well; otherwise the next move makes the pet +// clickable again and the window server keeps routing clicks to it. +// +// It drives the real platform implementation over a real SDL window created like the pet window and +// asserts the user-visible outcome: the window server's own hit test at the window centre. The move +// event is posted to this process only (no event tap, no other application, no cursor movement). +// +// Needs a window server and the Cocoa driver. Only when those are unavailable does it exit 77, which +// ctest reports as a skip (SKIP_RETURN_CODE in cmake/Tests.cmake); a window that cannot be created or +// that is not a Cocoa window fails instead. + +#import +#include +#include +#include "bongo_cat/platform.h" + +#include +#include +#include + +enum { CHECK_SKIP = 77, PET_SIZE = 280 }; + +static int failures; + +static void expect(int condition, const char *what) { + printf("%s %s\n", condition ? "ok " : "FAIL", what); + if (!condition) failures++; +} + +static void skip(const char *reason) { + printf("SKIP %s\n", reason); + exit(CHECK_SKIP); +} + +static NSWindow *cocoa_window(SDL_Window *window) { + return (__bridge NSWindow *)SDL_GetPointerProperty(SDL_GetWindowProperties(window), + SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, NULL); +} + +// Window number of the topmost window at a screen point, i.e. the window the window server would +// hand the mouse to there. +static NSInteger mouse_target_at(NSPoint point) { + return [NSWindow windowNumberAtPoint:point belowWindowWithWindowNumber:0]; +} + +static void pump_runloop(double seconds) { + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:seconds]]; +} + +static void pump_sdl(int milliseconds) { + uint64_t deadline = SDL_GetTicksNS() + (uint64_t)milliseconds * 1000000ull; + SDL_Event event; + while (SDL_GetTicksNS() < deadline) { + if (!SDL_WaitEventTimeout(&event, 10)) continue; + while (SDL_PollEvent(&event)) { } + } +} + +// A window move AppKit would deliver to this window while the pointer is over it. +static void post_mouse_move(NSWindow *window) { + NSEvent *event = [NSEvent mouseEventWithType:NSEventTypeMouseMoved + location:NSMakePoint(PET_SIZE / 2, PET_SIZE / 2) modifierFlags:0 + timestamp:[[NSProcessInfo processInfo] systemUptime] + windowNumber:window.windowNumber context:nil eventNumber:0 clickCount:0 pressure:0]; + [NSApp postEvent:event atStart:NO]; + pump_sdl(300); + pump_runloop(0.3); +} + +// Before anything is concluded about the pet window, confirm the hit test reflects the flag here, +// with two throwaway plain windows of the same shape. +static void require_working_instrument(void) { + NSRect frame = NSMakeRect(140, NSScreen.screens.firstObject.frame.size.height - 70, 80, 80); + NSWindow *under = [[NSWindow alloc] initWithContentRect:frame + styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO]; + NSWindow *upper = [[NSWindow alloc] initWithContentRect:frame + styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO]; + for (NSWindow *window in @[under, upper]) { + window.opaque = NO; + window.backgroundColor = [NSColor colorWithCalibratedWhite:0 alpha:0.01]; + } + upper.level = NSFloatingWindowLevel; + [under orderFront:nil]; + [upper orderFront:nil]; + pump_runloop(0.4); + NSPoint point = NSMakePoint(NSMidX(upper.frame), NSMidY(upper.frame)); + if (mouse_target_at(point) != upper.windowNumber) { + [upper orderOut:nil]; [under orderOut:nil]; + skip("the hit test query does not report our own floating window here"); + } + upper.ignoresMouseEvents = YES; + pump_runloop(0.2); + NSInteger skipped = mouse_target_at(point); + [upper orderOut:nil]; [under orderOut:nil]; + pump_runloop(0.2); + if (skipped == upper.windowNumber) skip("the hit test query cannot observe ignoresMouseEvents here"); +} + +// A missing window or a missing Cocoa window is a regression of what this check covers, not an +// environment limitation: those are only excused when SDL cannot use the Cocoa driver at all. +static void fail(const char *what) { + expect(0, what); + printf("click-through check failed\n"); + exit(1); +} + +int main(void) { + setvbuf(stdout, NULL, _IONBF, 0); + [NSApplication.sharedApplication setActivationPolicy:NSApplicationActivationPolicyAccessory]; + require_working_instrument(); + + SDL_SetHint(SDL_HINT_MOUSE_FOCUS_CLICKTHROUGH, "1"); + SDL_SetHint(SDL_HINT_WINDOW_ACTIVATE_WHEN_SHOWN, "0"); // a check must not steal focus + if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS)) { + skip(SDL_GetError()); + } + const char *driver = SDL_GetCurrentVideoDriver(); + if (!driver || strcmp(driver, "cocoa") != 0) { + char reason[96]; + snprintf(reason, sizeof(reason), "SDL video driver is %s, not cocoa", driver ? driver : "absent"); + skip(reason); + } + SDL_Window *window = SDL_CreateWindow("bongo-cat-click-through-check", PET_SIZE, PET_SIZE, + SDL_WINDOW_OPENGL | SDL_WINDOW_BORDERLESS | SDL_WINDOW_HIGH_PIXEL_DENSITY | + SDL_WINDOW_HIDDEN | SDL_WINDOW_TRANSPARENT); + if (!window) fail("SDL creates a transparent borderless window like the pet window"); + SDL_SetWindowPosition(window, 40, 40); + SDL_GLContext context = SDL_GL_CreateContext(window); + if (context) { + if (!SDL_GL_MakeCurrent(window, context)) printf("note: no current GL context: %s\n", + SDL_GetError()); + } else { + printf("note: no GL context (%s); the window is still the one the pet uses\n", SDL_GetError()); + } + + NSWindow *native = cocoa_window(window); + if (!native) fail("SDL hands the platform a Cocoa window"); + NSPoint centre = NSMakePoint(NSMidX(native.frame), NSMidY(native.frame)); + BongoCatPlatform platform; + memset(&platform, 0, sizeof(platform)); + platform.window = window; + + // bongo_cat_window_apply() order: the pet is configured while hidden, then revealed. + bongo_cat_platform_set_visible(&platform, false); + bongo_cat_platform_set_click_through(&platform, true, false); + bongo_cat_platform_set_always_on_top(&platform, true); + bongo_cat_platform_set_visible(&platform, true); + pump_runloop(0.5); + expect(native.ignoresMouseEvents, "pass-through reaches the native window"); + expect(mouse_target_at(centre) != native.windowNumber, + "the window server passes the mouse through the pet before any motion"); + + post_mouse_move(native); + expect(native.ignoresMouseEvents, "pass-through survives SDL mouse-motion handling"); + expect(mouse_target_at(centre) != native.windowNumber, + "the window server still passes the mouse through the pet after motion"); + + // Dynamic transparency (transparent pixel under the pointer) uses the same platform call. + bongo_cat_platform_set_click_through(&platform, false, true); + pump_runloop(0.2); + expect(native.ignoresMouseEvents, "dynamic transparency reaches the native window"); + post_mouse_move(native); + expect(native.ignoresMouseEvents, "dynamic transparency survives SDL mouse-motion handling"); + + // Turning pass-through off must give the pet its window interaction back, and it must stay that + // way: SDL rewrites ignoresMouseEvents from the window shape on the next mouse move. + bongo_cat_platform_set_click_through(&platform, false, false); + pump_runloop(0.2); + expect(!native.ignoresMouseEvents, "pass-through off reaches the native window"); + expect(mouse_target_at(centre) == native.windowNumber, + "the window server targets the pet again once pass-through is off"); + post_mouse_move(native); + expect(!native.ignoresMouseEvents, "pass-through off survives SDL mouse-motion handling"); + expect(mouse_target_at(centre) == native.windowNumber, + "the window server still targets the pet after motion once pass-through is off"); + + // The same has to hold when the state is toggled repeatedly, not only on the first switch off. + char label[128]; + for (int cycle = 1; cycle <= 2; cycle++) { + bongo_cat_platform_set_click_through(&platform, true, false); + pump_runloop(0.2); + post_mouse_move(native); + snprintf(label, sizeof(label), "pass-through on survives motion in cycle %d", cycle); + expect(native.ignoresMouseEvents, label); + snprintf(label, sizeof(label), "the window server passes the mouse through in cycle %d", + cycle); + expect(mouse_target_at(centre) != native.windowNumber, label); + + bongo_cat_platform_set_click_through(&platform, false, false); + pump_runloop(0.2); + post_mouse_move(native); + snprintf(label, sizeof(label), "pass-through off survives motion in cycle %d", cycle); + expect(!native.ignoresMouseEvents, label); + snprintf(label, sizeof(label), "the window server targets the pet in cycle %d", cycle); + expect(mouse_target_at(centre) == native.windowNumber, label); + } + + if (context) SDL_GL_DestroyContext(context); + SDL_DestroyWindow(window); + SDL_Quit(); + pump_runloop(0.2); + printf("%s\n", failures ? "click-through check failed" : "click-through check passed"); + return failures ? 1 : 0; +} From eded7bfed4d91dc4e53c59a2e6fb36fd7df2282c Mon Sep 17 00:00:00 2001 From: kmou424 Date: Mon, 14 Sep 2026 03:19:20 +0800 Subject: [PATCH 291/334] feat(macos): add input monitoring permission controls - Show an authorization action or granted status in macOS preferences. - Check permission on demand and open Input Monitoring settings only when needed. - Request access from user actions and repaint permission changes. - Add refresh regression coverage and synchronize localized restart guidance. Co-Authored-By: GPT-6 Astra --- cmake/Info.plist.in | 2 +- cmake/Tests.cmake | 18 ++++++ include/bongo_cat/platform.h | 8 +++ resources/assets/locales/de-DE.json | 7 +- resources/assets/locales/en-US.json | 7 +- resources/assets/locales/es-ES.json | 7 +- resources/assets/locales/fr-FR.json | 7 +- resources/assets/locales/ja-JP.json | 7 +- resources/assets/locales/ko-KR.json | 7 +- resources/assets/locales/pt-BR.json | 7 +- resources/assets/locales/ru-RU.json | 7 +- resources/assets/locales/zh-CN.json | 7 +- resources/assets/locales/zh-Hant.json | 7 +- src/platform/macos/macos.m | 8 +++ src/platform/macos/macos_input.m | 30 +++++++-- src/platform/macos/macos_internal.h | 2 + src/ui/preferences/preferences.c | 7 ++ src/ui/preferences/preferences_lifecycle.c | 3 + src/ui/preferences/preferences_pages.c | 75 ++++++++++++++++++++++ src/ui/preferences/preferences_state.h | 11 ++++ tests/ui/test_input_monitoring_refresh.c | 75 ++++++++++++++++++++++ 21 files changed, 272 insertions(+), 37 deletions(-) create mode 100644 tests/ui/test_input_monitoring_refresh.c diff --git a/cmake/Info.plist.in b/cmake/Info.plist.in index 514f1f12..7252103d 100644 --- a/cmake/Info.plist.in +++ b/cmake/Info.plist.in @@ -16,7 +16,7 @@ LSMinimumSystemVersion11.0 LSUIElement NSInputMonitoringUsageDescription - BongoCat needs input monitoring access to animate from global keyboard and mouse input. + BongoCat needs input monitoring access to animate from global keyboard input. NSHighResolutionCapable diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index 5d5acb2b..ce3a64dc 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -157,6 +157,24 @@ if(BUILD_TESTING) add_test(NAME live2d-motion-state COMMAND bongo_cat_motion_state_tests) endif() + if(APPLE) + # The refresh helper runs the real page code with the platform read replaced + # per target, so the repaint decision is tested without touching TCC. + add_executable(bongo_cat_input_monitoring_refresh_tests + tests/ui/test_input_monitoring_refresh.c + src/ui/preferences/preferences_pages.c) + target_compile_definitions(bongo_cat_input_monitoring_refresh_tests PRIVATE + bongo_cat_platform_input_monitoring_authorized=bongo_cat_test_input_monitoring_authorized) + target_include_directories(bongo_cat_input_monitoring_refresh_tests PRIVATE + ${BONGO_CAT_RUNTIME_INTERNAL_INCLUDE_DIRS}) + target_include_directories(bongo_cat_input_monitoring_refresh_tests SYSTEM PRIVATE + ${BONGO_CAT_NUKLEAR_INCLUDE_DIR}) + target_link_libraries(bongo_cat_input_monitoring_refresh_tests PRIVATE + bongo_cat_runtime bongo_cat_warnings) + add_test(NAME macos-input-monitoring-refresh + COMMAND bongo_cat_input_monitoring_refresh_tests) + endif() + if(WIN32) add_executable(bongo_cat_windows_input_tests tests/platform/test_windows_input.c diff --git a/include/bongo_cat/platform.h b/include/bongo_cat/platform.h index dd0c9cf3..a0a4b031 100644 --- a/include/bongo_cat/platform.h +++ b/include/bongo_cat/platform.h @@ -131,4 +131,12 @@ BongoCatMenuAction bongo_cat_platform_context_menu(BongoCatPlatform *platform, const BongoCatMenuLabels *labels); BongoCatResult bongo_cat_platform_embedded_assets(const char *target, BongoCatError *error); +#ifdef __APPLE__ +/* Read the permission macOS grants the app right now, without prompting. + Callers refresh on a user action or a focus change, never per frame. */ +bool bongo_cat_platform_input_monitoring_authorized(void); +/* Ask macOS for Input Monitoring; only an explicit user action may call it. */ +bool bongo_cat_platform_input_monitoring_request(void); +#endif + #endif diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index 79588f03..bf9ad907 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -63,8 +63,8 @@ "hints": { "showTrayIcon": "Wenn aktiviert, wird das Anwendungssymbol im Infobereich angezeigt.", "autostartFailed": "Die Einstellung für den automatischen Start konnte nicht aktualisiert werden", - "inputMonitoringPermission": "Aktivieren Sie die Eingabeüberwachung, um Tastatur- und Mausereignisse vom System zu empfangen.", - "inputMonitoringPermissionGuide": "Die Berechtigung zur Eingabeüberwachung ist erforderlich, damit die Anwendung Tastatur- und Mausereignisse vom System empfangen kann.\n\nSchritte:\n1. Öffnen Sie Systemeinstellungen > Datenschutz & Sicherheit > Eingabeüberwachung.\n2. Falls BongoCat bereits aufgeführt ist, wählen Sie es aus und entfernen Sie es mit der Schaltfläche \"-\".\n3. Klicken Sie auf \"+\", um BongoCat zu suchen und hinzuzufügen.\n4. Starten Sie die Anwendung neu, damit die Berechtigung wirksam wird.", + "inputMonitoringPermission": "Nach dem Erlauben neu starten.", + "inputMonitoringPermissionGuide": "Die Eingabeüberwachung erlaubt BongoCat, in anderen Apps gedrückte Tasten zu sehen; die Maus funktioniert auch ohne sie. BongoCat baut die Überwachung beim Start auf, eine Berechtigungsänderung erfordert daher einen Neustart.\n\nSchritte:\n1. Öffnen Sie Systemeinstellungen → Datenschutz & Sicherheit → Eingabeüberwachung.\n2. Schalten Sie den bereits gelisteten BongoCat-Eintrag ein; entfernen Sie keine vorhandenen Einträge.\n3. Falls BongoCat fehlt, klicken Sie auf \"+\" und fügen Sie die laufende Kopie hinzu (prüfen Sie den Speicherort).\n4. Beenden Sie BongoCat vollständig und starten Sie es erneut, damit es die Überwachung neu aufbaut.\n\nWenn die Tastatur weiterhin nicht reagiert, können Sie den BongoCat-Eintrag selbst entfernen und neu hinzufügen.", "administratorPermission": "Als Administrator kann die Anwendung bestimmte Systemtasten und Eingabeereignisse zuverlässiger erfassen.", "administratorPermissionGuide": "Für eine zuverlässigere Erfassung bestimmter Systemtasten und Eingabeereignisse wird empfohlen, die Anwendung als Administrator auszuführen.\n\nVorgehen:\n1. Beenden Sie zuerst die Anwendung.\n2. Suchen Sie die exe-Datei oder ihre Verknüpfung im Datei-Explorer.\n3. Wählen Sie die Startart:\n - Einmalig: Rechtsklick und \"Als Administrator ausführen\" wählen.\n - Immer: Rechtsklick > Eigenschaften > Kompatibilität > \"Programm als Administrator ausführen\" aktivieren > OK." }, @@ -72,7 +72,8 @@ "authorized": "Autorisiert", "adminEnabled": "Aktiviert", "authorize": "Aktivieren", - "viewGuide": "Anleitung anzeigen" + "viewGuide": "Anleitung anzeigen", + "openSettingsFailed": "Systemeinstellungen → Datenschutz & Sicherheit → Eingabeüberwachung." }, "buttons": { "openNow": "Jetzt öffnen", diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index 92c591a5..f43cdaf8 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -63,8 +63,8 @@ "hints": { "showTrayIcon": "When enabled, the app icon is displayed in the system tray.", "autostartFailed": "Unable to update launch-on-startup settings", - "inputMonitoringPermission": "Enable input monitoring to receive keyboard and mouse events from the system.", - "inputMonitoringPermissionGuide": "Input Monitoring permission is required for the app to receive keyboard and mouse events from the system.\n\nSteps:\n1. Open System Settings → Privacy & Security → Input Monitoring.\n2. If BongoCat already appears in the list, select it and click the \"-\" button to remove it.\n3. Click the \"+\" button to find and add BongoCat.\n4. Restart the app for the permission to take effect.", + "inputMonitoringPermission": "Restart after granting it.", + "inputMonitoringPermissionGuide": "Input Monitoring lets BongoCat see keys pressed in other apps; the mouse keeps working without it. BongoCat builds the listener when it starts, so a permission change needs a restart.\n\nSteps:\n1. Open System Settings → Privacy & Security → Input Monitoring.\n2. Turn on the BongoCat entry that is already listed; do not remove existing entries.\n3. If BongoCat is not listed, click the \"+\" button and add the copy you are running (check the app location).\n4. Quit BongoCat completely and start it again, so it rebuilds the listener.\n\nIf the keyboard still stays silent, you can remove and re-add the BongoCat entry yourself.", "administratorPermission": "Running the app as administrator helps capture some system-level keys and input events more reliably.", "administratorPermissionGuide": "To capture some system-level keys and input events more reliably, it is recommended to run the app as administrator.\n\nHow to do it:\n1. Exit the app first.\n2. Find the app exe or its shortcut in File Explorer.\n3. Choose how to launch:\n • One-time: Right-click and choose \"Run as administrator\".\n • Always (default): Right-click → Properties → Compatibility tab → check \"Run this program as an administrator\" → OK." }, @@ -72,7 +72,8 @@ "authorized": "Authorized", "adminEnabled": "Enabled", "authorize": "Go to Enable", - "viewGuide": "View Instructions" + "viewGuide": "View Instructions", + "openSettingsFailed": "Cannot open System Settings. Open Privacy & Security → Input Monitoring." }, "buttons": { "openNow": "Open Now", diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index 3999dd9d..d96f91d1 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -63,8 +63,8 @@ "hints": { "showTrayIcon": "Al activarlo, el icono de la aplicación aparece en la bandeja del sistema.", "autostartFailed": "No se pudo actualizar la configuración de inicio automático", - "inputMonitoringPermission": "Active la supervisión de entrada para recibir eventos de teclado y ratón del sistema.", - "inputMonitoringPermissionGuide": "Se necesita el permiso de supervisión de entrada para que la aplicación reciba eventos de teclado y ratón del sistema.\n\nPasos:\n1. Abra Ajustes del Sistema > Privacidad y seguridad > Supervisión de entrada.\n2. Si BongoCat ya aparece en la lista, selecciónelo y pulse el botón \"-\" para eliminarlo.\n3. Pulse el botón \"+\" para buscar y añadir BongoCat.\n4. Reinicie la aplicación para que el permiso tenga efecto.", + "inputMonitoringPermission": "Reinicie tras autorizar.", + "inputMonitoringPermissionGuide": "La Supervisión de entrada permite que BongoCat vea las teclas pulsadas en otras aplicaciones; el ratón sigue funcionando sin ella. BongoCat crea la escucha al iniciarse, así que un cambio de permiso requiere reiniciar.\n\nPasos:\n1. Abra Ajustes del Sistema → Privacidad y seguridad → Supervisión de entrada.\n2. Active la entrada de BongoCat que ya aparece en la lista; no elimine entradas existentes.\n3. Si BongoCat no está en la lista, pulse \"+\" y añada la copia que está ejecutando (compruebe la ubicación de la aplicación).\n4. Cierre BongoCat por completo y vuelva a iniciarlo para que reconstruya la escucha.\n\nSi el teclado sigue sin responder, puede eliminar y volver a añadir la entrada de BongoCat.", "administratorPermission": "Ejecutar la aplicación como administrador ayuda a capturar con mayor fiabilidad algunas teclas del sistema y eventos de entrada.", "administratorPermissionGuide": "Para capturar con mayor fiabilidad algunas teclas del sistema y eventos de entrada, se recomienda ejecutar la aplicación como administrador.\n\nCómo hacerlo:\n1. Cierre primero la aplicación.\n2. Busque el archivo exe o su acceso directo en el Explorador de archivos.\n3. Elija cómo iniciarla:\n - Una vez: haga clic con el botón derecho y elija \"Ejecutar como administrador\".\n - Siempre: clic derecho > Propiedades > Compatibilidad > marque \"Ejecutar este programa como administrador\" > Aceptar." }, @@ -72,7 +72,8 @@ "authorized": "Autorizado", "adminEnabled": "Activado", "authorize": "Activar", - "viewGuide": "Ver instrucciones" + "viewGuide": "Ver instrucciones", + "openSettingsFailed": "Ajustes del Sistema → Privacidad y seguridad → Supervisión de entrada." }, "buttons": { "openNow": "Abrir ahora", diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index ee9e4b6f..a8b9fd64 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -63,8 +63,8 @@ "hints": { "showTrayIcon": "Lorsque cette option est activée, l'icône de l'application apparaît dans la zone de notification.", "autostartFailed": "Impossible de modifier le lancement au démarrage", - "inputMonitoringPermission": "Activez la surveillance des entrées pour recevoir les événements clavier et souris du système.", - "inputMonitoringPermissionGuide": "L'autorisation de surveillance des entrées est nécessaire pour recevoir les événements clavier et souris du système.\n\nÉtapes :\n1. Ouvrez Réglages Système > Confidentialité et sécurité > Surveillance de l'entrée.\n2. Si BongoCat figure déjà dans la liste, sélectionnez-le et cliquez sur le bouton \"-\" pour le retirer.\n3. Cliquez sur le bouton \"+\" pour rechercher et ajouter BongoCat.\n4. Redémarrez l'application pour appliquer l'autorisation.", + "inputMonitoringPermission": "Redémarrez après l'autorisation.", + "inputMonitoringPermissionGuide": "La surveillance des entrées permet à BongoCat de voir les touches pressées dans d'autres applications ; la souris continue de fonctionner sans elle. BongoCat construit l'écoute à son démarrage, un changement d'autorisation demande donc un redémarrage.\n\nÉtapes :\n1. Ouvrez Réglages Système → Confidentialité et sécurité → Surveillance de l'entrée.\n2. Activez l'entrée BongoCat déjà présente dans la liste ; ne supprimez aucune entrée.\n3. Si BongoCat n'est pas listé, cliquez sur \"+\" et ajoutez la copie que vous exécutez (vérifiez son emplacement).\n4. Quittez complètement BongoCat puis relancez-le pour qu'il reconstruise l'écoute.\n\nSi le clavier reste sans réponse, vous pouvez supprimer puis rajouter vous-même l'entrée BongoCat.", "administratorPermission": "Exécuter l'application en tant qu'administrateur permet de capturer plus fiablement certaines touches système et certains événements d'entrée.", "administratorPermissionGuide": "Pour capturer plus fiablement certaines touches système et certains événements d'entrée, il est recommandé d'exécuter l'application en tant qu'administrateur.\n\nProcédure :\n1. Quittez d'abord l'application.\n2. Trouvez le fichier exe ou son raccourci dans l'Explorateur de fichiers.\n3. Choisissez le mode de lancement :\n - Une fois : faites un clic droit et choisissez \"Exécuter en tant qu'administrateur\".\n - Toujours : clic droit > Propriétés > onglet Compatibilité > cochez \"Exécuter ce programme en tant qu'administrateur\" > OK." }, @@ -72,7 +72,8 @@ "authorized": "Autorisé", "adminEnabled": "Activé", "authorize": "Activer", - "viewGuide": "Voir les instructions" + "viewGuide": "Voir les instructions", + "openSettingsFailed": "Réglages Système → Confidentialité et sécurité → Surveillance de l'entrée." }, "buttons": { "openNow": "Ouvrir maintenant", diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index f8ef5cb9..595c965f 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -63,8 +63,8 @@ "hints": { "showTrayIcon": "有効にすると、システムトレイにアプリアイコンを表示します。", "autostartFailed": "起動時に実行する設定を更新できません", - "inputMonitoringPermission": "システムからキーボードとマウスのイベントを受け取るには、入力監視を有効にしてください。", - "inputMonitoringPermissionGuide": "システムからキーボードとマウスのイベントを受け取るには、入力監視の権限が必要です。\n\n手順:\n1. システム設定 > プライバシーとセキュリティ > 入力監視を開きます。\n2. BongoCatがすでに一覧にある場合は選択し、\"-\"ボタンで削除します。\n3. \"+\"ボタンをクリックしてBongoCatを探し、追加します。\n4. 権限を反映するため、アプリを再起動します。", + "inputMonitoringPermission": "許可したら再起動してください。", + "inputMonitoringPermissionGuide": "入力監視の権限があると、BongoCat は他のアプリでのキー入力を取得できます。マウスは権限なしでも動作します。BongoCat は起動時に監視を構築するため、権限を変更した場合は再起動が必要です。\n\n手順:\n1. 「システム設定」→「プライバシーとセキュリティ」→「入力監視」を開きます。\n2. 一覧にすでにある BongoCat の項目をオンにします。既存の項目は削除しないでください。\n3. 一覧に BongoCat がない場合は「+」をクリックし、現在実行しているアプリを追加します(アプリの場所を確認してください)。\n4. BongoCat を完全に終了してから再起動し、監視を再構築させます。\n\nキーボードが反応しないままの場合は、ご自身で BongoCat の項目を削除して再追加して確認できます。", "administratorPermission": "管理者として実行すると、一部のシステムキーや入力イベントをより確実に取得できます。", "administratorPermissionGuide": "一部のシステムキーや入力イベントをより確実に取得するため、アプリを管理者として実行することを推奨します。\n\n手順:\n1. まずアプリを終了します。\n2. エクスプローラーでアプリのexeファイルまたはショートカットを探します。\n3. 起動方法を選びます:\n - 今回のみ:右クリックして\"管理者として実行\"を選びます。\n - 常に:右クリック > プロパティ > 互換性 > \"管理者としてこのプログラムを実行する\"にチェック > OK。" }, @@ -72,7 +72,8 @@ "authorized": "許可済み", "adminEnabled": "有効", "authorize": "有効にする", - "viewGuide": "手順を表示" + "viewGuide": "手順を表示", + "openSettingsFailed": "手動で:システム設定 → プライバシーとセキュリティ → 入力監視。" }, "buttons": { "openNow": "今すぐ開く", diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index 22030e3c..07cc7f95 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -63,8 +63,8 @@ "hints": { "showTrayIcon": "활성화하면 시스템 트레이에 앱 아이콘이 표시됩니다.", "autostartFailed": "시작 프로그램 설정을 변경할 수 없습니다", - "inputMonitoringPermission": "시스템의 키보드와 마우스 이벤트를 받으려면 입력 모니터링을 활성화하세요.", - "inputMonitoringPermissionGuide": "앱이 시스템의 키보드와 마우스 이벤트를 받으려면 입력 모니터링 권한이 필요합니다.\n\n단계:\n1. 시스템 설정 > 개인정보 보호 및 보안 > 입력 모니터링을 엽니다.\n2. BongoCat이 이미 목록에 있으면 선택하고 \"-\" 버튼을 눌러 제거합니다.\n3. \"+\" 버튼을 눌러 BongoCat을 찾아 추가합니다.\n4. 권한을 적용하려면 앱을 다시 시작합니다.", + "inputMonitoringPermission": "허용한 뒤 다시 시작하세요.", + "inputMonitoringPermissionGuide": "입력 모니터링 권한이 있으면 BongoCat이 다른 앱에서 누른 키를 받을 수 있습니다. 마우스는 권한 없이도 동작합니다. BongoCat은 시작할 때 감시를 만들기 때문에 권한을 바꾼 뒤에는 다시 시작해야 합니다.\n\n단계:\n1. 시스템 설정 → 개인정보 보호 및 보안 → 입력 모니터링을 엽니다.\n2. 목록에 이미 있는 BongoCat 항목을 켭니다. 기존 항목은 삭제하지 마세요.\n3. 목록에 BongoCat이 없으면 \"+\"를 클릭해 현재 실행 중인 앱을 추가합니다(앱 위치를 확인하세요).\n4. BongoCat을 완전히 종료한 뒤 다시 시작해 감시를 다시 만들게 합니다.\n\n키보드가 계속 반응하지 않으면 BongoCat 항목을 직접 삭제하고 다시 추가해 확인할 수 있습니다.", "administratorPermission": "앱을 관리자로 실행하면 일부 시스템 키와 입력 이벤트를 더 안정적으로 캡처할 수 있습니다.", "administratorPermissionGuide": "일부 시스템 키와 입력 이벤트를 더 안정적으로 캡처하려면 앱을 관리자로 실행하는 것이 좋습니다.\n\n방법:\n1. 먼저 앱을 종료합니다.\n2. 파일 탐색기에서 앱의 exe 파일 또는 바로 가기를 찾습니다.\n3. 실행 방법을 선택합니다:\n - 한 번만: 마우스 오른쪽 버튼을 누르고 \"관리자 권한으로 실행\"을 선택합니다.\n - 항상: 오른쪽 클릭 > 속성 > 호환성 > \"관리자 권한으로 이 프로그램 실행\" 선택 > 확인." }, @@ -72,7 +72,8 @@ "authorized": "허용됨", "adminEnabled": "활성화됨", "authorize": "활성화", - "viewGuide": "안내 보기" + "viewGuide": "안내 보기", + "openSettingsFailed": "직접 여세요: 시스템 설정 → 개인정보 보호 및 보안 → 입력 모니터링." }, "buttons": { "openNow": "지금 열기", diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index 9eee0191..d0eca81c 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -63,8 +63,8 @@ "hints": { "showTrayIcon": "Quando ativado, o ícone do aplicativo é exibido na bandeja do sistema.", "autostartFailed": "Não foi possível atualizar a inicialização automática", - "inputMonitoringPermission": "Ative a permissão de monitoramento de entrada para receber eventos de teclado e mouse do sistema para responder às suas ações.", - "inputMonitoringPermissionGuide": "A permissão de Monitoramento de Entrada é necessária para que o aplicativo receba eventos de teclado e mouse do sistema.\n\nPassos:\n1. Abra as Configurações do Sistema → Privacidade e Segurança → Monitoramento de Entrada.\n2. Se o BongoCat já estiver na lista, selecione-o e clique no botão \"-\" para removê-lo.\n3. Clique no botão \"+\" para localizar e adicionar o BongoCat.\n4. Reinicie o aplicativo para que a permissão entre em vigor.", + "inputMonitoringPermission": "Reinicie após autorizar.", + "inputMonitoringPermissionGuide": "O Monitoramento de Entrada permite que o BongoCat veja teclas pressionadas em outros aplicativos; o mouse continua funcionando sem ele. O BongoCat cria o monitoramento ao iniciar, então uma mudança de permissão exige reiniciar.\n\nPassos:\n1. Abra Ajustes do Sistema → Privacidade e Segurança → Monitoramento de Entrada.\n2. Ative a entrada do BongoCat que já está na lista; não remova entradas existentes.\n3. Se o BongoCat não estiver na lista, clique em \"+\" e adicione a cópia que você executa (confira o local do aplicativo).\n4. Encerre o BongoCat por completo e inicie de novo para recriar o monitoramento.\n\nSe o teclado continuar sem resposta, você pode remover e adicionar de novo a entrada do BongoCat.", "administratorPermission": "Executar o aplicativo como administrador ajuda a capturar algumas teclas e eventos de entrada em nível de sistema com mais confiabilidade.", "administratorPermissionGuide": "Para capturar algumas teclas e eventos de entrada em nível de sistema com mais confiabilidade, é recomendado executar o aplicativo como administrador.\n\nComo fazer:\n1. Feche o aplicativo primeiro.\n2. Encontre o exe do aplicativo ou o atalho no Explorador de Arquivos.\n3. Escolha como iniciar:\n • Apenas desta vez: Clique com o botão direito e escolha \"Executar como administrador\".\n • Sempre (padrão): Botão direito → Propriedades → aba Compatibilidade → marque \"Executar este programa como administrador\" → OK." }, @@ -72,7 +72,8 @@ "authorized": "Autorizado", "adminEnabled": "Ativado", "authorize": "Ir para Ativar", - "viewGuide": "Ver instruções" + "viewGuide": "Ver instruções", + "openSettingsFailed": "Ajustes → Privacidade e Segurança → Monitoramento de Entrada." }, "buttons": { "openNow": "Abrir Agora", diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index b24ae0e1..75a65967 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -63,8 +63,8 @@ "hints": { "showTrayIcon": "Если включено, значок приложения отображается в системном трее.", "autostartFailed": "Не удалось изменить настройку автозапуска", - "inputMonitoringPermission": "Включите мониторинг ввода, чтобы получать системные события клавиатуры и мыши.", - "inputMonitoringPermissionGuide": "Разрешение на мониторинг ввода необходимо, чтобы приложение получало системные события клавиатуры и мыши.\n\nШаги:\n1. Откройте Системные настройки > Конфиденциальность и безопасность > Мониторинг ввода.\n2. Если BongoCat уже есть в списке, выберите его и нажмите \"-\", чтобы удалить.\n3. Нажмите \"+\", найдите и добавьте BongoCat.\n4. Перезапустите приложение, чтобы разрешение вступило в силу.", + "inputMonitoringPermission": "Перезапустите после разрешения.", + "inputMonitoringPermissionGuide": "Мониторинг ввода позволяет BongoCat видеть нажатия клавиш в других приложениях; мышь работает и без него. BongoCat создаёт мониторинг при запуске, поэтому после изменения разрешения нужен перезапуск.\n\nШаги:\n1. Откройте Системные настройки → Конфиденциальность и безопасность → Мониторинг ввода.\n2. Включите уже имеющуюся запись BongoCat; не удаляйте существующие записи.\n3. Если BongoCat отсутствует в списке, нажмите \"+\" и добавьте запущенную копию (проверьте расположение приложения).\n4. Полностью закройте BongoCat и запустите снова, чтобы он пересоздал мониторинг.\n\nЕсли клавиатура по-прежнему не реагирует, вы можете сами удалить и заново добавить запись BongoCat.", "administratorPermission": "Запуск от имени администратора позволяет надёжнее захватывать некоторые системные клавиши и события ввода.", "administratorPermissionGuide": "Для более надёжного захвата некоторых системных клавиш и событий ввода рекомендуется запускать приложение от имени администратора.\n\nКак это сделать:\n1. Сначала закройте приложение.\n2. Найдите exe-файл приложения или его ярлык в Проводнике.\n3. Выберите способ запуска:\n - Один раз: щёлкните правой кнопкой и выберите \"Запуск от имени администратора\".\n - Всегда: правый щелчок > Свойства > Совместимость > отметьте \"Запускать эту программу от имени администратора\" > ОК." }, @@ -72,7 +72,8 @@ "authorized": "Разрешено", "adminEnabled": "Включено", "authorize": "Включить", - "viewGuide": "Показать инструкцию" + "viewGuide": "Показать инструкцию", + "openSettingsFailed": "Системные настройки → Конфиденциальность → Мониторинг ввода." }, "buttons": { "openNow": "Открыть сейчас", diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index 1acfa4bc..c31adc27 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -63,8 +63,8 @@ "hints": { "showTrayIcon": "启用后,在系统托盘中显示应用图标。", "autostartFailed": "无法更新开机启动设置", - "inputMonitoringPermission": "开启输入监控权限,以便接收系统的键盘和鼠标事件来响应你的操作。", - "inputMonitoringPermissionGuide": "开启输入监控权限后,应用才能接收系统的键盘和鼠标事件,从而响应你的操作。\n\n操作方式:\n1. 打开「系统设置」→「隐私与安全性」→「输入监控」。\n2. 若列表中已存在 BongoCat,请先选中它,点击「-」按钮将其删除。\n3. 点击「+」按钮,找到并添加 BongoCat 应用。\n4. 重启应用,以确保权限正式生效。", + "inputMonitoringPermission": "授权后请重启", + "inputMonitoringPermissionGuide": "输入监控权限让 BongoCat 能接收其他应用中的按键;鼠标移动和点击无需该权限。BongoCat 在启动时建立监听,因此权限变更后需要重启。\n\n操作方式:\n1. 打开「系统设置」→「隐私与安全性」→「输入监控」。\n2. 直接开启列表中已有的 BongoCat 条目,不要删除现有条目。\n3. 若列表中没有 BongoCat,点击「+」按钮,添加你当前运行的那份应用(注意确认应用位置)。\n4. 完全退出 BongoCat 后重新启动,让它重建监听。\n\n若键盘仍然无响应,可由你自行删除并重新添加 BongoCat 条目来排障。", "administratorPermission": "以管理员身份运行应用,可以更稳定地捕获部分系统级按键与输入事件。", "administratorPermissionGuide": "为了能更稳定地捕获部分系统级按键与输入事件,建议以管理员身份运行应用。\n\n操作方式:\n1. 先退出应用。\n2. 在资源管理器中找到应用的 exe 或快捷方式。\n3. 根据需要选择启动方式:\n • 仅本次:右键并选择「以管理员身份运行」。\n • 永久默认:右键 → 属性 → 兼容性选项卡 → 勾选「以管理员身份运行此程序」→ 确定。" }, @@ -72,7 +72,8 @@ "authorized": "已授权", "adminEnabled": "已启用", "authorize": "去授权", - "viewGuide": "查看操作说明" + "viewGuide": "查看操作说明", + "openSettingsFailed": "无法打开系统设置,请手动打开:隐私与安全性 → 输入监控" }, "buttons": { "openNow": "前往开启", diff --git a/resources/assets/locales/zh-Hant.json b/resources/assets/locales/zh-Hant.json index c647c426..04c8472e 100644 --- a/resources/assets/locales/zh-Hant.json +++ b/resources/assets/locales/zh-Hant.json @@ -63,8 +63,8 @@ "hints": { "showTrayIcon": "啟用後,在系統托盤中顯示應用程式圖示。", "autostartFailed": "無法更新開機啟動設定", - "inputMonitoringPermission": "開啟輸入監控權限,以便接收系統的鍵盤和滑鼠游標事件來回應您的操作。", - "inputMonitoringPermissionGuide": "開啟輸入監控權限後,應用程式才能接收系統的鍵盤和滑鼠事件,進而回應你的操作。\n\n操作方式:\n1. 開啟「系統設定」→「隱私權與安全性」→「輸入監控」。\n2. 若清單中已存在 BongoCat,請先選取它,點擊「-」按鈕將其刪除。\n3. 點擊「+」按鈕,找到並新增 BongoCat 應用程式。\n4. 重新啟動應用程式,以確保權限正式生效。", + "inputMonitoringPermission": "授權後請重新啟動", + "inputMonitoringPermissionGuide": "輸入監控權限讓 BongoCat 能接收其他應用程式中的按鍵;滑鼠移動和點擊無需該權限。BongoCat 在啟動時建立監聽,因此權限變更後需要重新啟動。\n\n操作方式:\n1. 開啟「系統設定」→「隱私權與安全性」→「輸入監控」。\n2. 直接開啟清單中已有的 BongoCat 項目,不要刪除現有項目。\n3. 若清單中沒有 BongoCat,點擊「+」按鈕,加入你目前執行的那份應用程式(請確認應用程式位置)。\n4. 完全結束 BongoCat 後重新啟動,讓它重建監聽。\n\n若鍵盤仍然沒有反應,可由你自行移除並重新加入 BongoCat 項目來排解問題。", "administratorPermission": "以系統管理員身分執行應用程式,可以更穩定地擷取部分系統層級按鍵與輸入事件。", "administratorPermissionGuide": "為了能更穩定地擷取部分系統層級按鍵與輸入事件,建議以系統管理員身分執行應用程式。\n\n操作方式:\n1. 先退出應用程式。\n2. 在檔案總管中找到應用程式的 exe 或捷徑。\n3. 根據需求選擇啟動方式:\n • 僅本次:右鍵並選擇「以系統管理員身分執行」。\n • 永久預設:右鍵 → 內容 → 相容性索引標籤 → 勾選「以系統管理員身分執行此程式」→ 確定。" }, @@ -72,7 +72,8 @@ "authorized": "已授權", "adminEnabled": "已啟用", "authorize": "去授權", - "viewGuide": "查看操作說明" + "viewGuide": "查看操作說明", + "openSettingsFailed": "無法開啟系統設定,請手動開啟:隱私權與安全性 → 輸入監控" }, "buttons": { "openNow": "前往開啟", diff --git a/src/platform/macos/macos.m b/src/platform/macos/macos.m index 2745270b..738f9adb 100644 --- a/src/platform/macos/macos.m +++ b/src/platform/macos/macos.m @@ -190,6 +190,14 @@ bool bongo_cat_platform_dynamic_hit_supported(void) { return bongo_cat_macos_input_supported(); } +bool bongo_cat_platform_input_monitoring_authorized(void) { + return bongo_cat_macos_input_monitoring_authorized(); +} + +bool bongo_cat_platform_input_monitoring_request(void) { + return bongo_cat_macos_input_monitoring_request(); +} + bool bongo_cat_platform_open_directory(const char *path) { if (!path || !path[0]) return false; @autoreleasepool { diff --git a/src/platform/macos/macos_input.m b/src/platform/macos/macos_input.m index 621c55c0..bb54eee1 100644 --- a/src/platform/macos/macos_input.m +++ b/src/platform/macos/macos_input.m @@ -104,6 +104,10 @@ static int SDLCALL input_thread(void *userdata) { CGEventMaskBit(kCGEventOtherMouseDown) | CGEventMaskBit(kCGEventOtherMouseUp) | CGEventMaskBit(kCGEventMouseMoved) | CGEventMaskBit(kCGEventLeftMouseDragged) | CGEventMaskBit(kCGEventRightMouseDragged) | CGEventMaskBit(kCGEventOtherMouseDragged); + /* The permission this listener is built with, kept only to word the + warning below; the page reads the current permission itself. */ + bool permitted = false; + if (@available(macOS 10.15, *)) permitted = CGPreflightListenEventAccess(); state->tap = CGEventTapCreate(kCGSessionEventTap, kCGTailAppendEventTap, kCGEventTapOptionListenOnly, mask, event_tap, state); if (state->tap) { @@ -117,9 +121,16 @@ static int SDLCALL input_thread(void *userdata) { atomic_store(&global_supported, true); } SDL_SignalSemaphore(state->ready); - if (!atomic_load(&state->supported)) SDL_LogWarn( - SDL_LOG_CATEGORY_APPLICATION, - "macOS input monitoring permission is required for global input"); + if (!atomic_load(&state->supported)) { + /* Not a permission verdict: a listener can also fail to build with + the permission already granted. */ + if (permitted) + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "macOS input listener failed to initialize"); + else + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "macOS input " + "monitoring permission is required for global keyboard input"); + } while (atomic_load(&state->supported) && !atomic_load(&state->stop_requested)) CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.1, true); @@ -136,9 +147,6 @@ static int SDLCALL input_thread(void *userdata) { } bool bongo_cat_macos_input_start(BongoCatPlatform *platform, BongoCatError *error) { - if (@available(macOS 10.15, *)) { - if (!CGPreflightListenEventAccess()) CGRequestListenEventAccess(); - } MacInputState *state = calloc(1, sizeof(*state)); if (!state) { bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, @@ -184,4 +192,14 @@ void bongo_cat_macos_input_stop(BongoCatPlatform *platform) { } bool bongo_cat_macos_input_supported(void) { return atomic_load(&global_supported); } + +bool bongo_cat_macos_input_monitoring_authorized(void) { + if (@available(macOS 10.15, *)) return CGPreflightListenEventAccess(); + return false; +} + +bool bongo_cat_macos_input_monitoring_request(void) { + if (@available(macOS 10.15, *)) return CGRequestListenEventAccess(); + return false; +} #endif diff --git a/src/platform/macos/macos_internal.h b/src/platform/macos/macos_internal.h index a673a1d1..090597fd 100644 --- a/src/platform/macos/macos_internal.h +++ b/src/platform/macos/macos_internal.h @@ -6,5 +6,7 @@ bool bongo_cat_macos_input_start(BongoCatPlatform *platform, BongoCatError *error); void bongo_cat_macos_input_stop(BongoCatPlatform *platform); bool bongo_cat_macos_input_supported(void); +bool bongo_cat_macos_input_monitoring_authorized(void); +bool bongo_cat_macos_input_monitoring_request(void); #endif diff --git a/src/ui/preferences/preferences.c b/src/ui/preferences/preferences.c index a1949525..120ab54b 100644 --- a/src/ui/preferences/preferences.c +++ b/src/ui/preferences/preferences.c @@ -229,6 +229,13 @@ bool bongo_cat_preferences_event(BongoCatPreferences *value, const SDL_Event *ev return true; } if (bongo_cat_preferences_scale_event(value, event)) return true; +#ifdef __APPLE__ + /* The user returns from System Settings through this window. */ + if (event->type == SDL_EVENT_WINDOW_FOCUS_GAINED) { + bongo_cat_preferences_input_monitoring_refresh(value); + value->render_dirty = true; + } +#endif if (event->type == SDL_EVENT_WINDOW_FOCUS_LOST) { bongo_cat_ui_input_reset(&value->ui); if (value->chrome_dragging) SDL_CaptureMouse(false); diff --git a/src/ui/preferences/preferences_lifecycle.c b/src/ui/preferences/preferences_lifecycle.c index ae3dd430..5ba6b097 100644 --- a/src/ui/preferences/preferences_lifecycle.c +++ b/src/ui/preferences/preferences_lifecycle.c @@ -59,6 +59,9 @@ static void release_window(BongoCatPreferences *value) { void bongo_cat_preferences_show(BongoCatPreferences *value) { if (!value) return; +#ifdef __APPLE__ + bongo_cat_preferences_input_monitoring_refresh(value); +#endif if (value->visible) { bongo_cat_platform_raise_window(value->window); bongo_cat_app_request_nearby_model_refresh(value->app); diff --git a/src/ui/preferences/preferences_pages.c b/src/ui/preferences/preferences_pages.c index cdef5b1e..3c538fe1 100644 --- a/src/ui/preferences/preferences_pages.c +++ b/src/ui/preferences/preferences_pages.c @@ -1,4 +1,5 @@ #include "preferences_internal.h" +#include "preferences_state.h" #include "preferences_theme.h" #include "preferences_widgets.h" #include "preferences_notice.h" @@ -176,6 +177,76 @@ static void update_autostart(BongoCatApp *app, bool old_value) { "Unable to update launch-on-startup settings"), true); } +#ifdef __APPLE__ +/* System Settings keeps Input Monitoring under Privacy & Security. */ +#define BONGO_CAT_INPUT_MONITORING_SETTINGS_URI \ + "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent" + +void bongo_cat_preferences_input_monitoring_refresh(BongoCatPreferences *value) { + if (!value) return; + /* The refresh points are user actions, not frames: one read here. */ + bool authorized = bongo_cat_platform_input_monitoring_authorized(); + /* A button drawn from the old reading stays on screen until something + repaints, so a reading that changed - or arrives for the first time - + schedules the next frame; an unchanged one must not, or every refresh + point would keep the window rendering. */ + if (value->input_monitoring_valid && + value->input_monitoring_authorized == authorized) return; + value->input_monitoring_authorized = authorized; + value->input_monitoring_valid = true; + value->render_dirty = true; +} + +static void enable_input_monitoring(BongoCatApp *app) { + BongoCatPreferences *value = app->preferences; + if (!value) return; + /* The permission can change while the page is open, so the button reads it + once more before acting: an already granted permission hides the button + instead of asking again or sending the user to System Settings. */ + bongo_cat_preferences_input_monitoring_refresh(value); + if (value->input_monitoring_authorized) return; + /* The system prompt belongs to this button and to a missing permission; a + refusal is an answer, not a failure. */ + (void)bongo_cat_platform_input_monitoring_request(); + /* SDL_OpenURL is the whole entry point, so a failure is where the path has + to be spelled out: the item itself only says when to restart. */ + if (!SDL_OpenURL(BONGO_CAT_INPUT_MONITORING_SETTINGS_URI)) + bongo_cat_preferences_notice_show(app, tr(app, + "pages.preference.general.status.openSettingsFailed", + "Cannot open System Settings. Open Privacy & Security → Input " + "Monitoring."), true); + bongo_cat_preferences_input_monitoring_refresh(value); +} + +static void input_monitoring_section(BongoCatApp *app, + struct nk_context *context) { + /* The page is drawn through the preferences that hold the cached reading; a + page drawn before the first refresh initializes it once, never per frame. */ + BongoCatPreferences *value = app->preferences; + if (!value) return; + if (!value->input_monitoring_valid) + bongo_cat_preferences_input_monitoring_refresh(value); + bongo_cat_pref_section_icon(context, tr(app, + "pages.preference.general.labels.permissionsSettings", + "Permissions Settings"), BONGO_CAT_PREF_ICON_SECTION_APPLICATION); + const char *title = tr(app, + "pages.preference.general.labels.inputMonitoringPermission", + "Input Monitoring Permission"); + /* Granted is a reading without an action; only a missing permission offers + the button, and nothing here asks for authorization twice. */ + if (value->input_monitoring_authorized) { + bongo_cat_pref_status(context, "input-monitoring", title, tr(app, + "pages.preference.general.status.authorized", "Authorized")); + return; + } + if (bongo_cat_pref_button(context, "input-monitoring", title, tr(app, + "pages.preference.general.hints.inputMonitoringPermission", + "Restart after granting it."), tr(app, + "pages.preference.general.status.authorize", "Go to Enable"))) + enable_input_monitoring(app); +} +#endif + static void page_general(BongoCatApp *app, struct nk_context *context) { BongoCatApplicationPreferences *options = &app->settings.app; // Keep each option in its own native language so the list is recognizable @@ -217,6 +288,10 @@ static void page_general(BongoCatApp *app, struct nk_context *context) { options->theme = (BongoCatTheme)bongo_cat_pref_theme(context, "theme", tr(app, "pages.preference.general.labels.themeMode", "Theme"), themes, options->theme); +#ifdef __APPLE__ + section_gap(context, 7); + input_monitoring_section(app, context); +#endif } void bongo_cat_preferences_page_settings(BongoCatApp *app, diff --git a/src/ui/preferences/preferences_state.h b/src/ui/preferences/preferences_state.h index cf8c6a13..2730a071 100644 --- a/src/ui/preferences/preferences_state.h +++ b/src/ui/preferences/preferences_state.h @@ -122,8 +122,19 @@ struct BongoCatPreferences { SDL_Keycode shortcut_key; bool shortcut_recording; uint64_t shortcut_suppress_until_ns; +#ifdef __APPLE__ + /* The permission changes while the user is in System Settings, so the page + reads it again when it becomes visible and when the window regains focus + instead of querying macOS per frame. */ + bool input_monitoring_authorized; + bool input_monitoring_valid; +#endif }; +#ifdef __APPLE__ +void bongo_cat_preferences_input_monitoring_refresh(BongoCatPreferences *value); +#endif + int bongo_cat_preferences_resolved_theme(const BongoCatPreferences *value); void bongo_cat_preferences_apply_theme(BongoCatPreferences *value); bool bongo_cat_preferences_open_window(BongoCatPreferences *value); diff --git a/tests/ui/test_input_monitoring_refresh.c b/tests/ui/test_input_monitoring_refresh.c new file mode 100644 index 00000000..1ae421a3 --- /dev/null +++ b/tests/ui/test_input_monitoring_refresh.c @@ -0,0 +1,75 @@ +#include "preferences_state.h" + +#include +#include + +/* The target compiles the real preferences_pages.c with this getter substituted + for the platform one, so the refresh path runs without touching TCC. */ +static bool probe_authorized; +static unsigned probe_reads; + +bool bongo_cat_test_input_monitoring_authorized(void) { + probe_reads++; + return probe_authorized; +} + +static int failures; + +static void check(bool condition, const char *what) { + if (!condition) { + fprintf(stderr, "failed: %s\n", what); + failures++; + } +} + +int main(void) { + BongoCatPreferences *value = calloc(1, sizeof(*value)); + if (!value) { + fprintf(stderr, "cannot allocate preferences\n"); + return 2; + } + + /* First reading: the page has never queried the platform, so the frame it is + drawing now needs another one to show what was read. */ + probe_authorized = false; + probe_reads = 0; + value->render_dirty = false; + bongo_cat_preferences_input_monitoring_refresh(value); + check(value->input_monitoring_valid, "first refresh marks the reading valid"); + check(!value->input_monitoring_authorized, + "first refresh stores the missing permission"); + check(value->render_dirty, "first refresh repaints the page"); + check(probe_reads == 1, "first refresh reads the platform once"); + + /* The user granted the permission while the page was open: the button the + current frame drew is stale, so the next frame has to draw again. */ + probe_authorized = true; + value->render_dirty = false; + bongo_cat_preferences_input_monitoring_refresh(value); + check(value->input_monitoring_authorized, + "granted refresh stores the permission"); + check(value->render_dirty, "granted refresh repaints the page"); + + /* Revoked again: the same kind of change, in the other direction. */ + probe_authorized = false; + value->render_dirty = false; + bongo_cat_preferences_input_monitoring_refresh(value); + check(!value->input_monitoring_authorized, + "revoked refresh stores the missing permission"); + check(value->render_dirty, "revoked refresh repaints the page"); + + /* An unchanged reading must not schedule a repaint, or every refresh point + would keep the page rendering. */ + probe_reads = 0; + value->render_dirty = false; + bongo_cat_preferences_input_monitoring_refresh(value); + check(!value->input_monitoring_authorized, + "unchanged refresh keeps the reading"); + check(!value->render_dirty, "unchanged refresh does not repaint"); + check(probe_reads == 1, "unchanged refresh still reads the platform once"); + + free(value); + if (failures) return 1; + puts("input monitoring refresh repaint checks passed"); + return 0; +} From 937a3584764d5d0d966820b38620704ac8ab661a Mon Sep 17 00:00:00 2001 From: kmou424 Date: Mon, 14 Sep 2026 04:01:25 +0800 Subject: [PATCH 292/334] fix(macos): stage the app icon before bundle signing - Copy the icon in the existing post-build resource step. - Track icon source changes for incremental Makefile and Ninja builds. Co-Authored-By: GPT-6 Astra --- cmake/ApplicationResources.cmake | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/cmake/ApplicationResources.cmake b/cmake/ApplicationResources.cmake index 07c0f3da..c56df9a5 100644 --- a/cmake/ApplicationResources.cmake +++ b/cmake/ApplicationResources.cmake @@ -7,11 +7,10 @@ if(APPLE) MACOSX_BUNDLE_ICON_FILE "icon.icns" MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/cmake/Info.plist.in") - # MACOSX_PACKAGE_LOCATION Resources lands the icon in Contents/Resources, - # which is where the CFBundleIconFile above is resolved from. - set_source_files_properties("${BONGO_CAT_APP_ICON}" - PROPERTIES MACOSX_PACKAGE_LOCATION Resources) - target_sources(bongo_cat PRIVATE "${BONGO_CAT_APP_ICON}") + # Stage the icon with other resources before any later POST_BUILD signing. + # Relink when it changes so incremental builds also run the resource copy. + set_property(TARGET bongo_cat APPEND PROPERTY LINK_DEPENDS + "${BONGO_CAT_APP_ICON}") add_custom_command(TARGET bongo_cat POST_BUILD COMMAND ${CMAKE_COMMAND} -E rm -rf "$/MacOS/assets" @@ -19,6 +18,9 @@ if(APPLE) "$/Resources/assets" COMMAND ${CMAKE_COMMAND} -E make_directory "$/Resources/assets" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${BONGO_CAT_APP_ICON}" + "$/Resources/icon.icns" COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/resources/assets" "$/Resources/assets" From 0b55f44a24a3e736a564ba31f62d7cb11a208e1d Mon Sep 17 00:00:00 2001 From: kmou424 Date: Mon, 14 Sep 2026 04:01:26 +0800 Subject: [PATCH 293/334] test(macos): group permission refresh checks with ui tests - Keep the permission refresh test beside the existing UI tests. - Preserve its build conditions, command, and coverage. Co-Authored-By: GPT-6 Astra --- cmake/Tests.cmake | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index ce3a64dc..fc8d1d90 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -60,6 +60,24 @@ if(BUILD_TESTING) target_link_libraries(bongo_cat_ui_tests PRIVATE bongo_cat_warnings) add_test(NAME ui COMMAND bongo_cat_ui_tests) + if(APPLE) + # The refresh helper runs the real page code with the platform read replaced + # per target, so the repaint decision is tested without touching TCC. + add_executable(bongo_cat_input_monitoring_refresh_tests + tests/ui/test_input_monitoring_refresh.c + src/ui/preferences/preferences_pages.c) + target_compile_definitions(bongo_cat_input_monitoring_refresh_tests PRIVATE + bongo_cat_platform_input_monitoring_authorized=bongo_cat_test_input_monitoring_authorized) + target_include_directories(bongo_cat_input_monitoring_refresh_tests PRIVATE + ${BONGO_CAT_RUNTIME_INTERNAL_INCLUDE_DIRS}) + target_include_directories(bongo_cat_input_monitoring_refresh_tests SYSTEM PRIVATE + ${BONGO_CAT_NUKLEAR_INCLUDE_DIR}) + target_link_libraries(bongo_cat_input_monitoring_refresh_tests PRIVATE + bongo_cat_runtime bongo_cat_warnings) + add_test(NAME macos-input-monitoring-refresh + COMMAND bongo_cat_input_monitoring_refresh_tests) + endif() + add_executable(bongo_cat_app_state_tests tests/core/test_app_state.c src/core/app_state.c src/runtime/model/model_behavior_state.c) @@ -157,24 +175,6 @@ if(BUILD_TESTING) add_test(NAME live2d-motion-state COMMAND bongo_cat_motion_state_tests) endif() - if(APPLE) - # The refresh helper runs the real page code with the platform read replaced - # per target, so the repaint decision is tested without touching TCC. - add_executable(bongo_cat_input_monitoring_refresh_tests - tests/ui/test_input_monitoring_refresh.c - src/ui/preferences/preferences_pages.c) - target_compile_definitions(bongo_cat_input_monitoring_refresh_tests PRIVATE - bongo_cat_platform_input_monitoring_authorized=bongo_cat_test_input_monitoring_authorized) - target_include_directories(bongo_cat_input_monitoring_refresh_tests PRIVATE - ${BONGO_CAT_RUNTIME_INTERNAL_INCLUDE_DIRS}) - target_include_directories(bongo_cat_input_monitoring_refresh_tests SYSTEM PRIVATE - ${BONGO_CAT_NUKLEAR_INCLUDE_DIR}) - target_link_libraries(bongo_cat_input_monitoring_refresh_tests PRIVATE - bongo_cat_runtime bongo_cat_warnings) - add_test(NAME macos-input-monitoring-refresh - COMMAND bongo_cat_input_monitoring_refresh_tests) - endif() - if(WIN32) add_executable(bongo_cat_windows_input_tests tests/platform/test_windows_input.c From 069b840b3090268896c48e890a66c558fcf7aac2 Mon Sep 17 00:00:00 2001 From: kmou424 Date: Mon, 14 Sep 2026 04:01:26 +0800 Subject: [PATCH 294/334] test(macos): register bundle signature checks independently - Place bundle signature verification before the runtime test groups. - Keep the macOS guard and native verification command unchanged. Co-Authored-By: GPT-6 Astra --- cmake/Tests.cmake | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index 16978741..e5dc80be 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -1,4 +1,9 @@ if(BUILD_TESTING) + if(APPLE) + add_test(NAME macos-bundle-signature COMMAND /usr/bin/codesign + --verify --strict --deep --verbose=2 "$") + endif() + add_executable(bongo_cat_hover_fade_tests tests/core/test_hover_fade.c src/runtime/input/mouse.c src/runtime/shell/modal_frame.c) @@ -194,9 +199,4 @@ if(BUILD_TESTING) target_link_libraries(bongo_cat_ui_tests PRIVATE m) target_link_libraries(bongo_cat_app_state_tests PRIVATE m) endif() - - if(APPLE) - add_test(NAME macos-bundle-signature COMMAND /usr/bin/codesign - --verify --strict --deep --verbose=2 "$") - endif() endif() From 7161b09873e7514ecc77c1530c79e1a5a8e1e503 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 14 Sep 2026 13:17:28 +0800 Subject: [PATCH 295/334] Fix model canvas alignment and organize import rendering modules --- .gitignore | 1 + cmake/RuntimeSources.cmake | 4 + cmake/Tests.cmake | 2 +- docs/windows-multimonitor-input-validation.md | 75 ------- include/bongo_cat/model.h | 1 + include/bongo_cat/overlay.h | 4 +- src/live2d/cubism_model_bounds.cpp | 8 +- src/render/overlay.c | 189 ++---------------- src/render/overlay_assets.c | 126 ++++++++++++ src/render/overlay_draw.c | 2 + src/render/overlay_input.c | 67 +++++++ src/render/overlay_internal.h | 10 + .../model/import/model_import_metadata.c | 1 + .../import/mver/model_import_mver_metadata.c | 2 + .../import/tauri/model_import_tauri_config.c | 2 + .../import/tauri/model_import_tauri_convert.c | 5 +- .../import/tauri/model_import_tauri_input.c | 89 +++++++++ .../tauri/model_import_tauri_internal.h | 9 +- .../import/tauri/model_import_tauri_keys.c | 98 +-------- .../import/tauri/model_import_tauri_legacy.c | 99 +++++++++ .../tauri/model_import_tauri_metadata.c | 74 ++----- .../tauri/model_import_tauri_resources.c | 12 -- src/runtime/model/model_files.c | 2 +- tests/model_import/test_mver_pointer_import.c | 1 + tests/model_import/test_tauri_portable.c | 101 ++++++++++ 25 files changed, 560 insertions(+), 424 deletions(-) delete mode 100644 docs/windows-multimonitor-input-validation.md create mode 100644 src/render/overlay_assets.c create mode 100644 src/render/overlay_input.c create mode 100644 src/runtime/model/import/tauri/model_import_tauri_input.c create mode 100644 src/runtime/model/import/tauri/model_import_tauri_legacy.c diff --git a/.gitignore b/.gitignore index 8f59ac20..bf58b4f1 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,7 @@ Desktop.ini !/docs/README.id-ID.md !/docs/wechat.png !/docs/windows-multimonitor-input-validation.md +!/docs/model-import-rendering.md # Local Windows audit tools /cmake/*.ps1 diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 7da3b089..3cf135f4 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -27,6 +27,8 @@ set(BONGO_CAT_MEDIA_SOURCES set(BONGO_CAT_RENDER_SOURCES src/render/gl_api.c src/render/overlay.c + src/render/overlay_assets.c + src/render/overlay_input.c src/render/overlay_draw.c src/render/mver_pointer_overlay.c src/render/mver_pointer_overlay_draw.c) @@ -141,7 +143,9 @@ set(BONGO_CAT_RUNTIME_IMPORT_SOURCES src/runtime/model/import/tauri/model_import_tauri_convert.c src/runtime/model/import/tauri/model_import_tauri_discovery.c src/runtime/model/import/tauri/model_import_tauri_images.c + src/runtime/model/import/tauri/model_import_tauri_input.c src/runtime/model/import/tauri/model_import_tauri_keys.c + src/runtime/model/import/tauri/model_import_tauri_legacy.c src/runtime/model/import/tauri/model_import_tauri_metadata.c src/runtime/model/import/tauri/model_import_tauri_resources.c src/runtime/model/import/tauri/model_import_tauri_tree.c diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index b35c7a6c..b47f2e00 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -111,7 +111,7 @@ if(BUILD_TESTING) target_include_directories(bongo_cat_mver_import_tests PRIVATE ${BONGO_CAT_RUNTIME_INTERNAL_INCLUDE_DIRS}) target_include_directories(bongo_cat_mver_import_tests SYSTEM PRIVATE - ${BONGO_CAT_NUKLEAR_INCLUDE_DIR}) + ${BONGO_CAT_NUKLEAR_INCLUDE_DIR} ${BONGO_CAT_STB_INCLUDE_DIR}) target_link_libraries(bongo_cat_mver_import_tests PRIVATE bongo_cat_runtime) target_compile_definitions(bongo_cat_mver_import_tests PRIVATE BONGO_CAT_NATIVE_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") diff --git a/docs/windows-multimonitor-input-validation.md b/docs/windows-multimonitor-input-validation.md deleted file mode 100644 index d525dd5e..00000000 --- a/docs/windows-multimonitor-input-validation.md +++ /dev/null @@ -1,75 +0,0 @@ -# Windows 双屏键盘输入回归 - -当前键盘链路使用独立消息窗口接收后台 Raw Input,不根据桌宠位置、 -鼠标坐标或前台窗口所在屏幕过滤按键。鼠标桌面位置另行读取,所以 -“鼠标跟随正常”并不能证明键盘订阅正常。 - -本次修复补齐注册丢失后的恢复:每秒检查进程内的键鼠订阅;订阅消失, -或本接收窗口丢失后台接收标志时,重新订阅对应设备类别。先释放旧的 -按下状态,避免恢复后卡键。另一接收窗口持有的注册不会被覆盖; -无法确认注册情况或当前不在正常输入桌面时,不尝试恢复。 - -这修复了代码中确认存在的恢复缺口,但尚未证明它就是用户反馈的原因。 -此次未编译、未运行新增 C 测试,也未完成真实双屏及游戏验证。 - -## 编译后验证 - -1. 普通权限启动桌宠,在主副屏各打开一个记事本。桌宠分别放在主屏、 - 副屏,各测试两个记事本中的按下、长按、松开和连续输入。 -2. 副屏分别放在主屏左侧、右侧、上方;使用不同缩放比例;切换主屏。 - 各组合都应响应键盘,鼠标跟随保持正常。 -3. 在两个记事本之间切换焦点,打开并关闭桌宠设置,拔插外接键盘、 - 断开再接上副屏,锁屏后解锁。确认恢复后无卡键或持续不响应。 -4. 在游戏允许后台桌面应用的前提下,分别测试窗口、无边框及全屏模式, - 并在游戏和副屏记事本之间切换。输入应正常送达游戏,桌宠不抢焦点。 - 游戏限制后台输入时,不通过提权、输入钩子或模拟输入绕过。 -5. 编译 Windows 输入测试目标 `bongo_cat_windows_input_tests` 后,运行 - `ctest --test-dir <构建目录> -R '^windows-input$' --output-on-failure`。 - 新增用例覆盖键盘注册丢失、 - 后台标志退化、保留鼠标订阅、不覆盖其他接收窗口,以及工作线程恢复。 - -## 仍能复现时 - -请用户在同一次运行中,先在主屏记事本连续敲击、松开按键至少 20 秒, -再在副屏重复至少 20 秒,每个阶段保持记事本在前台,并记录大致时间。 -之后打开设置的关于页面,点击“打开日志”,复制 `BongoCat.log` 发回。 -**先保存日志再重启桌宠**:主进程每次启动会清空上次会话日志。 -不需要开启调试模式,也不需要以管理员身份启动。 - -记录系统和桌宠版本、屏幕布局、问题软件、权限级别,以及同一软件移到 -另一屏幕后问题是否随之移动。优先用普通权限记事本区分屏幕因素和软件因素。 - -日志中的 `[input] Raw Input ownership changed` 表示监听注册发生变化, -`background registration restored` 表示已恢复注册,`recovery failed` 后的 -错误码用于定位恢复失败。类别位 `1` 为鼠标,`2` 为键盘,`3` 为两者。 -这些日志不记录用户输入内容。注册恢复不等于已收到真实键盘事件。 - -`[input] receiver` 在启动、退出和运行期间每 10 秒记录一次; -`[input] consumer` 在主循环运行期间每 10 秒记录一次。数值是本进程本次 -运行的累计计数,分析时比较相邻记录的增量,不能把累计数量当成当前按下键数。 -双屏相关字段只是打印时的快照,不代表整个统计区间的输入都来自该屏幕。 - -| 字段 | 用途 | -| --- | --- | -| `keys` / `background_keys` / `mouse` | 收到的有效原始键盘包、其中后台键盘包及鼠标包;按键长按重复也计数 | -| `filtered_keys` / `duplicate_keys` | 被规范化过滤的包及重复状态包;增长不一定是错误 | -| `queued_keys` | 成功送入主线程队列的键盘事件;包含清理状态产生的释放事件 | -| `queue_failures` / `wake_failures` | 入队和 SDL 唤醒失败的尝试次数,包含后续可能成功的重试 | -| `invalid` / `device_drops` / `read_failures` | 无效包、设备状态分配失败或达上限、系统读包失败次数 | -| `device_changes` / `devices` | 设备到达和移除通知次数、当前追踪设备数 | -| `owned` / `desktop_unavailable` / `query_error` | 后台订阅归属、输入桌面是否无法访问或不匹配、注册查询错误码 | -| `recovery_error` / `last_read_error` | 最近恢复失败或读包失败的错误码;最后读包错误不会因成功读取而清零 | -| `drained_keys` / `ignored_keys` | 主循环取出的键盘事件数、其中被诊断模式忽略的数量 | -| `mapped_keys` / `unmapped_keys` / `no_model_keys` | 模型按键映射接受、未接受、模型未就绪的事件次数;包含程序内部补发的事件 | -| `frames` / `visible` / `minimized` / `dirty` | 成功提交画面次数及当前窗口、重绘状态;提交成功不保证屏幕实际可见 | -| `monitors` / `foreground_pid` / `foreground_primary` / `foreground_rect` | 当前屏幕数量、前台 PID、前台是否在主屏及所在屏幕边界(左、上、右、下) | -| `pet_position` / `pet_display` / `pet_primary` / `pet_bounds` | 桌宠位置、SDL 屏幕 ID、是否在主屏及屏幕区域(横坐标、纵坐标、宽、高) | - -先确认各 `known` 字段再解读位置数据。若副屏阶段 `keys` 不增长,应检查 -系统投递、订阅归属及输入桌面;若 `keys` 增长而 `queued_keys` 不增长, -检查过滤、重复状态和入队失败;若已入队但 `drained_keys` 不增长,检查 -主循环是否卡住;若已消费,再看模型映射、显示状态和提交画面计数。 -日志不会记录具体按键、输入文字、窗口标题或进程路径。 - -游戏兼容性保持普通 Raw Input 订阅、不注入、不读写游戏内存、不安装驱动、 -不模拟输入的边界;这不构成任何反作弊产品的兼容性认证。 diff --git a/include/bongo_cat/model.h b/include/bongo_cat/model.h index b57b7109..b3c7258a 100644 --- a/include/bongo_cat/model.h +++ b/include/bongo_cat/model.h @@ -112,6 +112,7 @@ typedef struct BongoCatLive2DFrame { typedef struct BongoCatLive2DRenderOptions { bool mver_projection; + bool auto_frame; bool source_mirror; bool custom_pointer_bounds; bool pointer_left_handed; diff --git a/include/bongo_cat/overlay.h b/include/bongo_cat/overlay.h index 4be1f442..c59e5526 100644 --- a/include/bongo_cat/overlay.h +++ b/include/bongo_cat/overlay.h @@ -1,7 +1,7 @@ #ifndef BONGO_CAT_OVERLAY_H #define BONGO_CAT_OVERLAY_H -#include "bongo_cat/common.h" +#include "bongo_cat/model.h" typedef struct BongoCatOverlay BongoCatOverlay; @@ -10,7 +10,7 @@ void bongo_cat_overlay_destroy(BongoCatOverlay *overlay); void bongo_cat_overlay_clear(BongoCatOverlay *overlay); BongoCatResult bongo_cat_overlay_load(BongoCatOverlay *overlay, const char *model_directory, bool model_pointer_preferred, - BongoCatError *error); + const BongoCatLive2DRenderOptions *render_options, BongoCatError *error); int bongo_cat_overlay_key(BongoCatOverlay *overlay, const char *name, bool pressed); bool bongo_cat_overlay_effect(BongoCatOverlay *overlay, const char *path); bool bongo_cat_overlay_hand_active(const BongoCatOverlay *overlay, bool right); diff --git a/src/live2d/cubism_model_bounds.cpp b/src/live2d/cubism_model_bounds.cpp index 7c52d349..e7c05efd 100644 --- a/src/live2d/cubism_model_bounds.cpp +++ b/src/live2d/cubism_model_bounds.cpp @@ -107,9 +107,11 @@ static float frame_margin(float overflow, float padding) { void NativeModel::prepare_expression_frame() { frame_ = BongoCatLive2DFrame{}; - /* Mver models have authored background/input layers in the same canvas; - changing their viewport would break that compatibility contract. */ - if (!_model || render_options_.mver_projection) { + /* Authored Mver calibration keeps its exact frame. Legacy conversions + opt into extra space because their inferred projection can clip geometry. + The shared content viewport keeps background/input layers aligned. */ + if (!_model || (render_options_.mver_projection && + !render_options_.auto_frame)) { update_viewport(); return; } diff --git a/src/render/overlay.c b/src/render/overlay.c index c68b32ff..a8e738cd 100644 --- a/src/render/overlay.c +++ b/src/render/overlay.c @@ -1,17 +1,17 @@ #include "overlay_internal.h" -#include "bongo_cat/image.h" -#include "bongo_cat/path.h" -#include -#include -#include + #include -#include static const char *vertex_source = "#version 330 core\n" "layout(location=0) in vec2 pos; layout(location=1) in vec2 uv;\n" "out vec2 tex; uniform bool mirror;\n" - "void main(){float x=mirror?-pos.x:pos.x;gl_Position=vec4(x,pos.y,0,1);tex=uv;}"; + "uniform sampler2D image; uniform int reference_width,reference_height;\n" + "void main(){vec2 p=pos;" + "if(reference_width>0&&reference_height>0){" + "vec2 extent=vec2(textureSize(image,0))/vec2(reference_width,reference_height);" + "p=(p+vec2(1,-1))*extent+vec2(-1,1);}" + "if(mirror)p.x=-p.x;gl_Position=vec4(p,0,1);tex=uv;}"; static const char *fragment_source = "#version 330 core\n" "in vec2 tex; out vec4 color; uniform sampler2D image;\n" @@ -21,26 +21,6 @@ static const char *fragment_source = "vec2 dr=(tex-vec2(.275,.397))/vec2(.070,.160);" "bool l=dot(dl,dl)<1.;bool r=dot(dr,dr)<1.;" "if((erase_left&&l)||(erase_right&&r))color.rgb=vec3(1);color.rgb*=color.a;}"; -static void clear_textures(BongoCatOverlay *value) { - if (value->background) glDeleteTextures(1, &value->background); - if (value->composite) glDeleteTextures(1, &value->composite); - value->background = 0; - value->composite = 0; - value->clean_paws = false; - value->composed_cover = false; - value->composite_dirty = false; - value->model_pointer_preferred = false; - for (size_t i = 0; i < 4; ++i) { - if (value->cache[i].texture) glDeleteTextures(1, &value->cache[i].texture); - memset(&value->cache[i], 0, sizeof(value->cache[i])); - } - value->left = value->right = 0; - value->effect = 0; - value->left_name[0] = value->right_name[0] = '\0'; - value->left_path[0] = value->right_path[0] = '\0'; - value->effect_path[0] = '\0'; - value->background_path[0] = '\0'; -} BongoCatOverlay *bongo_cat_overlay_create(BongoCatError *error) { BongoCatOverlay *value = calloc(1, sizeof(*value)); @@ -52,6 +32,10 @@ BongoCatOverlay *bongo_cat_overlay_create(BongoCatError *error) { if (!value->program) { free(value); return NULL; } value->mirror_location = value->gl.uniform_location(value->program, "mirror"); value->image_location = value->gl.uniform_location(value->program, "image"); + value->reference_width_location = value->gl.uniform_location(value->program, + "reference_width"); + value->reference_height_location = value->gl.uniform_location(value->program, + "reference_height"); value->erase_left_location = value->gl.uniform_location(value->program, "erase_left"); value->erase_right_location = value->gl.uniform_location(value->program, "erase_right"); const float vertices[] = {-1, -1, 0, 1, 1, -1, 1, 1, -1, 1, 0, 0, 1, 1, 1, 0}; @@ -76,7 +60,7 @@ BongoCatOverlay *bongo_cat_overlay_create(BongoCatError *error) { void bongo_cat_overlay_destroy(BongoCatOverlay *value) { if (!value) return; bongo_cat_mver_pointer_overlay_destroy(value->mver_pointer); - clear_textures(value); + bongo_cat_overlay_clear_textures(value); if (value->vbo) value->gl.delete_buffers(1, &value->vbo); if (value->vao) value->gl.delete_vertex_arrays(1, &value->vao); if (value->program) value->gl.delete_program(value->program); @@ -85,144 +69,11 @@ void bongo_cat_overlay_destroy(BongoCatOverlay *value) { void bongo_cat_overlay_clear(BongoCatOverlay *value) { if (!value) return; - clear_textures(value); + bongo_cat_overlay_clear_textures(value); bongo_cat_mver_pointer_overlay_clear(value->mver_pointer); value->directory[0] = '\0'; } -BongoCatResult bongo_cat_overlay_load(BongoCatOverlay *value, - const char *directory, bool model_pointer_preferred, - BongoCatError *error) { - if (!value || !directory) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, - "Missing model overlay state or directory"); - return BONGO_CAT_ERROR_ARGUMENT; - } - BongoCatError local = {0}; - BongoCatError *failure = error ? error : &local; - *failure = (BongoCatError){0}; - char path[BONGO_CAT_PATH_CAP]; - /* Without the licensed Cubism runtime there is no model renderer. Use the - model's composed preview so the desktop pet remains visually complete; - Cubism builds keep the background-only layer behind the animated model. */ -#ifdef BONGO_CAT_HAS_CUBISM - if (!bongo_cat_path_join(path, sizeof(path), directory, - "resources/background.png")) { - bongo_cat_error_set(failure, BONGO_CAT_ERROR_IO, "Model overlay path is too long"); - return BONGO_CAT_ERROR_IO; - } -#else - if (!bongo_cat_path_join(path, sizeof(path), directory, - "resources/cover.png")) { - bongo_cat_error_set(failure, BONGO_CAT_ERROR_IO, "Model overlay path is too long"); - return BONGO_CAT_ERROR_IO; - } -#endif - clear_textures(value); - bongo_cat_mver_pointer_overlay_clear(value->mver_pointer); - GLuint background = 0; - BongoCatError background_error = {0}; - if (bongo_cat_path_is_file(path)) background = bongo_cat_image_texture(path, - NULL, NULL, &background_error); - BongoCatError pointer_error = {0}; - if (!model_pointer_preferred && - !bongo_cat_mver_pointer_overlay_load(value->mver_pointer, directory, - &pointer_error)) { - if (background) glDeleteTextures(1, &background); - *failure = pointer_error; - if (!failure->message[0] && background_error.message[0]) - *failure = background_error; - if (!failure->message[0]) bongo_cat_error_set(failure, BONGO_CAT_ERROR_IO, - "Unable to load model overlay"); - return BONGO_CAT_ERROR_IO; - } - if (!background && background_error.message[0]) - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", background_error.message); - value->background = background; - value->model_pointer_preferred = model_pointer_preferred; - value->composed_cover = false; - value->clean_paws = false; -#ifndef BONGO_CAT_HAS_CUBISM - value->composed_cover = true; - value->clean_paws = true; -#endif - snprintf(value->directory, sizeof(value->directory), "%s", directory); - snprintf(value->background_path, sizeof(value->background_path), "%s", path); - return BONGO_CAT_OK; -} - -#ifdef BONGO_CAT_HAS_CUBISM -static GLuint cached_texture(BongoCatOverlay *value, const char *path) { - value->clock++; - TextureSlot *oldest = NULL; - for (size_t i = 0; i < 4; ++i) { - TextureSlot *slot = &value->cache[i]; - if (slot->texture && strcmp(slot->path, path) == 0) { - slot->used = value->clock; - return slot->texture; - } - if (slot->texture && - (slot->texture == value->left || slot->texture == value->right || - slot->texture == value->effect)) continue; - if (!oldest || !slot->texture || slot->used < oldest->used) oldest = slot; - } - if (!oldest) return 0; - if (oldest->texture) glDeleteTextures(1, &oldest->texture); - BongoCatError ignored = {0}; - oldest->texture = bongo_cat_image_texture(path, NULL, NULL, &ignored); - snprintf(oldest->path, sizeof(oldest->path), "%s", path); - oldest->used = value->clock; - return oldest->texture; -} -#endif - -static bool key_path(BongoCatOverlay *value, const char *group, const char *name, - char path[BONGO_CAT_PATH_CAP]) { - char relative[BONGO_CAT_PATH_CAP]; - snprintf(relative, sizeof(relative), "resources/%s/%s.png", group, name); - bongo_cat_path_join(path, BONGO_CAT_PATH_CAP, value->directory, relative); - if (bongo_cat_path_is_file(path)) return true; - if (name[0] == 'F' && name[1] >= '0' && name[1] <= '9') { - snprintf(relative, sizeof(relative), "resources/%s/Fn.png", group); - bongo_cat_path_join(path, BONGO_CAT_PATH_CAP, value->directory, relative); - return bongo_cat_path_is_file(path); - } - return false; -} - -int bongo_cat_overlay_key(BongoCatOverlay *value, const char *name, bool pressed) { - if (!value || !name) return -1; - bool right; - char path[BONGO_CAT_PATH_CAP]; - if (key_path(value, "right-keys", name, path)) right = true; - else if (key_path(value, "left-keys", name, path)) right = false; - else return -1; - char *active_name = right ? value->right_name : value->left_name; - GLuint *active = right ? &value->right : &value->left; - char *active_path = right ? value->right_path : value->left_path; - if (pressed) { - snprintf(active_name, BONGO_CAT_ID_CAP, "%s", name); -#ifdef BONGO_CAT_HAS_CUBISM - *active = cached_texture(value, path); -#else - snprintf(active_path, BONGO_CAT_PATH_CAP, "%s", path); - *active = 1; -#endif - } else if (strcmp(active_name, name) == 0) { - active_name[0] = '\0'; - *active = 0; - active_path[0] = '\0'; - } -#ifndef BONGO_CAT_HAS_CUBISM - value->composite_dirty = true; -#endif - return right ? 1 : 0; -} - -bool bongo_cat_overlay_hand_active(const BongoCatOverlay *value, bool right) { - return value && (right ? value->right : value->left) != 0; -} - bool bongo_cat_overlay_mver_pointer_enabled(const BongoCatOverlay *value) { return value && !value->model_pointer_preferred && bongo_cat_mver_pointer_overlay_enabled(value->mver_pointer); @@ -237,17 +88,3 @@ void bongo_cat_overlay_set_mver_pointer(BongoCatOverlay *value, bongo_cat_mver_pointer_overlay_set(value->mver_pointer, x_ratio, y_ratio, left, right, side); } -bool bongo_cat_overlay_effect(BongoCatOverlay *value, const char *path) { - if (!value) return false; - value->effect = 0; - value->effect_path[0] = '\0'; - if (!path || !*path) return true; - if (!bongo_cat_path_is_file(path)) return false; -#ifdef BONGO_CAT_HAS_CUBISM - value->effect = cached_texture(value, path); -#else - value->effect = 1; -#endif - snprintf(value->effect_path, sizeof(value->effect_path), "%s", path); - return value->effect != 0; -} diff --git a/src/render/overlay_assets.c b/src/render/overlay_assets.c new file mode 100644 index 00000000..40f37763 --- /dev/null +++ b/src/render/overlay_assets.c @@ -0,0 +1,126 @@ +#include "overlay_internal.h" +#include "bongo_cat/image.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +void bongo_cat_overlay_clear_textures(BongoCatOverlay *value) { + if (value->background) glDeleteTextures(1, &value->background); + if (value->composite) glDeleteTextures(1, &value->composite); + value->background = 0; + value->composite = 0; + value->clean_paws = false; + value->composed_cover = false; + value->composite_dirty = false; + value->model_pointer_preferred = false; + value->reference_width = value->reference_height = 0; + for (size_t i = 0; i < 4; ++i) { + if (value->cache[i].texture) glDeleteTextures(1, &value->cache[i].texture); + memset(&value->cache[i], 0, sizeof(value->cache[i])); + } + value->left = value->right = 0; + value->effect = 0; + value->left_name[0] = value->right_name[0] = '\0'; + value->left_path[0] = value->right_path[0] = '\0'; + value->effect_path[0] = '\0'; + value->background_path[0] = '\0'; +} + +BongoCatResult bongo_cat_overlay_load(BongoCatOverlay *value, + const char *directory, bool model_pointer_preferred, + const BongoCatLive2DRenderOptions *render_options, BongoCatError *error) { + if (!value || !directory) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, + "Missing model overlay state or directory"); + return BONGO_CAT_ERROR_ARGUMENT; + } + BongoCatError local = {0}; + BongoCatError *failure = error ? error : &local; + *failure = (BongoCatError){0}; + char path[BONGO_CAT_PATH_CAP]; + /* Without the licensed Cubism runtime there is no model renderer. Use the + model's composed preview so the desktop pet remains visually complete; + Cubism builds keep the background-only layer behind the animated model. */ +#ifdef BONGO_CAT_HAS_CUBISM + if (!bongo_cat_path_join(path, sizeof(path), directory, + "resources/background.png")) { + bongo_cat_error_set(failure, BONGO_CAT_ERROR_IO, "Model overlay path is too long"); + return BONGO_CAT_ERROR_IO; + } +#else + if (!bongo_cat_path_join(path, sizeof(path), directory, + "resources/cover.png")) { + bongo_cat_error_set(failure, BONGO_CAT_ERROR_IO, "Model overlay path is too long"); + return BONGO_CAT_ERROR_IO; + } +#endif + bongo_cat_overlay_clear_textures(value); + bongo_cat_mver_pointer_overlay_clear(value->mver_pointer); + GLuint background = 0; + BongoCatError background_error = {0}; + if (bongo_cat_path_is_file(path)) background = bongo_cat_image_texture(path, + NULL, NULL, &background_error); + BongoCatError pointer_error = {0}; + if (!model_pointer_preferred && + !bongo_cat_mver_pointer_overlay_load(value->mver_pointer, directory, + &pointer_error)) { + if (background) glDeleteTextures(1, &background); + *failure = pointer_error; + if (!failure->message[0] && background_error.message[0]) + *failure = background_error; + if (!failure->message[0]) bongo_cat_error_set(failure, BONGO_CAT_ERROR_IO, + "Unable to load model overlay"); + return BONGO_CAT_ERROR_IO; + } + if (!background && background_error.message[0]) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", background_error.message); + value->background = background; + /* SFML sprites use original pixels at the top-left of the configured view. + Their image size is not necessarily the size of that view. */ +#ifdef BONGO_CAT_HAS_CUBISM + if (render_options && render_options->mver_projection) { + value->reference_width = render_options->reference_width; + value->reference_height = render_options->reference_height; + } +#else + (void)render_options; +#endif + value->model_pointer_preferred = model_pointer_preferred; + value->composed_cover = false; + value->clean_paws = false; +#ifndef BONGO_CAT_HAS_CUBISM + value->composed_cover = true; + value->clean_paws = true; +#endif + snprintf(value->directory, sizeof(value->directory), "%s", directory); + snprintf(value->background_path, sizeof(value->background_path), "%s", path); + return BONGO_CAT_OK; +} + +#ifdef BONGO_CAT_HAS_CUBISM +GLuint bongo_cat_overlay_cached_texture(BongoCatOverlay *value, const char *path) { + value->clock++; + TextureSlot *oldest = NULL; + for (size_t i = 0; i < 4; ++i) { + TextureSlot *slot = &value->cache[i]; + if (slot->texture && strcmp(slot->path, path) == 0) { + slot->used = value->clock; + return slot->texture; + } + if (slot->texture && + (slot->texture == value->left || slot->texture == value->right || + slot->texture == value->effect)) continue; + if (!oldest || !slot->texture || slot->used < oldest->used) oldest = slot; + } + if (!oldest) return 0; + if (oldest->texture) glDeleteTextures(1, &oldest->texture); + BongoCatError ignored = {0}; + oldest->texture = bongo_cat_image_texture(path, NULL, NULL, &ignored); + snprintf(oldest->path, sizeof(oldest->path), "%s", path); + oldest->used = value->clock; + return oldest->texture; +} +#endif + diff --git a/src/render/overlay_draw.c b/src/render/overlay_draw.c index e890e56a..da811c55 100644 --- a/src/render/overlay_draw.c +++ b/src/render/overlay_draw.c @@ -13,6 +13,8 @@ static void draw(BongoCatOverlay *value, GLuint texture, value->gl.use_program(value->program); value->gl.uniform_1i(value->mirror_location, mirror); value->gl.uniform_1i(value->image_location, 0); + value->gl.uniform_1i(value->reference_width_location, value->reference_width); + value->gl.uniform_1i(value->reference_height_location, value->reference_height); value->gl.uniform_1i(value->erase_left_location, !blend && value->composed_cover && !value->composite && value->left != 0); diff --git a/src/render/overlay_input.c b/src/render/overlay_input.c new file mode 100644 index 00000000..96bcc827 --- /dev/null +++ b/src/render/overlay_input.c @@ -0,0 +1,67 @@ +#include "overlay_internal.h" +#include "bongo_cat/path.h" + +#include +#include + +static bool key_path(BongoCatOverlay *value, const char *group, const char *name, + char path[BONGO_CAT_PATH_CAP]) { + char relative[BONGO_CAT_PATH_CAP]; + snprintf(relative, sizeof(relative), "resources/%s/%s.png", group, name); + bongo_cat_path_join(path, BONGO_CAT_PATH_CAP, value->directory, relative); + if (bongo_cat_path_is_file(path)) return true; + if (name[0] == 'F' && name[1] >= '0' && name[1] <= '9') { + snprintf(relative, sizeof(relative), "resources/%s/Fn.png", group); + bongo_cat_path_join(path, BONGO_CAT_PATH_CAP, value->directory, relative); + return bongo_cat_path_is_file(path); + } + return false; +} + +int bongo_cat_overlay_key(BongoCatOverlay *value, const char *name, bool pressed) { + if (!value || !name) return -1; + bool right; + char path[BONGO_CAT_PATH_CAP]; + if (key_path(value, "right-keys", name, path)) right = true; + else if (key_path(value, "left-keys", name, path)) right = false; + else return -1; + char *active_name = right ? value->right_name : value->left_name; + GLuint *active = right ? &value->right : &value->left; + char *active_path = right ? value->right_path : value->left_path; + if (pressed) { + snprintf(active_name, BONGO_CAT_ID_CAP, "%s", name); +#ifdef BONGO_CAT_HAS_CUBISM + *active = bongo_cat_overlay_cached_texture(value, path); +#else + snprintf(active_path, BONGO_CAT_PATH_CAP, "%s", path); + *active = 1; +#endif + } else if (strcmp(active_name, name) == 0) { + active_name[0] = '\0'; + *active = 0; + active_path[0] = '\0'; + } +#ifndef BONGO_CAT_HAS_CUBISM + value->composite_dirty = true; +#endif + return right ? 1 : 0; +} + +bool bongo_cat_overlay_hand_active(const BongoCatOverlay *value, bool right) { + return value && (right ? value->right : value->left) != 0; +} + +bool bongo_cat_overlay_effect(BongoCatOverlay *value, const char *path) { + if (!value) return false; + value->effect = 0; + value->effect_path[0] = '\0'; + if (!path || !*path) return true; + if (!bongo_cat_path_is_file(path)) return false; +#ifdef BONGO_CAT_HAS_CUBISM + value->effect = bongo_cat_overlay_cached_texture(value, path); +#else + value->effect = 1; +#endif + snprintf(value->effect_path, sizeof(value->effect_path), "%s", path); + return value->effect != 0; +} diff --git a/src/render/overlay_internal.h b/src/render/overlay_internal.h index 6554115f..b95c9634 100644 --- a/src/render/overlay_internal.h +++ b/src/render/overlay_internal.h @@ -19,6 +19,8 @@ struct BongoCatOverlay { GLuint program; GLint mirror_location; GLint image_location; + GLint reference_width_location; + GLint reference_height_location; GLint erase_left_location; GLint erase_right_location; GLuint vao; @@ -29,6 +31,8 @@ struct BongoCatOverlay { bool clean_paws; bool composite_dirty; bool model_pointer_preferred; + int reference_width; + int reference_height; TextureSlot cache[4]; GLuint left; GLuint right; @@ -43,4 +47,10 @@ struct BongoCatOverlay { uint64_t clock; }; +/* Shared texture ownership for model loading and input/effect activation. */ +void bongo_cat_overlay_clear_textures(BongoCatOverlay *value); +#ifdef BONGO_CAT_HAS_CUBISM +GLuint bongo_cat_overlay_cached_texture(BongoCatOverlay *value, const char *path); +#endif + #endif diff --git a/src/runtime/model/import/model_import_metadata.c b/src/runtime/model/import/model_import_metadata.c index c5630291..5deb4f8c 100644 --- a/src/runtime/model/import/model_import_metadata.c +++ b/src/runtime/model/import/model_import_metadata.c @@ -121,6 +121,7 @@ bool bongo_cat_import_render_options(const char *directory, yyjson_get_num(scale) <= 100.0 && width > 0 && height > 0; if (valid) { options->mver_projection = true; + options->auto_frame = yyjson_get_bool(yyjson_obj_get(render, "autoFrame")); options->projection_scale = (float)yyjson_get_num(scale); options->offset_x = (float)yyjson_get_num(yyjson_obj_get(render, "offsetX")); options->offset_y = (float)yyjson_get_num(yyjson_obj_get(render, "offsetY")); diff --git a/src/runtime/model/import/mver/model_import_mver_metadata.c b/src/runtime/model/import/mver/model_import_mver_metadata.c index f8dbfc08..5e2e8659 100644 --- a/src/runtime/model/import/mver/model_import_mver_metadata.c +++ b/src/runtime/model/import/mver/model_import_mver_metadata.c @@ -128,6 +128,8 @@ static bool add_render_profile(yyjson_mut_doc *output, yyjson_mut_val *root, yyjson_mut_val *render = yyjson_mut_obj_add_obj(output, root, "render"); return render && yyjson_mut_obj_add_str(output, render, "profile", "mver-0.1.6") && + yyjson_mut_obj_add_bool(output, render, "autoFrame", + yyjson_get_bool(yyjson_obj_get(decoration, "l2d_auto_frame"))) && yyjson_mut_obj_add_real(output, render, "projectionScale", scale) && yyjson_mut_obj_add_real(output, render, "offsetX", offset_x) && yyjson_mut_obj_add_real(output, render, "offsetY", offset_y) && diff --git a/src/runtime/model/import/tauri/model_import_tauri_config.c b/src/runtime/model/import/tauri/model_import_tauri_config.c index f0d30efe..c2f50c0a 100644 --- a/src/runtime/model/import/tauri/model_import_tauri_config.c +++ b/src/runtime/model/import/tauri/model_import_tauri_config.c @@ -42,6 +42,8 @@ bool bongo_cat_tauri_write_config(const char *path, BongoCatModelMode mode, yyjson_mut_arr_add_real(document, offset, calibration->l2d_offset_y) && yyjson_mut_obj_add_bool(document, decoration, "l2d_horizontal_flip", calibration->mirror) && + yyjson_mut_obj_add_bool(document, decoration, "l2d_auto_frame", + calibration->auto_frame) && yyjson_mut_obj_add_int(document, root, "mode", mode == BONGO_CAT_MODE_STANDARD ? 1 : mode == BONGO_CAT_MODE_KEYBOARD ? 2 : 3) && diff --git a/src/runtime/model/import/tauri/model_import_tauri_convert.c b/src/runtime/model/import/tauri/model_import_tauri_convert.c index 30e24788..2308b78e 100644 --- a/src/runtime/model/import/tauri/model_import_tauri_convert.c +++ b/src/runtime/model/import/tauri/model_import_tauri_convert.c @@ -38,10 +38,7 @@ bool bongo_cat_import_tauri_convert_to_mver( "Cannot allocate Tauri conversion workspace"); return false; } - char resource_directory[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_tauri_resource_root(candidate, resource_directory, - sizeof(resource_directory)) || - !bongo_cat_tauri_copy_input_images(candidate, resource_directory, + if (!bongo_cat_tauri_copy_input_images(candidate, mode_root, &work->left, &work->right, error)) { free(work); return false; diff --git a/src/runtime/model/import/tauri/model_import_tauri_input.c b/src/runtime/model/import/tauri/model_import_tauri_input.c new file mode 100644 index 00000000..4d522f19 --- /dev/null +++ b/src/runtime/model/import/tauri/model_import_tauri_input.c @@ -0,0 +1,89 @@ +#include "model_import_tauri_internal.h" + +#include +#include +#include +#include + +static BongoCatPathVisit collect_keys(void *userdata, + const char *dirname, const char *name) { + TauriKeyFiles *files = userdata; + if (files->count >= TAURI_KEY_CAP || + !bongo_cat_import_has_suffix_ci(name, ".png")) + return BONGO_CAT_PATH_CONTINUE; + int code = bongo_cat_tauri_key_code(name); + if (code < 0) return BONGO_CAT_PATH_CONTINUE; + TauriKeyFile *item = &files->values[files->count]; + if (!bongo_cat_path_join(item->path, sizeof(item->path), dirname, name)) + return BONGO_CAT_PATH_FAILURE; + if (!bongo_cat_path_is_file(item->path)) return BONGO_CAT_PATH_CONTINUE; + snprintf(item->name, sizeof(item->name), "%s", name); + item->code = code; + files->count++; + return BONGO_CAT_PATH_CONTINUE; +} + +static int compare_keys(const void *left, const void *right) { + const TauriKeyFile *a = left, *b = right; + return SDL_strcasecmp(a->name, b->name); +} + +static bool copy_keys(const BongoCatImportCandidate *candidate, + const char *mode_root, const char *source_group, const char *target_group, + TauriKeyFiles *files, BongoCatError *error) { + char source[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_tauri_find_resource_directory(candidate, source_group, + source, sizeof(source))) return true; + if (!bongo_cat_path_enumerate(source, collect_keys, files)) return false; + qsort(files->values, files->count, sizeof(files->values[0]), compare_keys); + char destination[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(destination, sizeof(destination), mode_root, + target_group) || !bongo_cat_path_create_directory(destination)) + return false; + for (size_t i = 0; i < files->count; ++i) { + char filename[32], path[BONGO_CAT_PATH_CAP]; + snprintf(filename, sizeof(filename), "%zu.png", i); + if (!bongo_cat_path_join(path, sizeof(path), destination, filename) || + !bongo_cat_path_copy_file(files->values[i].path, path)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot copy Tauri input image: %s", files->values[i].path); + return false; + } + } + return true; +} + +static bool ensure_key(const char *mode_root, const char *target_group, + int fallback_code, TauriKeyFiles *files, BongoCatError *error) { + if (files->count) return true; + char group[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(group, sizeof(group), mode_root, target_group) || + !bongo_cat_path_create_directory(group) || + !bongo_cat_path_join(target, sizeof(target), group, "0.png") || + !bongo_cat_tauri_copy_image_or_placeholder(NULL, target, error)) + return false; + files->values[0].code = fallback_code; + files->count = 1; + return true; +} + +bool bongo_cat_tauri_copy_input_images( + const BongoCatImportCandidate *candidate, + const char *mode_root, TauriKeyFiles *left, TauriKeyFiles *right, + BongoCatError *error) { + *left = (TauriKeyFiles){0}; + *right = (TauriKeyFiles){0}; + if (candidate->mode == BONGO_CAT_MODE_STANDARD) { + return copy_keys(candidate, mode_root, "left-keys", "hand", left, + error) && + ensure_key(mode_root, "hand", 65, left, error); + } + return copy_keys(candidate, mode_root, + "left-keys", "lefthand", left, error) && + copy_keys(candidate, mode_root, + "right-keys", "righthand", right, error) && + ensure_key(mode_root, "lefthand", + candidate->mode == BONGO_CAT_MODE_GAMEPAD ? 0 : 65, left, error) && + ensure_key(mode_root, "righthand", + candidate->mode == BONGO_CAT_MODE_GAMEPAD ? 1 : 65, right, error); +} diff --git a/src/runtime/model/import/tauri/model_import_tauri_internal.h b/src/runtime/model/import/tauri/model_import_tauri_internal.h index 343b5836..f8095566 100644 --- a/src/runtime/model/import/tauri/model_import_tauri_internal.h +++ b/src/runtime/model/import/tauri/model_import_tauri_internal.h @@ -15,6 +15,7 @@ typedef struct TauriMverCalibration { int window_width; int window_height; bool mirror; + bool auto_frame; } TauriMverCalibration; typedef struct TauriKeyFile { @@ -39,13 +40,15 @@ bool bongo_cat_tauri_find_resource_file( bool bongo_cat_tauri_find_resource_directory( const BongoCatImportCandidate *candidate, const char *name, char *path, size_t capacity); -bool bongo_cat_tauri_resource_root(const BongoCatImportCandidate *candidate, - char *path, size_t capacity); bool bongo_cat_tauri_find_package_file( const BongoCatImportCandidate *candidate, const char *name, char *path, size_t capacity); /* Mver normalization steps. */ +int bongo_cat_tauri_key_code(const char *filename); +void bongo_cat_tauri_calibration_defaults(TauriMverCalibration *calibration); +void bongo_cat_tauri_legacy_calibration( + const BongoCatImportCandidate *candidate, TauriMverCalibration *calibration); bool bongo_cat_tauri_copy_image_or_placeholder(const char *source, const char *target, BongoCatError *error); bool bongo_cat_tauri_copy_preview(const BongoCatImportCandidate *candidate, @@ -54,7 +57,7 @@ bool bongo_cat_tauri_copy_runtime_images( const BongoCatImportCandidate *candidate, const char *mode_root, BongoCatError *error); bool bongo_cat_tauri_copy_input_images( - const BongoCatImportCandidate *candidate, const char *resource_directory, + const BongoCatImportCandidate *candidate, const char *mode_root, TauriKeyFiles *left, TauriKeyFiles *right, BongoCatError *error); bool bongo_cat_tauri_write_config(const char *path, BongoCatModelMode mode, diff --git a/src/runtime/model/import/tauri/model_import_tauri_keys.c b/src/runtime/model/import/tauri/model_import_tauri_keys.c index 78cf46bf..0279c541 100644 --- a/src/runtime/model/import/tauri/model_import_tauri_keys.c +++ b/src/runtime/model/import/tauri/model_import_tauri_keys.c @@ -5,7 +5,7 @@ #include #include -static int key_code(const char *filename) { +int bongo_cat_tauri_key_code(const char *filename) { char name[BONGO_CAT_ID_CAP]; snprintf(name, sizeof(name), "%s", filename ? filename : ""); char *dot = strrchr(name, '.'); @@ -20,8 +20,9 @@ static int key_code(const char *filename) { if (strlen(name) == 4 && SDL_strncasecmp(name, "Num", 3) == 0 && name[3] >= '0' && name[3] <= '9') return name[3]; if ((name[0] == 'F' || name[0] == 'f') && strlen(name) <= 3) { - int value = atoi(name + 1); - if (value >= 1 && value <= 12) return 111 + value; + long value = strtol(name + 1, &end, 10); + if (end != name + 1 && !*end && value >= 1 && value <= 12) + return 111 + (int)value; } static const struct { const char *name; int code; } map[] = { {"Backspace", 8}, {"BackSpace", 8}, {"Tab", 9}, {"Return", 13}, @@ -57,94 +58,3 @@ static int key_code(const char *filename) { if (SDL_strcasecmp(name, gamepad[i].name) == 0) return gamepad[i].code; return -1; } - -static BongoCatPathVisit collect_keys(void *userdata, - const char *dirname, const char *name) { - TauriKeyFiles *files = userdata; - if (files->count >= TAURI_KEY_CAP || - !bongo_cat_import_has_suffix_ci(name, ".png")) - return BONGO_CAT_PATH_CONTINUE; - int code = key_code(name); - if (code < 0) return BONGO_CAT_PATH_CONTINUE; - TauriKeyFile *item = &files->values[files->count]; - if (!bongo_cat_path_join(item->path, sizeof(item->path), dirname, name)) - return BONGO_CAT_PATH_FAILURE; - if (!bongo_cat_path_is_file(item->path)) return BONGO_CAT_PATH_CONTINUE; - snprintf(item->name, sizeof(item->name), "%s", name); - item->code = code; - files->count++; - return BONGO_CAT_PATH_CONTINUE; -} - -static int compare_keys(const void *left, const void *right) { - const TauriKeyFile *a = left, *b = right; - return SDL_strcasecmp(a->name, b->name); -} - -static bool copy_keys(const BongoCatImportCandidate *candidate, - const char *resource_directory, const char *mode_root, - const char *source_group, const char *target_group, - TauriKeyFiles *files, BongoCatError *error) { - char source[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(source, sizeof(source), resource_directory, - source_group)) return false; - if (!bongo_cat_path_is_dir(source)) { - char resources[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(resources, sizeof(resources), - candidate->directory, "resources") || - !bongo_cat_path_join(source, sizeof(source), resources, - source_group) || !bongo_cat_path_is_dir(source)) return true; - } - if (!bongo_cat_path_enumerate(source, collect_keys, files)) return false; - qsort(files->values, files->count, sizeof(files->values[0]), compare_keys); - char destination[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(destination, sizeof(destination), mode_root, - target_group) || !bongo_cat_path_create_directory(destination)) - return false; - for (size_t i = 0; i < files->count; ++i) { - char filename[32], path[BONGO_CAT_PATH_CAP]; - snprintf(filename, sizeof(filename), "%zu.png", i); - if (!bongo_cat_path_join(path, sizeof(path), destination, filename) || - !bongo_cat_path_copy_file(files->values[i].path, path)) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot copy Tauri input image: %s", files->values[i].path); - return false; - } - } - return true; -} - -static bool ensure_key(const char *mode_root, const char *target_group, - int fallback_code, TauriKeyFiles *files, BongoCatError *error) { - if (files->count) return true; - char group[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(group, sizeof(group), mode_root, target_group) || - !bongo_cat_path_create_directory(group) || - !bongo_cat_path_join(target, sizeof(target), group, "0.png") || - !bongo_cat_tauri_copy_image_or_placeholder(NULL, target, error)) - return false; - files->values[0].code = fallback_code; - files->count = 1; - return true; -} - -bool bongo_cat_tauri_copy_input_images( - const BongoCatImportCandidate *candidate, const char *resource_directory, - const char *mode_root, TauriKeyFiles *left, TauriKeyFiles *right, - BongoCatError *error) { - *left = (TauriKeyFiles){0}; - *right = (TauriKeyFiles){0}; - if (candidate->mode == BONGO_CAT_MODE_STANDARD) { - return copy_keys(candidate, resource_directory, - mode_root, "left-keys", "hand", left, error) && - ensure_key(mode_root, "hand", 65, left, error); - } - return copy_keys(candidate, resource_directory, mode_root, - "left-keys", "lefthand", left, error) && - copy_keys(candidate, resource_directory, mode_root, - "right-keys", "righthand", right, error) && - ensure_key(mode_root, "lefthand", - candidate->mode == BONGO_CAT_MODE_GAMEPAD ? 0 : 65, left, error) && - ensure_key(mode_root, "righthand", - candidate->mode == BONGO_CAT_MODE_GAMEPAD ? 1 : 65, right, error); -} diff --git a/src/runtime/model/import/tauri/model_import_tauri_legacy.c b/src/runtime/model/import/tauri/model_import_tauri_legacy.c new file mode 100644 index 00000000..f7a0e6b9 --- /dev/null +++ b/src/runtime/model/import/tauri/model_import_tauri_legacy.c @@ -0,0 +1,99 @@ +#include "model_import_tauri_internal.h" +#include "bongo_cat/image.h" + +static bool valid_canvas_size(int width, int height) { + return width >= 64 && width <= 8192 && height >= 64 && height <= 8192; +} + +static bool legacy_converter_double_scale( + const BongoCatImportCandidate *candidate, int width, int height) { + if (!candidate || !valid_canvas_size(width, height) || + width < 256 || height < 256) return false; + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_tauri_find_resource_directory(candidate, "left-keys", + path, sizeof(path))) return false; + if (candidate->mode != BONGO_CAT_MODE_STANDARD) return true; + return width == height && width >= 512; +} + +typedef struct InputCanvas { + int width, height; + bool mismatch; +} InputCanvas; + +static BongoCatPathVisit inspect_input_canvas(void *userdata, + const char *directory, const char *name) { + InputCanvas *canvas = userdata; + char path[BONGO_CAT_PATH_CAP]; + int width = 0, height = 0; + if (bongo_cat_tauri_key_code(name) < 0 || + !bongo_cat_path_join(path, sizeof(path), directory, name) || + !bongo_cat_image_info(path, &width, &height) || + !valid_canvas_size(width, height)) return BONGO_CAT_PATH_CONTINUE; + if (canvas->width && (canvas->width != width || canvas->height != height)) + canvas->mismatch = true; + canvas->width = width; + canvas->height = height; + return BONGO_CAT_PATH_CONTINUE; +} + +static void legacy_input_canvas(const BongoCatImportCandidate *candidate, + TauriMverCalibration *calibration) { + InputCanvas canvas = {0}; + const char *groups[] = {"left-keys", "right-keys"}; + for (size_t i = 0; i < sizeof(groups) / sizeof(groups[0]); ++i) { + char path[BONGO_CAT_PATH_CAP]; + if (bongo_cat_tauri_find_resource_directory(candidate, groups[i], + path, sizeof(path)) && + !bongo_cat_path_enumerate(path, inspect_input_canvas, &canvas)) + return; + } + if (!canvas.width || canvas.mismatch || + (canvas.width == calibration->window_width && + canvas.height == calibration->window_height)) return; + /* Legacy exporters can leave a square placeholder background while keeping + the original input canvas. Preserve vertical projection when recovering + its aspect ratio; a cropped cover is not calibration metadata. */ + double vertical_scale = calibration->l2d_correct * + calibration->window_width / calibration->window_height; + double scale = vertical_scale * canvas.height / canvas.width; + if (scale <= 0.01 || scale > 100.0) return; + calibration->window_width = canvas.width; + calibration->window_height = canvas.height; + calibration->l2d_correct = scale; +} + +void bongo_cat_tauri_legacy_calibration(const BongoCatImportCandidate *candidate, + TauriMverCalibration *calibration) { + bongo_cat_tauri_calibration_defaults(calibration); + /* Legacy packages lost the authored projection. Keep aligned layers, but + allow visible model geometry to extend beyond the inferred canvas. */ + calibration->auto_frame = true; + char path[BONGO_CAT_PATH_CAP]; + const char *names[] = {"background.png", "cover.png"}; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) { + if (!bongo_cat_tauri_find_resource_file(candidate, names[i], path, + sizeof(path))) continue; + int width = 0, height = 0; + if (bongo_cat_image_info(path, &width, &height) && + valid_canvas_size(width, height)) { + calibration->window_width = width; + calibration->window_height = height; + break; + } + } + /* Converter packages before schemaVersion 1 omitted their Mver projection + calibration. Keyboard/gamepad packages and the converter's square + standard profile use approximately 2.0. Non-square standard packages + retain Mver's 1.1 default; cover.png is only a cropped preview and must + not be used to infer runtime projection. */ + if (legacy_converter_double_scale(candidate, calibration->window_width, + calibration->window_height)) { + calibration->l2d_correct = 2.0; + if (candidate->mode == BONGO_CAT_MODE_STANDARD && + calibration->window_width == calibration->window_height) + calibration->l2d_offset_y = -0.005; + legacy_input_canvas(candidate, calibration); + } +} + diff --git a/src/runtime/model/import/tauri/model_import_tauri_metadata.c b/src/runtime/model/import/tauri/model_import_tauri_metadata.c index c4ef3096..bde5d242 100644 --- a/src/runtime/model/import/tauri/model_import_tauri_metadata.c +++ b/src/runtime/model/import/tauri/model_import_tauri_metadata.c @@ -1,6 +1,5 @@ #include "model_import_tauri_internal.h" -#include "bongo_cat/image.h" #include "bongo_cat/json.h" #include @@ -11,10 +10,6 @@ static bool finite_number(yyjson_val *value) { return yyjson_is_num(value) && isfinite(yyjson_get_num(value)); } -static bool valid_canvas_size(int width, int height) { - return width >= 64 && width <= 8192 && height >= 64 && height <= 8192; -} - static bool read_offset(yyjson_val *value, double *x, double *y) { if (!yyjson_is_arr(value) || yyjson_arr_size(value) < 2 || !finite_number(yyjson_arr_get(value, 0)) || @@ -47,54 +42,14 @@ static bool read_window(yyjson_val *value, int *width, int *height) { return true; } -static bool legacy_converter_double_scale( - const BongoCatImportCandidate *candidate, int width, int height) { - if (!candidate || !valid_canvas_size(width, height) || - width < 256 || height < 256) return false; - char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_tauri_find_resource_directory(candidate, "left-keys", - path, sizeof(path))) return false; - if (candidate->mode != BONGO_CAT_MODE_STANDARD) return true; - return width == height && width >= 512; -} - -static void mver_defaults(TauriMverCalibration *calibration) { +void bongo_cat_tauri_calibration_defaults(TauriMverCalibration *calibration) { calibration->l2d_correct = 1.1; calibration->l2d_offset_x = 0.0; calibration->l2d_offset_y = 0.0; calibration->window_width = 612; calibration->window_height = 352; calibration->mirror = false; -} - -static void legacy_defaults(const BongoCatImportCandidate *candidate, - TauriMverCalibration *calibration) { - mver_defaults(calibration); - char path[BONGO_CAT_PATH_CAP]; - const char *names[] = {"background.png", "cover.png"}; - for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) { - if (!bongo_cat_tauri_find_resource_file(candidate, names[i], path, - sizeof(path))) continue; - int width = 0, height = 0; - if (bongo_cat_image_info(path, &width, &height) && - valid_canvas_size(width, height)) { - calibration->window_width = width; - calibration->window_height = height; - break; - } - } - /* Converter packages before schemaVersion 1 omitted their Mver projection - calibration. Keyboard/gamepad packages and the converter's square - standard profile use approximately 2.0. Non-square standard packages - retain Mver's 1.1 default; cover.png is only a cropped preview and must - not be used to infer runtime projection. */ - if (legacy_converter_double_scale(candidate, calibration->window_width, - calibration->window_height)) { - calibration->l2d_correct = 2.0; - if (candidate->mode == BONGO_CAT_MODE_STANDARD && - calibration->window_width == calibration->window_height) - calibration->l2d_offset_y = -0.005; - } + calibration->auto_frame = false; } bool bongo_cat_tauri_read_calibration( @@ -104,7 +59,7 @@ bool bongo_cat_tauri_read_calibration( char path[BONGO_CAT_PATH_CAP]; if (!bongo_cat_tauri_find_package_file(candidate, BONGO_CAT_TAURI_SOURCE_FILE, path, sizeof(path))) { - legacy_defaults(candidate, calibration); + bongo_cat_tauri_legacy_calibration(candidate, calibration); return true; } yyjson_doc *document = bongo_cat_json_read_file(path, 0, NULL); @@ -118,21 +73,34 @@ bool bongo_cat_tauri_read_calibration( (!mode || strcmp(mode, bongo_cat_mode_name(candidate->mode)) == 0) && yyjson_is_obj(decoration); if (valid) { - mver_defaults(calibration); + bongo_cat_tauri_calibration_defaults(calibration); yyjson_val *scale = yyjson_obj_get(decoration, "l2d_correct"); + bool have_scale = false; if (finite_number(scale)) { double value = yyjson_get_num(scale); - if (value > 0.01 && value <= 100.0) + if (value > 0.01 && value <= 100.0) { calibration->l2d_correct = value; + have_scale = true; + } + } + bool have_window = read_window(yyjson_obj_get(decoration, "window_size"), + &calibration->window_width, &calibration->window_height); + if (!have_scale || !have_window) { + TauriMverCalibration authored = *calibration; + bongo_cat_tauri_legacy_calibration(candidate, calibration); + if (have_scale) calibration->l2d_correct = authored.l2d_correct; + if (have_window) { + calibration->window_width = authored.window_width; + calibration->window_height = authored.window_height; + } } read_offset(yyjson_obj_get(decoration, "l2d_offset"), &calibration->l2d_offset_x, &calibration->l2d_offset_y); - read_window(yyjson_obj_get(decoration, "window_size"), - &calibration->window_width, &calibration->window_height); + calibration->auto_frame = !(have_scale && have_window); yyjson_val *mirror = yyjson_obj_get(decoration, "l2d_horizontal_flip"); if (yyjson_is_bool(mirror)) calibration->mirror = yyjson_get_bool(mirror); - } else legacy_defaults(candidate, calibration); + } else bongo_cat_tauri_legacy_calibration(candidate, calibration); yyjson_doc_free(document); return true; } diff --git a/src/runtime/model/import/tauri/model_import_tauri_resources.c b/src/runtime/model/import/tauri/model_import_tauri_resources.c index 16dbb4fe..583ca713 100644 --- a/src/runtime/model/import/tauri/model_import_tauri_resources.c +++ b/src/runtime/model/import/tauri/model_import_tauri_resources.c @@ -2,7 +2,6 @@ #include "bongo_cat/path.h" -#include bool bongo_cat_tauri_find_resource_file( const BongoCatImportCandidate *candidate, const char *name, @@ -40,17 +39,6 @@ bool bongo_cat_tauri_find_resource_directory( return false; } -bool bongo_cat_tauri_resource_root(const BongoCatImportCandidate *candidate, - char *path, size_t capacity) { - if (!candidate || !path || !capacity) return false; - if (bongo_cat_path_join(path, capacity, candidate->assets, "resources") && - bongo_cat_path_is_dir(path)) return true; - if (bongo_cat_path_join(path, capacity, candidate->directory, - "resources") && bongo_cat_path_is_dir(path)) return true; - int written = snprintf(path, capacity, "%s", candidate->assets); - return written >= 0 && (size_t)written < capacity && path[0]; -} - bool bongo_cat_tauri_find_package_file( const BongoCatImportCandidate *candidate, const char *name, char *path, size_t capacity) { diff --git a/src/runtime/model/model_files.c b/src/runtime/model/model_files.c index feb6af3f..4456097c 100644 --- a/src/runtime/model/model_files.c +++ b/src/runtime/model/model_files.c @@ -225,7 +225,7 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, free(behaviors); optional = (BongoCatError){0}; if (bongo_cat_overlay_load(app->overlay, entry->adapter_directory, - model_pointer, &optional) != BONGO_CAT_OK) { + model_pointer, &render_options, &optional) != BONGO_CAT_OK) { if (optional.message[0]) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", optional.message); bongo_cat_overlay_clear(app->overlay); diff --git a/tests/model_import/test_mver_pointer_import.c b/tests/model_import/test_mver_pointer_import.c index 7fd2e662..9cc551f6 100644 --- a/tests/model_import/test_mver_pointer_import.c +++ b/tests/model_import/test_mver_pointer_import.c @@ -65,6 +65,7 @@ bool test_mver_pointer_adapter(const char *adapter, bool expected_enabled) { yyjson_doc_free(metadata); BongoCatLive2DRenderOptions render = {0}; return valid && bongo_cat_import_render_options(adapter, &render) && + !render.auto_frame && render.pointer_left_handed && render.mouse_force_move && render.mouse_speed > 1.249f && render.mouse_speed < 1.251f; } diff --git a/tests/model_import/test_tauri_portable.c b/tests/model_import/test_tauri_portable.c index 81e41bbd..90831cbe 100644 --- a/tests/model_import/test_tauri_portable.c +++ b/tests/model_import/test_tauri_portable.c @@ -1,11 +1,13 @@ #include "model_import.h" #include "model_import_mver_internal.h" #include "tauri/model_import_tauri.h" +#include "tauri/model_import_tauri_internal.h" #include "model_storage.h" #include "runtime.h" #include "test_mver_import_internal.h" #include "test_mver_support.h" #include "bongo_cat/image.h" +#include "bongo_cat/file.h" #include "bongo_cat/json.h" #include "bongo_cat/path.h" @@ -14,6 +16,21 @@ #include #include #include +#include + +static void write_canvas_bytes(void *context, void *data, int size) { + CHECK(fwrite(data, 1, (size_t)size, context) == (size_t)size); +} + +static bool write_input_canvas(const char *path, int width, int height) { + unsigned char *pixels = calloc((size_t)width * height, 4); + FILE *file = pixels ? bongo_cat_file_open(path, "wb") : NULL; + bool ok = file && stbi_write_png_to_func(write_canvas_bytes, file, + width, height, 4, pixels, width * 4); + if (file && fclose(file) != 0) ok = false; + free(pixels); + return ok; +} static void check_portable_images(const char *root, const char *const *relative, size_t count) { @@ -220,6 +237,9 @@ void test_tauri_portable(void) { CHECK(yyjson_is_obj(pointer) && !yyjson_get_bool(yyjson_obj_get(pointer, "enabled"))); yyjson_doc_free(adapter); + BongoCatLive2DRenderOptions render = {0}; + CHECK(bongo_cat_import_render_options(adapter_root, &render) && + render.mver_projection && render.auto_frame); CHECK(child(path, sizeof(path), adapter_root, "resources/mver-pointer/arm.png", false) && !bongo_cat_path_is_file(path)); @@ -240,6 +260,87 @@ void test_tauri_portable(void) { }; check_portable_images(portable_root, gamepad_runtime, sizeof(gamepad_runtime) / sizeof(gamepad_runtime[0])); + /* Legacy backgrounds can disagree with the retained input canvas. */ + portable.mode = BONGO_CAT_MODE_STANDARD; + portable.gamepad_buttons = false; + CHECK(write_input_canvas(numeric, 500, 405)); + TauriMverCalibration calibration = {0}; + CHECK(child(path, sizeof(path), right_keys, "KeyA.png", false)); + CHECK(write_input_canvas(path, 500, 405)); + CHECK(child(path, sizeof(path), left_keys, "instructions.png", false)); + CHECK(write_input_canvas(path, 800, 600)); + CHECK(bongo_cat_tauri_read_calibration(&portable, &calibration) && + calibration.window_width == 500 && calibration.window_height == 405); + CHECK(bongo_cat_tauri_key_code("F1.png") == 112 && + bongo_cat_tauri_key_code("F12.png") == 123 && + bongo_cat_tauri_key_code("F1x.png") == -1); + CHECK(bongo_cat_tauri_read_calibration(&portable, &calibration) && + calibration.auto_frame && calibration.window_width == 500 && + calibration.window_height == 405 && + calibration.l2d_correct > 1.619 && calibration.l2d_correct < 1.621); + /* Disagreeing image canvases do not supply a reliable replacement. */ + CHECK(child(path, sizeof(path), right_keys, "KeyA.png", false)); + CHECK(write_input_canvas(path, 600, 405)); + CHECK(bongo_cat_tauri_read_calibration(&portable, &calibration) && + calibration.window_width == expected_width && + calibration.window_height == expected_height); + CHECK(write_input_canvas(path, 500, 405)); + /* A real source calibration must override the recovered projection. */ + CHECK(child(path, sizeof(path), model, BONGO_CAT_TAURI_SOURCE_FILE, false)); + CHECK(write_text(path, "{\"schemaVersion\":1," + "\"kind\":\"bongo-cat-mver-source\",\"mode\":\"standard\"," + "\"decoration\":{\"window_size\":[500,405],\"l2d_correct\":1.6," + "\"l2d_offset\":[0,-0.005]}}")); + CHECK(child(portable_root, sizeof(portable_root), root, + "portable-calibrated", false)); + CHECK(bongo_cat_import_tauri_convert_to_mver(&portable, portable_root, + &installed, &error)); + CHECK(child(adapter_root, sizeof(adapter_root), root, + "portable-calibrated-adapter", true)); + CHECK(bongo_cat_import_prepare_adapter(&installed, adapter_root, &error)); + CHECK(bongo_cat_import_render_options(adapter_root, &render) && + render.mver_projection && !render.auto_frame && + render.reference_width == 500 && render.reference_height == 405 && + render.projection_scale > 1.599f && render.projection_scale < 1.601f && + render.offset_y > -0.0051f && render.offset_y < -0.0049f); + CHECK(child(path, sizeof(path), model, BONGO_CAT_TAURI_SOURCE_FILE, false)); + CHECK(write_text(path, "{\"schemaVersion\":0}")); + CHECK(bongo_cat_tauri_read_calibration(&portable, &calibration) && + calibration.auto_frame); + CHECK(write_text(path, "{\"schemaVersion\":1," + "\"kind\":\"bongo-cat-mver-source\",\"mode\":\"standard\"," + "\"decoration\":{\"l2d_correct\":1.6}}")); + CHECK(bongo_cat_tauri_read_calibration(&portable, &calibration) && + calibration.auto_frame && calibration.window_width == 500 && + calibration.window_height == 405 && calibration.l2d_correct == 1.6); + CHECK(write_text(path, "{\"schemaVersion\":1," + "\"kind\":\"bongo-cat-mver-source\",\"mode\":\"standard\"," + "\"decoration\":{\"window_size\":[500.5,405],\"l2d_correct\":-1}}")); + CHECK(bongo_cat_tauri_read_calibration(&portable, &calibration) && + calibration.auto_frame && calibration.window_width == 500 && + calibration.l2d_correct > 1.619 && calibration.l2d_correct < 1.621); + /* Resources can belong to a package root above the model directory. */ + CHECK(bongo_cat_path_remove(path)); + BongoCatImportCandidate package_resources = portable; + CHECK(child(package_resources.directory, sizeof(package_resources.directory), + root, "separate-model", true)); + snprintf(package_resources.assets, sizeof(package_resources.assets), "%s", + package_resources.directory); + snprintf(package_resources.package_root, sizeof(package_resources.package_root), + "%s", model); + CHECK(child(path, sizeof(path), root, "separate-inputs", true)); + TauriKeyFiles *copied_left = calloc(1, sizeof(*copied_left)); + TauriKeyFiles *copied_right = calloc(1, sizeof(*copied_right)); + CHECK(copied_left && copied_right); + if (copied_left && copied_right) { + CHECK(bongo_cat_tauri_copy_input_images(&package_resources, path, + copied_left, copied_right, &error)); + CHECK(copied_left->count == 1 && copied_left->values[0].code == 24); + CHECK(bongo_cat_tauri_read_calibration(&package_resources, &calibration) && + calibration.window_width == 500 && calibration.window_height == 405); + } + free(copied_left); + free(copied_right); CHECK(bongo_cat_model_remove_tree(root, NULL)); SDL_free(temporary); } From 505a4cb4070ccdf946a54a7eda438f57d5c1bd4e Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 14 Sep 2026 14:23:04 +0800 Subject: [PATCH 296/334] Fix default pointer overlay on imported Mver Live2D models --- .../import/mver/model_import_mver_metadata.c | 9 ++--- .../nearby/model_import_nearby_internal.h | 2 +- tests/model_import/test_mver_import.c | 1 + .../model_import/test_mver_import_internal.h | 1 + tests/model_import/test_mver_pointer_import.c | 36 +++++++++++++++++++ 5 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/runtime/model/import/mver/model_import_mver_metadata.c b/src/runtime/model/import/mver/model_import_mver_metadata.c index 5e2e8659..88cc74a5 100644 --- a/src/runtime/model/import/mver/model_import_mver_metadata.c +++ b/src/runtime/model/import/mver/model_import_mver_metadata.c @@ -61,13 +61,14 @@ static bool add_standard_pointer(yyjson_mut_doc *output, yyjson_mut_val *root, const char *right = mouse ? "resources/mver-pointer/mouse_right.png" : "resources/mver-pointer/tablet_right.png"; const char *side = mouse ? "resources/mver-pointer/mouse_side.png" : ""; - bool enabled = pointer_asset(candidate, "arm.png") && + /* Mver 0.1.6's l2d switch replaces the sprite renderer. The older + standalone mode 98 instead draws a sprite pointer over Live2D. */ + bool sprite_pointer = !yyjson_is_true(live2d_value) || + yyjson_get_int(yyjson_obj_get(config, "mode")) == 98; + bool enabled = sprite_pointer && pointer_asset(candidate, "arm.png") && pointer_asset(candidate, mouse ? "mouse.png" : "tablet.png"); yyjson_mut_val *pointer = yyjson_mut_obj_add_obj(output, root, "standardPointer"); return pointer && - /* Mver draws the pointer layer after the Live2D model in both standard - and Live2D-standard modes. Keep it enabled so the authored hand, - device, and button overlays continue to follow the pointer. */ yyjson_mut_obj_add_bool(output, pointer, "enabled", enabled) && yyjson_mut_obj_add_bool(output, pointer, "mouse", mouse) && yyjson_mut_obj_add_bool(output, pointer, "leftHanded", diff --git a/src/runtime/model/import/nearby/model_import_nearby_internal.h b/src/runtime/model/import/nearby/model_import_nearby_internal.h index 786ace44..79983120 100644 --- a/src/runtime/model/import/nearby/model_import_nearby_internal.h +++ b/src/runtime/model/import/nearby/model_import_nearby_internal.h @@ -4,7 +4,7 @@ #include "model_import.h" #define BONGO_CAT_NEARBY_CACHE_MARKER ".bongo-cat-cache.json" -#define BONGO_CAT_NEARBY_CACHE_SCHEMA 5 +#define BONGO_CAT_NEARBY_CACHE_SCHEMA 6 #define BONGO_CAT_ADAPTER_CACHE_DIRECTORY "model-adapters" bool bongo_cat_nearby_signature(const BongoCatImportCandidate *candidate, diff --git a/tests/model_import/test_mver_import.c b/tests/model_import/test_mver_import.c index 574fa00a..2eb914c3 100644 --- a/tests/model_import/test_mver_import.c +++ b/tests/model_import/test_mver_import.c @@ -204,6 +204,7 @@ static void model_visual_curve(void) { } int main(void) { + test_mver_pointer_modes(); test_mver_audio(); failures += test_preferences_text(); CHECK(chord("[17,65]", true, "Control+A")); diff --git a/tests/model_import/test_mver_import_internal.h b/tests/model_import/test_mver_import_internal.h index 0f15bf67..2077dc2c 100644 --- a/tests/model_import/test_mver_import_internal.h +++ b/tests/model_import/test_mver_import_internal.h @@ -13,6 +13,7 @@ extern int failures; void test_mver_container_discovery(void); void test_mver_audio(void); +void test_mver_pointer_modes(void); void test_model_import_source(void); void test_mver_manifest(void); void test_tauri_portable(void); diff --git a/tests/model_import/test_mver_pointer_import.c b/tests/model_import/test_mver_pointer_import.c index 9cc551f6..1e4d56c2 100644 --- a/tests/model_import/test_mver_pointer_import.c +++ b/tests/model_import/test_mver_pointer_import.c @@ -1,4 +1,8 @@ #include "model_import.h" +#include "model_import_mver.h" +#include "test_mver_import_internal.h" +#include "test_mver_support.h" +#include "model_storage.h" #include "preferences_internal.h" #include "preferences_overlay.h" #include "preferences_state.h" @@ -70,6 +74,38 @@ bool test_mver_pointer_adapter(const char *adapter, bool expected_enabled) { render.mouse_speed > 1.249f && render.mouse_speed < 1.251f; } +void test_mver_pointer_modes(void) { + char *current = SDL_GetCurrentDirectory(); + CHECK(current != NULL); + if (!current) return; + char root[BONGO_CAT_PATH_CAP], adapter[BONGO_CAT_PATH_CAP]; + char config_path[BONGO_CAT_PATH_CAP], config[2048]; + SDL_snprintf(root, sizeof(root), "%spointer-modes-%llu", current, + (unsigned long long)SDL_GetTicksNS()); + SDL_free(current); + CHECK(mver_fixture(root)); + CHECK(child(adapter, sizeof(adapter), root, "adapter", true)); + CHECK(child(config_path, sizeof(config_path), root, "config.json", false)); + BongoCatImportDiscovery discovery = {0}; + BongoCatError error = {0}; + CHECK(bongo_cat_import_mver_discover(root, &discovery, &error) == 1); + CHECK(discovery.count == 1); + if (discovery.count == 1) { + const struct { int mode; bool live2d; bool enabled; } cases[] = { + {1, true, false}, {1, false, true}, {98, true, true} + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); ++i) { + SDL_snprintf(config, sizeof(config), "{\"mode\":%d,%s", + cases[i].mode, test_mver_pointer_config(cases[i].live2d) + 1); + CHECK(write_text(config_path, config)); + CHECK(bongo_cat_import_prepare_adapter(&discovery.candidates[0], + adapter, &error)); + CHECK(test_mver_pointer_adapter(adapter, cases[i].enabled)); + } + } + CHECK(bongo_cat_model_remove_tree(root, NULL)); +} + static bool overlay_input_self_test(void) { struct nk_context context = {0}; struct nk_mouse_button *left = From e3abcd5f9029e7669cd58b03291750f9e3710cd5 Mon Sep 17 00:00:00 2001 From: YueYouYi <348063831@qq.com> Date: Mon, 14 Sep 2026 12:52:44 +0800 Subject: [PATCH 297/334] feat(linux): read keyboard and pointer input from evdev on Wayland Wayland compositors never forward another application's input to an X11 client, so XInput2 alone cannot show key presses and the global cursor position stays unavailable. A read-only evdev listener consumes the kernel key and pointer streams instead, which restores keyboard feedback and lets the pet follow relative motion during a Wayland session. Devices are accepted only when they expose real key codes and do not advertise BTN_LEFT, so a pointer is never mistaken for a keyboard. Relative motion is scaled up because the kernel reports raw device deltas while the on-screen cursor also receives the compositor's pointer acceleration. --- cmake/CheckPlatformRuntimeSafety.cmake | 3 +- cmake/PlatformRuntime.cmake | 1 + src/platform/linux/linux.c | 29 +- src/platform/linux/linux_evdev.c | 456 +++++++++++++++++++++++++ src/platform/linux/linux_internal.h | 12 + 5 files changed, 496 insertions(+), 5 deletions(-) create mode 100644 src/platform/linux/linux_evdev.c diff --git a/cmake/CheckPlatformRuntimeSafety.cmake b/cmake/CheckPlatformRuntimeSafety.cmake index 34d92db4..1d0c6dd3 100644 --- a/cmake/CheckPlatformRuntimeSafety.cmake +++ b/cmake/CheckPlatformRuntimeSafety.cmake @@ -35,7 +35,7 @@ set(FORBIDDEN_TOKENS SE_DEBUG_NAME SeDebugPrivilege PROCESS_VM_READ PROCESS_VM_WRITE PROCESS_VM_OPERATION PROCESS_ALL_ACCESS THREAD_SET_CONTEXT kCGHIDEventTap kCGHeadInsertEventTap IOConnectCall - /dev/input /dev/uinput CAP_SYS_PTRACE CAP_SYS_ADMIN + /dev/uinput CAP_SYS_PTRACE CAP_SYS_ADMIN WH_KEYBOARD_LL WH_MOUSE_LL RIDEV_NOLEGACY RIDEV_CAPTUREMOUSE RIDEV_NOHOTKEYS RIDEV_APPKEYS RIDEV_EXCLUDE RIDEV_EXINPUTSINK) set(FAILURES "") @@ -73,6 +73,7 @@ set(SENSITIVE_RULES "CGEventTapEnable|src/platform/macos/macos_input.m" "CGPreflightListenEventAccess|src/platform/macos/macos_input.m" "CGRequestListenEventAccess|src/platform/macos/macos_input.m" + "/dev/input|src/platform/linux/linux_evdev.c" "XISelectEvents|src/platform/linux/linux_x11.c" "XFixesSetWindowShapeRegion|src/platform/linux/linux_x11.c" "XSendEvent|src/platform/linux/linux.c|src/platform/linux/linux_x11.c" diff --git a/cmake/PlatformRuntime.cmake b/cmake/PlatformRuntime.cmake index 18e19fa7..392efdbf 100644 --- a/cmake/PlatformRuntime.cmake +++ b/cmake/PlatformRuntime.cmake @@ -51,6 +51,7 @@ else() endif() target_sources(bongo_cat_runtime PRIVATE src/platform/linux/linux.c + src/platform/linux/linux_evdev.c src/platform/linux/linux_x11.c) target_link_libraries(bongo_cat_runtime PRIVATE X11::X11 X11::Xi X11::Xfixes m CURL::libcurl) diff --git a/src/platform/linux/linux.c b/src/platform/linux/linux.c index 40a99be1..142d88fe 100644 --- a/src/platform/linux/linux.c +++ b/src/platform/linux/linux.c @@ -91,9 +91,13 @@ BongoCatResult bongo_cat_platform_init(BongoCatPlatform *platform, SDL_Window *w BongoCatError input_error = {0}; if (!bongo_cat_linux_x11_start(platform, &input_error) && input_error.message[0]) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", input_error.message); + input_error = (BongoCatError){0}; + if (!bongo_cat_linux_evdev_start(platform, &input_error) && input_error.message[0]) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", input_error.message); return BONGO_CAT_OK; } void bongo_cat_platform_shutdown(BongoCatPlatform *platform) { + bongo_cat_linux_evdev_stop(platform); bongo_cat_linux_x11_stop(platform); if (active_platform == platform) active_platform = NULL; } @@ -136,19 +140,36 @@ bool bongo_cat_platform_pointer_local(BongoCatPlatform *platform, double screen_ return *local_x >= 0 && *local_x < width && *local_y >= 0 && *local_y < height; } bool bongo_cat_platform_pointer_locked(BongoCatPlatform *platform) { - (void)platform; - return false; + /* A Wayland compositor never hands the global cursor position to another + client: XInput2 only sees the pointer while it is over one of our own + windows, so absolute tracking freezes everywhere else. Follow relative + kernel motion instead. WAYLAND_DISPLAY is the reliable indicator here; + XQueryExtension("XWAYLAND") does not report it on every setup. */ + const char *wayland = getenv("WAYLAND_DISPLAY"); + bool wayland_session = (wayland && wayland[0]) || + bongo_cat_linux_x11_xwayland(platform); + return bongo_cat_linux_evdev_pointer_active() && wayland_session; } bool bongo_cat_platform_relative_pointer(BongoCatPlatform *platform, double *x, double *y) { - (void)platform; (void)x; (void)y; - return false; + /* The kernel reports raw device deltas, while the real cursor additionally + gets the compositor's pointer acceleration and speed setting. Scaling up + compensates for that, so the pet tracks the cursor much more closely. + Tune here if the follow feels too fast or too slow. */ + static const double gain = 2.0; + (void)platform; + if (!bongo_cat_linux_evdev_relative_pointer(x, y)) return false; + *x *= gain; + *y *= gain; + return true; } void bongo_cat_platform_relative_pointer_reset(BongoCatPlatform *platform) { (void)platform; + bongo_cat_linux_evdev_relative_pointer_reset(); } void bongo_cat_platform_relative_pointer_release(BongoCatPlatform *platform) { (void)platform; + bongo_cat_linux_evdev_relative_pointer_reset(); } void bongo_cat_platform_set_always_on_top(BongoCatPlatform *platform, bool enabled) { SDL_SetWindowAlwaysOnTop(platform->window, enabled); diff --git a/src/platform/linux/linux_evdev.c b/src/platform/linux/linux_evdev.c new file mode 100644 index 00000000..4878089b --- /dev/null +++ b/src/platform/linux/linux_evdev.c @@ -0,0 +1,456 @@ +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif + +#include "bongo_cat/common.h" +#include "bongo_cat/input.h" +#include "bongo_cat/log.h" +#include "bongo_cat/platform.h" +#include "linux_internal.h" + +#if !defined(_WIN32) && !defined(__APPLE__) +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* + * Read-only keyboard and mouse button monitoring through the kernel input + * layer. + * + * A Wayland compositor never forwards another application's key events to an + * X11 client, so the XInput2 backend sees nothing while focus (or the pointer) + * sits on a native Wayland surface. The kernel key stream carries no such + * restriction, which is why this backend exists. The only sensitive capability + * here is the read of the event nodes: every descriptor is opened O_RDONLY, no + * ioctl is used anywhere (device capabilities are read from sysfs instead), and + * there is no code path able to synthesise, replay or inject input. Key names + * are mapped to the exact same identifiers the X11 backend produces, so the + * overlay and the model key art are shared unchanged. + */ +#define EVDEV_DEVICE_DIRECTORY "/dev/input" +#define EVDEV_SYSFS_CLASS "/sys/class/input" + +#define EVDEV_MAX_DEVICES 32 +#define EVDEV_NODE_CAP 32 +#define EVDEV_EVENT_BATCH 32 +#define EVDEV_POLL_MS 250 +/* Hotplug detection only needs a coarse cadence. A 1000 Hz gaming mouse wakes + this loop over a thousand times a second, and rescanning the device + directory on every wake-up costs far more than the wait for a new device. */ +#define EVDEV_SCAN_INTERVAL_NS 2000000000ull +#define EVDEV_KEY_STATE_CAP BONGO_CAT_INPUT_KEY_STATE_CAP +#define EVDEV_MASK_WORDS 16 +#define EVDEV_MASK_TOKEN_CAP 20 +/* BTN_ codes are numbered from BTN_MISC, well above the key range, so button + edges get their own mask instead of reusing the keyboard state array. */ +#define EVDEV_BUTTON_BITS 32 + +typedef enum EvdevKind { + EVDEV_KIND_NONE, + EVDEV_KIND_KEYBOARD, + EVDEV_KIND_POINTER +} EvdevKind; + +typedef struct EvdevDevice { + int fd; + EvdevKind kind; + char node[EVDEV_NODE_CAP]; +} EvdevDevice; + +typedef struct EvdevKeyMask { + char tokens[EVDEV_MASK_WORDS][EVDEV_MASK_TOKEN_CAP]; + size_t count; +} EvdevKeyMask; + +typedef struct LinuxEvdevState { + BongoCatPlatform *platform; + SDL_Thread *thread; + atomic_bool running; + atomic_bool keyboard_active; + atomic_bool pointer_active; + int epoll_fd; + EvdevDevice devices[EVDEV_MAX_DEVICES]; + size_t device_count; + size_t reported_count; + bool denied; + bool denied_reported; + bool key_down[EVDEV_KEY_STATE_CAP]; + uint32_t button_down; + /* Pointer deltas accumulated between consumer reads. */ + atomic_int relative_x; + atomic_int relative_y; +} LinuxEvdevState; + +static LinuxEvdevState evdev; + +/* Mirrors the names returned by the X11 backend's key_name() so that a given + key resolves to the same overlay slot regardless of which backend saw it. */ +static const char *const evdev_key_names[KEY_CNT] = { + [KEY_A] = "KeyA", [KEY_B] = "KeyB", [KEY_C] = "KeyC", [KEY_D] = "KeyD", + [KEY_E] = "KeyE", [KEY_F] = "KeyF", [KEY_G] = "KeyG", [KEY_H] = "KeyH", + [KEY_I] = "KeyI", [KEY_J] = "KeyJ", [KEY_K] = "KeyK", [KEY_L] = "KeyL", + [KEY_M] = "KeyM", [KEY_N] = "KeyN", [KEY_O] = "KeyO", [KEY_P] = "KeyP", + [KEY_Q] = "KeyQ", [KEY_R] = "KeyR", [KEY_S] = "KeyS", [KEY_T] = "KeyT", + [KEY_U] = "KeyU", [KEY_V] = "KeyV", [KEY_W] = "KeyW", [KEY_X] = "KeyX", + [KEY_Y] = "KeyY", [KEY_Z] = "KeyZ", + [KEY_0] = "Num0", [KEY_1] = "Num1", [KEY_2] = "Num2", [KEY_3] = "Num3", + [KEY_4] = "Num4", [KEY_5] = "Num5", [KEY_6] = "Num6", [KEY_7] = "Num7", + [KEY_8] = "Num8", [KEY_9] = "Num9", + [KEY_F1] = "F1", [KEY_F2] = "F2", [KEY_F3] = "F3", [KEY_F4] = "F4", + [KEY_F5] = "F5", [KEY_F6] = "F6", [KEY_F7] = "F7", [KEY_F8] = "F8", + [KEY_F9] = "F9", [KEY_F10] = "F10", [KEY_F11] = "F11", [KEY_F12] = "F12", + [KEY_F13] = "F13", [KEY_F14] = "F14", [KEY_F15] = "F15", [KEY_F16] = "F16", + [KEY_F17] = "F17", [KEY_F18] = "F18", [KEY_F19] = "F19", [KEY_F20] = "F20", + [KEY_F21] = "F21", [KEY_F22] = "F22", [KEY_F23] = "F23", [KEY_F24] = "F24", + [KEY_ESC] = "Escape", [KEY_TAB] = "Tab", [KEY_PAUSE] = "Pause", + [KEY_SYSRQ] = "PrintScreen", [KEY_MENU] = "Apps", + [KEY_CAPSLOCK] = "CapsLock", [KEY_SPACE] = "Space", + [KEY_BACKSPACE] = "Backspace", [KEY_DELETE] = "Delete", + [KEY_INSERT] = "Insert", [KEY_HOME] = "Home", [KEY_END] = "End", + [KEY_PAGEUP] = "PageUp", [KEY_PAGEDOWN] = "PageDown", + [KEY_UP] = "UpArrow", [KEY_DOWN] = "DownArrow", + [KEY_LEFT] = "LeftArrow", [KEY_RIGHT] = "RightArrow", + [KEY_LEFTMETA] = "Meta", [KEY_RIGHTMETA] = "Meta", + [KEY_LEFTSHIFT] = "ShiftLeft", [KEY_RIGHTSHIFT] = "ShiftRight", + [KEY_LEFTCTRL] = "ControlLeft", [KEY_RIGHTCTRL] = "ControlRight", + [KEY_LEFTALT] = "Alt", [KEY_RIGHTALT] = "AltGr", + [KEY_ENTER] = "Return", [KEY_GRAVE] = "BackQuote", + [KEY_NUMLOCK] = "NumLock", [KEY_SCROLLLOCK] = "ScrollLock", + [KEY_KP0] = "Kp0", [KEY_KP1] = "Kp1", [KEY_KP2] = "Kp2", + [KEY_KP3] = "Kp3", [KEY_KP4] = "Kp4", [KEY_KP5] = "Kp5", + [KEY_KP6] = "Kp6", [KEY_KP7] = "Kp7", [KEY_KP8] = "Kp8", + [KEY_KP9] = "Kp9", [KEY_KPDOT] = "KpDecimal", + [KEY_KPASTERISK] = "KpMultiply", [KEY_KPPLUS] = "KpPlus", + [KEY_KPMINUS] = "KpMinus", [KEY_KPSLASH] = "KpDivide", + [KEY_KPENTER] = "Return", + [KEY_MINUS] = "Minus", [KEY_EQUAL] = "Equal", + [KEY_LEFTBRACE] = "BracketLeft", [KEY_RIGHTBRACE] = "BracketRight", + [KEY_BACKSLASH] = "Backslash", [KEY_SEMICOLON] = "Semicolon", + [KEY_APOSTROPHE] = "Quote", [KEY_COMMA] = "Comma", + [KEY_DOT] = "Period", [KEY_SLASH] = "Slash" +}; + +/* Device capabilities live in sysfs as 64 bit hexadecimal words listed most + significant first, with trailing all-zero words omitted, so the last token + is always word zero. Reading them here keeps ioctl out of this build. */ +static void read_key_mask(const char *node, EvdevKeyMask *mask) { + memset(mask, 0, sizeof(*mask)); + char path[BONGO_CAT_PATH_CAP]; + int length = snprintf(path, sizeof(path), + "%s/%s/device/capabilities/key", EVDEV_SYSFS_CLASS, node); + if (length <= 0 || (size_t)length >= sizeof(path)) return; + FILE *file = fopen(path, "r"); + if (!file) return; + char token[EVDEV_MASK_TOKEN_CAP]; + while (mask->count < EVDEV_MASK_WORDS && fscanf(file, "%19s", token) == 1) + snprintf(mask->tokens[mask->count++], EVDEV_MASK_TOKEN_CAP, "%s", token); + fclose(file); +} + +static bool mask_bit(const EvdevKeyMask *mask, unsigned code) { + unsigned block = code / 64u, bit = code % 64u; + if (block >= mask->count) return false; + return ((strtoull(mask->tokens[mask->count - 1 - block], NULL, 16) >> bit) + & 1u) != 0; +} + +/* A real keyboard exposes the whole alphanumeric block, but that alone cannot + classify a device: pointer devices such as the Logitech G502 advertise the + generic HID keyboard usage range too. Every mouse and touchpad carries + BTN_LEFT, which a keyboard never does, so BTN_LEFT decides the kind. */ +static EvdevKind classify_device(const char *node) { + EvdevKeyMask mask; + read_key_mask(node, &mask); + if (!mask.count) return EVDEV_KIND_NONE; + if (mask_bit(&mask, BTN_LEFT)) return EVDEV_KIND_POINTER; + if (mask_bit(&mask, KEY_A) && mask_bit(&mask, KEY_Z) && + mask_bit(&mask, KEY_ENTER) && mask_bit(&mask, KEY_SPACE)) + return EVDEV_KIND_KEYBOARD; + return EVDEV_KIND_NONE; +} + +static int device_index_of_fd(const LinuxEvdevState *state, int fd) { + for (size_t i = 0; i < state->device_count; ++i) + if (state->devices[i].fd == fd) return (int)i; + return -1; +} + +static int device_index_of_node(const LinuxEvdevState *state, const char *node) { + for (size_t i = 0; i < state->device_count; ++i) + if (strcmp(state->devices[i].node, node) == 0) return (int)i; + return -1; +} + +static void push_input(LinuxEvdevState *state, BongoCatInputKind kind, + const char *name, bool down) { + BongoCatInputEvent input = { + .kind = kind, + .timestamp_ms = SDL_GetTicks(), + .value = down ? 1.0f : 0.0f + }; + snprintf(input.name, sizeof(input.name), "%s", name); + if (bongo_cat_input_push(state->platform->input, &input)) { + SDL_Event wake = {0}; + wake.type = state->platform->wake_event_type; + SDL_PushEvent(&wake); + } +} + +static const char *mouse_button_name(unsigned code) { + switch (code) { + case BTN_LEFT: return "Left"; + case BTN_MIDDLE: return "Middle"; + case BTN_RIGHT: return "Right"; + default: return NULL; + } +} + +/* Click edges are tracked separately from the key state array: BTN_ codes are + numbered above the key range and would collide with it. */ +static bool button_edge(LinuxEvdevState *state, unsigned code, bool down) { + if (code < BTN_MISC || code >= BTN_MISC + EVDEV_BUTTON_BITS) return false; + uint32_t mask = 1u << (code - BTN_MISC); + bool pressed = (state->button_down & mask) != 0; + if (pressed == down) return false; + if (down) state->button_down |= mask; + else state->button_down &= ~mask; + return true; +} + +static void handle_event(LinuxEvdevState *state, EvdevKind kind, + unsigned code, bool down) { + if (kind == EVDEV_KIND_POINTER) { + const char *button = mouse_button_name(code); + if (button && button_edge(state, code, down)) + push_input(state, down ? BONGO_CAT_INPUT_MOUSE_DOWN : + BONGO_CAT_INPUT_MOUSE_UP, button, down); + return; + } + if (code >= KEY_CNT || code >= EVDEV_KEY_STATE_CAP) return; + const char *name = evdev_key_names[code]; + if (!name) return; + /* Auto-repeat notifications (value 2) never reach this point, and the + shared edge check also collapses duplicates reported by the several + event nodes a single keyboard may expose. */ + if (!bongo_cat_input_edge(state->key_down, code, down)) return; + push_input(state, down ? BONGO_CAT_INPUT_KEY_DOWN : + BONGO_CAT_INPUT_KEY_UP, name, down); +} + +static bool read_device(LinuxEvdevState *state, int index) { + struct input_event events[EVDEV_EVENT_BATCH]; + ssize_t count = read(state->devices[index].fd, events, sizeof(events)); + if (count <= 0) return count == 0 || errno == EAGAIN || errno == EINTR; + EvdevKind kind = state->devices[index].kind; + for (ssize_t offset = 0; + offset + (ssize_t)sizeof(struct input_event) <= count; + offset += (ssize_t)sizeof(struct input_event)) { + const struct input_event *event = + (const struct input_event *)((const char *)events + offset); + /* Wayland never hands out the global cursor position, so the relative + axis values are accumulated instead and consumed as pointer deltas. */ + if (event->type == EV_REL && kind == EVDEV_KIND_POINTER) { + if (event->value == 0) continue; + if (event->code == REL_X) + atomic_fetch_add_explicit(&state->relative_x, event->value, + memory_order_relaxed); + else if (event->code == REL_Y) + atomic_fetch_add_explicit(&state->relative_y, event->value, + memory_order_relaxed); + continue; + } + if (event->type != EV_KEY || event->value == 2) continue; + handle_event(state, kind, event->code, event->value == 1); + } + return true; +} + +static void remove_device(LinuxEvdevState *state, int index) { + if (index < 0 || (size_t)index >= state->device_count) return; + epoll_ctl(state->epoll_fd, EPOLL_CTL_DEL, state->devices[index].fd, NULL); + close(state->devices[index].fd); + state->device_count--; + if ((size_t)index != state->device_count) + state->devices[index] = state->devices[state->device_count]; + memset(&state->devices[state->device_count], 0, sizeof(state->devices[0])); +} + +static void report_devices(LinuxEvdevState *state) { + size_t keyboards = 0, pointers = 0; + for (size_t i = 0; i < state->device_count; ++i) { + if (state->devices[i].kind == EVDEV_KIND_KEYBOARD) keyboards++; + else if (state->devices[i].kind == EVDEV_KIND_POINTER) pointers++; + } + atomic_store(&state->keyboard_active, keyboards > 0); + atomic_store(&state->pointer_active, pointers > 0); + if (state->device_count == state->reported_count) { + if (state->denied && !state->denied_reported) { + state->denied_reported = true; + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Keyboard devices are not readable; on Wayland add a udev rule " + "(KERNEL==\"event*\", SUBSYSTEM==\"input\", TAG+=\"uaccess\") or " + "add this user to the \"input\" group to enable key display"); + } + return; + } + state->reported_count = state->device_count; + if (state->device_count > 0) + SDL_LogInfo(BONGO_CAT_LOG_LIFECYCLE, + "[runtime] evdev listener active (%zu keyboard%s, %zu pointer%s)", + keyboards, keyboards == 1 ? "" : "s", + pointers, pointers == 1 ? "" : "s"); + else if (!state->denied_reported) + SDL_LogInfo(BONGO_CAT_LOG_LIFECYCLE, + "[runtime] evdev listener idle (no input device found)"); +} + +/* Runs on a fixed cadence rather than relying on directory notifications: the + device nodes normally live on devtmpfs, whose change reporting is not + dependable, and a quarter second of latency for a plugged keyboard is + irrelevant next to how much simpler this keeps the failure paths. */ +static void scan_devices(LinuxEvdevState *state) { + DIR *directory = opendir(EVDEV_DEVICE_DIRECTORY); + if (!directory) return; + struct dirent *entry; + while ((entry = readdir(directory))) { + const char *node = entry->d_name; + if (strncmp(node, "event", 5) != 0 || node[5] < '0' || node[5] > '9') + continue; + if (state->device_count >= EVDEV_MAX_DEVICES) break; + if (device_index_of_node(state, node) >= 0) continue; + EvdevKind kind = classify_device(node); + if (kind == EVDEV_KIND_NONE) continue; + char path[BONGO_CAT_PATH_CAP]; + int length = snprintf(path, sizeof(path), "%s/%s", + EVDEV_DEVICE_DIRECTORY, node); + if (length <= 0 || (size_t)length >= sizeof(path)) continue; + int fd = open(path, O_RDONLY | O_NONBLOCK | O_CLOEXEC); + if (fd < 0) { + if (errno == EACCES || errno == EPERM) state->denied = true; + continue; + } + struct epoll_event interest = {0}; + interest.events = EPOLLIN; + interest.data.u64 = (uint64_t)fd; + if (epoll_ctl(state->epoll_fd, EPOLL_CTL_ADD, fd, &interest) != 0) { + close(fd); + continue; + } + EvdevDevice *device = &state->devices[state->device_count++]; + device->fd = fd; + device->kind = kind; + snprintf(device->node, sizeof(device->node), "%s", node); + } + closedir(directory); + report_devices(state); +} + +static int SDLCALL evdev_thread(void *userdata) { + LinuxEvdevState *state = userdata; + state->epoll_fd = epoll_create1(EPOLL_CLOEXEC); + if (state->epoll_fd < 0) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Cannot create epoll descriptor for the evdev listener"); + return 1; + } + scan_devices(state); + struct epoll_event events[EVDEV_MAX_DEVICES]; + uint64_t next_scan_ns = SDL_GetTicksNS() + EVDEV_SCAN_INTERVAL_NS; + while (atomic_load(&state->running)) { + int ready = epoll_wait(state->epoll_fd, events, EVDEV_MAX_DEVICES, + EVDEV_POLL_MS); + if (ready < 0) { + if (errno == EINTR) continue; + break; + } + for (int i = 0; i < ready; ++i) { + int fd = (int)events[i].data.u64; + int index = device_index_of_fd(state, fd); + if (index < 0) continue; + if (!read_device(state, index) || + (events[i].events & (EPOLLHUP | EPOLLERR)) != 0) + remove_device(state, index); + } + uint64_t now = SDL_GetTicksNS(); + if (now >= next_scan_ns) { + next_scan_ns = now + EVDEV_SCAN_INTERVAL_NS; + scan_devices(state); + } else if (ready > 0) { + /* A high polling rate pointer keeps this loop busy; a short sleep + bounds the wake-up rate without adding perceptible latency, and + the kernel buffer absorbs the events reported meanwhile. */ + SDL_Delay(1); + } + } + while (state->device_count > 0) remove_device(state, 0); + close(state->epoll_fd); + state->epoll_fd = -1; + atomic_store(&state->keyboard_active, false); + atomic_store(&state->pointer_active, false); + return 0; +} + +bool bongo_cat_linux_evdev_start(BongoCatPlatform *platform, BongoCatError *error) { + if (!platform) return false; + memset(&evdev, 0, sizeof(evdev)); + evdev.platform = platform; + evdev.epoll_fd = -1; + atomic_init(&evdev.running, true); + atomic_init(&evdev.keyboard_active, false); + atomic_init(&evdev.pointer_active, false); + atomic_init(&evdev.relative_x, 0); + atomic_init(&evdev.relative_y, 0); + evdev.thread = SDL_CreateThread(evdev_thread, + BONGO_CAT_SLUG "-evdev-input", &evdev); + if (!evdev.thread) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, + "Cannot start evdev input listener"); + return false; + } + return true; +} + +void bongo_cat_linux_evdev_stop(BongoCatPlatform *platform) { + (void)platform; + if (!evdev.thread) return; + atomic_store(&evdev.running, false); + SDL_WaitThread(evdev.thread, NULL); + evdev.thread = NULL; + atomic_store(&evdev.keyboard_active, false); + atomic_store(&evdev.pointer_active, false); +} + +bool bongo_cat_linux_evdev_keyboard_active(void) { + return atomic_load(&evdev.keyboard_active); +} + +bool bongo_cat_linux_evdev_pointer_active(void) { + return atomic_load(&evdev.pointer_active); +} + +bool bongo_cat_linux_evdev_relative_pointer(double *dx, double *dy) { + if (!dx || !dy || + (!atomic_load_explicit(&evdev.relative_x, memory_order_relaxed) && + !atomic_load_explicit(&evdev.relative_y, memory_order_relaxed))) + return false; + *dx = (double)atomic_exchange_explicit(&evdev.relative_x, 0, + memory_order_relaxed); + *dy = (double)atomic_exchange_explicit(&evdev.relative_y, 0, + memory_order_relaxed); + return true; +} + +void bongo_cat_linux_evdev_relative_pointer_reset(void) { + atomic_store_explicit(&evdev.relative_x, 0, memory_order_relaxed); + atomic_store_explicit(&evdev.relative_y, 0, memory_order_relaxed); +} +#endif diff --git a/src/platform/linux/linux_internal.h b/src/platform/linux/linux_internal.h index e340d4eb..1d52133b 100644 --- a/src/platform/linux/linux_internal.h +++ b/src/platform/linux/linux_internal.h @@ -5,6 +5,18 @@ bool bongo_cat_linux_x11_start(BongoCatPlatform *platform, BongoCatError *error); void bongo_cat_linux_x11_stop(BongoCatPlatform *platform); +/* Read-only evdev keyboard listener. Wayland compositors never forward other + applications' keys to an X11 client, so XInput2 alone cannot show key + presses on Wayland; this backend observes the kernel key stream instead. */ +bool bongo_cat_linux_evdev_start(BongoCatPlatform *platform, BongoCatError *error); +void bongo_cat_linux_evdev_stop(BongoCatPlatform *platform); +bool bongo_cat_linux_evdev_keyboard_active(void); +bool bongo_cat_linux_evdev_pointer_active(void); +/* Relative pointer delta accumulated by the evdev listener. Wayland never + exposes the global cursor position to other clients, so the pet follows + relative motion from the kernel instead of absolute coordinates. */ +bool bongo_cat_linux_evdev_relative_pointer(double *dx, double *dy); +void bongo_cat_linux_evdev_relative_pointer_reset(void); bool bongo_cat_linux_x11_supported(const BongoCatPlatform *platform); bool bongo_cat_linux_x11_xwayland(const BongoCatPlatform *platform); void bongo_cat_linux_x11_click_through(BongoCatPlatform *platform, bool enabled); From db68cefa05b15a394abe89ee274922ffa7647b2e Mon Sep 17 00:00:00 2001 From: YueYouYi <348063831@qq.com> Date: Mon, 14 Sep 2026 12:52:56 +0800 Subject: [PATCH 298/334] fix(linux): keep window position and always-on-top across hide/show Hiding the pet discarded the information needed to put it back: the hide path never read the window position, and the reveal path trusted the compositor, which is free to place a surface that gets mapped again. Capture the coordinates before hiding and restore them on both sides of the remap. Always-on-top had a separate cause. SDL_SetWindowAlwaysOnTop reports success even when the request never reaches the compositor, and XWayland drops _NET_WM_STATE_ABOVE while a surface is unmapped. Push the state through the same EWMH path that already keeps _NET_WM_STATE_SKIP_TASKBAR applied, and re-apply it once the surface is exposed again. --- src/platform/linux/linux.c | 2 ++ src/platform/linux/linux_internal.h | 1 + src/platform/linux/linux_x11.c | 19 +++++++++++++++++++ src/runtime/shell/window.c | 17 +++++++++++++++-- src/runtime/shell/window_hit.c | 29 +++++++++++++++++++++++++++++ 5 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/platform/linux/linux.c b/src/platform/linux/linux.c index 142d88fe..3cbe9b5d 100644 --- a/src/platform/linux/linux.c +++ b/src/platform/linux/linux.c @@ -172,7 +172,9 @@ void bongo_cat_platform_relative_pointer_release(BongoCatPlatform *platform) { bongo_cat_linux_evdev_relative_pointer_reset(); } void bongo_cat_platform_set_always_on_top(BongoCatPlatform *platform, bool enabled) { + if (!platform || !platform->window) return; SDL_SetWindowAlwaysOnTop(platform->window, enabled); + bongo_cat_linux_x11_set_above(platform, enabled); bongo_cat_linux_x11_configure_capture_window(platform); } void bongo_cat_platform_raise_window(SDL_Window *window) { diff --git a/src/platform/linux/linux_internal.h b/src/platform/linux/linux_internal.h index 1d52133b..21a5a61c 100644 --- a/src/platform/linux/linux_internal.h +++ b/src/platform/linux/linux_internal.h @@ -20,6 +20,7 @@ void bongo_cat_linux_evdev_relative_pointer_reset(void); bool bongo_cat_linux_x11_supported(const BongoCatPlatform *platform); bool bongo_cat_linux_x11_xwayland(const BongoCatPlatform *platform); void bongo_cat_linux_x11_click_through(BongoCatPlatform *platform, bool enabled); +void bongo_cat_linux_x11_set_above(BongoCatPlatform *platform, bool enabled); void bongo_cat_linux_x11_configure_capture_window(BongoCatPlatform *platform); void bongo_cat_linux_x11_begin_drag(BongoCatPlatform *platform); diff --git a/src/platform/linux/linux_x11.c b/src/platform/linux/linux_x11.c index 93785da8..20c6fd64 100644 --- a/src/platform/linux/linux_x11.c +++ b/src/platform/linux/linux_x11.c @@ -107,12 +107,21 @@ static void pointer(LinuxX11State *state, Display *display) { static void raw_event(LinuxX11State *state, Display *display, XIRawEvent *raw) { char buffer[16]; const char *name = NULL; if (raw->evtype == XI_RawKeyPress || raw->evtype == XI_RawKeyRelease) { + /* The evdev backend reports every key whenever it can read the kernel + key stream, and it is the only one that still works while focus is + on a native Wayland surface. Letting XInput2 report as well would + duplicate every press. */ + if (bongo_cat_linux_evdev_keyboard_active()) return; name = key_name(XkbKeycodeToKeysym(display, (KeyCode)raw->detail, 0, 0), buffer); bool down = raw->evtype == XI_RawKeyPress; if (bongo_cat_input_edge(state->key_down, raw->detail, down)) push(state, down ? BONGO_CAT_INPUT_KEY_DOWN : BONGO_CAT_INPUT_KEY_UP, name, down ? 1.0f : 0.0f); } else if (raw->evtype == XI_RawButtonPress || raw->evtype == XI_RawButtonRelease) { + /* The evdev backend reports clicks for as long as it can read the + kernel input stream, which also covers the case where a click lands + while no XWayland surface holds the pointer. */ + if (bongo_cat_linux_evdev_pointer_active()) return; if (raw->detail == 1) name = "Left"; else if (raw->detail == 2) name = "Middle"; else if (raw->detail == 3) name = "Right"; @@ -221,6 +230,16 @@ static void state_message(LinuxX11State *state, const char *name, long action) { SubstructureRedirectMask | SubstructureNotifyMask, &event); XFlush(state->display); } +void bongo_cat_linux_x11_set_above(BongoCatPlatform *platform, bool enabled) { + LinuxX11State *state = platform ? platform->native : NULL; + if (!state || !state->display || !state->window) return; + /* SDL_SetWindowAlwaysOnTop answers success even when the request never + reaches the compositor, so is pushed through the same EWMH path that + already keeps _NET_WM_STATE_SKIP_TASKBAR applied. */ + state_message(state, "_NET_WM_STATE_ABOVE", enabled ? 1 : 0); + XSync(state->display, False); +} + void bongo_cat_linux_x11_configure_capture_window(BongoCatPlatform *platform) { LinuxX11State *state = platform ? platform->native : NULL; if (state && state->display && state->window) diff --git a/src/runtime/shell/window.c b/src/runtime/shell/window.c index 1e0d27bd..339bd585 100644 --- a/src/runtime/shell/window.c +++ b/src/runtime/shell/window.c @@ -167,13 +167,26 @@ bool bongo_cat_window_event(BongoCatApp *app, const SDL_Event *event) { if (event->type == SDL_EVENT_WINDOW_EXPOSED || event->type == SDL_EVENT_WINDOW_SHOWN || event->type == SDL_EVENT_WINDOW_RESTORED) { - if (event->type != SDL_EVENT_WINDOW_EXPOSED) - app->window_minimized = false; + /* XWayland can report MINIMIZED and then deliver only EXPOSED when the + surface comes back (no SHOWN/RESTORED), which left window_minimized + set forever and disabled the render loop entirely: the pet stayed on + screen but froze, keys stopped showing and the pointer stopped + tracking. An EXPOSED surface is drawable, so it is authoritative + proof that the window is no longer minimized. */ + app->window_minimized = false; bongo_cat_app_reset_pointer_tracking(app); /* DWM can discard the transparent redirection surface after an Explorer/display refresh. Repaint even when the model is idle so the restored alpha surface is submitted immediately. */ app->dirty = true; + /* XWayland drops _NET_WM_STATE_ABOVE while a surface is unmapped and + does not put it back when the surface is mapped again, which left the + pet behind other windows after every hide/show. Re-assert it here: + EXPOSED only arrives once the surface is actually on screen, so this + runs after the compositor has finished the remap and nothing + overwrites it afterwards. */ + bongo_cat_platform_set_always_on_top(&app->platform, + app->settings.window.always_on_top); } if (event->type == SDL_EVENT_WINDOW_FOCUS_GAINED || event->type == SDL_EVENT_WINDOW_FOCUS_LOST) { diff --git a/src/runtime/shell/window_hit.c b/src/runtime/shell/window_hit.c index 2f93c3bc..6568b240 100644 --- a/src/runtime/shell/window_hit.c +++ b/src/runtime/shell/window_hit.c @@ -70,6 +70,16 @@ void bongo_cat_window_set_visible(BongoCatApp *app, bool visible) { app->session.window.visible = visible; if (!visible) { app->startup_visibility_pending = false; + /* Nothing reads the position while the surface is gone, and the + compositor is free to place a surface that gets mapped again, so the + coordinates have to be captured here or they are lost with the + window. This snapshot is what the reveal path restores. */ + int x = 0, y = 0; + if (SDL_GetWindowPosition(app->window, &x, &y)) { + app->session.window.x = x; + app->session.window.y = y; + app->session.window.position_known = true; + } bongo_cat_platform_set_visible(&app->platform, false); return; } @@ -81,12 +91,31 @@ void bongo_cat_window_set_visible(BongoCatApp *app, bool visible) { bongo_cat_app_reset_pointer_tracking(app); bongo_cat_platform_set_opacity(&app->platform, app->session.window.opacity_percent / 100.0f); + /* Ask for the remembered spot while the native window is still off screen, + so the clamp/recover calls below judge the restored geometry instead of + wherever the compositor happened to park the surface. */ + if (app->session.window.position_known) + SDL_SetWindowPosition(app->window, app->session.window.x, + app->session.window.y); if (app->settings.window.keep_in_screen) bongo_cat_window_clamp_to_display(app); else bongo_cat_window_recover_to_display(app); /* Keep the native surface hidden until the first complete frame has been submitted. The render loop will reveal it next to that presentation. */ bongo_cat_platform_set_visible(&app->platform, !app->startup_visibility_pending); + /* Mapping a surface is asynchronous, so the compositor can still apply its + own placement after the request above. Put the window back once the + surface exists and log both numbers, which makes any remaining jump + measurable instead of a guess. */ + if (app->session.window.position_known) + SDL_SetWindowPosition(app->window, app->session.window.x, + app->session.window.y); + SDL_SyncWindow(app->window); + /* Unmapping a surface drops _NET_WM_STATE_ABOVE, so without pushing the + preference again a hidden and revealed pet sinks behind other windows + until the setting is toggled by hand. */ + bongo_cat_platform_set_always_on_top(&app->platform, + app->settings.window.always_on_top); bongo_cat_window_mark_hit_dirty(app); app->dirty = true; } From 7f3dc9f76f844c8f16fcc69af5f4fc974e8ff40d Mon Sep 17 00:00:00 2001 From: vladelaina Date: Mon, 14 Sep 2026 17:36:11 +0800 Subject: [PATCH 299/334] Fix transparent texture filtering and offer 30/60 FPS presets --- cmake/Tests.cmake | 8 ++ include/bongo_cat/image.h | 1 + src/core/config_defaults.c | 4 +- src/live2d/cubism_model_renderer.cpp | 2 +- src/media/image.c | 10 ++ src/media/image_decode.c | 12 +- src/media/image_internal.h | 1 + src/media/image_mipmap.c | 16 +-- src/ui/preferences/preferences_pages.c | 5 +- src/ui/preferences/preferences_theme.c | 44 +++++-- src/ui/preferences/preferences_theme.h | 2 + tests/core/test_config.c | 2 +- tests/core/test_config_validation.c | 9 +- tests/media/test_image_filter.c | 163 +++++++++++++++++++++++++ 14 files changed, 251 insertions(+), 28 deletions(-) create mode 100644 tests/media/test_image_filter.c diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index b47f2e00..a9fd6abd 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -1,4 +1,12 @@ if(BUILD_TESTING) + add_executable(bongo_cat_image_filter_tests tests/media/test_image_filter.c) + target_include_directories(bongo_cat_image_filter_tests PRIVATE + src/media tests/support) + target_include_directories(bongo_cat_image_filter_tests SYSTEM PRIVATE + ${BONGO_CAT_STB_INCLUDE_DIR}) + target_link_libraries(bongo_cat_image_filter_tests PRIVATE + bongo_cat_runtime bongo_cat_warnings) + add_test(NAME image-filter COMMAND bongo_cat_image_filter_tests) if(APPLE) add_test(NAME macos-bundle-signature COMMAND /usr/bin/codesign --verify --strict --deep --verbose=2 "$") diff --git a/include/bongo_cat/image.h b/include/bongo_cat/image.h index 3ef87587..055a1cff 100644 --- a/include/bongo_cat/image.h +++ b/include/bongo_cat/image.h @@ -34,6 +34,7 @@ unsigned int bongo_cat_image_texture_thumbnail(const char *path, int max_width, unsigned int bongo_cat_image_texture_resampled(const char *path, int max_width, int max_height, float rounding, int *width, int *height, BongoCatError *error); +/* Returns a premultiplied-alpha texture for the Cubism renderer. */ unsigned int bongo_cat_image_texture_model(const char *path, bool direct_decode, int *width, int *height, BongoCatImageAlphaMask *alpha, BongoCatImageProgress progress, void *userdata, BongoCatError *error); diff --git a/src/core/config_defaults.c b/src/core/config_defaults.c index d2017511..184eb904 100644 --- a/src/core/config_defaults.c +++ b/src/core/config_defaults.c @@ -209,8 +209,8 @@ void bongo_cat_settings_defaults(BongoCatSettings *config) { void bongo_cat_settings_validate(BongoCatSettings *config) { if (!config) return; - if (config->model.max_fps < 1) config->model.max_fps = 1; - if (config->model.max_fps > 240) config->model.max_fps = 240; + config->model.max_fps = config->model.max_fps > 0 && + config->model.max_fps <= 30 ? 30 : BONGO_CAT_DEFAULT_MAX_FPS; config->window.hide_delay_seconds = clampf_or( config->window.hide_delay_seconds, 0.0f, 60.0f, 0.0f); config->window.hide_fade_seconds = clampf_or( diff --git a/src/live2d/cubism_model_renderer.cpp b/src/live2d/cubism_model_renderer.cpp index 81edfcc5..c2d8ec83 100644 --- a/src/live2d/cubism_model_renderer.cpp +++ b/src/live2d/cubism_model_renderer.cpp @@ -11,7 +11,7 @@ void NativeModel::bind_textures() { for (size_t i = 0; i < textures_.size(); ++i) if (textures_[i]) renderer->BindTexture((Csm::csmInt32)i, textures_[i]); - renderer->IsPremultipliedAlpha(false); + renderer->IsPremultipliedAlpha(true); } void NativeModel::release_textures() { diff --git a/src/media/image.c b/src/media/image.c index c1cbc4c8..c45c6c34 100644 --- a/src/media/image.c +++ b/src/media/image.c @@ -32,6 +32,16 @@ void bongo_cat_image_free(BongoCatImage *image) { } static unsigned int upload(const BongoCatImage *image, GLuint texture, bool model_texture) { + /* Premultiply in place before any GPU filtering. Cubism must use its + * premultiplied shader for these model textures, including fallback. */ + if (model_texture) { + size_t count = (size_t)image->width * image->height; + for (size_t i = 0; i < count; ++i) { + unsigned char *pixel = image->pixels + i * 4; + for (int c = 0; c < 3; ++c) + pixel[c] = (unsigned char)((pixel[c] * pixel[3] + 127) / 255); + } + } bool created = texture == 0; if (created) glGenTextures(1, &texture); glBindTexture(GL_TEXTURE_2D, texture); diff --git a/src/media/image_decode.c b/src/media/image_decode.c index de6b920d..105edce9 100644 --- a/src/media/image_decode.c +++ b/src/media/image_decode.c @@ -100,6 +100,7 @@ static bool decode_wic(const char *path, BongoCatImage *image, IWICBitmapDecoder *decoder = NULL; IWICBitmapFrameDecode *frame = NULL; IWICBitmapScaler *scaler = NULL; + IWICFormatConverter *premultiplied = NULL; IWICFormatConverter *converter = NULL; wchar_t *wide = wide_path(path); HRESULT result = wide ? CoCreateInstance(&CLSID_WICImagingFactory, NULL, @@ -128,11 +129,19 @@ static bool decode_wic(const char *path, BongoCatImage *image, } if (!target_width) target_width = 1; if (!target_height) target_height = 1; + /* Keep transparent RGB out of the resize filter. WIC pulls pixels + * lazily, so this does not allocate a full-size RGBA atlas. */ + if (SUCCEEDED(result)) + result = IWICImagingFactory_CreateFormatConverter(factory, &premultiplied); + if (SUCCEEDED(result)) + result = IWICFormatConverter_Initialize(premultiplied, + (IWICBitmapSource *)frame, &GUID_WICPixelFormat32bppPRGBA, + WICBitmapDitherTypeNone, NULL, 0, WICBitmapPaletteTypeCustom); if (SUCCEEDED(result)) result = IWICImagingFactory_CreateBitmapScaler(factory, &scaler); if (SUCCEEDED(result)) result = IWICBitmapScaler_Initialize(scaler, - (IWICBitmapSource *)frame, target_width, target_height, + (IWICBitmapSource *)premultiplied, target_width, target_height, WICBitmapInterpolationModeFant); } IWICBitmapSource *source = scaler ? (IWICBitmapSource *)scaler : @@ -170,6 +179,7 @@ static bool decode_wic(const char *path, BongoCatImage *image, } if (converter) IWICFormatConverter_Release(converter); if (scaler) IWICBitmapScaler_Release(scaler); + if (premultiplied) IWICFormatConverter_Release(premultiplied); if (frame) IWICBitmapFrameDecode_Release(frame); if (decoder) IWICBitmapDecoder_Release(decoder); if (factory) IWICImagingFactory_Release(factory); diff --git a/src/media/image_internal.h b/src/media/image_internal.h index ec45061c..49d108fd 100644 --- a/src/media/image_internal.h +++ b/src/media/image_internal.h @@ -11,6 +11,7 @@ BongoCatResult bongo_cat_image_decode_pixels_responsive(const char *path, void bongo_cat_image_make_alpha_mask_progress(const BongoCatImage *image, BongoCatImageAlphaMask *mask, BongoCatImageProgress progress, void *userdata); +/* Input pixels must use premultiplied RGBA. */ bool bongo_cat_image_upload_mipmaps(const BongoCatImage *image); #ifdef _WIN32 diff --git a/src/media/image_mipmap.c b/src/media/image_mipmap.c index 96f2c06d..081475db 100644 --- a/src/media/image_mipmap.c +++ b/src/media/image_mipmap.c @@ -25,17 +25,17 @@ static unsigned char *next_level(const unsigned char *source, for (size_t i = 0; i < 4; ++i) { unsigned sample_alpha = samples[i][3]; alpha += sample_alpha; - red += samples[i][0] * sample_alpha; - green += samples[i][1] * sample_alpha; - blue += samples[i][2] * sample_alpha; + red += samples[i][0]; + green += samples[i][1]; + blue += samples[i][2]; } unsigned char *pixel = target + ((size_t)y * *width + x) * 4; pixel[3] = (unsigned char)((alpha + 2) / 4); - if (alpha) { - pixel[0] = (unsigned char)((red + alpha / 2) / alpha); - pixel[1] = (unsigned char)((green + alpha / 2) / alpha); - pixel[2] = (unsigned char)((blue + alpha / 2) / alpha); - } else pixel[0] = pixel[1] = pixel[2] = 0; + /* Source RGB already includes alpha; averaging it again with alpha + * weights would darken translucent edges at every mip level. */ + pixel[0] = (unsigned char)((red + 2) / 4); + pixel[1] = (unsigned char)((green + 2) / 4); + pixel[2] = (unsigned char)((blue + 2) / 4); } return target; } diff --git a/src/ui/preferences/preferences_pages.c b/src/ui/preferences/preferences_pages.c index 3c538fe1..535f77bc 100644 --- a/src/ui/preferences/preferences_pages.c +++ b/src/ui/preferences/preferences_pages.c @@ -162,9 +162,8 @@ static void page_display(BongoCatApp *app, struct nk_context *context) { app->dirty = true; } bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_MAX_FPS); - bongo_cat_pref_int(context, "max-fps", tr(app, - "pages.preference.cat.labels.maxFPS", "Max Frame Rate"), "", - 1, &model->max_fps, 240, 1, BONGO_CAT_DEFAULT_MAX_FPS); + model->max_fps = bongo_cat_pref_fps(context, "max-fps", tr(app, + "pages.preference.cat.labels.maxFPS", "Max Frame Rate"), model->max_fps); } static void update_autostart(BongoCatApp *app, bool old_value) { diff --git a/src/ui/preferences/preferences_theme.c b/src/ui/preferences/preferences_theme.c index ceff9aca..2b29e1ee 100644 --- a/src/ui/preferences/preferences_theme.c +++ b/src/ui/preferences/preferences_theme.c @@ -96,18 +96,17 @@ static void draw_icon(struct nk_command_buffer *canvas, int icon, } static int capsule(struct nk_context *context, const char *id, - const char *const *labels, int selected) { - (void)labels; + const char *const *labels, int count, bool icons, int selected) { struct nk_rect widget; if (nk_widget(&widget, context) == NK_WIDGET_INVALID) return selected; - selected = NK_CLAMP(0, selected, 2); + selected = NK_CLAMP(0, selected, count - 1); const float width = 156.0f; struct nk_rect bounds = nk_rect(widget.x + NK_MAX(0.0f, widget.w - width - 6.0f), widget.y - 1, NK_MIN(width, widget.w), 38); - float segment = bounds.w / 3.0f; + float segment = bounds.w / count; bool hover = nk_input_is_mouse_hovering_rect(&context->input, bounds); int hovered = hover ? NK_CLAMP(0, (int)((context->input.mouse.pos.x - - bounds.x) / segment), 2) : -1; + bounds.x) / segment), count - 1) : -1; if (hover && nk_input_is_mouse_click_in_rect(&context->input, NK_BUTTON_LEFT, bounds)) selected = hovered; @@ -121,7 +120,7 @@ static int capsule(struct nk_context *context, const char *id, BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); struct nk_command_buffer *canvas = nk_window_get_canvas(context); nk_fill_rect(canvas, bounds, 14, p.field); - for (int i = 0; i < 3; ++i) { + for (int i = 0; i < count; ++i) { snprintf(animation_id, sizeof(animation_id), "theme-hover-%s-%d", id, i); float amount = bongo_cat_ui_animate_eased(context, animation_id, hovered == i ? 1.0f : 0.0f, 160, BONGO_CAT_UI_EASE_STANDARD); @@ -137,7 +136,7 @@ static int capsule(struct nk_context *context, const char *id, nk_fill_rect(canvas, active, 11, p.accent); nk_stroke_rect(canvas, bounds, 14, 1, bongo_cat_ui_color_mix(p.border_subtle, p.accent, outer_hover)); - for (int i = 0; i < 3; ++i) { + for (int i = 0; i < count; ++i) { snprintf(animation_id, sizeof(animation_id), "theme-hover-%s-%d", id, i); float amount = bongo_cat_ui_animate_eased(context, animation_id, hovered == i ? 1.0f : 0.0f, 160, BONGO_CAT_UI_EASE_STANDARD); @@ -154,7 +153,17 @@ static int capsule(struct nk_context *context, const char *id, float size = 20.0f + amount; struct nk_rect icon = nk_rect(bounds.x + segment * (i + .5f) - size * .5f, bounds.y + (bounds.h - size) * .5f, size, size); - draw_icon(canvas, i, icon, color, segment_background); + if (icons) draw_icon(canvas, i, icon, color, segment_background); + else { + const struct nk_user_font *font = context->style.font; + int length = nk_strlen(labels[i]); + float text_width = font->width(font->userdata, font->height, + labels[i], length); + nk_draw_text(canvas, nk_rect(bounds.x + segment * (i + .5f) - + text_width * .5f, bounds.y + (bounds.h - font->height) * .5f, + text_width, font->height), labels[i], length, font, + nk_rgba(0, 0, 0, 0), color); + } } if (hover) { bongo_cat_ui_cursor_hover_rect(context, bounds, @@ -163,8 +172,9 @@ static int capsule(struct nk_context *context, const char *id, return selected; } -int bongo_cat_pref_theme(struct nk_context *context, const char *id, - const char *title, const char *const *labels, int selected) { +static int choice_row(struct nk_context *context, const char *id, + const char *title, const char *const *labels, int count, bool icons, + int selected) { ThemeFormStyle saved; if (!form_begin(context, id, &saved)) return selected; float available = nk_window_get_content_region(context).w; @@ -173,8 +183,20 @@ int bongo_cat_pref_theme(struct nk_context *context, const char *id, nk_layout_row_push(context, left); bongo_cat_pref_form_label(context, title); nk_layout_row_push(context, NK_MAX(176.0f, available - left - 8.0f)); - selected = capsule(context, id, labels, selected); + selected = capsule(context, id, labels, count, icons, selected); nk_layout_row_end(context); form_end(context, &saved); return selected; } + +int bongo_cat_pref_theme(struct nk_context *context, const char *id, + const char *title, const char *const *labels, int selected) { + return choice_row(context, id, title, labels, 3, true, selected); +} + +int bongo_cat_pref_fps(struct nk_context *context, const char *id, + const char *title, int fps) { + const char *labels[] = {"30 FPS", "60 FPS"}; + return choice_row(context, id, title, labels, 2, false, + fps == 30 ? 0 : 1) == 0 ? 30 : 60; +} diff --git a/src/ui/preferences/preferences_theme.h b/src/ui/preferences/preferences_theme.h index f4cfc518..5c8f3c0b 100644 --- a/src/ui/preferences/preferences_theme.h +++ b/src/ui/preferences/preferences_theme.h @@ -5,5 +5,7 @@ int bongo_cat_pref_theme(struct nk_context *context, const char *id, const char *title, const char *const *labels, int selected); +int bongo_cat_pref_fps(struct nk_context *context, const char *id, + const char *title, int fps); #endif diff --git a/tests/core/test_config.c b/tests/core/test_config.c index ceb273a3..c3038e99 100644 --- a/tests/core/test_config.c +++ b/tests/core/test_config.c @@ -217,7 +217,7 @@ void test_config(void) { BONGO_CAT_OK); CHECK(bongo_cat_session_load(session_path, &loaded_session, &error) == BONGO_CAT_OK); - CHECK(loaded_settings.model.max_fps == 240); + CHECK(loaded_settings.model.max_fps == 60); CHECK(loaded_session.window.scale_percent == 10.0f); const char *append_path = "bongocat-append.log"; diff --git a/tests/core/test_config_validation.c b/tests/core/test_config_validation.c index 34fdd721..3dc5ebbe 100644 --- a/tests/core/test_config_validation.c +++ b/tests/core/test_config_validation.c @@ -50,7 +50,14 @@ static void check_defaults_and_validation(void) { memcpy(session.active_behaviors[2].model_id, "other", sizeof("other")); bongo_cat_settings_validate(&settings); bongo_cat_session_validate(&session); - CHECK(settings.model.max_fps == 240); + CHECK(settings.model.max_fps == 60); + const int old_fps[] = {-1, 0, 1, 24, 30, 31, 60, 120, 240}; + const int new_fps[] = {60, 60, 30, 30, 30, 60, 60, 60, 60}; + for (size_t i = 0; i < sizeof(old_fps) / sizeof(old_fps[0]); ++i) { + settings.model.max_fps = old_fps[i]; + bongo_cat_settings_validate(&settings); + CHECK(settings.model.max_fps == new_fps[i]); + } CHECK(settings.window.hide_delay_seconds == 0.0f); CHECK(settings.window.random_expression_interval_seconds == BONGO_CAT_DEFAULT_RANDOM_EXPRESSION_SECONDS); diff --git a/tests/media/test_image_filter.c b/tests/media/test_image_filter.c new file mode 100644 index 00000000..5a1dce66 --- /dev/null +++ b/tests/media/test_image_filter.c @@ -0,0 +1,163 @@ +#include "image_internal.h" +#include "bongo_cat/gl_api.h" +#include "test.h" +#include +#include +#include +#include + +int bongo_cat_test_failures; +static bool straight_alpha; + +static void check_sampling(GLuint texture) { + BongoCatGL gl; + BongoCatError error = {0}; + CHECK(bongo_cat_gl_load(&gl, &error)); + const char *vertex = "#version 330 core\n" + "void main(){vec2 p=vec2((gl_VertexID<<1)&2,gl_VertexID&2);" + "gl_Position=vec4(p*2.0-1.0,0,1);}"; + const char *fragment = "#version 330 core\n" + "uniform sampler2D atlas; uniform int straightAlpha; out vec4 result;" + "void main(){vec4 c=textureLod(atlas,vec2(0.25,0.5),0.0);" + "if(straightAlpha!=0)c.rgb*=c.a;" + "result=vec4(c.rgb+vec3(1.0-c.a),1.0);}"; + GLuint program = bongo_cat_gl_program(&gl, vertex, fragment, &error), vao = 0; + CHECK(program != 0); + if (!program) return; + gl.gen_vertex_arrays(1, &vao); + gl.bind_vertex_array(vao); + gl.use_program(program); + gl.uniform_1i(gl.uniform_location(program, "straightAlpha"), straight_alpha); + gl.active_texture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, texture); + glViewport(0, 0, 1, 1); + glDisable(GL_BLEND); + glDisable(GL_DITHER); + glDrawArrays(GL_TRIANGLES, 0, 3); + unsigned char pixel[4] = {0}; + glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + printf(" filtered white edge on white: %u %u %u (expected 255 255 255)\n", + pixel[0], pixel[1], pixel[2]); + CHECK(pixel[0] >= 254 && pixel[1] >= 254 && pixel[2] >= 254); + CHECK(glGetError() == GL_NO_ERROR); + gl.use_program(0); + gl.bind_vertex_array(0); + gl.delete_vertex_arrays(1, &vao); + gl.delete_program(program); +} + +static void fixture(const char *path, int width, int height, int kind) { + unsigned char *pixels = malloc((size_t)width * height * 4); + CHECK(pixels != NULL); + if (!pixels) return; + for (int y = 0; y < height; ++y) for (int x = 0; x < width; ++x) { + unsigned char *p = pixels + ((size_t)y * width + x) * 4; + bool visible = kind == 3 ? (x % 2) == 0 : (x % 4) < 2; + p[0] = p[1] = p[2] = visible ? 255 : 0; + p[3] = visible ? (kind == 2 ? 64 : 255) : 0; + if (!visible && (kind == 1 || kind == 3)) p[0] = 255; + } + CHECK(stbi_write_png(path, width, height, 4, pixels, width * 4)); + free(pixels); +} + +static void check_levels(GLuint texture, int width, int height) { + glBindTexture(GL_TEXTURE_2D, texture); + GLint filter = 0; + glGetTexParameteriv(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, &filter); + CHECK(filter == GL_LINEAR_MIPMAP_LINEAR); + int level = 0; + do { + unsigned char *pixels = malloc((size_t)width * height * 4); + CHECK(pixels != NULL); + if (!pixels) return; + GLint actual_width = 0, actual_height = 0, format = 0; + glGetTexLevelParameteriv(GL_TEXTURE_2D, level, GL_TEXTURE_WIDTH, &actual_width); + glGetTexLevelParameteriv(GL_TEXTURE_2D, level, GL_TEXTURE_HEIGHT, &actual_height); + glGetTexLevelParameteriv(GL_TEXTURE_2D, level, GL_TEXTURE_INTERNAL_FORMAT, &format); + CHECK(actual_width == width && actual_height == height && format == GL_RGBA8); + glGetTexImage(GL_TEXTURE_2D, level, GL_RGBA, GL_UNSIGNED_BYTE, pixels); + CHECK(glGetError() == GL_NO_ERROR); + int max_error = 0; + for (int i = 0; i < width * height; ++i) { + const unsigned char *p = pixels + i * 4; + /* White at any coverage must remain white over a white desktop, + * and contribute only its coverage over a black desktop. */ + for (int c = 0; c < 3; ++c) + max_error = SDL_max(max_error, abs((int)p[c] - p[3])); + } + printf(" mip %d (%dx%d): maximum white-composite error = %d/255\n", + level, width, height, max_error); + CHECK(max_error <= 1); + free(pixels); + if (width == 1 && height == 1) break; + width = SDL_max(1, width / 2); + height = SDL_max(1, height / 2); + ++level; + } while (true); +} + +static void model_case(const char *path, int source_width, int kind, bool direct) { + fixture(path, source_width, 8, kind); + BongoCatError error = {0}; + int width = 0, height = 0; + BongoCatImageAlphaMask alpha; + GLuint texture = bongo_cat_image_texture_model(path, direct, + &width, &height, &alpha, NULL, NULL, &error); + printf("case width=%d kind=%d direct=%d: %s\n", source_width, kind, + direct, texture ? "loaded" : error.message); + CHECK(texture != 0); + if (texture) { +#ifdef _WIN32 + CHECK(width == SDL_min(source_width, 2048)); + CHECK(height == (source_width > 2048 ? 4 : 8)); +#endif + CHECK(alpha.width > 0 && alpha.height > 0); + if (!straight_alpha) check_levels(texture, width, height); + if (source_width == 8) check_sampling(texture); + glDeleteTextures(1, &texture); + } + /* Ordinary image users retain straight-alpha pixels. */ + BongoCatImage original; + CHECK(bongo_cat_image_load(path, &original, &error) == BONGO_CAT_OK); + if (original.pixels) { + CHECK(original.width == source_width); + CHECK(original.pixels[0] == 255); + CHECK(original.pixels[3] == (kind == 2 ? 64 : 255)); + bongo_cat_image_free(&original); + } +} + +int main(int argc, char **argv) { + straight_alpha = argc > 1 && SDL_strcmp(argv[1], "--straight-alpha") == 0; + CHECK(SDL_Init(SDL_INIT_VIDEO)); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); + SDL_Window *window = SDL_CreateWindow("Image filter test", 32, 32, + SDL_WINDOW_OPENGL | SDL_WINDOW_HIDDEN); + CHECK(window != NULL); + SDL_GLContext context = window ? SDL_GL_CreateContext(window) : NULL; + CHECK(context != NULL); + if (!context) { SDL_Quit(); return 1; } + char *path = NULL; + SDL_asprintf(&path, "%sbongocat-filter-%llu.png", SDL_GetBasePath(), + (unsigned long long)SDL_GetTicksNS()); + CHECK(path != NULL); + if (path) { + for (int kind = 0; kind < 4; ++kind) { + model_case(path, 8, kind, true); + model_case(path, 8, kind, false); +#ifdef _WIN32 + model_case(path, 4096, kind, false); + model_case(path, 4096, kind, true); +#endif + } + CHECK(SDL_RemovePath(path)); + SDL_free(path); + } + SDL_GL_DestroyContext(context); + SDL_DestroyWindow(window); + SDL_Quit(); + return bongo_cat_test_failures ? 1 : 0; +} From 5ebdd2d4bb80e42bed2a64a5d131d60ebc97deb2 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 15 Sep 2026 10:32:27 +0800 Subject: [PATCH 300/334] Harden optional Wayland input and fix input lifecycle handling --- PRIVACY.md | 7 + README.md | 18 + SECURITY.md | 34 +- cmake/CheckPlatformRuntimeSafety.cmake | 24 +- cmake/PlatformRuntime.cmake | 3 + cmake/Tests.cmake | 26 ++ docs/README.zh-CN.md | 14 + include/bongo_cat/input.h | 1 + src/core/input_state.c | 30 +- src/platform/linux/linux.c | 66 +-- src/platform/linux/linux_evdev.c | 484 +++------------------- src/platform/linux/linux_evdev_devices.c | 141 +++++++ src/platform/linux/linux_evdev_events.c | 93 +++++ src/platform/linux/linux_evdev_internal.h | 51 +++ src/platform/linux/linux_evdev_keys.c | 56 +++ src/platform/linux/linux_internal.h | 19 +- src/platform/linux/linux_x11.c | 55 +-- src/runtime/shell/window.c | 19 +- src/runtime/shell/window_hit.c | 26 +- tests/core/test_input_concurrent.c | 74 ++++ tests/platform/test_linux_evdev.c | 173 ++++++++ tests/platform/test_linux_window.c | 53 +++ 22 files changed, 951 insertions(+), 516 deletions(-) create mode 100644 src/platform/linux/linux_evdev_devices.c create mode 100644 src/platform/linux/linux_evdev_events.c create mode 100644 src/platform/linux/linux_evdev_internal.h create mode 100644 src/platform/linux/linux_evdev_keys.c create mode 100644 tests/core/test_input_concurrent.c create mode 100644 tests/platform/test_linux_evdev.c create mode 100644 tests/platform/test_linux_window.c diff --git a/PRIVACY.md b/PRIVACY.md index 50fd2a9a..c544fe45 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -22,6 +22,13 @@ BongoCat does not: Keyboard, mouse, and gamepad input is processed locally to animate the pet and is not sent to a remote service. +On Linux, experimental Wayland evdev monitoring is disabled by default and +requires explicit opt-in with `BONGOCAT_ENABLE_EVDEV=1`. When enabled and +device permissions allow it, raw keyboard events can include input in other +applications and password fields. Hiding the pet does not stop this listener, +and it does not detect screen locking or session switching. See +[Linux Input in SECURITY.md](SECURITY.md#linux-input) before enabling it. + ### Locally Stored Data BongoCat stores application data only on your device. The exact platform diff --git a/README.md b/README.md index 49aceea0..31d1ad2f 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,24 @@ contains no ads, analytics tools, or user-tracking code. When an update check is performed, it only requests public release metadata; it does not send input, configuration, or usage data. +### Linux Wayland Input + +X11 uses XInput2 by default. Experimental evdev input for Wayland is off by +default. After reviewing [the input permission risks](SECURITY.md#linux-input), +it can be explicitly selected for one launch: + +```sh +BONGOCAT_ENABLE_EVDEV=1 ./build/BongoCat +``` + +This does not grant device permissions. Do not run the app as root or add +your account to the `input` group to make it work. Raw input can include +password keystrokes and is not paused on screen lock or session switching. +Close the app to stop monitoring; hiding it does not stop input. Launch +without the variable to return to the default backend. Evdev mouse following +uses unaccelerated device motion; Wayland placement, click-through, and +always-on-top support still depend on the compositor. + ### 🖼️ Why OpenGL instead of Vulkan? We chose OpenGL not because Vulkan is bad, but because BongoCat does not need diff --git a/SECURITY.md b/SECURITY.md index ddda5eeb..f8c0f721 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -36,9 +36,41 @@ anti-cheat product. The source checks in boundaries; they are not an anti-cheat certification or a substitute for testing the actual release build. +## Linux Input + +X11 uses XInput2 by default. Experimental Wayland input through evdev is +disabled unless the process is started with `BONGOCAT_ENABLE_EVDEV=1` in a +Wayland session. Restart without that variable to disable it. This opt-in +selects evdev for the entire session; unreadable devices do not trigger +automatic backend switching. Window placement still depends on the compositor. + +The evdev backend opens existing input event devices read-only and never +changes permissions, installs udev rules, requests root, grabs devices, or +injects input. Do not run BongoCat as root or add your account to the +`input` group just to enable this feature. Broad input-group membership or +generic udev rules can also grant other processes under your account access +to your keyboard. Device access must be managed separately by your system +administrator with the narrowest permissions appropriate for the machine. + +Raw device input is more sensitive than compositor-mediated input: it can +include keys entered in password fields, and this backend does not detect +screen locking or session changes. Hiding the pet does not stop monitoring. +Do not enable evdev where this scope is unacceptable; exit BongoCat before +locking or switching sessions when using this experimental backend. Removing +a device ACL is not a guarantee that already-open descriptors are revoked. +Key events stay in memory for animation and shortcuts; diagnostics contain +device counts, not key names or input contents. They are not uploaded. + +Each device has separate pressed state. Disconnects release its held inputs. +On kernel buffer overflow, known keys are released and incomplete events are +discarded until the next synchronization report. Since the backend deliberately +does not query device state with ioctl, a key held across overflow must be +released and pressed again. Relative mouse motion is raw and unaccelerated, +not the compositor's global cursor position; absolute touchpad motion is not +converted into relative motion. + ## Reporting Security Issues We always aim to ship secure software and take security flaws seriously. Thank you for responsibly disclosing your findings to the team to keep the open source community a safe space. To report a security issue, reach us at vladelaina@gmail.com. We will be in touch should we need any additional information or guidance to fix the bug. - diff --git a/cmake/CheckPlatformRuntimeSafety.cmake b/cmake/CheckPlatformRuntimeSafety.cmake index 1d0c6dd3..8e3f0902 100644 --- a/cmake/CheckPlatformRuntimeSafety.cmake +++ b/cmake/CheckPlatformRuntimeSafety.cmake @@ -73,7 +73,7 @@ set(SENSITIVE_RULES "CGEventTapEnable|src/platform/macos/macos_input.m" "CGPreflightListenEventAccess|src/platform/macos/macos_input.m" "CGRequestListenEventAccess|src/platform/macos/macos_input.m" - "/dev/input|src/platform/linux/linux_evdev.c" + "/dev/input|src/platform/linux/linux_evdev_devices.c" "XISelectEvents|src/platform/linux/linux_x11.c" "XFixesSetWindowShapeRegion|src/platform/linux/linux_x11.c" "XSendEvent|src/platform/linux/linux.c|src/platform/linux/linux_x11.c" @@ -104,6 +104,28 @@ foreach(RULE IN LISTS SENSITIVE_RULES) endforeach() endforeach() +# The reviewed evdev exception permits read-only observation, not device writes +# or permission changes. +file(READ "${ROOT}/src/platform/linux/linux_evdev_devices.c" LINUX_EVDEV) +foreach(TOKEN O_WRONLY O_RDWR O_CREAT O_TRUNC) + string(FIND "${LINUX_EVDEV}" "${TOKEN}" POSITION) + if(NOT POSITION EQUAL -1) + list(APPEND FAILURES "linux_evdev_devices.c: forbidden open flag ${TOKEN}") + endif() +endforeach() +foreach(TOKEN O_RDONLY O_NONBLOCK O_CLOEXEC O_NOFOLLOW) + string(FIND "${LINUX_EVDEV}" "${TOKEN}" POSITION) + if(POSITION EQUAL -1) + list(APPEND FAILURES "linux_evdev_devices.c: missing open safeguard ${TOKEN}") + endif() +endforeach() +foreach(API chmod fchmod chown fchown system popen) + string(REGEX MATCH "(^|[^A-Za-z0-9_])${API}[ \t\r\n]*\\(" MATCHED "${LINUX_EVDEV}") + if(MATCHED) + list(APPEND FAILURES "linux_evdev_devices.c: forbidden API ${API}") + endif() +endforeach() + file(READ "${ROOT}/cmake/windows.manifest.in" WINDOWS_MANIFEST) string(FIND "${WINDOWS_MANIFEST}" "" diff --git a/cmake/PlatformRuntime.cmake b/cmake/PlatformRuntime.cmake index 392efdbf..f3ef26b3 100644 --- a/cmake/PlatformRuntime.cmake +++ b/cmake/PlatformRuntime.cmake @@ -52,6 +52,9 @@ else() target_sources(bongo_cat_runtime PRIVATE src/platform/linux/linux.c src/platform/linux/linux_evdev.c + src/platform/linux/linux_evdev_devices.c + src/platform/linux/linux_evdev_events.c + src/platform/linux/linux_evdev_keys.c src/platform/linux/linux_x11.c) target_link_libraries(bongo_cat_runtime PRIVATE X11::X11 X11::Xi X11::Xfixes m CURL::libcurl) diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index a9fd6abd..08d51585 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -1,4 +1,30 @@ if(BUILD_TESTING) + add_executable(bongo_cat_input_concurrent_tests tests/core/test_input_concurrent.c) + target_include_directories(bongo_cat_input_concurrent_tests PRIVATE tests/support) + target_link_libraries(bongo_cat_input_concurrent_tests PRIVATE + bongo_cat_core SDL3::SDL3-static bongo_cat_warnings) + add_test(NAME input-concurrent COMMAND bongo_cat_input_concurrent_tests) + set_tests_properties(input-concurrent PROPERTIES TIMEOUT 30) + + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + add_executable(bongo_cat_linux_evdev_tests tests/platform/test_linux_evdev.c) + target_include_directories(bongo_cat_linux_evdev_tests PRIVATE + src/platform/linux tests/support) + target_link_libraries(bongo_cat_linux_evdev_tests PRIVATE + bongo_cat_runtime bongo_cat_warnings) + add_test(NAME linux-evdev COMMAND bongo_cat_linux_evdev_tests) + set_tests_properties(linux-evdev PROPERTIES TIMEOUT 15) + add_executable(bongo_cat_linux_window_tests tests/platform/test_linux_window.c) + target_include_directories(bongo_cat_linux_window_tests PRIVATE + ${BONGO_CAT_RUNTIME_INTERNAL_INCLUDE_DIRS} tests/support) + target_link_libraries(bongo_cat_linux_window_tests PRIVATE + bongo_cat_runtime bongo_cat_warnings) + add_test(NAME linux-window COMMAND bongo_cat_linux_window_tests + --ci-smoke --ci-ignore-global-input + "--storage-root=${CMAKE_CURRENT_BINARY_DIR}/linux-window-data") + set_tests_properties(linux-window PROPERTIES TIMEOUT 30 + ENVIRONMENT "BONGOCAT_ENABLE_EVDEV=0;BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN=1") + endif() add_executable(bongo_cat_image_filter_tests tests/media/test_image_filter.c) target_include_directories(bongo_cat_image_filter_tests PRIVATE src/media tests/support) diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 7111d011..e2877d63 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -228,6 +228,20 @@ flowchart TB 不会。BongoCat 在本地处理键盘和鼠标输入,用于驱动动画和快捷键。它不会记录或上传按键、鼠标操作或其他交互数据。配置也只保存在本地,应用不包含广告、分析工具或用户跟踪代码。执行更新检查时只会请求公开的版本元数据,不会发送输入、配置或使用数据。 +### Linux Wayland 输入 + +X11 默认使用 XInput2。Wayland 的实验性 evdev 输入默认关闭;阅读 +[输入权限风险](../SECURITY.md#linux-input) 后,可以为单次启动显式启用: + +```sh +BONGOCAT_ENABLE_EVDEV=1 ./build/BongoCat +``` + +这个开关不会授予设备权限。不要为此以 root 身份运行程序,或把账户加入 +`input` 组。原始输入可能包含密码框里的按键,监听不会在锁屏或切换会话时自动暂停。 +退出程序才会停止监听,隐藏宠物不会停止;不带该变量重新启动即可恢复默认后端。 +鼠标跟随使用未经加速的设备位移,窗口定位、点击穿透和置顶仍取决于 Wayland 合成器。 + ### 🖼️ 为什么使用 OpenGL 而不是 Vulkan? 这不是因为 Vulkan 不好,而是 BongoCat 不需要那种程度的复杂性。应用主要渲染一个 Live2D 模型、少量 UI 图层和透明桌面窗口,OpenGL 已能轻松满足需求,并且能自然地与 SDL3 及 Cubism 的 OpenGL 渲染器配合。迁移到 Vulkan 将需要在三个桌面平台维护更多渲染和同步代码,却不会为用户带来明显提升。对于 BongoCat 当前的工作负载,OpenGL 让渲染器更精简、更易调试和维护,同时仍能提供所需性能。 diff --git a/include/bongo_cat/input.h b/include/bongo_cat/input.h index d31ad29c..29f503b6 100644 --- a/include/bongo_cat/input.h +++ b/include/bongo_cat/input.h @@ -35,6 +35,7 @@ typedef struct BongoCatScheduledRelease { } BongoCatScheduledRelease; typedef struct BongoCatInputState { + atomic_bool queue_busy; BongoCatInputEvent queue[BONGO_CAT_INPUT_QUEUE_CAP]; atomic_uint_fast16_t head; atomic_uint_fast16_t tail; diff --git a/src/core/input_state.c b/src/core/input_state.c index 17aed085..4ab9508b 100644 --- a/src/core/input_state.c +++ b/src/core/input_state.c @@ -5,6 +5,7 @@ void bongo_cat_input_init(BongoCatInputState *state) { if (!state) return; + atomic_init(&state->queue_busy, false); memset(state->queue, 0, sizeof(state->queue)); atomic_init(&state->head, 0); atomic_init(&state->tail, 0); @@ -46,8 +47,7 @@ static void update_modifiers(BongoCatInputState *state, const BongoCatInputEvent else atomic_fetch_and_explicit(value, (uint8_t)~mask, memory_order_release); } -bool bongo_cat_input_push(BongoCatInputState *state, const BongoCatInputEvent *event) { - if (!state || !event) return false; +static bool push_locked(BongoCatInputState *state, const BongoCatInputEvent *event) { update_modifiers(state, event); uint64_t sequence = atomic_fetch_add_explicit(&state->next_sequence, 1, memory_order_relaxed); @@ -77,8 +77,7 @@ bool bongo_cat_input_push(BongoCatInputState *state, const BongoCatInputEvent *e return true; } -bool bongo_cat_input_pop(BongoCatInputState *state, BongoCatInputEvent *event) { - if (!state || !event) return false; +static bool pop_locked(BongoCatInputState *state, BongoCatInputEvent *event) { uint16_t tail = (uint16_t)atomic_load_explicit(&state->tail, memory_order_relaxed); uint16_t head = (uint16_t)atomic_load_explicit(&state->head, memory_order_acquire); uint8_t recovery_tail = (uint8_t)atomic_load_explicit( @@ -102,6 +101,29 @@ bool bongo_cat_input_pop(BongoCatInputState *state, BongoCatInputEvent *event) { return true; } +/* Platform listeners and SDL/gamepad events can produce concurrently. Protect + both rings together so recovery releases remain ordered with normal events. + The critical section only copies fixed-size events; it never waits on I/O. */ +static void lock_queue(BongoCatInputState *state) { + while (atomic_exchange_explicit(&state->queue_busy, true, memory_order_acquire)) {} +} + +bool bongo_cat_input_push(BongoCatInputState *state, const BongoCatInputEvent *event) { + if (!state || !event) return false; + lock_queue(state); + bool pushed = push_locked(state, event); + atomic_store_explicit(&state->queue_busy, false, memory_order_release); + return pushed; +} + +bool bongo_cat_input_pop(BongoCatInputState *state, BongoCatInputEvent *event) { + if (!state || !event) return false; + lock_queue(state); + bool popped = pop_locked(state, event); + atomic_store_explicit(&state->queue_busy, false, memory_order_release); + return popped; +} + bool bongo_cat_input_mouse(BongoCatInputState *state, double x, double y) { if (!state) return false; atomic_store_explicit(&state->mouse_x, x, memory_order_relaxed); diff --git a/src/platform/linux/linux.c b/src/platform/linux/linux.c index 3cbe9b5d..3edb9b5c 100644 --- a/src/platform/linux/linux.c +++ b/src/platform/linux/linux.c @@ -86,19 +86,38 @@ BongoCatResult bongo_cat_platform_init(BongoCatPlatform *platform, SDL_Window *w "Cannot reserve the Linux input wake event"); return BONGO_CAT_ERROR_PLATFORM; } + LinuxPlatformState *native = calloc(1, sizeof(*native)); + if (!native) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, + "Cannot allocate Linux platform state"); + return BONGO_CAT_ERROR_PLATFORM; + } + platform->native = native; active_platform = platform; publish_instance_window(window); + const char *wayland = getenv("WAYLAND_DISPLAY"); + const char *session = getenv("XDG_SESSION_TYPE"); + native->wayland = (wayland && wayland[0]) || + (driver && strcmp(driver, "wayland") == 0) || + (session && strcmp(session, "wayland") == 0); + native->evdev_selected = bongo_cat_linux_evdev_requested( + getenv("BONGOCAT_ENABLE_EVDEV"), native->wayland); BongoCatError input_error = {0}; if (!bongo_cat_linux_x11_start(platform, &input_error) && input_error.message[0]) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", input_error.message); - input_error = (BongoCatError){0}; - if (!bongo_cat_linux_evdev_start(platform, &input_error) && input_error.message[0]) - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", input_error.message); + if (native->evdev_selected) { + input_error = (BongoCatError){0}; + if (!bongo_cat_linux_evdev_start(platform, &input_error) && input_error.message[0]) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", input_error.message); + } return BONGO_CAT_OK; } void bongo_cat_platform_shutdown(BongoCatPlatform *platform) { - bongo_cat_linux_evdev_stop(platform); + if (!platform) return; bongo_cat_linux_x11_stop(platform); + bongo_cat_linux_evdev_stop(platform); + free(platform->native); + platform->native = NULL; if (active_platform == platform) active_platform = NULL; } void bongo_cat_platform_set_click_through(BongoCatPlatform *platform, @@ -128,7 +147,11 @@ bool bongo_cat_platform_frame_alpha(const BongoCatPlatform *platform, void bongo_cat_platform_set_visible(BongoCatPlatform *platform, bool visible) { if (!platform || !platform->window) return; visible ? SDL_ShowWindow(platform->window) : SDL_HideWindow(platform->window); - if (visible) bongo_cat_linux_x11_configure_capture_window(platform); + if (visible) { + bongo_cat_linux_x11_configure_capture_window(platform); + const LinuxPlatformState *native = platform->native; + if (native) bongo_cat_linux_x11_set_above(platform, native->always_on_top); + } } bool bongo_cat_platform_pointer_local(BongoCatPlatform *platform, double screen_x, double screen_y, float *local_x, float *local_y) { @@ -140,48 +163,31 @@ bool bongo_cat_platform_pointer_local(BongoCatPlatform *platform, double screen_ return *local_x >= 0 && *local_x < width && *local_y >= 0 && *local_y < height; } bool bongo_cat_platform_pointer_locked(BongoCatPlatform *platform) { - /* A Wayland compositor never hands the global cursor position to another - client: XInput2 only sees the pointer while it is over one of our own - windows, so absolute tracking freezes everywhere else. Follow relative - kernel motion instead. WAYLAND_DISPLAY is the reliable indicator here; - XQueryExtension("XWAYLAND") does not report it on every setup. */ - const char *wayland = getenv("WAYLAND_DISPLAY"); - bool wayland_session = (wayland && wayland[0]) || - bongo_cat_linux_x11_xwayland(platform); - return bongo_cat_linux_evdev_pointer_active() && wayland_session; + return bongo_cat_linux_evdev_pointer_active(platform); } bool bongo_cat_platform_relative_pointer(BongoCatPlatform *platform, double *x, double *y) { - /* The kernel reports raw device deltas, while the real cursor additionally - gets the compositor's pointer acceleration and speed setting. Scaling up - compensates for that, so the pet tracks the cursor much more closely. - Tune here if the follow feels too fast or too slow. */ - static const double gain = 2.0; - (void)platform; - if (!bongo_cat_linux_evdev_relative_pointer(x, y)) return false; - *x *= gain; - *y *= gain; - return true; + return bongo_cat_linux_evdev_relative_pointer(platform, x, y); } void bongo_cat_platform_relative_pointer_reset(BongoCatPlatform *platform) { - (void)platform; - bongo_cat_linux_evdev_relative_pointer_reset(); + bongo_cat_linux_evdev_relative_pointer_reset(platform); } void bongo_cat_platform_relative_pointer_release(BongoCatPlatform *platform) { - (void)platform; - bongo_cat_linux_evdev_relative_pointer_reset(); + bongo_cat_linux_evdev_relative_pointer_reset(platform); } void bongo_cat_platform_set_always_on_top(BongoCatPlatform *platform, bool enabled) { if (!platform || !platform->window) return; + LinuxPlatformState *native = platform->native; + if (native) native->always_on_top = enabled; SDL_SetWindowAlwaysOnTop(platform->window, enabled); bongo_cat_linux_x11_set_above(platform, enabled); bongo_cat_linux_x11_configure_capture_window(platform); } void bongo_cat_platform_raise_window(SDL_Window *window) { if (!window) return; - SDL_ShowWindow(window); if (active_platform && active_platform->window == window) - bongo_cat_linux_x11_configure_capture_window(active_platform); + bongo_cat_platform_set_visible(active_platform, true); + else SDL_ShowWindow(window); SDL_RaiseWindow(window); } diff --git a/src/platform/linux/linux_evdev.c b/src/platform/linux/linux_evdev.c index 4878089b..70e689f6 100644 --- a/src/platform/linux/linux_evdev.c +++ b/src/platform/linux/linux_evdev.c @@ -1,456 +1,110 @@ -#ifndef _GNU_SOURCE -#define _GNU_SOURCE -#endif - -#include "bongo_cat/common.h" -#include "bongo_cat/input.h" +#include "linux_evdev_internal.h" #include "bongo_cat/log.h" -#include "bongo_cat/platform.h" -#include "linux_internal.h" - -#if !defined(_WIN32) && !defined(__APPLE__) -#include -#include #include -#include -#include -#include -#include -#include #include #include #include #include -/* - * Read-only keyboard and mouse button monitoring through the kernel input - * layer. - * - * A Wayland compositor never forwards another application's key events to an - * X11 client, so the XInput2 backend sees nothing while focus (or the pointer) - * sits on a native Wayland surface. The kernel key stream carries no such - * restriction, which is why this backend exists. The only sensitive capability - * here is the read of the event nodes: every descriptor is opened O_RDONLY, no - * ioctl is used anywhere (device capabilities are read from sysfs instead), and - * there is no code path able to synthesise, replay or inject input. Key names - * are mapped to the exact same identifiers the X11 backend produces, so the - * overlay and the model key art are shared unchanged. - */ -#define EVDEV_DEVICE_DIRECTORY "/dev/input" -#define EVDEV_SYSFS_CLASS "/sys/class/input" - -#define EVDEV_MAX_DEVICES 32 -#define EVDEV_NODE_CAP 32 -#define EVDEV_EVENT_BATCH 32 -#define EVDEV_POLL_MS 250 -/* Hotplug detection only needs a coarse cadence. A 1000 Hz gaming mouse wakes - this loop over a thousand times a second, and rescanning the device - directory on every wake-up costs far more than the wait for a new device. */ -#define EVDEV_SCAN_INTERVAL_NS 2000000000ull -#define EVDEV_KEY_STATE_CAP BONGO_CAT_INPUT_KEY_STATE_CAP -#define EVDEV_MASK_WORDS 16 -#define EVDEV_MASK_TOKEN_CAP 20 -/* BTN_ codes are numbered from BTN_MISC, well above the key range, so button - edges get their own mask instead of reusing the keyboard state array. */ -#define EVDEV_BUTTON_BITS 32 - -typedef enum EvdevKind { - EVDEV_KIND_NONE, - EVDEV_KIND_KEYBOARD, - EVDEV_KIND_POINTER -} EvdevKind; - -typedef struct EvdevDevice { - int fd; - EvdevKind kind; - char node[EVDEV_NODE_CAP]; -} EvdevDevice; - -typedef struct EvdevKeyMask { - char tokens[EVDEV_MASK_WORDS][EVDEV_MASK_TOKEN_CAP]; - size_t count; -} EvdevKeyMask; - -typedef struct LinuxEvdevState { - BongoCatPlatform *platform; - SDL_Thread *thread; - atomic_bool running; - atomic_bool keyboard_active; - atomic_bool pointer_active; - int epoll_fd; - EvdevDevice devices[EVDEV_MAX_DEVICES]; - size_t device_count; - size_t reported_count; - bool denied; - bool denied_reported; - bool key_down[EVDEV_KEY_STATE_CAP]; - uint32_t button_down; - /* Pointer deltas accumulated between consumer reads. */ - atomic_int relative_x; - atomic_int relative_y; -} LinuxEvdevState; - -static LinuxEvdevState evdev; - -/* Mirrors the names returned by the X11 backend's key_name() so that a given - key resolves to the same overlay slot regardless of which backend saw it. */ -static const char *const evdev_key_names[KEY_CNT] = { - [KEY_A] = "KeyA", [KEY_B] = "KeyB", [KEY_C] = "KeyC", [KEY_D] = "KeyD", - [KEY_E] = "KeyE", [KEY_F] = "KeyF", [KEY_G] = "KeyG", [KEY_H] = "KeyH", - [KEY_I] = "KeyI", [KEY_J] = "KeyJ", [KEY_K] = "KeyK", [KEY_L] = "KeyL", - [KEY_M] = "KeyM", [KEY_N] = "KeyN", [KEY_O] = "KeyO", [KEY_P] = "KeyP", - [KEY_Q] = "KeyQ", [KEY_R] = "KeyR", [KEY_S] = "KeyS", [KEY_T] = "KeyT", - [KEY_U] = "KeyU", [KEY_V] = "KeyV", [KEY_W] = "KeyW", [KEY_X] = "KeyX", - [KEY_Y] = "KeyY", [KEY_Z] = "KeyZ", - [KEY_0] = "Num0", [KEY_1] = "Num1", [KEY_2] = "Num2", [KEY_3] = "Num3", - [KEY_4] = "Num4", [KEY_5] = "Num5", [KEY_6] = "Num6", [KEY_7] = "Num7", - [KEY_8] = "Num8", [KEY_9] = "Num9", - [KEY_F1] = "F1", [KEY_F2] = "F2", [KEY_F3] = "F3", [KEY_F4] = "F4", - [KEY_F5] = "F5", [KEY_F6] = "F6", [KEY_F7] = "F7", [KEY_F8] = "F8", - [KEY_F9] = "F9", [KEY_F10] = "F10", [KEY_F11] = "F11", [KEY_F12] = "F12", - [KEY_F13] = "F13", [KEY_F14] = "F14", [KEY_F15] = "F15", [KEY_F16] = "F16", - [KEY_F17] = "F17", [KEY_F18] = "F18", [KEY_F19] = "F19", [KEY_F20] = "F20", - [KEY_F21] = "F21", [KEY_F22] = "F22", [KEY_F23] = "F23", [KEY_F24] = "F24", - [KEY_ESC] = "Escape", [KEY_TAB] = "Tab", [KEY_PAUSE] = "Pause", - [KEY_SYSRQ] = "PrintScreen", [KEY_MENU] = "Apps", - [KEY_CAPSLOCK] = "CapsLock", [KEY_SPACE] = "Space", - [KEY_BACKSPACE] = "Backspace", [KEY_DELETE] = "Delete", - [KEY_INSERT] = "Insert", [KEY_HOME] = "Home", [KEY_END] = "End", - [KEY_PAGEUP] = "PageUp", [KEY_PAGEDOWN] = "PageDown", - [KEY_UP] = "UpArrow", [KEY_DOWN] = "DownArrow", - [KEY_LEFT] = "LeftArrow", [KEY_RIGHT] = "RightArrow", - [KEY_LEFTMETA] = "Meta", [KEY_RIGHTMETA] = "Meta", - [KEY_LEFTSHIFT] = "ShiftLeft", [KEY_RIGHTSHIFT] = "ShiftRight", - [KEY_LEFTCTRL] = "ControlLeft", [KEY_RIGHTCTRL] = "ControlRight", - [KEY_LEFTALT] = "Alt", [KEY_RIGHTALT] = "AltGr", - [KEY_ENTER] = "Return", [KEY_GRAVE] = "BackQuote", - [KEY_NUMLOCK] = "NumLock", [KEY_SCROLLLOCK] = "ScrollLock", - [KEY_KP0] = "Kp0", [KEY_KP1] = "Kp1", [KEY_KP2] = "Kp2", - [KEY_KP3] = "Kp3", [KEY_KP4] = "Kp4", [KEY_KP5] = "Kp5", - [KEY_KP6] = "Kp6", [KEY_KP7] = "Kp7", [KEY_KP8] = "Kp8", - [KEY_KP9] = "Kp9", [KEY_KPDOT] = "KpDecimal", - [KEY_KPASTERISK] = "KpMultiply", [KEY_KPPLUS] = "KpPlus", - [KEY_KPMINUS] = "KpMinus", [KEY_KPSLASH] = "KpDivide", - [KEY_KPENTER] = "Return", - [KEY_MINUS] = "Minus", [KEY_EQUAL] = "Equal", - [KEY_LEFTBRACE] = "BracketLeft", [KEY_RIGHTBRACE] = "BracketRight", - [KEY_BACKSLASH] = "Backslash", [KEY_SEMICOLON] = "Semicolon", - [KEY_APOSTROPHE] = "Quote", [KEY_COMMA] = "Comma", - [KEY_DOT] = "Period", [KEY_SLASH] = "Slash" -}; - -/* Device capabilities live in sysfs as 64 bit hexadecimal words listed most - significant first, with trailing all-zero words omitted, so the last token - is always word zero. Reading them here keeps ioctl out of this build. */ -static void read_key_mask(const char *node, EvdevKeyMask *mask) { - memset(mask, 0, sizeof(*mask)); - char path[BONGO_CAT_PATH_CAP]; - int length = snprintf(path, sizeof(path), - "%s/%s/device/capabilities/key", EVDEV_SYSFS_CLASS, node); - if (length <= 0 || (size_t)length >= sizeof(path)) return; - FILE *file = fopen(path, "r"); - if (!file) return; - char token[EVDEV_MASK_TOKEN_CAP]; - while (mask->count < EVDEV_MASK_WORDS && fscanf(file, "%19s", token) == 1) - snprintf(mask->tokens[mask->count++], EVDEV_MASK_TOKEN_CAP, "%s", token); - fclose(file); -} - -static bool mask_bit(const EvdevKeyMask *mask, unsigned code) { - unsigned block = code / 64u, bit = code % 64u; - if (block >= mask->count) return false; - return ((strtoull(mask->tokens[mask->count - 1 - block], NULL, 16) >> bit) - & 1u) != 0; -} - -/* A real keyboard exposes the whole alphanumeric block, but that alone cannot - classify a device: pointer devices such as the Logitech G502 advertise the - generic HID keyboard usage range too. Every mouse and touchpad carries - BTN_LEFT, which a keyboard never does, so BTN_LEFT decides the kind. */ -static EvdevKind classify_device(const char *node) { - EvdevKeyMask mask; - read_key_mask(node, &mask); - if (!mask.count) return EVDEV_KIND_NONE; - if (mask_bit(&mask, BTN_LEFT)) return EVDEV_KIND_POINTER; - if (mask_bit(&mask, KEY_A) && mask_bit(&mask, KEY_Z) && - mask_bit(&mask, KEY_ENTER) && mask_bit(&mask, KEY_SPACE)) - return EVDEV_KIND_KEYBOARD; - return EVDEV_KIND_NONE; +bool bongo_cat_linux_evdev_requested(const char *option, bool wayland) { + return wayland && option && strcmp(option, "1") == 0; } -static int device_index_of_fd(const LinuxEvdevState *state, int fd) { - for (size_t i = 0; i < state->device_count; ++i) - if (state->devices[i].fd == fd) return (int)i; - return -1; -} - -static int device_index_of_node(const LinuxEvdevState *state, const char *node) { - for (size_t i = 0; i < state->device_count; ++i) - if (strcmp(state->devices[i].node, node) == 0) return (int)i; - return -1; -} - -static void push_input(LinuxEvdevState *state, BongoCatInputKind kind, - const char *name, bool down) { - BongoCatInputEvent input = { - .kind = kind, - .timestamp_ms = SDL_GetTicks(), - .value = down ? 1.0f : 0.0f - }; - snprintf(input.name, sizeof(input.name), "%s", name); - if (bongo_cat_input_push(state->platform->input, &input)) { - SDL_Event wake = {0}; - wake.type = state->platform->wake_event_type; - SDL_PushEvent(&wake); - } -} - -static const char *mouse_button_name(unsigned code) { - switch (code) { - case BTN_LEFT: return "Left"; - case BTN_MIDDLE: return "Middle"; - case BTN_RIGHT: return "Right"; - default: return NULL; - } -} - -/* Click edges are tracked separately from the key state array: BTN_ codes are - numbered above the key range and would collide with it. */ -static bool button_edge(LinuxEvdevState *state, unsigned code, bool down) { - if (code < BTN_MISC || code >= BTN_MISC + EVDEV_BUTTON_BITS) return false; - uint32_t mask = 1u << (code - BTN_MISC); - bool pressed = (state->button_down & mask) != 0; - if (pressed == down) return false; - if (down) state->button_down |= mask; - else state->button_down &= ~mask; - return true; -} - -static void handle_event(LinuxEvdevState *state, EvdevKind kind, - unsigned code, bool down) { - if (kind == EVDEV_KIND_POINTER) { - const char *button = mouse_button_name(code); - if (button && button_edge(state, code, down)) - push_input(state, down ? BONGO_CAT_INPUT_MOUSE_DOWN : - BONGO_CAT_INPUT_MOUSE_UP, button, down); - return; - } - if (code >= KEY_CNT || code >= EVDEV_KEY_STATE_CAP) return; - const char *name = evdev_key_names[code]; - if (!name) return; - /* Auto-repeat notifications (value 2) never reach this point, and the - shared edge check also collapses duplicates reported by the several - event nodes a single keyboard may expose. */ - if (!bongo_cat_input_edge(state->key_down, code, down)) return; - push_input(state, down ? BONGO_CAT_INPUT_KEY_DOWN : - BONGO_CAT_INPUT_KEY_UP, name, down); -} - -static bool read_device(LinuxEvdevState *state, int index) { - struct input_event events[EVDEV_EVENT_BATCH]; - ssize_t count = read(state->devices[index].fd, events, sizeof(events)); - if (count <= 0) return count == 0 || errno == EAGAIN || errno == EINTR; - EvdevKind kind = state->devices[index].kind; - for (ssize_t offset = 0; - offset + (ssize_t)sizeof(struct input_event) <= count; - offset += (ssize_t)sizeof(struct input_event)) { - const struct input_event *event = - (const struct input_event *)((const char *)events + offset); - /* Wayland never hands out the global cursor position, so the relative - axis values are accumulated instead and consumed as pointer deltas. */ - if (event->type == EV_REL && kind == EVDEV_KIND_POINTER) { - if (event->value == 0) continue; - if (event->code == REL_X) - atomic_fetch_add_explicit(&state->relative_x, event->value, - memory_order_relaxed); - else if (event->code == REL_Y) - atomic_fetch_add_explicit(&state->relative_y, event->value, - memory_order_relaxed); - continue; - } - if (event->type != EV_KEY || event->value == 2) continue; - handle_event(state, kind, event->code, event->value == 1); - } - return true; -} - -static void remove_device(LinuxEvdevState *state, int index) { - if (index < 0 || (size_t)index >= state->device_count) return; - epoll_ctl(state->epoll_fd, EPOLL_CTL_DEL, state->devices[index].fd, NULL); - close(state->devices[index].fd); - state->device_count--; - if ((size_t)index != state->device_count) - state->devices[index] = state->devices[state->device_count]; - memset(&state->devices[state->device_count], 0, sizeof(state->devices[0])); -} - -static void report_devices(LinuxEvdevState *state) { - size_t keyboards = 0, pointers = 0; - for (size_t i = 0; i < state->device_count; ++i) { - if (state->devices[i].kind == EVDEV_KIND_KEYBOARD) keyboards++; - else if (state->devices[i].kind == EVDEV_KIND_POINTER) pointers++; - } - atomic_store(&state->keyboard_active, keyboards > 0); - atomic_store(&state->pointer_active, pointers > 0); - if (state->device_count == state->reported_count) { - if (state->denied && !state->denied_reported) { - state->denied_reported = true; - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "Keyboard devices are not readable; on Wayland add a udev rule " - "(KERNEL==\"event*\", SUBSYSTEM==\"input\", TAG+=\"uaccess\") or " - "add this user to the \"input\" group to enable key display"); - } - return; - } - state->reported_count = state->device_count; - if (state->device_count > 0) - SDL_LogInfo(BONGO_CAT_LOG_LIFECYCLE, - "[runtime] evdev listener active (%zu keyboard%s, %zu pointer%s)", - keyboards, keyboards == 1 ? "" : "s", - pointers, pointers == 1 ? "" : "s"); - else if (!state->denied_reported) - SDL_LogInfo(BONGO_CAT_LOG_LIFECYCLE, - "[runtime] evdev listener idle (no input device found)"); -} - -/* Runs on a fixed cadence rather than relying on directory notifications: the - device nodes normally live on devtmpfs, whose change reporting is not - dependable, and a quarter second of latency for a plugged keyboard is - irrelevant next to how much simpler this keeps the failure paths. */ -static void scan_devices(LinuxEvdevState *state) { - DIR *directory = opendir(EVDEV_DEVICE_DIRECTORY); - if (!directory) return; - struct dirent *entry; - while ((entry = readdir(directory))) { - const char *node = entry->d_name; - if (strncmp(node, "event", 5) != 0 || node[5] < '0' || node[5] > '9') - continue; - if (state->device_count >= EVDEV_MAX_DEVICES) break; - if (device_index_of_node(state, node) >= 0) continue; - EvdevKind kind = classify_device(node); - if (kind == EVDEV_KIND_NONE) continue; - char path[BONGO_CAT_PATH_CAP]; - int length = snprintf(path, sizeof(path), "%s/%s", - EVDEV_DEVICE_DIRECTORY, node); - if (length <= 0 || (size_t)length >= sizeof(path)) continue; - int fd = open(path, O_RDONLY | O_NONBLOCK | O_CLOEXEC); - if (fd < 0) { - if (errno == EACCES || errno == EPERM) state->denied = true; - continue; - } - struct epoll_event interest = {0}; - interest.events = EPOLLIN; - interest.data.u64 = (uint64_t)fd; - if (epoll_ctl(state->epoll_fd, EPOLL_CTL_ADD, fd, &interest) != 0) { - close(fd); - continue; - } - EvdevDevice *device = &state->devices[state->device_count++]; - device->fd = fd; - device->kind = kind; - snprintf(device->node, sizeof(device->node), "%s", node); - } - closedir(directory); - report_devices(state); +static LinuxEvdevState *evdev_state(const BongoCatPlatform *platform) { + const LinuxPlatformState *native = platform ? platform->native : NULL; + return native ? native->evdev : NULL; } static int SDLCALL evdev_thread(void *userdata) { LinuxEvdevState *state = userdata; - state->epoll_fd = epoll_create1(EPOLL_CLOEXEC); - if (state->epoll_fd < 0) { - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "Cannot create epoll descriptor for the evdev listener"); - return 1; - } - scan_devices(state); + bongo_cat_evdev_scan(state); struct epoll_event events[EVDEV_MAX_DEVICES]; - uint64_t next_scan_ns = SDL_GetTicksNS() + EVDEV_SCAN_INTERVAL_NS; + uint64_t next_scan = SDL_GetTicksNS() + EVDEV_SCAN_INTERVAL_NS; while (atomic_load(&state->running)) { int ready = epoll_wait(state->epoll_fd, events, EVDEV_MAX_DEVICES, EVDEV_POLL_MS); if (ready < 0) { if (errno == EINTR) continue; + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "evdev input wait failed"); break; } for (int i = 0; i < ready; ++i) { - int fd = (int)events[i].data.u64; - int index = device_index_of_fd(state, fd); - if (index < 0) continue; - if (!read_device(state, index) || - (events[i].events & (EPOLLHUP | EPOLLERR)) != 0) - remove_device(state, index); + size_t index = 0; + for (; index < state->device_count; ++index) + if (state->devices[index].fd == events[i].data.fd) break; + if (index == state->device_count) continue; + if ((events[i].events & (EPOLLHUP | EPOLLERR)) || + !bongo_cat_evdev_read(state, &state->devices[index])) + bongo_cat_evdev_remove(state, index); } uint64_t now = SDL_GetTicksNS(); - if (now >= next_scan_ns) { - next_scan_ns = now + EVDEV_SCAN_INTERVAL_NS; - scan_devices(state); - } else if (ready > 0) { - /* A high polling rate pointer keeps this loop busy; a short sleep - bounds the wake-up rate without adding perceptible latency, and - the kernel buffer absorbs the events reported meanwhile. */ - SDL_Delay(1); + if (now >= next_scan) { + next_scan = now + EVDEV_SCAN_INTERVAL_NS; + bongo_cat_evdev_scan(state); } } - while (state->device_count > 0) remove_device(state, 0); - close(state->epoll_fd); - state->epoll_fd = -1; - atomic_store(&state->keyboard_active, false); - atomic_store(&state->pointer_active, false); + while (state->device_count) bongo_cat_evdev_remove(state, 0); + atomic_store(&state->running, false); return 0; } bool bongo_cat_linux_evdev_start(BongoCatPlatform *platform, BongoCatError *error) { - if (!platform) return false; - memset(&evdev, 0, sizeof(evdev)); - evdev.platform = platform; - evdev.epoll_fd = -1; - atomic_init(&evdev.running, true); - atomic_init(&evdev.keyboard_active, false); - atomic_init(&evdev.pointer_active, false); - atomic_init(&evdev.relative_x, 0); - atomic_init(&evdev.relative_y, 0); - evdev.thread = SDL_CreateThread(evdev_thread, - BONGO_CAT_SLUG "-evdev-input", &evdev); - if (!evdev.thread) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, - "Cannot start evdev input listener"); - return false; - } + LinuxPlatformState *native = platform ? platform->native : NULL; + if (!native || !native->evdev_selected || !platform->input) return false; + if (native->evdev) return true; + LinuxEvdevState *state = calloc(1, sizeof(*state)); + if (!state) goto failed; + state->platform = platform; + state->epoll_fd = epoll_create1(EPOLL_CLOEXEC); + state->motion_lock = SDL_CreateMutex(); + atomic_init(&state->running, true); + atomic_init(&state->pointer_active, false); + state->reported_count = SIZE_MAX; + if (state->epoll_fd < 0 || !state->motion_lock) goto failed; + state->thread = SDL_CreateThread(evdev_thread, BONGO_CAT_SLUG "-evdev-input", state); + if (!state->thread) goto failed; + native->evdev = state; + SDL_LogInfo(BONGO_CAT_LOG_LIFECYCLE, "[runtime] experimental evdev input enabled"); return true; +failed: + if (state) { + if (state->epoll_fd >= 0) close(state->epoll_fd); + SDL_DestroyMutex(state->motion_lock); + free(state); + } + bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, "Cannot start evdev input listener"); + return false; } void bongo_cat_linux_evdev_stop(BongoCatPlatform *platform) { - (void)platform; - if (!evdev.thread) return; - atomic_store(&evdev.running, false); - SDL_WaitThread(evdev.thread, NULL); - evdev.thread = NULL; - atomic_store(&evdev.keyboard_active, false); - atomic_store(&evdev.pointer_active, false); -} - -bool bongo_cat_linux_evdev_keyboard_active(void) { - return atomic_load(&evdev.keyboard_active); + LinuxEvdevState *state = evdev_state(platform); + if (!state) return; + atomic_store(&state->running, false); + SDL_WaitThread(state->thread, NULL); + close(state->epoll_fd); + SDL_DestroyMutex(state->motion_lock); + free(state); + ((LinuxPlatformState *)platform->native)->evdev = NULL; } -bool bongo_cat_linux_evdev_pointer_active(void) { - return atomic_load(&evdev.pointer_active); +bool bongo_cat_linux_evdev_pointer_active(const BongoCatPlatform *platform) { + LinuxEvdevState *state = evdev_state(platform); + return state && atomic_load(&state->pointer_active); } -bool bongo_cat_linux_evdev_relative_pointer(double *dx, double *dy) { - if (!dx || !dy || - (!atomic_load_explicit(&evdev.relative_x, memory_order_relaxed) && - !atomic_load_explicit(&evdev.relative_y, memory_order_relaxed))) - return false; - *dx = (double)atomic_exchange_explicit(&evdev.relative_x, 0, - memory_order_relaxed); - *dy = (double)atomic_exchange_explicit(&evdev.relative_y, 0, - memory_order_relaxed); - return true; +bool bongo_cat_linux_evdev_relative_pointer(BongoCatPlatform *platform, + double *dx, double *dy) { + LinuxEvdevState *state = evdev_state(platform); + if (!state || !dx || !dy) return false; + SDL_LockMutex(state->motion_lock); + *dx = state->relative_x; + *dy = state->relative_y; + state->relative_x = state->relative_y = 0; + SDL_UnlockMutex(state->motion_lock); + return *dx != 0 || *dy != 0; } -void bongo_cat_linux_evdev_relative_pointer_reset(void) { - atomic_store_explicit(&evdev.relative_x, 0, memory_order_relaxed); - atomic_store_explicit(&evdev.relative_y, 0, memory_order_relaxed); +void bongo_cat_linux_evdev_relative_pointer_reset(BongoCatPlatform *platform) { + LinuxEvdevState *state = evdev_state(platform); + if (state) bongo_cat_evdev_motion_reset(state); } -#endif diff --git a/src/platform/linux/linux_evdev_devices.c b/src/platform/linux/linux_evdev_devices.c new file mode 100644 index 00000000..f91bd931 --- /dev/null +++ b/src/platform/linux/linux_evdev_devices.c @@ -0,0 +1,141 @@ +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif +#include "linux_evdev_internal.h" +#include "bongo_cat/log.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define EVDEV_DEVICE_DIRECTORY "/dev/input" +#define EVDEV_SYSFS_CLASS "/sys/class/input" + +bool bongo_cat_evdev_node_valid(const char *node) { + if (!node || strncmp(node, "event", 5) || !node[5] || + strlen(node) >= EVDEV_NODE_CAP) return false; + for (const char *p = node + 5; *p; ++p) + if (*p < '0' || *p > '9') return false; + return true; +} + +bool bongo_cat_evdev_mask_bit(const char *text, unsigned word_bits, unsigned code) { + if (!text || (word_bits != 32 && word_bits != 64)) return false; + unsigned long long words[KEY_CNT / 32 + 1]; + size_t count = 0; + while (*text) { + if (*text == ' ' || *text == '\n' || *text == '\t') { ++text; continue; } + if (count == sizeof(words) / sizeof(words[0]) || + !((*text >= '0' && *text <= '9') || (*text >= 'a' && *text <= 'f') || + (*text >= 'A' && *text <= 'F'))) return false; + char *end = NULL; + errno = 0; + unsigned long long word = strtoull(text, &end, 16); + if (errno || end == text || (*end && *end != ' ' && *end != '\n' && + *end != '\t') || (word_bits == 32 && word > UINT32_MAX)) return false; + words[count++] = word; + text = end; + } + unsigned block = code / word_bits, bit = code % word_bits; + return block < count && ((words[count - block - 1] >> bit) & 1u); +} + +static bool read_mask(const char *node, const char *name, char text[512]) { + char path[BONGO_CAT_PATH_CAP]; + int length = snprintf(path, sizeof(path), "%s/%s/device/capabilities/%s", + EVDEV_SYSFS_CLASS, node, name); + if (length <= 0 || (size_t)length >= sizeof(path)) return false; + FILE *file = fopen(path, "r"); + if (!file) return false; + bool ok = fgets(text, 512, file) != NULL && !ferror(file); + if (ok && !strchr(text, '\n') && !feof(file)) ok = false; + fclose(file); + return ok; +} + +static bool classify_device(const char *node, EvdevDevice *device) { + char keys[512], relative[512]; + if (!read_mask(node, "key", keys)) return false; + /* Sysfs uses the native kernel unsigned-long bitmap representation. */ + unsigned bits = sizeof(unsigned long) * CHAR_BIT; + bool left = bongo_cat_evdev_mask_bit(keys, bits, BTN_LEFT); + device->pointer = left; + device->keyboard = !left && bongo_cat_evdev_mask_bit(keys, bits, KEY_A) && + bongo_cat_evdev_mask_bit(keys, bits, KEY_Z) && + bongo_cat_evdev_mask_bit(keys, bits, KEY_ENTER) && + bongo_cat_evdev_mask_bit(keys, bits, KEY_SPACE); + device->relative = left && read_mask(node, "rel", relative) && + bongo_cat_evdev_mask_bit(relative, bits, REL_X) && + bongo_cat_evdev_mask_bit(relative, bits, REL_Y); + return device->keyboard || device->pointer; +} + +static void report_devices(LinuxEvdevState *state) { + bool relative = false; + for (size_t i = 0; i < state->device_count; ++i) + relative |= state->devices[i].relative; + atomic_store(&state->pointer_active, relative); + if (!relative) bongo_cat_evdev_motion_reset(state); + if (state->reported_count == state->device_count) return; + state->reported_count = state->device_count; + SDL_LogInfo(BONGO_CAT_LOG_LIFECYCLE, + "[runtime] evdev listener: %zu readable input devices", state->device_count); +} + +void bongo_cat_evdev_remove(LinuxEvdevState *state, size_t index) { + if (index >= state->device_count) return; + EvdevDevice *device = &state->devices[index]; + bongo_cat_evdev_release(state, device); + epoll_ctl(state->epoll_fd, EPOLL_CTL_DEL, device->fd, NULL); + close(device->fd); + --state->device_count; + if (index != state->device_count) *device = state->devices[state->device_count]; + memset(&state->devices[state->device_count], 0, sizeof(*device)); + report_devices(state); +} + +void bongo_cat_evdev_scan(LinuxEvdevState *state) { + DIR *directory = opendir(EVDEV_DEVICE_DIRECTORY); + if (!directory) return; + struct dirent *entry; + while ((entry = readdir(directory)) && state->device_count < EVDEV_MAX_DEVICES) { + const char *node = entry->d_name; + if (!bongo_cat_evdev_node_valid(node)) continue; + size_t i = 0; + for (; i < state->device_count; ++i) + if (!strcmp(state->devices[i].node, node)) break; + if (i < state->device_count) continue; + EvdevDevice device = {0}; + if (!classify_device(node, &device)) continue; + int fd = openat(dirfd(directory), node, + O_RDONLY | O_NONBLOCK | O_CLOEXEC | O_NOFOLLOW); + if (fd < 0) { + if ((errno == EACCES || errno == EPERM) && !state->denied_reported) { + state->denied_reported = true; + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "evdev input permission denied; see SECURITY.md. " + "BongoCat does not change device permissions."); + } + continue; + } + struct stat info; + if (fstat(fd, &info) || !S_ISCHR(info.st_mode)) { close(fd); continue; } + struct epoll_event interest = {.events = EPOLLIN}; + interest.data.fd = fd; + if (epoll_ctl(state->epoll_fd, EPOLL_CTL_ADD, fd, &interest)) { + close(fd); + continue; + } + device.fd = fd; + memcpy(device.node, node, strlen(node) + 1); + state->devices[state->device_count++] = device; + } + closedir(directory); + report_devices(state); +} diff --git a/src/platform/linux/linux_evdev_events.c b/src/platform/linux/linux_evdev_events.c new file mode 100644 index 00000000..9236b381 --- /dev/null +++ b/src/platform/linux/linux_evdev_events.c @@ -0,0 +1,93 @@ +#include "linux_evdev_internal.h" +#include +#include +#include + +static void wake_consumer(LinuxEvdevState *state) { + SDL_Event wake = {0}; + wake.type = state->platform->wake_event_type; + SDL_PushEvent(&wake); +} + +static const char *button_name(unsigned code) { + switch (code) { + case BTN_LEFT: return "Left"; + case BTN_MIDDLE: return "Middle"; + case BTN_RIGHT: return "Right"; + case BTN_SIDE: return "Back"; + case BTN_EXTRA: return "Forward"; + default: return NULL; + } +} + +static void key_event(LinuxEvdevState *state, EvdevDevice *device, + unsigned code, bool down) { + if (code >= KEY_CNT || device->down[code] == down) return; + bool mouse = code >= BTN_MISC; + const char *name = mouse ? button_name(code) : bongo_cat_evdev_key_name(code); + if (!name || (mouse ? !device->pointer : !device->keyboard)) return; + unsigned index = bongo_cat_evdev_key_index(code); + device->down[code] = down; + if (down) { + if (state->held[index]++ > 0) return; + } else { + if (!state->held[index] || --state->held[index] > 0) return; + } + BongoCatInputEvent input = { + .kind = mouse ? (down ? BONGO_CAT_INPUT_MOUSE_DOWN : BONGO_CAT_INPUT_MOUSE_UP) : + (down ? BONGO_CAT_INPUT_KEY_DOWN : BONGO_CAT_INPUT_KEY_UP), + .timestamp_ms = SDL_GetTicks(), .value = down ? 1.0f : 0.0f + }; + snprintf(input.name, sizeof(input.name), "%s", name); + if (bongo_cat_input_push(state->platform->input, &input)) wake_consumer(state); +} + +void bongo_cat_evdev_release(LinuxEvdevState *state, EvdevDevice *device) { + for (unsigned code = 0; code < KEY_CNT; ++code) + if (device->down[code]) key_event(state, device, code, false); +} + +void bongo_cat_evdev_motion_reset(LinuxEvdevState *state) { + SDL_LockMutex(state->motion_lock); + state->relative_x = state->relative_y = 0; + SDL_UnlockMutex(state->motion_lock); +} + +void bongo_cat_evdev_event(LinuxEvdevState *state, EvdevDevice *device, + const struct input_event *event) { + if (event->type == EV_SYN && event->code == SYN_DROPPED) { + bongo_cat_evdev_release(state, device); + bongo_cat_evdev_motion_reset(state); + device->desynchronized = true; + return; + } + /* No state-query ioctl is permitted. After overflow, release known keys + and ignore the incomplete packet; held keys resume on their next press. */ + if (device->desynchronized) { + if (event->type == EV_SYN && event->code == SYN_REPORT) + device->desynchronized = false; + return; + } + if (event->type == EV_KEY && (event->value == 0 || event->value == 1)) { + key_event(state, device, event->code, event->value == 1); + } else if (event->type == EV_REL && device->relative && + (event->code == REL_X || event->code == REL_Y) && event->value) { + SDL_LockMutex(state->motion_lock); + bool wake = state->relative_x == 0 && state->relative_y == 0; + double *axis = event->code == REL_X ? &state->relative_x : &state->relative_y; + *axis = SDL_clamp(*axis + (double)event->value, -32768.0, 32768.0); + SDL_UnlockMutex(state->motion_lock); + if (wake) wake_consumer(state); + } +} + +bool bongo_cat_evdev_read(LinuxEvdevState *state, EvdevDevice *device) { + struct input_event events[64]; + ssize_t bytes = read(device->fd, events, sizeof(events)); + if (bytes < 0) return errno == EAGAIN || errno == EINTR; + if (!bytes || (size_t)bytes % sizeof(events[0])) return false; + size_t count = (size_t)bytes / sizeof(events[0]); + for (size_t i = 0; i < count; ++i) + bongo_cat_evdev_event(state, device, &events[i]); + return true; +} diff --git a/src/platform/linux/linux_evdev_internal.h b/src/platform/linux/linux_evdev_internal.h new file mode 100644 index 00000000..994403e8 --- /dev/null +++ b/src/platform/linux/linux_evdev_internal.h @@ -0,0 +1,51 @@ +#ifndef BONGO_CAT_LINUX_EVDEV_INTERNAL_H +#define BONGO_CAT_LINUX_EVDEV_INTERNAL_H + +#include "linux_internal.h" +#include +#include + +#define EVDEV_MAX_DEVICES 32 +#define EVDEV_NODE_CAP 32 +#define EVDEV_SCAN_INTERVAL_NS 2000000000ull +#define EVDEV_POLL_MS 250 + +typedef struct EvdevDevice { + int fd; + bool keyboard; + bool pointer; + bool relative; + bool desynchronized; + bool down[KEY_CNT]; + char node[EVDEV_NODE_CAP]; +} EvdevDevice; + +struct LinuxEvdevState { + BongoCatPlatform *platform; + SDL_Thread *thread; + SDL_Mutex *motion_lock; + atomic_bool running; + atomic_bool pointer_active; + int epoll_fd; + EvdevDevice devices[EVDEV_MAX_DEVICES]; + size_t device_count; + size_t reported_count; + bool denied_reported; + unsigned held[KEY_CNT]; + double relative_x; + double relative_y; +}; + +const char *bongo_cat_evdev_key_name(unsigned code); +unsigned bongo_cat_evdev_key_index(unsigned code); +bool bongo_cat_evdev_node_valid(const char *node); +bool bongo_cat_evdev_mask_bit(const char *text, unsigned word_bits, unsigned code); +void bongo_cat_evdev_event(LinuxEvdevState *state, EvdevDevice *device, + const struct input_event *event); +void bongo_cat_evdev_release(LinuxEvdevState *state, EvdevDevice *device); +bool bongo_cat_evdev_read(LinuxEvdevState *state, EvdevDevice *device); +void bongo_cat_evdev_scan(LinuxEvdevState *state); +void bongo_cat_evdev_remove(LinuxEvdevState *state, size_t index); +void bongo_cat_evdev_motion_reset(LinuxEvdevState *state); + +#endif diff --git a/src/platform/linux/linux_evdev_keys.c b/src/platform/linux/linux_evdev_keys.c new file mode 100644 index 00000000..5faaab1f --- /dev/null +++ b/src/platform/linux/linux_evdev_keys.c @@ -0,0 +1,56 @@ +#include "linux_evdev_internal.h" + +static const char *const evdev_key_names[KEY_CNT] = { + [KEY_A] = "KeyA", [KEY_B] = "KeyB", [KEY_C] = "KeyC", [KEY_D] = "KeyD", + [KEY_E] = "KeyE", [KEY_F] = "KeyF", [KEY_G] = "KeyG", [KEY_H] = "KeyH", + [KEY_I] = "KeyI", [KEY_J] = "KeyJ", [KEY_K] = "KeyK", [KEY_L] = "KeyL", + [KEY_M] = "KeyM", [KEY_N] = "KeyN", [KEY_O] = "KeyO", [KEY_P] = "KeyP", + [KEY_Q] = "KeyQ", [KEY_R] = "KeyR", [KEY_S] = "KeyS", [KEY_T] = "KeyT", + [KEY_U] = "KeyU", [KEY_V] = "KeyV", [KEY_W] = "KeyW", [KEY_X] = "KeyX", + [KEY_Y] = "KeyY", [KEY_Z] = "KeyZ", + [KEY_0] = "Num0", [KEY_1] = "Num1", [KEY_2] = "Num2", [KEY_3] = "Num3", + [KEY_4] = "Num4", [KEY_5] = "Num5", [KEY_6] = "Num6", [KEY_7] = "Num7", + [KEY_8] = "Num8", [KEY_9] = "Num9", + [KEY_F1] = "F1", [KEY_F2] = "F2", [KEY_F3] = "F3", [KEY_F4] = "F4", + [KEY_F5] = "F5", [KEY_F6] = "F6", [KEY_F7] = "F7", [KEY_F8] = "F8", + [KEY_F9] = "F9", [KEY_F10] = "F10", [KEY_F11] = "F11", [KEY_F12] = "F12", + [KEY_F13] = "F13", [KEY_F14] = "F14", [KEY_F15] = "F15", [KEY_F16] = "F16", + [KEY_F17] = "F17", [KEY_F18] = "F18", [KEY_F19] = "F19", [KEY_F20] = "F20", + [KEY_F21] = "F21", [KEY_F22] = "F22", [KEY_F23] = "F23", [KEY_F24] = "F24", + [KEY_ESC] = "Escape", [KEY_TAB] = "Tab", [KEY_PAUSE] = "Pause", + [KEY_SYSRQ] = "PrintScreen", [KEY_MENU] = "Apps", + [KEY_CAPSLOCK] = "CapsLock", [KEY_SPACE] = "Space", + [KEY_BACKSPACE] = "Backspace", [KEY_DELETE] = "Delete", + [KEY_INSERT] = "Insert", [KEY_HOME] = "Home", [KEY_END] = "End", + [KEY_PAGEUP] = "PageUp", [KEY_PAGEDOWN] = "PageDown", + [KEY_UP] = "UpArrow", [KEY_DOWN] = "DownArrow", + [KEY_LEFT] = "LeftArrow", [KEY_RIGHT] = "RightArrow", + [KEY_LEFTMETA] = "Meta", [KEY_RIGHTMETA] = "Meta", + [KEY_LEFTSHIFT] = "ShiftLeft", [KEY_RIGHTSHIFT] = "ShiftRight", + [KEY_LEFTCTRL] = "ControlLeft", [KEY_RIGHTCTRL] = "ControlRight", + [KEY_LEFTALT] = "Alt", [KEY_RIGHTALT] = "AltGr", + [KEY_ENTER] = "Return", [KEY_GRAVE] = "BackQuote", + [KEY_NUMLOCK] = "NumLock", [KEY_SCROLLLOCK] = "ScrollLock", + [KEY_KP0] = "Kp0", [KEY_KP1] = "Kp1", [KEY_KP2] = "Kp2", + [KEY_KP3] = "Kp3", [KEY_KP4] = "Kp4", [KEY_KP5] = "Kp5", + [KEY_KP6] = "Kp6", [KEY_KP7] = "Kp7", [KEY_KP8] = "Kp8", + [KEY_KP9] = "Kp9", [KEY_KPDOT] = "KpDecimal", + [KEY_KPASTERISK] = "KpMultiply", [KEY_KPPLUS] = "KpPlus", + [KEY_KPMINUS] = "KpMinus", [KEY_KPSLASH] = "KpDivide", + [KEY_KPENTER] = "Return", + [KEY_MINUS] = "Minus", [KEY_EQUAL] = "Equal", + [KEY_LEFTBRACE] = "BracketLeft", [KEY_RIGHTBRACE] = "BracketRight", + [KEY_BACKSLASH] = "Backslash", [KEY_SEMICOLON] = "Semicolon", + [KEY_APOSTROPHE] = "Quote", [KEY_COMMA] = "Comma", + [KEY_DOT] = "Period", [KEY_SLASH] = "Slash" +}; + +const char *bongo_cat_evdev_key_name(unsigned code) { + return code < KEY_CNT ? evdev_key_names[code] : NULL; +} + +unsigned bongo_cat_evdev_key_index(unsigned code) { + if (code == KEY_RIGHTMETA) return KEY_LEFTMETA; + if (code == KEY_KPENTER) return KEY_ENTER; + return code; +} diff --git a/src/platform/linux/linux_internal.h b/src/platform/linux/linux_internal.h index 21a5a61c..220e8509 100644 --- a/src/platform/linux/linux_internal.h +++ b/src/platform/linux/linux_internal.h @@ -3,6 +3,17 @@ #include "bongo_cat/platform.h" +typedef struct LinuxEvdevState LinuxEvdevState; +typedef struct LinuxPlatformState { + void *x11; + LinuxEvdevState *evdev; + bool wayland; + bool evdev_selected; + bool always_on_top; +} LinuxPlatformState; + +bool bongo_cat_linux_evdev_requested(const char *option, bool wayland); + bool bongo_cat_linux_x11_start(BongoCatPlatform *platform, BongoCatError *error); void bongo_cat_linux_x11_stop(BongoCatPlatform *platform); /* Read-only evdev keyboard listener. Wayland compositors never forward other @@ -10,13 +21,13 @@ void bongo_cat_linux_x11_stop(BongoCatPlatform *platform); presses on Wayland; this backend observes the kernel key stream instead. */ bool bongo_cat_linux_evdev_start(BongoCatPlatform *platform, BongoCatError *error); void bongo_cat_linux_evdev_stop(BongoCatPlatform *platform); -bool bongo_cat_linux_evdev_keyboard_active(void); -bool bongo_cat_linux_evdev_pointer_active(void); +bool bongo_cat_linux_evdev_pointer_active(const BongoCatPlatform *platform); /* Relative pointer delta accumulated by the evdev listener. Wayland never exposes the global cursor position to other clients, so the pet follows relative motion from the kernel instead of absolute coordinates. */ -bool bongo_cat_linux_evdev_relative_pointer(double *dx, double *dy); -void bongo_cat_linux_evdev_relative_pointer_reset(void); +bool bongo_cat_linux_evdev_relative_pointer(BongoCatPlatform *platform, + double *dx, double *dy); +void bongo_cat_linux_evdev_relative_pointer_reset(BongoCatPlatform *platform); bool bongo_cat_linux_x11_supported(const BongoCatPlatform *platform); bool bongo_cat_linux_x11_xwayland(const BongoCatPlatform *platform); void bongo_cat_linux_x11_click_through(BongoCatPlatform *platform, bool enabled); diff --git a/src/platform/linux/linux_x11.c b/src/platform/linux/linux_x11.c index 20c6fd64..9da241a7 100644 --- a/src/platform/linux/linux_x11.c +++ b/src/platform/linux/linux_x11.c @@ -28,6 +28,11 @@ typedef struct LinuxX11State { atomic_bool supported; } LinuxX11State; +static LinuxX11State *x11_state(const BongoCatPlatform *platform) { + const LinuxPlatformState *native = platform ? platform->native : NULL; + return native ? native->x11 : NULL; +} + static const char *key_name(KeySym key, char output[16]) { if (key >= XK_a && key <= XK_z) { snprintf(output, 16, "Key%c", (char)('A' + key - XK_a)); return output; @@ -107,21 +112,12 @@ static void pointer(LinuxX11State *state, Display *display) { static void raw_event(LinuxX11State *state, Display *display, XIRawEvent *raw) { char buffer[16]; const char *name = NULL; if (raw->evtype == XI_RawKeyPress || raw->evtype == XI_RawKeyRelease) { - /* The evdev backend reports every key whenever it can read the kernel - key stream, and it is the only one that still works while focus is - on a native Wayland surface. Letting XInput2 report as well would - duplicate every press. */ - if (bongo_cat_linux_evdev_keyboard_active()) return; name = key_name(XkbKeycodeToKeysym(display, (KeyCode)raw->detail, 0, 0), buffer); bool down = raw->evtype == XI_RawKeyPress; if (bongo_cat_input_edge(state->key_down, raw->detail, down)) push(state, down ? BONGO_CAT_INPUT_KEY_DOWN : BONGO_CAT_INPUT_KEY_UP, name, down ? 1.0f : 0.0f); } else if (raw->evtype == XI_RawButtonPress || raw->evtype == XI_RawButtonRelease) { - /* The evdev backend reports clicks for as long as it can read the - kernel input stream, which also covers the case where a click lands - while no XWayland surface holds the pointer. */ - if (bongo_cat_linux_evdev_pointer_active()) return; if (raw->detail == 1) name = "Left"; else if (raw->detail == 2) name = "Middle"; else if (raw->detail == 3) name = "Right"; @@ -165,25 +161,34 @@ static int SDLCALL input_thread(void *userdata) { } bool bongo_cat_linux_x11_start(BongoCatPlatform *platform, BongoCatError *error) { + if (!platform || !platform->native || !platform->window) return false; + if (x11_state(platform)) return true; SDL_PropertiesID properties = SDL_GetWindowProperties(platform->window); Display *display = SDL_GetPointerProperty(properties, SDL_PROP_WINDOW_X11_DISPLAY_POINTER, NULL); Window window = (Window)SDL_GetNumberProperty(properties, SDL_PROP_WINDOW_X11_WINDOW_NUMBER, 0); LinuxX11State *state = calloc(1, sizeof(*state)); - if (!state) return false; + if (!state) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, + "Cannot allocate X11 input state"); + return false; + } state->platform = platform; state->display = display; state->window = window; atomic_init(&state->running, true); atomic_init(&state->supported, false); - platform->native = state; + LinuxPlatformState *native = platform->native; + native->x11 = state; /* XWayland advertises a marker extension that native X servers lack. XWayland stops delivering pointer events for a window whose input region is empty, so dynamic hit testing cannot restore the region by itself. */ int marker_opcode = 0, marker_event = 0, marker_error = 0; - state->xwayland = display && XQueryExtension(display, "XWAYLAND", - &marker_opcode, &marker_event, &marker_error); + state->xwayland = native->wayland || (display && XQueryExtension(display, + "XWAYLAND", &marker_opcode, &marker_event, &marker_error)); if (state->xwayland) SDL_LogInfo(BONGO_CAT_LOG_LIFECYCLE, "XWayland detected; automatic transparent-pixel click-through is disabled"); - if (!display || !window) return true; + /* Select one input backend for the session. Device hotplug must not + switch producers midway through a held key or mouse button. */ + if (!display || !window || native->evdev_selected) return true; state->thread = SDL_CreateThread(input_thread, BONGO_CAT_SLUG "-x11-input", state); if (!state->thread) { @@ -194,25 +199,26 @@ bool bongo_cat_linux_x11_start(BongoCatPlatform *platform, BongoCatError *error) } void bongo_cat_linux_x11_stop(BongoCatPlatform *platform) { - LinuxX11State *state = platform ? platform->native : NULL; + LinuxX11State *state = x11_state(platform); if (!state) return; atomic_store(&state->running, false); if (state->thread) SDL_WaitThread(state->thread, NULL); - free(state); platform->native = NULL; + free(state); + ((LinuxPlatformState *)platform->native)->x11 = NULL; } bool bongo_cat_linux_x11_supported(const BongoCatPlatform *platform) { - const LinuxX11State *state = platform ? platform->native : NULL; + const LinuxX11State *state = x11_state(platform); return state && atomic_load(&state->supported); } bool bongo_cat_linux_x11_xwayland(const BongoCatPlatform *platform) { - const LinuxX11State *state = platform ? platform->native : NULL; + const LinuxX11State *state = x11_state(platform); return state && state->xwayland; } void bongo_cat_linux_x11_click_through(BongoCatPlatform *platform, bool enabled) { - LinuxX11State *state = platform ? platform->native : NULL; + LinuxX11State *state = x11_state(platform); if (!state || !state->display || !state->window) return; XserverRegion region = enabled ? XFixesCreateRegion(state->display, NULL, 0) : None; XFixesSetWindowShapeRegion(state->display, state->window, ShapeInput, 0, 0, region); @@ -231,23 +237,20 @@ static void state_message(LinuxX11State *state, const char *name, long action) { } void bongo_cat_linux_x11_set_above(BongoCatPlatform *platform, bool enabled) { - LinuxX11State *state = platform ? platform->native : NULL; + LinuxX11State *state = x11_state(platform); if (!state || !state->display || !state->window) return; - /* SDL_SetWindowAlwaysOnTop answers success even when the request never - reaches the compositor, so is pushed through the same EWMH path that - already keeps _NET_WM_STATE_SKIP_TASKBAR applied. */ + /* Some XWayland compositors need an explicit EWMH request after mapping. */ state_message(state, "_NET_WM_STATE_ABOVE", enabled ? 1 : 0); - XSync(state->display, False); } void bongo_cat_linux_x11_configure_capture_window(BongoCatPlatform *platform) { - LinuxX11State *state = platform ? platform->native : NULL; + LinuxX11State *state = x11_state(platform); if (state && state->display && state->window) state_message(state, "_NET_WM_STATE_SKIP_TASKBAR", 1); } void bongo_cat_linux_x11_begin_drag(BongoCatPlatform *platform) { - LinuxX11State *state = platform ? platform->native : NULL; + LinuxX11State *state = x11_state(platform); if (!state || !state->display || !state->window) return; Window root = DefaultRootWindow(state->display), child; int x, y, wx, wy; unsigned int mask; diff --git a/src/runtime/shell/window.c b/src/runtime/shell/window.c index 339bd585..38f640f0 100644 --- a/src/runtime/shell/window.c +++ b/src/runtime/shell/window.c @@ -167,26 +167,15 @@ bool bongo_cat_window_event(BongoCatApp *app, const SDL_Event *event) { if (event->type == SDL_EVENT_WINDOW_EXPOSED || event->type == SDL_EVENT_WINDOW_SHOWN || event->type == SDL_EVENT_WINDOW_RESTORED) { - /* XWayland can report MINIMIZED and then deliver only EXPOSED when the - surface comes back (no SHOWN/RESTORED), which left window_minimized - set forever and disabled the render loop entirely: the pet stayed on - screen but froze, keys stopped showing and the pointer stopped - tracking. An EXPOSED surface is drawable, so it is authoritative - proof that the window is no longer minimized. */ - app->window_minimized = false; + /* An expose can arrive without a restore on XWayland, but a queued + expose is not proof that the window is currently restored. */ + app->window_minimized = + (SDL_GetWindowFlags(app->window) & SDL_WINDOW_MINIMIZED) != 0; bongo_cat_app_reset_pointer_tracking(app); /* DWM can discard the transparent redirection surface after an Explorer/display refresh. Repaint even when the model is idle so the restored alpha surface is submitted immediately. */ app->dirty = true; - /* XWayland drops _NET_WM_STATE_ABOVE while a surface is unmapped and - does not put it back when the surface is mapped again, which left the - pet behind other windows after every hide/show. Re-assert it here: - EXPOSED only arrives once the surface is actually on screen, so this - runs after the compositor has finished the remap and nothing - overwrites it afterwards. */ - bongo_cat_platform_set_always_on_top(&app->platform, - app->settings.window.always_on_top); } if (event->type == SDL_EVENT_WINDOW_FOCUS_GAINED || event->type == SDL_EVENT_WINDOW_FOCUS_LOST) { diff --git a/src/runtime/shell/window_hit.c b/src/runtime/shell/window_hit.c index 6568b240..70a16e52 100644 --- a/src/runtime/shell/window_hit.c +++ b/src/runtime/shell/window_hit.c @@ -70,16 +70,15 @@ void bongo_cat_window_set_visible(BongoCatApp *app, bool visible) { app->session.window.visible = visible; if (!visible) { app->startup_visibility_pending = false; - /* Nothing reads the position while the surface is gone, and the - compositor is free to place a surface that gets mapped again, so the - coordinates have to be captured here or they are lost with the - window. This snapshot is what the reveal path restores. */ +#if defined(__linux__) + /* Remember XWayland placement before unmapping the surface. */ int x = 0, y = 0; if (SDL_GetWindowPosition(app->window, &x, &y)) { app->session.window.x = x; app->session.window.y = y; app->session.window.position_known = true; } +#endif bongo_cat_platform_set_visible(&app->platform, false); return; } @@ -91,31 +90,18 @@ void bongo_cat_window_set_visible(BongoCatApp *app, bool visible) { bongo_cat_app_reset_pointer_tracking(app); bongo_cat_platform_set_opacity(&app->platform, app->session.window.opacity_percent / 100.0f); - /* Ask for the remembered spot while the native window is still off screen, - so the clamp/recover calls below judge the restored geometry instead of - wherever the compositor happened to park the surface. */ +#if defined(__linux__) + /* Restore before clamping so stale off-screen coordinates cannot undo it. */ if (app->session.window.position_known) SDL_SetWindowPosition(app->window, app->session.window.x, app->session.window.y); +#endif if (app->settings.window.keep_in_screen) bongo_cat_window_clamp_to_display(app); else bongo_cat_window_recover_to_display(app); /* Keep the native surface hidden until the first complete frame has been submitted. The render loop will reveal it next to that presentation. */ bongo_cat_platform_set_visible(&app->platform, !app->startup_visibility_pending); - /* Mapping a surface is asynchronous, so the compositor can still apply its - own placement after the request above. Put the window back once the - surface exists and log both numbers, which makes any remaining jump - measurable instead of a guess. */ - if (app->session.window.position_known) - SDL_SetWindowPosition(app->window, app->session.window.x, - app->session.window.y); - SDL_SyncWindow(app->window); - /* Unmapping a surface drops _NET_WM_STATE_ABOVE, so without pushing the - preference again a hidden and revealed pet sinks behind other windows - until the setting is toggled by hand. */ - bongo_cat_platform_set_always_on_top(&app->platform, - app->settings.window.always_on_top); bongo_cat_window_mark_hit_dirty(app); app->dirty = true; } diff --git a/tests/core/test_input_concurrent.c b/tests/core/test_input_concurrent.c new file mode 100644 index 00000000..498e83a4 --- /dev/null +++ b/tests/core/test_input_concurrent.c @@ -0,0 +1,74 @@ +#include "bongo_cat/input.h" +#include "test.h" +#include +#include +#include + +#define PRODUCERS 4 +#define EVENTS 20000 +int bongo_cat_test_failures; +static BongoCatInputState input; +static atomic_int finished; +static atomic_uint accepted; + +static int SDLCALL produce(void *userdata) { + unsigned id = *(unsigned *)userdata; + for (unsigned i = 0; i < EVENTS; ++i) { + BongoCatInputEvent event = { + .kind = i % 2 ? BONGO_CAT_INPUT_KEY_UP : BONGO_CAT_INPUT_KEY_DOWN, + .timestamp_ms = i, .x = id, .y = i, .value = (float)id + }; + snprintf(event.name, sizeof(event.name), "producer%u", id); + if (bongo_cat_input_push(&input, &event)) atomic_fetch_add(&accepted, 1); + if (i % 128 == 0) SDL_Delay(1); + } + atomic_fetch_add(&finished, 1); + return 0; +} + +int main(void) { + bongo_cat_input_init(&input); + atomic_init(&finished, 0); + atomic_init(&accepted, 0); + SDL_Thread *threads[PRODUCERS]; + unsigned ids[PRODUCERS], last[PRODUCERS] = {0}; + bool seen[PRODUCERS] = {0}; + for (unsigned i = 0; i < PRODUCERS; ++i) { + ids[i] = i; + threads[i] = SDL_CreateThread(produce, "input-producer", &ids[i]); + CHECK(threads[i] != NULL); + if (!threads[i]) return 1; + } + unsigned received = 0; + uint64_t sequence = 0; + for (;;) { + BongoCatInputEvent event; + bool done = atomic_load(&finished) == PRODUCERS; + if (!bongo_cat_input_pop(&input, &event)) { + if (done) break; + SDL_Delay(1); + continue; + } + unsigned id = (unsigned)event.x; + CHECK(id < PRODUCERS); + if (id >= PRODUCERS) continue; + char expected[32]; + snprintf(expected, sizeof(expected), "producer%u", id); + CHECK(strcmp(expected, event.name) == 0); + CHECK(event.value == (float)id); + CHECK(event.y == (double)event.timestamp_ms); + CHECK(event.kind == (event.timestamp_ms % 2 ? + BONGO_CAT_INPUT_KEY_UP : BONGO_CAT_INPUT_KEY_DOWN)); + CHECK(!seen[id] || event.timestamp_ms > last[id]); + CHECK(!received || event.sequence > sequence); + seen[id] = true; + last[id] = (unsigned)event.timestamp_ms; + sequence = event.sequence; + ++received; + } + for (unsigned i = 0; i < PRODUCERS; ++i) SDL_WaitThread(threads[i], NULL); + CHECK(received == atomic_load(&accepted)); + CHECK(received + atomic_load(&input.dropped) == PRODUCERS * EVENTS); + for (unsigned i = 0; i < PRODUCERS; ++i) CHECK(seen[i]); + return bongo_cat_test_failures ? 1 : 0; +} diff --git a/tests/platform/test_linux_evdev.c b/tests/platform/test_linux_evdev.c new file mode 100644 index 00000000..50f1685d --- /dev/null +++ b/tests/platform/test_linux_evdev.c @@ -0,0 +1,173 @@ +#include "linux_evdev_internal.h" +#include "test.h" +#include +#include +#include +#include +#include + +int bongo_cat_test_failures; +static BongoCatInputState input; +static LinuxPlatformState native; +static BongoCatPlatform platform = {.input = &input, .native = &native}; +static LinuxEvdevState state; + +static void send_event(EvdevDevice *device, unsigned type, unsigned code, int value) { + struct input_event event = {.type = type, .code = code, .value = value}; + bongo_cat_evdev_event(&state, device, &event); +} + +static void expect(BongoCatInputKind kind, const char *name) { + BongoCatInputEvent event = {0}; + CHECK(bongo_cat_input_pop(&input, &event)); + CHECK(event.kind == kind); + CHECK(strcmp(event.name, name) == 0); +} + +static void expect_empty(void) { + BongoCatInputEvent event; + CHECK(!bongo_cat_input_pop(&input, &event)); +} + +static void test_policy_and_masks(void) { + CHECK(!bongo_cat_linux_evdev_requested(NULL, true)); + CHECK(!bongo_cat_linux_evdev_requested("", true)); + CHECK(!bongo_cat_linux_evdev_requested("0", true)); + CHECK(!bongo_cat_linux_evdev_requested("true", true)); + CHECK(!bongo_cat_linux_evdev_requested("1", false)); + CHECK(bongo_cat_linux_evdev_requested("1", true)); + BongoCatError error = {0}; + CHECK(!bongo_cat_linux_evdev_start(&platform, &error)); + CHECK(native.evdev == NULL); + CHECK(!bongo_cat_linux_evdev_pointer_active(&platform)); + CHECK(bongo_cat_evdev_node_valid("event0")); + CHECK(bongo_cat_evdev_node_valid("event123")); + CHECK(!bongo_cat_evdev_node_valid(NULL)); + CHECK(!bongo_cat_evdev_node_valid("event")); + CHECK(!bongo_cat_evdev_node_valid("event1/../event2")); + CHECK(!bongo_cat_evdev_node_valid("event1x")); + CHECK(!bongo_cat_evdev_node_valid("event12345678901234567890123456789")); + CHECK(bongo_cat_evdev_mask_bit("1 0", 64, 64)); + CHECK(!bongo_cat_evdev_mask_bit("1 0", 64, 0)); + CHECK(bongo_cat_evdev_mask_bit("1 0 0", 32, 64)); + CHECK(bongo_cat_evdev_mask_bit("8000000000000000\n", 64, 63)); + CHECK(bongo_cat_evdev_mask_bit("80000000", 32, 31)); + CHECK(!bongo_cat_evdev_mask_bit("100000000", 32, 32)); + CHECK(!bongo_cat_evdev_mask_bit("-1", 64, 0)); + CHECK(!bongo_cat_evdev_mask_bit("1x", 64, 0)); + CHECK(!bongo_cat_evdev_mask_bit("10000000000000000", 64, 0)); + CHECK(!bongo_cat_evdev_mask_bit("", 64, 0)); + CHECK(!bongo_cat_evdev_mask_bit("1", 64, KEY_CNT)); +} + +static void test_multiple_devices(void) { + EvdevDevice a = {.keyboard = true}, b = {.keyboard = true}; + send_event(&a, EV_KEY, KEY_A, 1); + send_event(&a, EV_KEY, KEY_A, 1); + send_event(&a, EV_KEY, KEY_A, 2); + send_event(&b, EV_KEY, KEY_A, 1); + expect(BONGO_CAT_INPUT_KEY_DOWN, "KeyA"); + bongo_cat_evdev_release(&state, &a); + expect_empty(); + bongo_cat_evdev_release(&state, &b); + expect(BONGO_CAT_INPUT_KEY_UP, "KeyA"); + expect_empty(); + send_event(&a, EV_KEY, KEY_LEFTMETA, 1); + send_event(&a, EV_KEY, KEY_RIGHTMETA, 1); + send_event(&a, EV_KEY, KEY_LEFTMETA, 0); + expect(BONGO_CAT_INPUT_KEY_DOWN, "Meta"); + expect_empty(); + send_event(&a, EV_KEY, KEY_RIGHTMETA, 0); + expect(BONGO_CAT_INPUT_KEY_UP, "Meta"); + send_event(&a, EV_KEY, KEY_ENTER, 1); + send_event(&b, EV_KEY, KEY_KPENTER, 1); + expect(BONGO_CAT_INPUT_KEY_DOWN, "Return"); + bongo_cat_evdev_release(&state, &a); + expect_empty(); + bongo_cat_evdev_release(&state, &b); + expect(BONGO_CAT_INPUT_KEY_UP, "Return"); + send_event(&a, EV_KEY, KEY_CNT, 1); + send_event(&a, EV_KEY, KEY_A, -1); + expect_empty(); +} + +static void test_overflow_and_motion(void) { + EvdevDevice device = {.keyboard = true, .pointer = true, .relative = true}; + send_event(&device, EV_KEY, KEY_LEFTCTRL, 1); + expect(BONGO_CAT_INPUT_KEY_DOWN, "ControlLeft"); + CHECK(bongo_cat_input_control_down(&input)); + send_event(&device, EV_SYN, SYN_DROPPED, 0); + expect(BONGO_CAT_INPUT_KEY_UP, "ControlLeft"); + CHECK(!bongo_cat_input_control_down(&input)); + send_event(&device, EV_KEY, KEY_B, 1); + send_event(&device, EV_REL, REL_X, 10); + expect_empty(); + CHECK(state.relative_x == 0); + send_event(&device, EV_SYN, SYN_REPORT, 0); + send_event(&device, EV_KEY, KEY_B, 1); + expect(BONGO_CAT_INPUT_KEY_DOWN, "KeyB"); + bongo_cat_evdev_release(&state, &device); + expect(BONGO_CAT_INPUT_KEY_UP, "KeyB"); + send_event(&device, EV_KEY, BTN_SIDE, 1); + send_event(&device, EV_KEY, BTN_SIDE, 0); + expect(BONGO_CAT_INPUT_MOUSE_DOWN, "Back"); + expect(BONGO_CAT_INPUT_MOUSE_UP, "Back"); + SDL_FlushEvents(SDL_EVENT_FIRST, SDL_EVENT_LAST); + send_event(&device, EV_REL, REL_X, 12); + send_event(&device, EV_REL, REL_Y, -7); + CHECK(SDL_HasEvent(platform.wake_event_type)); + native.evdev = &state; + double x = 0, y = 0; + CHECK(bongo_cat_linux_evdev_relative_pointer(&platform, &x, &y)); + CHECK(x == 12 && y == -7); + CHECK(!bongo_cat_linux_evdev_relative_pointer(&platform, &x, &y)); + send_event(&device, EV_REL, REL_X, INT_MAX); + send_event(&device, EV_REL, REL_X, INT_MAX); + CHECK(bongo_cat_linux_evdev_relative_pointer(&platform, &x, &y)); + CHECK(x == 32768 && y == 0); + device.relative = false; + send_event(&device, EV_REL, REL_X, 10); + CHECK(!bongo_cat_linux_evdev_relative_pointer(&platform, &x, &y)); + native.evdev = NULL; +} + +static void test_read_and_disconnect(void) { + int pipes[2]; + CHECK(pipe(pipes) == 0); + CHECK(fcntl(pipes[0], F_SETFL, O_NONBLOCK) == 0); + state.epoll_fd = epoll_create1(EPOLL_CLOEXEC); + CHECK(state.epoll_fd >= 0); + state.device_count = 1; + EvdevDevice *device = &state.devices[0]; + *device = (EvdevDevice){.fd = pipes[0], .keyboard = true, .relative = true}; + CHECK(bongo_cat_evdev_read(&state, device)); + struct input_event event = {.type = EV_KEY, .code = KEY_A, .value = 1}; + CHECK(write(pipes[1], &event, sizeof(event)) == sizeof(event)); + CHECK(bongo_cat_evdev_read(&state, device)); + expect(BONGO_CAT_INPUT_KEY_DOWN, "KeyA"); + close(pipes[1]); + CHECK(!bongo_cat_evdev_read(&state, device)); + bongo_cat_evdev_remove(&state, 0); + expect(BONGO_CAT_INPUT_KEY_UP, "KeyA"); + CHECK(state.device_count == 0); + CHECK(!atomic_load(&state.pointer_active)); + close(state.epoll_fd); +} + +int main(void) { + CHECK(SDL_Init(SDL_INIT_EVENTS)); + bongo_cat_input_init(&input); + platform.wake_event_type = SDL_RegisterEvents(1); + state.platform = &platform; + state.motion_lock = SDL_CreateMutex(); + CHECK(state.motion_lock != NULL); + atomic_init(&state.pointer_active, false); + test_policy_and_masks(); + test_multiple_devices(); + test_overflow_and_motion(); + test_read_and_disconnect(); + expect_empty(); + SDL_DestroyMutex(state.motion_lock); + SDL_Quit(); + return bongo_cat_test_failures ? 1 : 0; +} diff --git a/tests/platform/test_linux_window.c b/tests/platform/test_linux_window.c new file mode 100644 index 00000000..e933c963 --- /dev/null +++ b/tests/platform/test_linux_window.c @@ -0,0 +1,53 @@ +#include "runtime.h" +#include "test.h" +#include + +int bongo_cat_test_failures; + +int main(int argc, char **argv) { + BongoCatApp *app = calloc(1, sizeof(*app)); + BongoCatError error = {0}; + if (!app) return 1; + if (!bongo_cat_app_initialize(app, argc, argv, &error)) { + fprintf(stderr, "Initialization failed: %s\n", error.message); + bongo_cat_app_shutdown(app, "test:failed", 1); + free(app); + return 1; + } + app->startup_visibility_pending = false; + app->settings.window.keep_in_screen = true; + app->settings.window.always_on_top = true; + bongo_cat_platform_set_always_on_top(&app->platform, true); + bongo_cat_window_set_visible(app, true); + SDL_SyncWindow(app->window); + int x, y; + CHECK(SDL_GetWindowPosition(app->window, &x, &y)); + bongo_cat_window_set_visible(app, false); + CHECK(app->session.window.position_known); + CHECK(app->session.window.x == x && app->session.window.y == y); + CHECK(SDL_GetWindowFlags(app->window) & SDL_WINDOW_HIDDEN); + SDL_Rect bounds = {0}; + CHECK(SDL_GetDisplayUsableBounds(SDL_GetDisplayForWindow(app->window), &bounds)); + app->session.window.x = bounds.x - 10000; + app->session.window.y = bounds.y - 10000; + bongo_cat_window_set_visible(app, true); + SDL_SyncWindow(app->window); + CHECK(SDL_GetWindowPosition(app->window, &x, &y)); + CHECK(x >= bounds.x && y >= bounds.y); + CHECK(!(SDL_GetWindowFlags(app->window) & SDL_WINDOW_HIDDEN)); + CHECK(SDL_GetWindowFlags(app->window) & SDL_WINDOW_ALWAYS_ON_TOP); + SDL_Event exposed = {.type = SDL_EVENT_WINDOW_EXPOSED}; + exposed.window.windowID = SDL_GetWindowID(app->window); + app->window_minimized = true; + CHECK(bongo_cat_window_event(app, &exposed)); + CHECK(app->window_minimized == + ((SDL_GetWindowFlags(app->window) & SDL_WINDOW_MINIMIZED) != 0)); + for (int i = 0; i < 3; ++i) { + bongo_cat_window_set_visible(app, false); + bongo_cat_window_set_visible(app, true); + } + CHECK(!app->startup_visibility_pending && app->session.window.visible); + bongo_cat_app_shutdown(app, "test:complete", bongo_cat_test_failures ? 1 : 0); + free(app); + return bongo_cat_test_failures ? 1 : 0; +} From 88b2395a0666e758f60ab19375d3498eaef986dc Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 15 Sep 2026 11:55:05 +0800 Subject: [PATCH 301/334] fix: add localized tray hint for mouse pass-through --- resources/assets/locales/de-DE.json | 2 +- resources/assets/locales/en-US.json | 2 +- resources/assets/locales/es-ES.json | 2 +- resources/assets/locales/fr-FR.json | 2 +- resources/assets/locales/ja-JP.json | 2 +- resources/assets/locales/ko-KR.json | 2 +- resources/assets/locales/pt-BR.json | 2 +- resources/assets/locales/ru-RU.json | 2 +- resources/assets/locales/zh-CN.json | 2 +- resources/assets/locales/zh-Hant.json | 2 +- src/ui/preferences/preferences_pages.c | 4 +++- 11 files changed, 13 insertions(+), 11 deletions(-) diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index bf9ad907..505eab75 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -33,7 +33,7 @@ "hoverUnavailable": "Ausblenden bei Mausberührung ist in dieser Desktop-Umgebung nicht verfügbar.", "gameInput": "Desktop-Tier reagiert im Spiel nicht?", "gameInputHelp": "Versuche, BongoCat als Administrator auszuführen und das Spiel in den Fenstermodus zu versetzen.", - "passThrough": "Wenn aktiviert, gehen Klicks durch das Fenster hindurch, ohne es zu beeinflussen.", + "passThrough": "Auch per Rechtsklick auf das Symbol im Infobereich deaktivierbar", "alwaysOnTop": "Wenn aktiviert, bleibt das Fenster über allen anderen Fenstern.", "keepInScreen": "Wenn aktiviert, bleibt das Fenster automatisch innerhalb der Bildschirmgrenzen.", "obsBackground": "Fensteraufnahme schwarz?", diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index f43cdaf8..259848c8 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -33,7 +33,7 @@ "hoverUnavailable": "Hover hiding is unavailable in the current desktop environment.", "gameInput": "Pet not responding in games?", "gameInputHelp": "Try running BongoCat as administrator and setting the game to windowed mode.", - "passThrough": "When enabled, clicks pass through the window without affecting it.", + "passThrough": "You can also turn this off by right-clicking the tray icon", "alwaysOnTop": "When enabled, the window stays above all other windows.", "keepInScreen": "When enabled, the window automatically stays within the screen boundaries.", "obsBackground": "Window capture is black?", diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index d96f91d1..75fd6455 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -33,7 +33,7 @@ "hoverUnavailable": "Ocultar al pasar el cursor no está disponible en el entorno de escritorio actual.", "gameInput": "¿La mascota no responde al jugar?", "gameInputHelp": "Prueba a ejecutar BongoCat como administrador y a poner el juego en modo ventana.", - "passThrough": "Al activarlo, los clics atraviesan la ventana sin afectarla.", + "passThrough": "También puedes desactivarlo con un clic derecho en el icono de la bandeja", "alwaysOnTop": "Al activarlo, la ventana permanece por encima de las demás.", "keepInScreen": "Al activarlo, la ventana se mantiene automáticamente dentro de los límites de la pantalla.", "obsBackground": "¿La captura de ventana aparece en negro?", diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index a8b9fd64..7b9e5097 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -33,7 +33,7 @@ "hoverUnavailable": "Le masquage au survol est indisponible dans cet environnement de bureau.", "gameInput": "La mascotte ne réagit pas en jeu ?", "gameInputHelp": "Essayez de lancer BongoCat en tant qu’administrateur et de passer le jeu en mode fenêtré.", - "passThrough": "Lorsque cette option est activée, les clics traversent la fenêtre sans l'affecter.", + "passThrough": "Désactivation possible aussi par clic droit sur l’icône de la zone de notification", "alwaysOnTop": "Lorsque cette option est activée, la fenêtre reste au-dessus des autres fenêtres.", "keepInScreen": "Lorsque cette option est activée, la fenêtre reste automatiquement dans les limites de l'écran.", "obsBackground": "La capture de fenêtre est noire ?", diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index 595c965f..4d68e57a 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -33,7 +33,7 @@ "hoverUnavailable": "現在のデスクトップ環境では、マウスを重ねて非表示にする機能は利用できません。", "gameInput": "ゲーム中にデスクトップペットが反応しない?", "gameInputHelp": "BongoCat を管理者として起動し、ゲームをウィンドウモードに設定してみてください。", - "passThrough": "有効にすると、クリックはウィンドウに影響せず背後へ通過します。", + "passThrough": "トレイアイコンの右クリックからもオフにできます", "alwaysOnTop": "有効にすると、ウィンドウがほかのすべてのウィンドウより手前に表示されます。", "keepInScreen": "有効にすると、ウィンドウが自動的に画面の範囲内に収まります。", "obsBackground": "ウィンドウキャプチャが黒い?", diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index 07cc7f95..8acdce90 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -33,7 +33,7 @@ "hoverUnavailable": "현재 데스크톱 환경에서는 마우스를 올려 숨기기 기능을 사용할 수 없습니다.", "gameInput": "게임 중 데스크톱 펫이 반응하지 않나요?", "gameInputHelp": "BongoCat을 관리자 권한으로 실행하고 게임을 창 모드로 설정해 보세요.", - "passThrough": "활성화하면 클릭이 창에 영향을 주지 않고 뒤로 통과합니다.", + "passThrough": "트레이 아이콘을 우클릭해서도 끌 수 있습니다", "alwaysOnTop": "활성화하면 창이 다른 모든 창 위에 유지됩니다.", "keepInScreen": "활성화하면 창이 자동으로 화면 경계 안에 유지됩니다.", "obsBackground": "창 캡처가 검은 화면인가요?", diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index d0eca81c..658ff72d 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -33,7 +33,7 @@ "hoverUnavailable": "Ocultar ao passar o mouse não está disponível no ambiente de desktop atual.", "gameInput": "O mascote não responde ao jogar?", "gameInputHelp": "Tente executar o BongoCat como administrador e colocar o jogo em modo janela.", - "passThrough": "Quando ativado, a janela não afetará operações em outros aplicativos.", + "passThrough": "Também é possível desativar pelo clique direito no ícone da bandeja", "alwaysOnTop": "Quando ativado, a janela sempre ficará acima de outros aplicativos.", "keepInScreen": "Quando ativado, a janela permanece automaticamente dentro dos limites da tela.", "obsBackground": "A captura da janela está preta?", diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index 75a65967..2cfb14d2 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -33,7 +33,7 @@ "hoverUnavailable": "Скрытие при наведении недоступно в текущем окружении рабочего стола.", "gameInput": "Питомец не реагирует в игре?", "gameInputHelp": "Попробуйте запустить BongoCat от имени администратора и перевести игру в оконный режим.", - "passThrough": "Если включено, щелчки проходят сквозь окно, не воздействуя на него.", + "passThrough": "Также можно отключить через правый клик по значку в трее", "alwaysOnTop": "Если включено, окно остаётся поверх всех остальных окон.", "keepInScreen": "Если включено, окно автоматически остаётся в границах экрана.", "obsBackground": "Захват окна показывает чёрный экран?", diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index c31adc27..62c05641 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -33,7 +33,7 @@ "hoverUnavailable": "当前桌面环境暂不支持鼠标移入隐藏。", "gameInput": "游戏中桌宠没反应?", "gameInputHelp": "试试以管理员身份启动 BongoCat,并将游戏设为窗口模式。", - "passThrough": "启用后,窗口不影响对其他应用程序的操作。", + "passThrough": "右键托盘图标也可关闭此功能", "alwaysOnTop": "启用后,窗口始终显示在其他应用程序上方。", "keepInScreen": "启用后,窗口会自动调整位置,防止超出屏幕边界。", "obsBackground": "窗口捕获黑屏?", diff --git a/resources/assets/locales/zh-Hant.json b/resources/assets/locales/zh-Hant.json index 04c8472e..584620e9 100644 --- a/resources/assets/locales/zh-Hant.json +++ b/resources/assets/locales/zh-Hant.json @@ -33,7 +33,7 @@ "hoverUnavailable": "目前桌面環境暫不支援滑鼠移入隱藏。", "gameInput": "遊戲中桌寵沒反應?", "gameInputHelp": "試試以系統管理員身分啟動 BongoCat,並將遊戲設為視窗模式。", - "passThrough": "啟用後,視窗不影響對其他應用程式的操作。", + "passThrough": "右鍵點擊系統匣圖示也可關閉此功能", "alwaysOnTop": "啟用後,視窗始終顯示在其他應用程式上方。", "keepInScreen": "啟用後,視窗會自動調整位置,防止超出螢幕邊界。", "obsBackground": "視窗擷取黑屏?", diff --git a/src/ui/preferences/preferences_pages.c b/src/ui/preferences/preferences_pages.c index 535f77bc..a45b36da 100644 --- a/src/ui/preferences/preferences_pages.c +++ b/src/ui/preferences/preferences_pages.c @@ -35,7 +35,9 @@ static void page_display(BongoCatApp *app, struct nk_context *context) { "Try running BongoCat as administrator and setting the game to windowed mode.")); bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_PASS_THROUGH); if (bongo_cat_pref_toggle(context, "pass-through", tr(app, - "composables.useAppMenu.labels.passThrough", "Pass Through"), "", + "composables.useAppMenu.labels.passThrough", "Pass Through"), tr(app, + "pages.preference.cat.hints.passThrough", + "You can also turn this off by right-clicking the tray icon"), &window->pass_through)) { bongo_cat_window_mark_hit_dirty(app); bongo_cat_window_sync_click_through(app); From f9d949dd6cdd5153bcd25f52468092e71f1f5d2f Mon Sep 17 00:00:00 2001 From: vladelaina Date: Tue, 15 Sep 2026 16:57:16 +0800 Subject: [PATCH 302/334] Add cached fading circle behind radial menu center --- src/ui/dial/dial_internal.h | 5 +++++ src/ui/dial/dial_scene.c | 39 +++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/ui/dial/dial_internal.h b/src/ui/dial/dial_internal.h index 5a9fd00a..fac7bbd1 100644 --- a/src/ui/dial/dial_internal.h +++ b/src/ui/dial/dial_internal.h @@ -11,6 +11,8 @@ #define DIAL_REVEAL_DELAY_MS 22 #define DIAL_REVEAL_DURATION_MS 360 #define DIAL_PATH_POINTS 160 +#define DIAL_CENTER_SEGMENTS 64 +#define DIAL_CENTER_RINGS 16 typedef struct DialPoint { float x, y; } DialPoint; typedef struct DialPath { DialPoint points[DIAL_PATH_POINTS]; @@ -44,6 +46,9 @@ typedef struct DialPaint { int child_path_count, child_path_active, child_path_roots; float child_path_step; float zoom, offset_x, offset_y, alpha; + DialPoint center_points[DIAL_CENTER_RINGS + 1][DIAL_CENTER_SEGMENTS]; + uint8_t center_alpha[DIAL_CENTER_RINGS + 1]; + bool center_ready; bool failed; } DialPaint; typedef struct Dial { diff --git a/src/ui/dial/dial_scene.c b/src/ui/dial/dial_scene.c index 7941d07a..89ebd8cf 100644 --- a/src/ui/dial/dial_scene.c +++ b/src/ui/dial/dial_scene.c @@ -84,9 +84,48 @@ static void root(Dial *d, int index, uint64_t now) { } } +static void center_background(Dial *d) { + enum { segments = DIAL_CENTER_SEGMENTS, rings = DIAL_CENTER_RINGS }; + const float radius = 80.0f; + uint32_t color = d->dark ? 0x00010409 : 0x00f6f9ff; + DialPaint *p = &d->paint; + /* Cache the original mesh per menu; transforms and opening alpha stay live. */ + if (!p->center_ready) { + DialPoint directions[segments]; + for (int i = 0; i < segments; ++i) { + float angle = (float)i * 2 * DIAL_PI / segments; + directions[i] = (DialPoint){cosf(angle), sinf(angle)}; + } + for (int ring = 0; ring <= rings; ++ring) { + float t = (float)ring / rings; + p->center_alpha[ring] = (uint8_t)(alpha(0xe6000000, + 1 - t * t * (3 - 2 * t)) >> 24); + for (int i = 0; i < segments; ++i) + p->center_points[ring][i] = (DialPoint){ + radius * t * directions[i].x, radius * t * directions[i].y}; + } + p->center_ready = true; + } + /* Adjacent rings share edges; smoothstep fades to a fully clear rim. */ + for (int ring = 0; ring < rings; ++ring) { + uint32_t ci = color | ((uint32_t)p->center_alpha[ring] << 24); + uint32_t co = color | ((uint32_t)p->center_alpha[ring+1] << 24); + for (int i = 0; i < segments; ++i) { + int next = (i + 1) % segments; + DialPoint a = p->center_points[ring][i]; + DialPoint b = p->center_points[ring+1][i]; + DialPoint c = p->center_points[ring+1][next]; + DialPoint e = p->center_points[ring][next]; + dial_triangle(d, a, b, c, ci, co, co); + if (ring > 0) dial_triangle(d, a, c, e, ci, co, ci); + } + } +} + static void center(Dial *d) { if (d->active < 0) return; dial_transform(d, 1, 0, 0); + center_background(d); DialItem item = d->items[d->active]; char buffer[32]; bool child_hovered = d->child >= 0; From 8d0681844bb9a611ed6e01735780a026e8c6bfe7 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 16 Sep 2026 13:08:49 +0800 Subject: [PATCH 303/334] fix: smooth wheel resizing and avoid redundant transparency resets --- src/platform/windows/windows_pointer.c | 6 +++--- src/platform/windows/windows_transparency.c | 3 ++- src/runtime/lifecycle/frame_clock.c | 13 +++++++++++-- src/runtime/shell/window_wheel.c | 10 ++++++---- src/runtime/shell/window_wheel_internal.h | 1 + src/runtime/shell/window_wheel_test.c | 20 +++++++++++++++----- 6 files changed, 38 insertions(+), 15 deletions(-) diff --git a/src/platform/windows/windows_pointer.c b/src/platform/windows/windows_pointer.c index e367c68d..b69fd3a8 100644 --- a/src/platform/windows/windows_pointer.c +++ b/src/platform/windows/windows_pointer.c @@ -78,8 +78,8 @@ bool bongo_cat_platform_set_geometry(BongoCatPlatform *platform, size_changed ? height : current_height, SWP_NOZORDER | SWP_NOACTIVATE) != 0; if (!changed) return false; - bool synced = SDL_SyncWindow(platform->window); - if (synced) bongo_cat_windows_capture_repair_transparency(window); - return synced; + /* Geometry changes retain DWM alpha composition. Reapplying it here + invalidates the surface on every animation frame. */ + return SDL_SyncWindow(platform->window); } #endif diff --git a/src/platform/windows/windows_transparency.c b/src/platform/windows/windows_transparency.c index 9e1b475f..57859a0b 100644 --- a/src/platform/windows/windows_transparency.c +++ b/src/platform/windows/windows_transparency.c @@ -102,6 +102,8 @@ bool bongo_cat_windows_capture_handle_transparency_message( if (is_transparent(window)) { /* SDL's default erase handler fills the client area with black. */ if (message == WM_ERASEBKGND) return true; + /* Ordinary moves/resizes preserve transparency. Repairing on every + WM_WINDOWPOSCHANGED repeatedly invalidates the animated surface. */ switch (message) { case WM_DWMCOMPOSITIONCHANGED: case WM_DWMNCRENDERINGCHANGED: @@ -112,7 +114,6 @@ bool bongo_cat_windows_capture_handle_transparency_message( case WM_NCACTIVATE: case WM_SHOWWINDOW: case WM_STYLECHANGED: - case WM_WINDOWPOSCHANGED: schedule_repair(window); break; default: diff --git a/src/runtime/lifecycle/frame_clock.c b/src/runtime/lifecycle/frame_clock.c index db93be7d..cd1f5d7e 100644 --- a/src/runtime/lifecycle/frame_clock.c +++ b/src/runtime/lifecycle/frame_clock.c @@ -1,4 +1,5 @@ #include "runtime.h" +#include "window_wheel_internal.h" #include "bongo_cat/preferences.h" #include @@ -33,7 +34,11 @@ int bongo_cat_window_wait_timeout(const BongoCatApp *app, uint64_t now) { frame_deadline, now) : 250; if (bongo_cat_preferences_needs_frame(app->preferences) && wait_ms > 4) wait_ms = 4; - if (app->wheel_animation_active && wait_ms > 8) wait_ms = 8; + if (app->wheel_animation_active) { + int wheel_wait = remaining_ms(app->wheel_animation_ns + + BONGO_CAT_WHEEL_FRAME_INTERVAL_NS, now); + if (wait_ms > wheel_wait) wait_ms = wheel_wait; + } if (app->hover_fade_active && wait_ms > 8) wait_ms = 8; if (app->session.window.visible && !app->window_minimized && (app->click_through_applied || (app->settings.window.pass_through && @@ -118,7 +123,11 @@ bool bongo_cat_window_wait_timeout_self_test(void) { app->session.window.visible = false; if (bongo_cat_window_wait_timeout(app, now) != 250) goto done; app->wheel_animation_active = true; - passed = bongo_cat_window_wait_timeout(app, now) == 8; + app->wheel_animation_ns = now; + if (bongo_cat_window_wait_timeout(app, now) != 8 || + bongo_cat_window_wait_timeout(app, now + 3000000ull) != 5 || + bongo_cat_window_wait_timeout(app, now + 8000000ull) != 0) goto done; + passed = true; done: free(app); return passed; diff --git a/src/runtime/shell/window_wheel.c b/src/runtime/shell/window_wheel.c index 45fe869c..d82c4366 100644 --- a/src/runtime/shell/window_wheel.c +++ b/src/runtime/shell/window_wheel.c @@ -6,10 +6,10 @@ #define WHEEL_SCALE_STEP 5.0f #define WHEEL_SCALE_TARGET_LEAD 10.0f #define WHEEL_OPACITY_RESPONSE_SECONDS 0.05f -#define WHEEL_SCALE_RESPONSE_SECONDS 0.01f +#define WHEEL_SCALE_RESPONSE_SECONDS 0.025f #define WHEEL_OPACITY_SPEED_PER_SECOND 100.0f #define WHEEL_SCALE_SPEED_PER_SECOND 150.0f -#define WHEEL_MAX_FRAME_SECONDS (1.0f / 60.0f) +#define WHEEL_MAX_FRAME_SECONDS 0.05f static float wheel_delta(const SDL_MouseWheelEvent *event) { float value = event ? event->y : 0.0f; @@ -85,7 +85,7 @@ void bongo_cat_window_wheel(BongoCatApp *app, const SDL_MouseWheelEvent *event) uint64_t event_ns = event->timestamp ? event->timestamp : SDL_GetTicksNS(); bool continuing = app->wheel_gesture_active && event_ns >= app->wheel_event_ns && event_ns - app->wheel_event_ns < BONGO_CAT_WHEEL_GESTURE_IDLE_NS; - if (!initialize_targets(app, !continuing)) return; + if (!initialize_targets(app, !continuing && !app->wheel_animation_active)) return; app->wheel_event_ns = event_ns; app->wheel_gesture_active = true; float old_opacity_target = app->wheel_opacity_target; @@ -161,6 +161,8 @@ void bongo_cat_window_update_wheel_animation(BongoCatApp *app, uint64_t now) { if (!app || !app->wheel_animation_active) return; uint64_t elapsed_ns = now >= app->wheel_animation_ns ? now - app->wheel_animation_ns : 0; + /* Resize/expose messages can wake the loop before the next animation frame. */ + if (elapsed_ns < BONGO_CAT_WHEEL_FRAME_INTERVAL_NS) return; app->wheel_animation_ns = now; float elapsed_seconds = SDL_min((float)elapsed_ns / 1000000000.0f, WHEEL_MAX_FRAME_SECONDS); @@ -169,7 +171,7 @@ void bongo_cat_window_update_wheel_animation(BongoCatApp *app, uint64_t now) { WHEEL_OPACITY_RESPONSE_SECONDS, WHEEL_OPACITY_SPEED_PER_SECOND, 0.01f); float scale = approach(app->session.window.scale_percent, app->wheel_scale_target, elapsed_seconds, - WHEEL_SCALE_RESPONSE_SECONDS, WHEEL_SCALE_SPEED_PER_SECOND, 0.5f); + WHEEL_SCALE_RESPONSE_SECONDS, WHEEL_SCALE_SPEED_PER_SECOND, 0.05f); bool changed = SDL_fabsf(opacity - app->session.window.opacity_percent) > 0.001f || SDL_fabsf(scale - app->session.window.scale_percent) > 0.001f; if (SDL_fabsf(opacity - app->session.window.opacity_percent) > 0.001f) { diff --git a/src/runtime/shell/window_wheel_internal.h b/src/runtime/shell/window_wheel_internal.h index 7909b1ab..394ec799 100644 --- a/src/runtime/shell/window_wheel_internal.h +++ b/src/runtime/shell/window_wheel_internal.h @@ -2,6 +2,7 @@ #define BONGO_CAT_WINDOW_WHEEL_INTERNAL_H #define BONGO_CAT_WHEEL_GESTURE_IDLE_NS 120000000ull +#define BONGO_CAT_WHEEL_FRAME_INTERVAL_NS 8000000ull int bongo_cat_window_wheel_round_position(float value); diff --git a/src/runtime/shell/window_wheel_test.c b/src/runtime/shell/window_wheel_test.c index d4924d88..94f38b33 100644 --- a/src/runtime/shell/window_wheel_test.c +++ b/src/runtime/shell/window_wheel_test.c @@ -41,12 +41,22 @@ bool bongo_cat_window_wheel_self_test(BongoCatApp *app) { wheel.y = 1.0f; bongo_cat_window_wheel(app, &wheel); started = app->wheel_animation_ns; - for (int i = 1; i <= 8; ++i) + float scale_before_frame = app->session.window.scale_percent; + for (uint64_t offset = 1000000ull; + offset < BONGO_CAT_WHEEL_FRAME_INTERVAL_NS; offset += 1000000ull) + bongo_cat_window_update_wheel_animation(app, started + offset); + bool coalesced = app->session.window.scale_percent == scale_before_frame && + app->wheel_animation_ns == started; + bongo_cat_window_update_wheel_animation(app, + started + BONGO_CAT_WHEEL_FRAME_INTERVAL_NS); + coalesced = coalesced && app->session.window.scale_percent > scale_before_frame && + app->session.window.scale_percent < app->wheel_scale_target; + for (int i = 1; i <= 12; ++i) bongo_cat_window_update_wheel_animation(app, started + i * 16666667ull); bool responsive = SDL_fabsf( app->session.window.scale_percent - 105.0f) < 0.1f; bongo_cat_window_update_wheel_animation(app, - app->wheel_input_ns + BONGO_CAT_WHEEL_GESTURE_IDLE_NS + 1); + started + 250000000ull); bool scale = responsive && !app->wheel_animation_active && SDL_fabsf(app->session.window.opacity_percent - 75.0f) < 0.1f; SDL_SetModState(modifiers & ~SDL_KMOD_CTRL); @@ -118,12 +128,12 @@ bool bongo_cat_window_wheel_self_test(BongoCatApp *app) { original_width, original_height); bool passed = foreign && opacity && scale && burst && aggregated && reversal && flipped && maximum && minimum && opacity_maximum && - opacity_minimum && rounding; + opacity_minimum && rounding && coalesced; if (!passed) fprintf(stderr, "wheel self-test: foreign=%d opacity=%d scale=%d " "burst=%d aggregated=%d reversal=%d flipped=%d maximum=%d " - "minimum=%d opacity_max=%d opacity_min=%d rounding=%d\n", + "minimum=%d opacity_max=%d opacity_min=%d rounding=%d coalesced=%d\n", foreign, opacity, scale, burst, aggregated, reversal, flipped, - maximum, minimum, opacity_maximum, opacity_minimum, rounding); + maximum, minimum, opacity_maximum, opacity_minimum, rounding, coalesced); return passed; } From 1d558be7482298e3f69c210d46230c355b45ecad Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 17 Sep 2026 09:49:57 +0800 Subject: [PATCH 304/334] feat: update virustotal --- .github/scripts/scan-virustotal.py | 89 +++++++++++++++++++++--------- .github/workflows/ci.yml | 63 ++++++++------------- .github/workflows/release.yml | 28 +++++----- 3 files changed, 99 insertions(+), 81 deletions(-) diff --git a/.github/scripts/scan-virustotal.py b/.github/scripts/scan-virustotal.py index 7d1bf383..251bbe43 100644 --- a/.github/scripts/scan-virustotal.py +++ b/.github/scripts/scan-virustotal.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Upload one release artifact to VirusTotal and publish a CI summary. +"""Scan release artifacts in batches of four and publish CI summaries. Detections are advisory because unsigned Windows software can trigger a small number of heuristic engines. API and report-processing failures still fail the @@ -17,12 +17,18 @@ import urllib.error import urllib.request import uuid +from collections import deque from pathlib import Path from typing import Any API_ROOT = "https://www.virustotal.com/api/v3" MAX_DIRECT_UPLOAD_BYTES = 32 * 1024 * 1024 +REQUEST_WINDOW_SECONDS = 61 +REQUEST_LIMIT = 4 +BATCH_SIZE = 4 +BATCH_DELAY_SECONDS = 120 +_request_times: deque[float] = deque() class VirusTotalError(RuntimeError): @@ -68,6 +74,18 @@ def api_request( url, data=body, headers=headers, method=method ) for attempt in range(5): + # Upload URLs, uploads, polling and retries all consume API quota. + while _request_times: + now = time.monotonic() + if now - _request_times[0] >= REQUEST_WINDOW_SECONDS: + _request_times.popleft() + elif len(_request_times) >= REQUEST_LIMIT: + delay = REQUEST_WINDOW_SECONDS - (now - _request_times[0]) + print(f"VirusTotal request quota used; waiting {delay:.1f}s") + time.sleep(delay) + else: + break + _request_times.append(time.monotonic()) try: with urllib.request.urlopen(request, timeout=60) as response: payload = response.read() @@ -80,11 +98,12 @@ def api_request( except urllib.error.HTTPError as exc: response_body = exc.read().decode("utf-8", errors="replace") if exc.code in (429, 500, 502, 503, 504) and attempt < 4: - retry_after = exc.headers.get("Retry-After", "5") + minimum_delay = 120 if exc.code == 429 else 5 + retry_after = exc.headers.get("Retry-After", str(minimum_delay)) try: - delay = min(max(int(retry_after), 1), 60) + delay = max(int(retry_after), minimum_delay) except ValueError: - delay = 5 + delay = minimum_delay print( f"VirusTotal HTTP {exc.code}; retrying in {delay}s", file=sys.stderr, @@ -214,7 +233,8 @@ def wait_for_analysis( def write_summary( - path: Path, sha256: str, attributes: dict[str, Any] + path: Path, sha256: str, attributes: dict[str, Any], + report_path: Path | None = None, ) -> None: stats = attributes.get("stats", {}) if not isinstance(stats, dict): @@ -256,7 +276,7 @@ def write_summary( f"https://www.virustotal.com/gui/file/{sha256}" ), } - report_path = Path( + report_path = report_path or Path( os.environ.get("VT_REPORT_PATH", "virustotal-report.json") ) report_path.write_text( @@ -336,18 +356,20 @@ def write_skip_summary(reason: str) -> None: def main() -> int: parser = argparse.ArgumentParser() - parser.add_argument("--file", required=True, type=Path) + parser.add_argument("--file", required=True, type=Path, nargs="+") + parser.add_argument("--report-directory", type=Path) parser.add_argument("--require-api-key", action="store_true") parser.add_argument("--timeout-seconds", type=int, default=900) parser.add_argument("--poll-seconds", type=int, default=15) args = parser.parse_args() - if not args.file.is_file(): - print( - f"VirusTotal input file does not exist: {args.file}", - file=sys.stderr, - ) - return 2 + for path in args.file: + if not path.is_file(): + print( + f"VirusTotal input file does not exist: {path}", + file=sys.stderr, + ) + return 2 api_key = os.environ.get("VIRUSTOTAL_API_KEY", "").strip() if not api_key: if args.require_api_key: @@ -359,21 +381,34 @@ def main() -> int: write_skip_summary("VIRUSTOTAL_API_KEY is not configured") return 0 - sha256 = file_sha256(args.file) - print( - f"VirusTotal input: {args.file} " - f"({args.file.stat().st_size} bytes)" - ) - print(f"VirusTotal SHA-256: {sha256}") try: - analysis_id = upload(args.file, api_key) - attributes = wait_for_analysis( - analysis_id, - api_key, - max(args.timeout_seconds, 30), - max(args.poll_seconds, 5), - ) - write_summary(args.file, sha256, attributes) + if args.report_directory: + args.report_directory.mkdir(parents=True, exist_ok=True) + for start in range(0, len(args.file), BATCH_SIZE): + if start: + print("Previous batch completed; waiting 120s before next batch") + time.sleep(BATCH_DELAY_SECONDS) + batch = args.file[start:start + BATCH_SIZE] + print(f"VirusTotal batch {start // BATCH_SIZE + 1}: {len(batch)} files") + pending = [] + # Submit the whole batch before waiting for any analysis result. + for path in batch: + sha256 = file_sha256(path) + print(f"VirusTotal input: {path} ({path.stat().st_size} bytes)") + print(f"VirusTotal SHA-256: {sha256}") + pending.append((path, sha256, upload(path, api_key))) + for index, (path, sha256, analysis_id) in enumerate(pending, start + 1): + attributes = wait_for_analysis( + analysis_id, api_key, + max(args.timeout_seconds, 30), + max(args.poll_seconds, 5), + ) + report_path = None + if args.report_directory or len(args.file) > 1: + report_path = (args.report_directory or Path(".")) / ( + f"virustotal-report-{index}-{path.name}.json" + ) + write_summary(path, sha256, attributes, report_path) return 0 except VirusTotalError as exc: print(f"::error::VirusTotal scan failed: {exc}") diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc5c3e3d..6f9be793 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -286,7 +286,8 @@ jobs: if-no-files-found: error retention-days: 30 compression-level: 0 - archive: false + # Preserve package ZIPs inside the artifact transport archive. + archive: true store-package: name: package / ${{ matrix.platform }} @@ -439,7 +440,8 @@ jobs: if-no-files-found: error retention-days: 90 compression-level: 0 - archive: false + # Preserve package ZIPs inside the artifact transport archive. + archive: true - name: Upload Windows desktop release uses: actions/upload-artifact@v7 @@ -449,7 +451,8 @@ jobs: if-no-files-found: error retention-days: 30 compression-level: 0 - archive: false + # Preserve package ZIPs inside the artifact transport archive. + archive: true - name: Upload Windows portable release uses: actions/upload-artifact@v7 @@ -459,7 +462,8 @@ jobs: if-no-files-found: error retention-days: 30 compression-level: 0 - archive: false + # Preserve package ZIPs inside the artifact transport archive. + archive: true virus-total: name: security / virustotal @@ -469,6 +473,10 @@ jobs: github.ref == 'refs/heads/main' needs: [desktop-package, store-package] runs-on: ubuntu-latest + # CI and releases share the same VirusTotal API quota. + concurrency: + group: bongocat-virustotal + cancel-in-progress: false permissions: contents: read actions: write @@ -477,53 +485,28 @@ jobs: - name: Checkout source uses: actions/checkout@v5 - - name: Download Linux x64 archive + # New package artifacts join scanning through the bongocat-vt- prefix. + - name: Download all scan artifacts uses: actions/download-artifact@v8 with: - pattern: BongoCat-*-linux-x64.tar.gz - path: vt-input - - # Raw ZIP artifacts are unpacked by download-artifact, so scan formats - # that remain intact as standalone files in the workspace. - - name: Download Windows x86 installer - uses: actions/download-artifact@v8 - with: - pattern: BongoCat-*-windows-x86-setup.exe - path: vt-input - - - name: Download Windows x64 installer - uses: actions/download-artifact@v8 - with: - pattern: BongoCat-*-windows-x64-setup.exe - path: vt-input - - - name: Download Windows x64 MSIX - uses: actions/download-artifact@v8 - with: - pattern: bongocat_*_x64.msix + pattern: bongocat-vt-* path: vt-input + merge-multiple: false - name: Scan all release artifacts with VirusTotal env: VIRUSTOTAL_API_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }} run: | set -euo pipefail - mapfile -d '' files < <(find vt-input -type f \( \ - -iname '*-windows-x64-setup.exe' -o \ - -iname 'bongocat_*_x64.msix' -o \ - -iname '*-linux-x64.tar.gz' -o \ - -iname '*-windows-x86-setup.exe' \ - \) -print0 | sort -z) - if [ "${#files[@]}" -ne 4 ]; then - echo "::error::Expected 4 representative VirusTotal inputs, found ${#files[@]}" - printf 'Found: %s\n' "${files[@]}" + # Scan every packaged file, including future formats and platforms. + mapfile -d '' files < <(find vt-input -type f ! -iname '*.sha256' -print0 | sort -z) + if [ "${#files[@]}" -eq 0 ]; then + echo "::error::No packaged artifacts found for VirusTotal" exit 1 fi - for file in "${files[@]}"; do - report=$(basename "$file" | tr -c 'A-Za-z0-9._-' '_') - VT_REPORT_PATH="virustotal-report-$report.json" \ - python3 .github/scripts/scan-virustotal.py --file "$file" - done + echo "Discovered ${#files[@]} VirusTotal inputs" + # One process shares the request quota across batches of four files. + python3 -u .github/scripts/scan-virustotal.py --report-directory . --file "${files[@]}" - name: Add VirusTotal note when scanning is skipped if: always() && hashFiles('virustotal-report-*.json') == '' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 98f9369c..f5c8ce1f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -248,6 +248,10 @@ jobs: needs: build if: github.repository == 'vladelaina/BongoCat' runs-on: ubuntu-latest + # CI and releases share the same VirusTotal API quota. + concurrency: + group: bongocat-virustotal + cancel-in-progress: false permissions: contents: read actions: write @@ -259,28 +263,24 @@ jobs: - name: Download packaged releases uses: actions/download-artifact@v8 with: - pattern: bongocat-release-windows-* + pattern: bongocat-release-* path: vt-input - merge-multiple: true + merge-multiple: false - - name: Scan all Windows release artifacts with VirusTotal + - name: Scan all release artifacts with VirusTotal env: VIRUSTOTAL_API_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }} run: | set -euo pipefail - mapfile -d '' files < <(find vt-input -type f \( \ - -iname '*-setup.exe' -o -iname '*-portable.exe' \) \ - -print0 | sort -z) - if [ "${#files[@]}" -ne 4 ]; then - echo "::error::Expected 4 VirusTotal inputs, found ${#files[@]}" - printf 'Found: %s\n' "${files[@]}" + # Scan every packaged file, including future formats and platforms. + mapfile -d '' files < <(find vt-input -type f ! -iname '*.sha256' -print0 | sort -z) + if [ "${#files[@]}" -eq 0 ]; then + echo "::error::No packaged artifacts found for VirusTotal" exit 1 fi - for file in "${files[@]}"; do - report=$(basename "$file" | tr -c 'A-Za-z0-9._-' '_') - VT_REPORT_PATH="virustotal-report-$report.json" \ - python3 .github/scripts/scan-virustotal.py --file "$file" - done + echo "Discovered ${#files[@]} VirusTotal inputs" + # One process shares the request quota across batches of four files. + python3 -u .github/scripts/scan-virustotal.py --report-directory . --file "${files[@]}" - name: Add VirusTotal note when scanning is skipped if: always() && hashFiles('virustotal-report-*.json') == '' From 703fa32196d65c803676a25430b8ca622367f0ea Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 17 Sep 2026 11:13:49 +0800 Subject: [PATCH 305/334] fix: the computer cannot enter sleep mode after startup --- .../scan-virustotal.cpython-314.pyc | Bin 0 -> 21533 bytes .../watch-virustotal.cpython-314.pyc | Bin 0 -> 12595 bytes .github/scripts/scan-virustotal.py | 3 +- .github/scripts/watch-virustotal.py | 170 ++++++++++++++++++ .github/workflows/ci.yml | 35 ++-- .github/workflows/release.yml | 33 ++-- src/runtime/shell/window.c | 1 + 7 files changed, 196 insertions(+), 46 deletions(-) create mode 100644 .github/scripts/__pycache__/scan-virustotal.cpython-314.pyc create mode 100644 .github/scripts/__pycache__/watch-virustotal.cpython-314.pyc create mode 100644 .github/scripts/watch-virustotal.py diff --git a/.github/scripts/__pycache__/scan-virustotal.cpython-314.pyc b/.github/scripts/__pycache__/scan-virustotal.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..368c26ac8b372fbe16fc76fbb609fa28eccef702 GIT binary patch literal 21533 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 Kv=vN2@c#h2G8EVV literal 0 HcmV?d00001 diff --git a/.github/scripts/__pycache__/watch-virustotal.cpython-314.pyc b/.github/scripts/__pycache__/watch-virustotal.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..48f5dedb2b614324589dd6f6709b95ce31b70e16 GIT binary patch literal 12595 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 literal 0 HcmV?d00001 diff --git a/.github/scripts/scan-virustotal.py b/.github/scripts/scan-virustotal.py index 251bbe43..e624ab58 100644 --- a/.github/scripts/scan-virustotal.py +++ b/.github/scripts/scan-virustotal.py @@ -24,7 +24,7 @@ API_ROOT = "https://www.virustotal.com/api/v3" MAX_DIRECT_UPLOAD_BYTES = 32 * 1024 * 1024 -REQUEST_WINDOW_SECONDS = 61 +REQUEST_WINDOW_SECONDS = 120 REQUEST_LIMIT = 4 BATCH_SIZE = 4 BATCH_DELAY_SECONDS = 120 @@ -97,6 +97,7 @@ def api_request( ) from exc except urllib.error.HTTPError as exc: response_body = exc.read().decode("utf-8", errors="replace") + exc.close() if exc.code in (429, 500, 502, 503, 504) and attempt < 4: minimum_delay = 120 if exc.code == 429 else 5 retry_after = exc.headers.get("Retry-After", str(minimum_delay)) diff --git a/.github/scripts/watch-virustotal.py b/.github/scripts/watch-virustotal.py new file mode 100644 index 00000000..f54a4bb8 --- /dev/null +++ b/.github/scripts/watch-virustotal.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Consume artifacts while builds run; one worker owns the shared VT quota. + +Both workflows must use the same job concurrency group. The initial cooldown +protects the quota when a previous worker finished or was cancelled abruptly. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import io +import json +import os +from pathlib import Path +import subprocess +import time +import zipfile + + +spec = importlib.util.spec_from_file_location( + "scan_virustotal", Path(__file__).with_name("scan-virustotal.py") +) +vt = importlib.util.module_from_spec(spec) +spec.loader.exec_module(vt) + + +def github_bytes(endpoint: str) -> bytes: + result = subprocess.run( + ["gh", "api", endpoint], capture_output=True, check=False + ) + if result.returncode: + raise vt.VirusTotalError( + f"GitHub artifact discovery/download failed: {result.stderr.decode(errors='replace')}" + ) + return result.stdout + + +def github_items(endpoint: str, key: str) -> list[dict]: + items = [] + separator = "&" if "?" in endpoint else "?" + page = 1 + while True: + data = json.loads(github_bytes(f"{endpoint}{separator}per_page=100&page={page}")) + batch = data[key] + items.extend(batch) + if len(batch) < 100: + return items + page += 1 + + +class ArtifactSource: + def __init__(self, prefix: str, producer_prefix: str, directory: Path): + self.repo = f"repos/{os.environ['GITHUB_REPOSITORY']}" + self.run = f"{self.repo}/actions/runs/{os.environ['GITHUB_RUN_ID']}" + self.prefix = prefix + self.producer_prefix = producer_prefix + self.directory = directory + self.seen: set[int] = set() + + def discover(self) -> list[Path]: + files = [] + for artifact in github_items(f"{self.run}/artifacts", "artifacts"): + artifact_id = artifact["id"] + if not artifact["name"].startswith(self.prefix) or artifact_id in self.seen: + continue + if artifact["expired"]: + raise vt.VirusTotalError(f"Scan artifact expired: {artifact_id}") + payload = github_bytes(f"{self.repo}/actions/artifacts/{artifact_id}/zip") + destination = (self.directory / str(artifact_id)).resolve() + with zipfile.ZipFile(io.BytesIO(payload)) as archive: + for member in archive.infolist(): + target = (destination / member.filename).resolve() + if not target.is_relative_to(destination): + raise vt.VirusTotalError("Unsafe path in artifact archive") + if member.is_dir() or target.name.lower().endswith(".sha256"): + continue + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(archive.read(member)) + files.append(target) + self.seen.add(artifact_id) + return files + + def finished(self) -> bool: + # Include earlier successful jobs on a failed-jobs rerun, keeping the + # newest job for each matrix name. Queued jobs are included by the API. + jobs = github_items(f"{self.run}/jobs?filter=all", "jobs") + latest = {} + for job in sorted(jobs, key=lambda item: item["id"]): + latest[job["name"]] = job + producers = [job for name, job in latest.items() + if name.startswith(self.producer_prefix)] + others = [job for name, job in latest.items() + if name != "security / virustotal"] + # The second condition also handles a skipped matrix after an upstream + # failure, for which GitHub may expose a single unexpanded job. + return bool(producers) and ( + (len(producers) >= 5 and all(job["status"] == "completed" for job in producers)) + or (bool(others) and all(job["status"] == "completed" for job in others)) + ) + + +def watch(source: ArtifactSource, api_key: str, reports: Path, + timeout: int = 18000, analysis_timeout: int = 900) -> None: + deadline = time.monotonic() + timeout + pending = [] + uploaded = 0 + failures = [] + reports.mkdir(parents=True, exist_ok=True) + # The concurrency lock excludes other CI/release workers; this covers the + # previous holder's final requests even if it could not persist any state. + print("Waiting 120s for the previous VirusTotal worker's quota to expire", flush=True) + time.sleep(vt.REQUEST_WINDOW_SECONDS) + while True: + if time.monotonic() >= deadline: + raise vt.VirusTotalError("Timed out waiting for build artifacts/scans") + finished = source.finished() + # Discover AFTER checking jobs so final uploads cannot fall between the + # last discovery and the decision to stop. + ready = source.discover() + for path in ready: + sha256 = vt.file_sha256(path) + print(f"Submitting completed artifact: {path.name}", flush=True) + analysis_id = vt.upload(path, api_key) + uploaded += 1 + pending.append((path, sha256, analysis_id, time.monotonic(), uploaded)) + if pending and not ready: + # One poll per discovery pass. New artifacts take priority over + # polling, and a slow analysis never blocks other uploads. + path, sha256, analysis_id, started, index = pending.pop(0) + response = vt.api_request("GET", f"{vt.API_ROOT}/analyses/{analysis_id}", api_key) + attributes = response.get("data", {}).get("attributes", {}) + status = attributes.get("status", "unknown") + if status == "completed": + vt.write_summary(path, sha256, attributes, + reports / f"virustotal-report-{index}-{path.name}.json") + elif status in ("failure", "timeout") or time.monotonic() - started >= analysis_timeout: + failures.append(f"{path.name}: analysis {status} or polling timed out") + else: + pending.append((path, sha256, analysis_id, started, index)) + if finished and not pending: + if not uploaded: + raise vt.VirusTotalError("No packaged artifacts found for VirusTotal") + if failures: + raise vt.VirusTotalError("; ".join(failures)) + return + if not ready: + time.sleep(15) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--artifact-prefix", required=True) + parser.add_argument("--producer-prefix", required=True) + args = parser.parse_args() + api_key = os.environ.get("VIRUSTOTAL_API_KEY", "").strip() + if not api_key: + vt.write_skip_summary("VIRUSTOTAL_API_KEY is not configured") + return 0 + try: + watch(ArtifactSource(args.artifact_prefix, args.producer_prefix, Path("vt-input")), + api_key, Path(".")) + except (vt.VirusTotalError, OSError, ValueError, zipfile.BadZipFile) as exc: + print(f"::error::{vt.escape_workflow_value(exc)}", flush=True) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f9be793..bfcdff5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -471,42 +471,31 @@ jobs: github.repository == 'vladelaina/BongoCat' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' - needs: [desktop-package, store-package] + needs: [publishing-policy, quality] runs-on: ubuntu-latest + timeout-minutes: 310 # CI and releases share the same VirusTotal API quota. concurrency: group: bongocat-virustotal cancel-in-progress: false permissions: contents: read - actions: write + actions: read steps: - name: Checkout source uses: actions/checkout@v5 - # New package artifacts join scanning through the bongocat-vt- prefix. - - name: Download all scan artifacts - uses: actions/download-artifact@v8 - with: - pattern: bongocat-vt-* - path: vt-input - merge-multiple: false - - - name: Scan all release artifacts with VirusTotal + # Discover artifacts while matrix jobs are still building. A single + # worker owns the rolling request window for every platform. + - name: Scan artifacts as they become available env: VIRUSTOTAL_API_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }} - run: | - set -euo pipefail - # Scan every packaged file, including future formats and platforms. - mapfile -d '' files < <(find vt-input -type f ! -iname '*.sha256' -print0 | sort -z) - if [ "${#files[@]}" -eq 0 ]; then - echo "::error::No packaged artifacts found for VirusTotal" - exit 1 - fi - echo "Discovered ${#files[@]} VirusTotal inputs" - # One process shares the request quota across batches of four files. - python3 -u .github/scripts/scan-virustotal.py --report-directory . --file "${files[@]}" + GH_TOKEN: ${{ github.token }} + run: >- + python3 -u .github/scripts/watch-virustotal.py + --artifact-prefix 'bongocat-vt-' + --producer-prefix 'package / ' - name: Add VirusTotal note when scanning is skipped if: always() && hashFiles('virustotal-report-*.json') == '' @@ -514,7 +503,7 @@ jobs: { echo "## VirusTotal scans" echo - echo "No report was produced. Check that VIRUSTOTAL_API_KEY is configured." + echo "No report was produced. Check the scan logs and VIRUSTOTAL_API_KEY configuration." } >> "$GITHUB_STEP_SUMMARY" - name: Upload VirusTotal reports diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f5c8ce1f..e1c29e5d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -245,42 +245,31 @@ jobs: virus-total: name: security / virustotal - needs: build if: github.repository == 'vladelaina/BongoCat' runs-on: ubuntu-latest + timeout-minutes: 310 # CI and releases share the same VirusTotal API quota. concurrency: group: bongocat-virustotal cancel-in-progress: false permissions: contents: read - actions: write + actions: read steps: - name: Checkout source uses: actions/checkout@v5 - - name: Download packaged releases - uses: actions/download-artifact@v8 - with: - pattern: bongocat-release-* - path: vt-input - merge-multiple: false - - - name: Scan all release artifacts with VirusTotal + # Discover artifacts while matrix jobs are still building. A single + # worker owns the rolling request window for every platform. + - name: Scan artifacts as they become available env: VIRUSTOTAL_API_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }} - run: | - set -euo pipefail - # Scan every packaged file, including future formats and platforms. - mapfile -d '' files < <(find vt-input -type f ! -iname '*.sha256' -print0 | sort -z) - if [ "${#files[@]}" -eq 0 ]; then - echo "::error::No packaged artifacts found for VirusTotal" - exit 1 - fi - echo "Discovered ${#files[@]} VirusTotal inputs" - # One process shares the request quota across batches of four files. - python3 -u .github/scripts/scan-virustotal.py --report-directory . --file "${files[@]}" + GH_TOKEN: ${{ github.token }} + run: >- + python3 -u .github/scripts/watch-virustotal.py + --artifact-prefix 'bongocat-release-' + --producer-prefix 'build / ' - name: Add VirusTotal note when scanning is skipped if: always() && hashFiles('virustotal-report-*.json') == '' @@ -288,7 +277,7 @@ jobs: { echo "## VirusTotal scans" echo - echo "No report was produced. Check that VIRUSTOTAL_API_KEY is configured." + echo "No report was produced. Check the scan logs and VIRUSTOTAL_API_KEY configuration." } >> "$GITHUB_STEP_SUMMARY" - name: Upload VirusTotal reports diff --git a/src/runtime/shell/window.c b/src/runtime/shell/window.c index 38f640f0..1399faf8 100644 --- a/src/runtime/shell/window.c +++ b/src/runtime/shell/window.c @@ -48,6 +48,7 @@ static bool try_window(BongoCatApp *app, bool transparent, int samples, } BongoCatResult bongo_cat_window_create(BongoCatApp *app, BongoCatError *error) { + SDL_SetHintWithPriority(SDL_HINT_VIDEO_ALLOW_SCREENSAVER, "1", SDL_HINT_OVERRIDE); SDL_SetHint(SDL_HINT_MOUSE_FOCUS_CLICKTHROUGH, "1"); #ifdef _WIN32 /* The native input thread owns keyboard Raw Input for this process. */ From 736f46ffcd83f6998c15aa641ae90530fb89a278 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 17 Sep 2026 13:44:45 +0800 Subject: [PATCH 306/334] chore(deps): remove unused dependencies --- CMakeLists.txt | 12 +++- cmake/AudioDecoder.cmake | 3 +- cmake/CheckPlatformRuntimeSafety.cmake | 2 + cmake/CheckWindowsImports.cmake | 20 ++++++ cmake/Cubism.cmake | 24 +++---- cmake/Dependencies.cmake | 17 +++++ cmake/SDLWindowsRuntime.cmake | 92 ++++++++++++++++++++++++++ src/media/audio/miniaudio_impl.c | 1 + src/media/stb_image_impl.c | 1 + src/ui/backend/nuklear_config.h | 2 - 10 files changed, 158 insertions(+), 16 deletions(-) create mode 100644 cmake/CheckWindowsImports.cmake create mode 100644 cmake/SDLWindowsRuntime.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index 87e99362..7a92cd89 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -107,7 +107,7 @@ else() PROPERTIES COMPILE_OPTIONS "-w") endif() if(BONGO_CAT_CUBISM_ENABLED) - target_link_libraries(bongo_cat_runtime PUBLIC Framework Live2DCubismCore glew_s) + target_link_libraries(bongo_cat_runtime PUBLIC Framework) target_compile_definitions(bongo_cat_runtime PUBLIC BONGO_CAT_HAS_CUBISM=1) endif() target_compile_definitions(bongo_cat_runtime PRIVATE @@ -123,6 +123,16 @@ bongo_cat_enable_release_ipo(Framework bongo_cat_core bongo_cat_runtime bongo_ca include(cmake/ValidationTools.cmake) include(cmake/ApplicationResources.cmake) +if(MSVC AND BONGO_CAT_SDL_WINDOWS_TRIMMED) + get_filename_component(BONGO_CAT_MSVC_TOOL_DIR "${CMAKE_LINKER}" DIRECTORY) + find_program(BONGO_CAT_DUMPBIN dumpbin PATHS "${BONGO_CAT_MSVC_TOOL_DIR}" REQUIRED) + add_custom_command(TARGET bongo_cat POST_BUILD + COMMAND ${CMAKE_COMMAND} "-DEXECUTABLE=$" + "-DDUMPBIN=${BONGO_CAT_DUMPBIN}" + -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/CheckWindowsImports.cmake" + COMMENT "Checking the final Windows executable's input capabilities" + VERBATIM) +endif() if(MSVC) set_property(SOURCE src/main.c APPEND PROPERTY COMPILE_OPTIONS "/experimental:c11atomics") get_target_property(BONGO_CAT_RUNTIME_SOURCES bongo_cat_runtime SOURCES) diff --git a/cmake/AudioDecoder.cmake b/cmake/AudioDecoder.cmake index 0d9fc23a..19aae316 100644 --- a/cmake/AudioDecoder.cmake +++ b/cmake/AudioDecoder.cmake @@ -22,7 +22,6 @@ else() endif() target_include_directories(bongo_cat_audio_decoder SYSTEM PRIVATE "${BONGO_CAT_STB_INCLUDE_DIR}") - target_compile_definitions(bongo_cat_audio_decoder PRIVATE MA_NO_ENCODING MA_NO_GENERATION) find_package(Threads REQUIRED) target_link_libraries(bongo_cat_audio_decoder PRIVATE Threads::Threads ${CMAKE_DL_LIBS}) if(UNIX) @@ -30,5 +29,7 @@ else() endif() set(BONGO_CAT_MINIAUDIO_TARGET bongo_cat_audio_decoder) endif() +target_compile_definitions(${BONGO_CAT_MINIAUDIO_TARGET} PUBLIC + MA_NO_ENCODING MA_NO_GENERATION MA_NO_CUSTOM) target_compile_definitions(${BONGO_CAT_MINIAUDIO_TARGET} PRIVATE BONGO_CAT_VORBIS_HEADER="${BONGO_CAT_VORBIS_HEADER}") diff --git a/cmake/CheckPlatformRuntimeSafety.cmake b/cmake/CheckPlatformRuntimeSafety.cmake index 8e3f0902..428f27ee 100644 --- a/cmake/CheckPlatformRuntimeSafety.cmake +++ b/cmake/CheckPlatformRuntimeSafety.cmake @@ -22,6 +22,7 @@ set(FORBIDDEN_APIS mouse_event SetWindowsHookEx SetWindowsHookExA SetWindowsHookExW DirectInput8Create SetCursorPos SetPhysicalCursorPos ClipCursor BlockInput SDL_SetWindowRelativeMouseMode + SDL_SetWindowKeyboardGrab CGEventPost CGEventPostToPid CGEventCreateKeyboardEvent CGEventCreateMouseEvent CGWarpMouseCursorPosition CGAssociateMouseAndMouseCursorPosition IOHIDManagerCreate @@ -37,6 +38,7 @@ set(FORBIDDEN_TOKENS kCGHIDEventTap kCGHeadInsertEventTap IOConnectCall /dev/uinput CAP_SYS_PTRACE CAP_SYS_ADMIN WH_KEYBOARD_LL WH_MOUSE_LL RIDEV_NOLEGACY RIDEV_CAPTUREMOUSE + SDL_WINDOW_KEYBOARD_GRABBED SDL_HINT_FORCE_RAISEWINDOW RIDEV_NOHOTKEYS RIDEV_APPKEYS RIDEV_EXCLUDE RIDEV_EXINPUTSINK) set(FAILURES "") diff --git a/cmake/CheckWindowsImports.cmake b/cmake/CheckWindowsImports.cmake new file mode 100644 index 00000000..df1646ad --- /dev/null +++ b/cmake/CheckWindowsImports.cmake @@ -0,0 +1,20 @@ +if(NOT EXISTS "${EXECUTABLE}" OR NOT EXISTS "${DUMPBIN}") + message(FATAL_ERROR "Windows import audit requires an executable and dumpbin") +endif() +execute_process(COMMAND "${DUMPBIN}" /nologo /imports "${EXECUTABLE}" + RESULT_VARIABLE status OUTPUT_VARIABLE imports ERROR_VARIABLE error) +if(NOT status EQUAL 0) + message(FATAL_ERROR "Cannot inspect Windows imports: ${error}") +endif() +foreach(api SetWindowsHookExA SetWindowsHookExW CallNextHookEx + UnhookWindowsHookEx AttachThreadInput) + if(imports MATCHES "[ \t]${api}[ \t\r]*\n") + message(FATAL_ERROR "Unused Windows capability reintroduced: ${api}") + endif() +endforeach() +foreach(api RegisterRawInputDevices GetRawInputData) + if(NOT imports MATCHES "[ \t]${api}[ \t\r]*\n") + message(FATAL_ERROR "Required background input capability missing: ${api}") + endif() +endforeach() +message(STATUS "Windows import audit passed: ${EXECUTABLE}") diff --git a/cmake/Cubism.cmake b/cmake/Cubism.cmake index 1dec5d94..68fde2db 100644 --- a/cmake/Cubism.cmake +++ b/cmake/Cubism.cmake @@ -2,10 +2,6 @@ set(CUBISM_CORE_PATH "${BONGO_CAT_CUBISM_SDK}/Core") set(CUBISM_FRAMEWORK_PATH "${BONGO_CAT_CUBISM_SDK}/Framework") set(CUBISM_GLEW_PATH "${BONGO_CAT_CUBISM_SDK}/Samples/OpenGL/thirdParty/glew") -# GLEW 2.2.0 declares an obsolete policy floor. Keep its unchanged upstream -# project compatible with current CMake releases without patching vendor files. -set(CMAKE_POLICY_VERSION_MINIMUM 3.10) - foreach(PATH IN ITEMS CUBISM_FRAMEWORK_PATH CUBISM_GLEW_PATH) if(NOT EXISTS "${${PATH}}") message(FATAL_ERROR "Incomplete Cubism SDK: ${${PATH}} is missing") @@ -43,9 +39,14 @@ set_target_properties(Live2DCubismCore PROPERTIES IMPORTED_LOCATION "${CUBISM_CORE_LIBRARY}" INTERFACE_INCLUDE_DIRECTORIES "${CUBISM_CORE_PATH}/include") -set(BUILD_UTILS OFF CACHE BOOL "" FORCE) -add_subdirectory("${CUBISM_GLEW_PATH}/build/cmake" "${CMAKE_BINARY_DIR}/cubism-glew" - EXCLUDE_FROM_ALL) +add_library(glew_s STATIC "${CUBISM_GLEW_PATH}/src/glew.c") +target_include_directories(glew_s SYSTEM PUBLIC "${CUBISM_GLEW_PATH}/include") +target_compile_definitions(glew_s PUBLIC GLEW_STATIC GLEW_NO_GLU) +target_link_libraries(glew_s PUBLIC OpenGL::GL) +if(UNIX AND NOT APPLE) + find_package(X11 REQUIRED) + target_link_libraries(glew_s PRIVATE X11::X11 ${CMAKE_DL_LIBS}) +endif() set(FRAMEWORK_SOURCE OpenGL) add_subdirectory("${CUBISM_FRAMEWORK_PATH}" "${CMAKE_BINARY_DIR}/cubism-framework") include(cmake/CubismUserModelSafety.cmake) @@ -59,14 +60,13 @@ if(APPLE) endif() if(WIN32) - target_compile_definitions(Framework PUBLIC CSM_TARGET_WIN_GL GLEW_NO_GLU) + target_compile_definitions(Framework PUBLIC CSM_TARGET_WIN_GL) elseif(APPLE) - target_compile_definitions(Framework PUBLIC CSM_TARGET_MAC_GL GLEW_NO_GLU) + target_compile_definitions(Framework PUBLIC CSM_TARGET_MAC_GL) else() - target_compile_definitions(Framework PUBLIC CSM_TARGET_LINUX_GL GLEW_NO_GLU) + target_compile_definitions(Framework PUBLIC CSM_TARGET_LINUX_GL) endif() target_include_directories(Framework SYSTEM PUBLIC "${CUBISM_FRAMEWORK_PATH}/src" - "${CUBISM_CORE_PATH}/include" - "${CUBISM_GLEW_PATH}/include") + "${CUBISM_CORE_PATH}/include") target_link_libraries(Framework PUBLIC Live2DCubismCore glew_s) diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake index 81888efe..05a87d33 100644 --- a/cmake/Dependencies.cmake +++ b/cmake/Dependencies.cmake @@ -27,6 +27,7 @@ if(BONGO_CAT_FETCH_DEPS) # dummy fallback enabled for diagnostics, while omitting unused APIs. set(SDL_OPENGLES OFF CACHE BOOL "" FORCE) set(SDL_VULKAN OFF CACHE BOOL "" FORCE) + set(SDL_METAL OFF CACHE BOOL "" FORCE) set(SDL_OFFSCREEN OFF CACHE BOOL "" FORCE) set(SDL_VIRTUAL_JOYSTICK OFF CACHE BOOL "" FORCE) set(SDL_CAMERA OFF CACHE BOOL "" FORCE) @@ -34,13 +35,25 @@ if(BONGO_CAT_FETCH_DEPS) set(SDL_HAPTIC OFF CACHE BOOL "" FORCE) set(SDL_POWER OFF CACHE BOOL "" FORCE) set(SDL_SENSOR OFF CACHE BOOL "" FORCE) + if(WIN32) + set(SDL_DIALOG OFF CACHE BOOL "" FORCE) + endif() + set(YYJSON_DISABLE_INCR_READER ON CACHE BOOL "" FORCE) + set(YYJSON_DISABLE_UTILS ON CACHE BOOL "" FORCE) + set(YYJSON_BUILD_TESTS OFF CACHE BOOL "" FORCE) + set(YYJSON_BUILD_FUZZER OFF CACHE BOOL "" FORCE) + set(YYJSON_BUILD_MISC OFF CACHE BOOL "" FORCE) + set(YYJSON_BUILD_DOC OFF CACHE BOOL "" FORCE) + set(YYJSON_INSTALL OFF CACHE BOOL "" FORCE) set(MINIAUDIO_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) set(MINIAUDIO_BUILD_TESTS OFF CACHE BOOL "" FORCE) + set(MINIAUDIO_BUILD_TOOLS OFF CACHE BOOL "" FORCE) set(MINIAUDIO_INSTALL OFF CACHE BOOL "" FORCE) set(MINIAUDIO_NO_EXTRA_NODES ON CACHE BOOL "" FORCE) set(MINIAUDIO_NO_LIBVORBIS ON CACHE BOOL "" FORCE) set(MINIAUDIO_NO_LIBOPUS ON CACHE BOOL "" FORCE) set(MINIAUDIO_NO_ENCODING ON CACHE BOOL "" FORCE) + set(MINIAUDIO_NO_CUSTOM ON CACHE BOOL "" FORCE) # Motion metadata may reference WAV/MP3 files, so keep both decoders enabled. # FORCE also repairs build trees created by older size-only configurations. set(MINIAUDIO_NO_WAV OFF CACHE BOOL "" FORCE) @@ -58,6 +71,10 @@ if(BONGO_CAT_FETCH_DEPS) FetchContent_Declare(nuklear URL https://github.com/Immediate-Mode-UI/Nuklear/archive/8109cfbabe04f8705408c5d8ab1a6cd48649ccda.tar.gz URL_HASH SHA256=23e5e1b12e897f1d568eb703aa313b7224c9b75e1118764ceba477d13b8e39f4) FetchContent_MakeAvailable(SDL3 yyjson stb miniaudio nuklear) + if(WIN32) + include("${CMAKE_CURRENT_LIST_DIR}/SDLWindowsRuntime.cmake") + bongo_cat_trim_sdl_windows(SDL3-static "${sdl3_SOURCE_DIR}") + endif() set(BONGO_CAT_STB_INCLUDE_DIR "${stb_SOURCE_DIR}") set(BONGO_CAT_MINIAUDIO_INCLUDE_DIR "${miniaudio_SOURCE_DIR}") set(BONGO_CAT_NUKLEAR_INCLUDE_DIR "${nuklear_SOURCE_DIR}") diff --git a/cmake/SDLWindowsRuntime.cmake b/cmake/SDLWindowsRuntime.cmake new file mode 100644 index 00000000..8803d8a1 --- /dev/null +++ b/cmake/SDLWindowsRuntime.cmake @@ -0,0 +1,92 @@ +function(bongo_cat_sdl_replace_section variable begin end replacement) + set(source "${${variable}}") + string(FIND "${source}" "${begin}" first) + if(first LESS 0) + message(FATAL_ERROR "Pinned SDL Windows patch mismatch: ${begin}") + endif() + string(LENGTH "${begin}" begin_size) + math(EXPR after_begin "${first} + ${begin_size}") + string(SUBSTRING "${source}" ${after_begin} -1 tail) + string(FIND "${tail}" "${begin}" duplicate) + string(FIND "${tail}" "${end}" last) + if(NOT duplicate EQUAL -1 OR last LESS 0) + message(FATAL_ERROR "Ambiguous pinned SDL Windows patch: ${begin}") + endif() + string(SUBSTRING "${source}" 0 ${first} prefix) + string(SUBSTRING "${tail}" ${last} -1 suffix) + set(${variable} "${prefix}${replacement}${suffix}" PARENT_SCOPE) +endfunction() + +function(bongo_cat_sdl_generated_source target original output source) + get_target_property(sources ${target} SOURCES) + if(NOT "${original}" IN_LIST sources) + message(FATAL_ERROR "Pinned SDL target no longer contains ${original}") + endif() + set(marked "#define BONGO_CAT_MODIFIED_SDL_WINDOWS_BACKEND 1\n${source}") + if(EXISTS "${output}") + file(READ "${output}" previous) + endif() + if(NOT marked STREQUAL previous) + file(WRITE "${output}" "${marked}") + endif() + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${original}") + list(REMOVE_ITEM sources "${original}") + set_property(TARGET ${target} PROPERTY SOURCES "${sources}") + target_sources(${target} PRIVATE "${output}") +endfunction() + +function(bongo_cat_trim_sdl_windows target source_root) + set(windows_dir "${source_root}/src/video/windows") + set(output_dir "${CMAKE_CURRENT_BINARY_DIR}/generated/sdl-windows") + file(MAKE_DIRECTORY "${output_dir}") + target_include_directories(${target} PRIVATE "${windows_dir}") + + set(window_path "${windows_dir}/SDL_windowswindow.c") + file(READ "${window_path}" window) + string(REPLACE "\r\n" "\n" window "${window}") + bongo_cat_sdl_replace_section(window + " if (data->keyboard_hook) {" " ReleaseDC(data->hwnd, data->hdc);" "") + bongo_cat_sdl_replace_section(window + "static void WIN_GrabKeyboard(SDL_Window *window)" + "bool WIN_SetWindowMouseRect(" "") + bongo_cat_sdl_replace_section(window + "bool WIN_SetWindowKeyboardGrab(" "\n#endif // !defined(SDL_PLATFORM_XBOXONE)" + [=[bool WIN_SetWindowKeyboardGrab(SDL_VideoDevice *_this, SDL_Window *window, bool grabbed) +{ + (void)_this; + (void)window; + return grabbed ? SDL_Unsupported() : true; +} +]=]) + bongo_cat_sdl_replace_section(window + "void WIN_RaiseWindow(" "void WIN_MaximizeWindow(" + [=[void WIN_RaiseWindow(SDL_VideoDevice *_this, SDL_Window *window) +{ +#if !defined(SDL_PLATFORM_XBOXONE) && !defined(SDL_PLATFORM_XBOXSERIES) + const bool activate = SDL_GetHintBoolean(SDL_HINT_WINDOW_ACTIVATE_WHEN_RAISED, true); + SDL_WindowData *data = window->internal; + HWND hwnd = data->hwnd; + if (activate) { + SetForegroundWindow(hwnd); + if ((window->flags & SDL_WINDOW_POPUP_MENU) && !(window->flags & SDL_WINDOW_NOT_FOCUSABLE)) { + WIN_SetKeyboardFocus(window, window->parent == SDL_GetKeyboardFocus()); + } + } else { + SetWindowPos(hwnd, HWND_TOP, 0, 0, 0, 0, data->copybits_flag | SWP_NOMOVE | SWP_NOSIZE | SWP_NOOWNERZORDER | SWP_NOACTIVATE); + } +#endif +} + +]=]) + bongo_cat_sdl_generated_source(${target} "${window_path}" + "${output_dir}/SDL_windowswindow.c" "${window}") + + set(events_path "${windows_dir}/SDL_windowsevents.c") + file(READ "${events_path}" events) + string(REPLACE "\r\n" "\n" events "${events}") + bongo_cat_sdl_replace_section(events + "LRESULT CALLBACK\nWIN_KeyboardHookProc(" "static bool WIN_SwapButtons(" "") + bongo_cat_sdl_generated_source(${target} "${events_path}" + "${output_dir}/SDL_windowsevents.c" "${events}") + set(BONGO_CAT_SDL_WINDOWS_TRIMMED TRUE PARENT_SCOPE) +endfunction() diff --git a/src/media/audio/miniaudio_impl.c b/src/media/audio/miniaudio_impl.c index 2091ce2d..5f1fff65 100644 --- a/src/media/audio/miniaudio_impl.c +++ b/src/media/audio/miniaudio_impl.c @@ -2,6 +2,7 @@ #ifndef BONGO_CAT_VORBIS_HEADER #define BONGO_CAT_VORBIS_HEADER #endif +#define STB_VORBIS_NO_INTEGER_CONVERSION #define STB_VORBIS_HEADER_ONLY #include BONGO_CAT_VORBIS_HEADER #define MINIAUDIO_IMPLEMENTATION diff --git a/src/media/stb_image_impl.c b/src/media/stb_image_impl.c index 513f7b1e..211999da 100644 --- a/src/media/stb_image_impl.c +++ b/src/media/stb_image_impl.c @@ -1,6 +1,7 @@ #define STB_IMAGE_IMPLEMENTATION #define STBI_ONLY_PNG #define STBI_ONLY_JPEG +#define STBI_NO_LINEAR #include #define STB_IMAGE_WRITE_IMPLEMENTATION diff --git a/src/ui/backend/nuklear_config.h b/src/ui/backend/nuklear_config.h index 55585331..a8dd2cec 100644 --- a/src/ui/backend/nuklear_config.h +++ b/src/ui/backend/nuklear_config.h @@ -2,8 +2,6 @@ #define BONGO_CAT_NUKLEAR_CONFIG_H #define NK_INCLUDE_FIXED_TYPES -#define NK_INCLUDE_STANDARD_IO -#define NK_INCLUDE_STANDARD_VARARGS #define NK_INCLUDE_DEFAULT_ALLOCATOR #define NK_INCLUDE_VERTEX_BUFFER_OUTPUT #define NK_INCLUDE_FONT_BAKING From f21fb07f31f43b37785765db98a94021f8eb5d7e Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 17 Sep 2026 15:52:28 +0800 Subject: [PATCH 307/334] feat:change niri to inno --- .github/scripts/install-inno.ps1 | 17 ++ .github/scripts/install-nsis.ps1 | 38 ----- .github/workflows/ci.yml | 16 +- .github/workflows/release.yml | 16 +- cmake/CheckPlatformRuntimeSafety.cmake | 17 +- cmake/Packaging.cmake | 187 +-------------------- cmake/PackagingInno.cmake | 14 ++ cmake/PackagingPlatform.cmake | 26 --- cmake/PackagingWindowsInit.cmake | 77 --------- packaging/windows/BongoCat.iss.in | 46 +++++ packaging/windows/build-installer.ps1 | 28 +++ packaging/windows/find-inno.ps1 | 54 ++++++ packaging/windows/inno-version.txt | 1 + packaging/windows/install-lifecycle.iss.in | 147 ++++++++++++++++ scripts/build-windows.ps1 | 74 +------- src/runtime/update/update_channel.c | 36 ++-- 16 files changed, 358 insertions(+), 436 deletions(-) create mode 100644 .github/scripts/install-inno.ps1 delete mode 100644 .github/scripts/install-nsis.ps1 create mode 100644 cmake/PackagingInno.cmake delete mode 100644 cmake/PackagingWindowsInit.cmake create mode 100644 packaging/windows/BongoCat.iss.in create mode 100644 packaging/windows/build-installer.ps1 create mode 100644 packaging/windows/find-inno.ps1 create mode 100644 packaging/windows/inno-version.txt create mode 100644 packaging/windows/install-lifecycle.iss.in diff --git a/.github/scripts/install-inno.ps1 b/.github/scripts/install-inno.ps1 new file mode 100644 index 00000000..35bdcb23 --- /dev/null +++ b/.github/scripts/install-inno.ps1 @@ -0,0 +1,17 @@ +[CmdletBinding()] +param() +$ErrorActionPreference = 'Stop' +$PackageVersion = (Get-Content -LiteralPath "$PSScriptRoot/../../packaging/windows/inno-version.txt" -Raw).Trim() +choco upgrade innosetup --version $PackageVersion ` + --source https://community.chocolatey.org/api/v2/ ` + --force --allow-downgrade --fail-on-unfound --no-progress --yes +if ($LASTEXITCODE -ne 0) { throw "Pinned Inno Setup installation failed (exit $LASTEXITCODE)" } +$info = & "$PSScriptRoot/../../packaging/windows/find-inno.ps1" -PassThru +$compiler = $info.Path +if ($info.Version.ToString(3) -ne $PackageVersion) { + throw "Expected Inno Setup $PackageVersion, found $($info.Version) at $compiler" +} +$compilerDir = Split-Path $compiler -Parent +$env:Path = "$compilerDir;$env:Path" +$compilerDir | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 +Write-Host "Inno Setup compiler: $compiler" diff --git a/.github/scripts/install-nsis.ps1 b/.github/scripts/install-nsis.ps1 deleted file mode 100644 index 5fc47d48..00000000 --- a/.github/scripts/install-nsis.ps1 +++ /dev/null @@ -1,38 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory = $true)][string]$PackageVersion, - [Parameter(Mandatory = $true)][string]$CompilerVersion -) - -$ErrorActionPreference = 'Stop' -Set-StrictMode -Version Latest - -# Install the concrete package from the public feed, even on preloaded runners. -choco upgrade nsis.install --version $PackageVersion ` - --source https://community.chocolatey.org/api/v2/ ` - --force --allow-downgrade --fail-on-unfound --no-progress --yes -if ($LASTEXITCODE -ne 0) { - throw "Pinned NSIS installation failed (exit $LASTEXITCODE)" -} - -$candidates = @( - (Join-Path ${env:ProgramFiles(x86)} 'NSIS/makensis.exe'), - (Join-Path $env:ProgramFiles 'NSIS/makensis.exe') -) -$command = Get-Command makensis.exe -ErrorAction SilentlyContinue -if ($command) { $candidates += $command.Source } -$found = @() -foreach ($candidate in ($candidates | Select-Object -Unique)) { - if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue } - $version = (& $candidate /VERSION | Out-String).Trim() - $status = $LASTEXITCODE - $found += "$candidate ($version, exit $status)" - if ($status -eq 0 -and $version -eq $CompilerVersion) { - $root = Split-Path $candidate - $env:PATH = "$root;$env:PATH" - $root | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 - Write-Host "NSIS compiler: $candidate ($version)" - return - } -} -throw "NSIS $CompilerVersion was not installed. Candidates: $($found -join '; ')" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfcdff5b..e23b02f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,10 +10,6 @@ on: permissions: contents: read -env: - BONGO_CAT_NSIS_PACKAGE_VERSION: '3.12.0' - BONGO_CAT_NSIS_COMPILER_VERSION: 'v3.12' - concurrency: group: bongocat-ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -351,12 +347,10 @@ jobs: "version=$($projectVersion.AppVersion)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - - name: Install NSIS + - name: Install Inno Setup shell: pwsh run: >- - & ./.github/scripts/install-nsis.ps1 - -PackageVersion $env:BONGO_CAT_NSIS_PACKAGE_VERSION - -CompilerVersion $env:BONGO_CAT_NSIS_COMPILER_VERSION + & ./.github/scripts/install-inno.ps1 - name: Package Windows desktop release id: desktop-package @@ -392,8 +386,8 @@ jobs: $cubismCore = "vendor/CubismSdkForNative/Core/lib/windows/$cubismArch/143/Live2DCubismCore_MT.lib" $cubismHash = (Get-FileHash -LiteralPath $cubismCore -Algorithm SHA256).Hash.ToLowerInvariant() $cmakeVersion = (& cmake --version | Select-Object -First 1).Trim() - $nsisCommand = Get-Command makensis.exe -ErrorAction Stop - $nsisVersion = (& $nsisCommand.Source /VERSION).Trim() + $innoInfo = & ./packaging/windows/find-inno.ps1 -PassThru + $innoVersion = $innoInfo.Version.ToString(3) @" ## Windows build provenance (${{ matrix.platform }}) @@ -402,7 +396,7 @@ jobs: | Commit | ``${{ github.sha }}`` | | Runner image | ``$env:ImageOS $env:ImageVersion`` | | CMake | ``$cmakeVersion`` | - | NSIS | ``$nsisVersion`` at ``$($nsisCommand.Source)`` | + | Inno Setup | ``$innoVersion`` at ``$($innoInfo.Path)`` | | Cubism Core SHA-256 | ``$cubismHash`` | | BongoCat.exe SHA-256 | ``$appHash`` | | Portable SHA-256 | ``$portableHash`` | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1c29e5d..366a3553 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,10 +8,6 @@ on: permissions: contents: read -env: - BONGO_CAT_NSIS_PACKAGE_VERSION: '3.12.0' - BONGO_CAT_NSIS_COMPILER_VERSION: 'v3.12' - concurrency: group: bongocat-release-${{ github.ref }} cancel-in-progress: false @@ -120,13 +116,11 @@ jobs: if: runner.os != 'Windows' run: bash .github/scripts/test-unix.sh ctest --test-dir build-release --output-on-failure - - name: Install NSIS + - name: Install Inno Setup if: runner.os == 'Windows' shell: pwsh run: >- - & ./.github/scripts/install-nsis.ps1 - -PackageVersion $env:BONGO_CAT_NSIS_PACKAGE_VERSION - -CompilerVersion $env:BONGO_CAT_NSIS_COMPILER_VERSION + & ./.github/scripts/install-inno.ps1 - name: Package Windows release if: runner.os == 'Windows' @@ -166,8 +160,8 @@ jobs: $cubismCore = "vendor/CubismSdkForNative/Core/lib/windows/$cubismArch/143/Live2DCubismCore_MT.lib" $cubismHash = (Get-FileHash -LiteralPath $cubismCore -Algorithm SHA256).Hash.ToLowerInvariant() $cmakeVersion = (& cmake --version | Select-Object -First 1).Trim() - $nsisCommand = Get-Command makensis.exe -ErrorAction Stop - $nsisVersion = (& $nsisCommand.Source /VERSION).Trim() + $innoInfo = & ./packaging/windows/find-inno.ps1 -PassThru + $innoVersion = $innoInfo.Version.ToString(3) @" ## Windows build provenance (${{ matrix.platform }}) @@ -176,7 +170,7 @@ jobs: | Commit | ``${{ github.sha }}`` | | Runner image | ``$env:ImageOS $env:ImageVersion`` | | CMake | ``$cmakeVersion`` | - | NSIS | ``$nsisVersion`` at ``$($nsisCommand.Source)`` | + | Inno Setup | ``$innoVersion`` at ``$($innoInfo.Path)`` | | Cubism Core SHA-256 | ``$cubismHash`` | | BongoCat.exe SHA-256 | ``$appHash`` | | Portable SHA-256 | ``$portableHash`` | diff --git a/cmake/CheckPlatformRuntimeSafety.cmake b/cmake/CheckPlatformRuntimeSafety.cmake index 428f27ee..24b857a4 100644 --- a/cmake/CheckPlatformRuntimeSafety.cmake +++ b/cmake/CheckPlatformRuntimeSafety.cmake @@ -137,17 +137,20 @@ if(MANIFEST_POSITION EQUAL -1) "cmake/windows.manifest.in: expected asInvoker with uiAccess=false") endif() -file(READ "${ROOT}/cmake/Packaging.cmake" WINDOWS_PACKAGING) -file(READ "${ROOT}/cmake/PackagingPlatform.cmake" WINDOWS_PACKAGE_PLATFORM) -string(APPEND WINDOWS_PACKAGING "\n${WINDOWS_PACKAGE_PLATFORM}") -string(FIND "${WINDOWS_PACKAGING}" "RequestExecutionLevel user" +file(READ "${ROOT}/packaging/windows/BongoCat.iss.in" WINDOWS_PACKAGING) +file(READ "${ROOT}/packaging/windows/install-lifecycle.iss.in" WINDOWS_INSTALL_LIFECYCLE) +string(APPEND WINDOWS_PACKAGING "\n${WINDOWS_INSTALL_LIFECYCLE}") +string(FIND "${WINDOWS_PACKAGING}" "PrivilegesRequired=lowest" INSTALLER_LEVEL_POSITION) string(FIND "${WINDOWS_PACKAGING}" - "set(CPACK_NSIS_INSTALL_ROOT \"$LOCALAPPDATA/Programs\")" + "DefaultDirName={code:GetInstallDir}" INSTALLER_ROOT_POSITION) -if(INSTALLER_LEVEL_POSITION EQUAL -1 OR INSTALLER_ROOT_POSITION EQUAL -1) +string(FIND "${WINDOWS_PACKAGING}" + "{localappdata}\\Programs\\BongoCat" INSTALLER_DEFAULT_POSITION) +if(INSTALLER_LEVEL_POSITION EQUAL -1 OR INSTALLER_ROOT_POSITION EQUAL -1 + OR INSTALLER_DEFAULT_POSITION EQUAL -1) list(APPEND FAILURES - "cmake/Packaging.cmake: expected current-user NSIS installation") + "packaging/windows/BongoCat.iss.in: expected current-user Inno installation") endif() file(READ "${ROOT}/cmake/Info.plist.in" MACOS_INFO) diff --git a/cmake/Packaging.cmake b/cmake/Packaging.cmake index 9514a9d6..192f8847 100644 --- a/cmake/Packaging.cmake +++ b/cmake/Packaging.cmake @@ -48,195 +48,10 @@ if(WIN32) VERBATIM) add_custom_target(package-portable DEPENDS "${BONGO_CAT_PORTABLE_EXECUTABLE}") - - # CPack's stock NSIS template requests elevation and switches administrator - # accounts to the machine-wide shell context. Generate a small, versioned - # override from the CMake-provided template so this installer is always - # current-user-only, including when the logged-in user belongs to - # Administrators. - function(bongo_cat_replace_nsis_function input_variable function_name - replacement output_variable) - set(source "${${input_variable}}") - string(FIND "${source}" "Function ${function_name}" start) - if(start LESS 0) - message(FATAL_ERROR - "Could not customize the CPack NSIS ${function_name} function") - endif() - string(SUBSTRING "${source}" ${start} -1 function_tail) - string(FIND "${function_tail}" "FunctionEnd" relative_finish) - if(relative_finish LESS 0) - message(FATAL_ERROR - "Could not find the end of the CPack NSIS ${function_name} function") - endif() - math(EXPR finish "${start} + ${relative_finish}") - math(EXPR suffix_start "${finish} + 11") - string(SUBSTRING "${source}" 0 ${start} prefix) - string(SUBSTRING "${source}" ${suffix_start} -1 suffix) - set(${output_variable} "${prefix}${replacement}${suffix}" PARENT_SCOPE) - endfunction() - - set(BONGO_CAT_CPACK_TEMPLATE_DIR - "${CMAKE_CURRENT_BINARY_DIR}/cpack-templates") - file(MAKE_DIRECTORY "${BONGO_CAT_CPACK_TEMPLATE_DIR}") - file(READ "${CMAKE_ROOT}/Modules/Internal/CPack/NSIS.template.in" - BONGO_CAT_NSIS_TEMPLATE) - string(REPLACE "\r\n" "\n" BONGO_CAT_NSIS_TEMPLATE - "${BONGO_CAT_NSIS_TEMPLATE}") - string(REPLACE [=[RequestExecutionLevel admin]=] - [=[RequestExecutionLevel user]=] BONGO_CAT_NSIS_TEMPLATE - "${BONGO_CAT_NSIS_TEMPLATE}") - string(REPLACE [=[ !include "MUI.nsh"]=] - [=[ !include "MUI.nsh" - !include "nsDialogs.nsh" - !include "WordFunc.nsh" - !include "Win\RestartManager.nsh"]=] BONGO_CAT_NSIS_TEMPLATE - "${BONGO_CAT_NSIS_TEMPLATE}") - # The only choice before installation is the destination directory. - # Shortcuts are created automatically using the fixed BongoCat folder. - string(REPLACE [=[ !insertmacro MUI_PAGE_WELCOME]=] "" - BONGO_CAT_NSIS_TEMPLATE "${BONGO_CAT_NSIS_TEMPLATE}") - string(REPLACE [=[ Page custom InstallOptionsPage]=] "" - BONGO_CAT_NSIS_TEMPLATE "${BONGO_CAT_NSIS_TEMPLATE}") - string(REPLACE - [=[ !insertmacro MUI_PAGE_STARTMENU Application $STARTMENU_FOLDER]=] - [=[ !define MUI_PAGE_CUSTOMFUNCTION_PRE bongo_cat_startmenu_page_pre - !insertmacro MUI_PAGE_STARTMENU Application $STARTMENU_FOLDER]=] - BONGO_CAT_NSIS_TEMPLATE "${BONGO_CAT_NSIS_TEMPLATE}") - string(REPLACE - [=[ !insertmacro MUI_INSTALLOPTIONS_READ $INSTALL_DESKTOP "NSIS.InstallOptions.ini" "Field 5" "State"]=] - [=[ StrCpy $INSTALL_DESKTOP "1"]=] BONGO_CAT_NSIS_TEMPLATE - "${BONGO_CAT_NSIS_TEMPLATE}") - # Silent uninstalls are used during upgrades, so user data defaults to kept. - string(REPLACE [=[ !insertmacro MUI_UNPAGE_CONFIRM]=] - [=[ UninstPage custom un.bongo_cat_uninstall_options_page \ - un.bongo_cat_uninstall_options_leave]=] BONGO_CAT_NSIS_TEMPLATE - "${BONGO_CAT_NSIS_TEMPLATE}") - string(REPLACE [=[ Var IS_DEFAULT_INSTALLDIR]=] - [=[ Var IS_DEFAULT_INSTALLDIR - Var BONGO_CAT_UPGRADE_DIR - Var BONGO_CAT_UPDATE_SHUTDOWN - Var BONGO_CAT_KEEP_USER_DATA - Var BONGO_CAT_KEEP_USER_DATA_CHECKBOX]=] BONGO_CAT_NSIS_TEMPLATE - "${BONGO_CAT_NSIS_TEMPLATE}") - string(REPLACE [=[ !insertmacro MUI_PAGE_DIRECTORY]=] - [=[ !define MUI_PAGE_CUSTOMFUNCTION_PRE bongo_cat_directory_page_pre - !insertmacro MUI_PAGE_DIRECTORY]=] BONGO_CAT_NSIS_TEMPLATE - "${BONGO_CAT_NSIS_TEMPLATE}") - string(REPLACE [=[Function InstallOptionsPage]=] - [=[Function bongo_cat_directory_page_pre - StrCmp "$BONGO_CAT_UPGRADE_DIR" "" bongo_cat_directory_page_show - Abort -bongo_cat_directory_page_show: -FunctionEnd - -Function bongo_cat_startmenu_page_pre - Abort -FunctionEnd - -Function un.bongo_cat_uninstall_options_page - !insertmacro MUI_HEADER_TEXT "Uninstall BongoCat" \ - "Choose whether to keep your user data" - nsDialogs::Create 1018 - Pop $0 - StrCmp "$0" "error" 0 bongo_cat_uninstall_options_ready - Abort -bongo_cat_uninstall_options_ready: - ${NSD_CreateLabel} 0 0 100% 24u \ - "BongoCat will be removed from this computer." - Pop $0 - ${NSD_CreateCheckbox} 0 36u 100% 18u \ - "Keep settings, imported models, and other user data" - Pop $BONGO_CAT_KEEP_USER_DATA_CHECKBOX - StrCmp "$BONGO_CAT_KEEP_USER_DATA" "1" 0 \ - bongo_cat_uninstall_options_show - ${NSD_Check} $BONGO_CAT_KEEP_USER_DATA_CHECKBOX -bongo_cat_uninstall_options_show: - nsDialogs::Show -FunctionEnd - -Function un.bongo_cat_uninstall_options_leave - ${NSD_GetState} $BONGO_CAT_KEEP_USER_DATA_CHECKBOX \ - $BONGO_CAT_KEEP_USER_DATA -FunctionEnd - -Function InstallOptionsPage]=] BONGO_CAT_NSIS_TEMPLATE - "${BONGO_CAT_NSIS_TEMPLATE}") - string(REPLACE [=[@CPACK_NSIS_CREATE_ICONS_EXTRA@]=] [=[ - SetOutPath "$SMPROGRAMS\$STARTMENU_FOLDER" - CreateShortCut "$SMPROGRAMS\$STARTMENU_FOLDER\BongoCat.lnk" \ - "$INSTDIR\.\BongoCat.exe" \ - "--nearby-root=$\"$SMPROGRAMS\$STARTMENU_FOLDER$\"" - StrCmp "$INSTALL_DESKTOP" "1" bongo_cat_create_desktop_shortcut \ - bongo_cat_shortcuts_done -bongo_cat_create_desktop_shortcut: - SetOutPath "$DESKTOP" - CreateShortCut "$DESKTOP\BongoCat.lnk" "$INSTDIR\.\BongoCat.exe" \ - "--nearby-root=$\"$DESKTOP$\"" -bongo_cat_shortcuts_done: - SetOutPath "$INSTDIR" -]=] BONGO_CAT_NSIS_TEMPLATE "${BONGO_CAT_NSIS_TEMPLATE}") - string(REPLACE [=[@CPACK_NSIS_EXTRA_INSTALL_COMMANDS@]=] [=[ - WriteRegStr SHCTX \ - "Software\Microsoft\Windows\CurrentVersion\Uninstall\BongoCat" \ - "InstallLocation" "$INSTDIR" -]=] BONGO_CAT_NSIS_TEMPLATE "${BONGO_CAT_NSIS_TEMPLATE}") - string(REPLACE [=[@CPACK_NSIS_EXTRA_UNINSTALL_COMMANDS@]=] [=[ - StrCmp "$BONGO_CAT_KEEP_USER_DATA" "1" bongo_cat_user_data_done - RMDir /r "$LOCALAPPDATA\BongoCat" -bongo_cat_user_data_done: -]=] BONGO_CAT_NSIS_TEMPLATE "${BONGO_CAT_NSIS_TEMPLATE}") - string(REPLACE [=[@CPACK_NSIS_EXTRA_PREINSTALL_COMMANDS@]=] [=[ - StrCmp "$BONGO_CAT_UPGRADE_DIR" "" bongo_cat_upgrade_ready - StrCmp "$BONGO_CAT_UPDATE_SHUTDOWN" "1" \ - bongo_cat_signal_update_shutdown bongo_cat_restart_manager_shutdown - -bongo_cat_signal_update_shutdown: - ClearErrors - ExecWait '"$BONGO_CAT_UPGRADE_DIR\BongoCat.exe" --shutdown-for-update' $3 - IfErrors bongo_cat_restart_manager_shutdown - StrCmp "$3" "0" bongo_cat_uninstall_old \ - bongo_cat_restart_manager_shutdown - -bongo_cat_restart_manager_shutdown: - ; Older versions lack the update shutdown command. Use Windows Restart - ; Manager for this exact executable instead of probing tray windows and - ; sending guessed menu commands to legacy processes. If shutdown fails, - ; leave the installation intact and ask the user to close the application. - !insertmacro RestartManager_ShutdownFile \ - "$BONGO_CAT_UPGRADE_DIR\BongoCat.exe" $9 - StrCmp "$9" "0" bongo_cat_uninstall_old bongo_cat_upgrade_failed - -bongo_cat_uninstall_old: - IfFileExists "$BONGO_CAT_UPGRADE_DIR\Uninstall.exe" 0 \ - bongo_cat_upgrade_ready - ClearErrors - ExecWait '"$BONGO_CAT_UPGRADE_DIR\Uninstall.exe" /S \ - _?=$BONGO_CAT_UPGRADE_DIR' $3 - IfErrors bongo_cat_upgrade_failed - StrCmp "$3" "0" bongo_cat_upgrade_ready bongo_cat_upgrade_failed - -bongo_cat_upgrade_failed: - MessageBox MB_OK|MB_ICONSTOP \ - "BongoCat could not be updated. Close the running application and try again." - Abort - -bongo_cat_upgrade_ready: - SetOutPath "$INSTDIR" -]=] BONGO_CAT_NSIS_TEMPLATE "${BONGO_CAT_NSIS_TEMPLATE}") - include(cmake/PackagingWindowsInit.cmake) endif() include(CPack) include(cmake/PackagingAppImage.cmake) - if(WIN32) - add_custom_target(package-installer - COMMAND "${CMAKE_CPACK_COMMAND}" -G NSIS -C "$" - --config "${CMAKE_BINARY_DIR}/CPackConfig.cmake" - -D "CPACK_PACKAGE_FILE_NAME=${BONGO_CAT_PACKAGE_NAME}-setup" - -D "CPACK_INCLUDE_TOPLEVEL_DIRECTORY=OFF" - DEPENDS bongo_cat - WORKING_DIRECTORY "${CMAKE_BINARY_DIR}" - COMMENT "Building the BongoCat Windows installer" - VERBATIM) + include(cmake/PackagingInno.cmake) endif() diff --git a/cmake/PackagingInno.cmake b/cmake/PackagingInno.cmake new file mode 100644 index 00000000..375a83b9 --- /dev/null +++ b/cmake/PackagingInno.cmake @@ -0,0 +1,14 @@ +# Locate ISCC at package time: CI installs it after configuring the application. +foreach(template BongoCat install-lifecycle) + configure_file("${CMAKE_CURRENT_SOURCE_DIR}/packaging/windows/${template}.iss.in" + "${CMAKE_CURRENT_BINARY_DIR}/${template}.iss" @ONLY) +endforeach() +add_custom_target(package-installer + COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass + -File "${CMAKE_CURRENT_SOURCE_DIR}/packaging/windows/build-installer.ps1" + -BuildDir "${CMAKE_BINARY_DIR}" -Configuration "$" + -PackageName "${BONGO_CAT_PACKAGE_NAME}" + DEPENDS bongo_cat + WORKING_DIRECTORY "${CMAKE_BINARY_DIR}" + COMMENT "Building the BongoCat Inno Setup installer" + VERBATIM) diff --git a/cmake/PackagingPlatform.cmake b/cmake/PackagingPlatform.cmake index 9692beac..c2640ac5 100644 --- a/cmake/PackagingPlatform.cmake +++ b/cmake/PackagingPlatform.cmake @@ -23,32 +23,6 @@ if(APPLE) elseif(WIN32) set(BONGO_CAT_PACKAGE_PLATFORM "windows-${BONGO_CAT_PACKAGE_ARCH}") set(CPACK_GENERATOR "ZIP") - set(CPACK_PACKAGE_INSTALL_DIRECTORY "BongoCat") - set(CPACK_PACKAGE_INSTALL_REGISTRY_KEY "BongoCat") - set(CPACK_MONOLITHIC_INSTALL ON) - set(CPACK_COMPONENT_INSTALL OFF) - set(CPACK_PACKAGE_EXECUTABLES "BongoCat" "BongoCat") - set(CPACK_CREATE_DESKTOP_LINKS "BongoCat") - set(CPACK_NSIS_EXECUTABLES_DIRECTORY ".") - set(CPACK_NSIS_INSTALL_ROOT "$LOCALAPPDATA/Programs") - set(CPACK_NSIS_DISPLAY_NAME "BongoCat") - set(CPACK_NSIS_PACKAGE_NAME "BongoCat") - # LZMA-packed NSIS payloads are frequently classified as cryptors by - # antivirus heuristics. zlib changes only installer compression, not the - # packaged files or installation behavior. - set(CPACK_NSIS_COMPRESSOR "zlib") - set(CPACK_NSIS_INSTALLED_ICON_NAME "BongoCat.exe") - set(CPACK_NSIS_MUI_ICON - "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/icon.ico") - set(CPACK_NSIS_MUI_UNIICON - "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/icon.ico") - set(CPACK_NSIS_IGNORE_LICENSE_PAGE ON) - set(CPACK_NSIS_ENABLE_UNINSTALL_BEFORE_INSTALL OFF) - set(CPACK_NSIS_MODIFY_PATH OFF) - set(CPACK_NSIS_MUI_FINISHPAGE_RUN "BongoCat.exe") - set(CPACK_NSIS_HELP_LINK "${PROJECT_HOMEPAGE_URL}") - set(CPACK_NSIS_URL_INFO_ABOUT "${PROJECT_HOMEPAGE_URL}") - set(CPACK_NSIS_MANIFEST_DPI_AWARE ON) else() if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(arm64|aarch64|ARM64)$") set(BONGO_CAT_PACKAGE_ARCH "arm64") diff --git a/cmake/PackagingWindowsInit.cmake b/cmake/PackagingWindowsInit.cmake deleted file mode 100644 index 835e5099..00000000 --- a/cmake/PackagingWindowsInit.cmake +++ /dev/null @@ -1,77 +0,0 @@ -bongo_cat_replace_nsis_function(BONGO_CAT_NSIS_TEMPLATE ".onInit" - [=[Function .onInit - SetShellVarContext current - StrCpy $STARTMENU_FOLDER "BongoCat" - StrCpy $INSTALL_DESKTOP "1" - StrCpy $BONGO_CAT_UPGRADE_DIR "" - StrCpy $BONGO_CAT_UPDATE_SHUTDOWN "" - - ReadRegStr $0 SHCTX \ - "Software\Microsoft\Windows\CurrentVersion\Uninstall\BongoCat" \ - "DisplayVersion" - StrCmp "$0" "" bongo_cat_check_legacy - ReadRegStr $1 SHCTX \ - "Software\Microsoft\Windows\CurrentVersion\Uninstall\BongoCat" \ - "InstallLocation" - StrCmp "$1" "" 0 bongo_cat_compare_versions - ReadRegStr $1 SHCTX "Software\BongoCat\BongoCat" "" -bongo_cat_compare_versions: - StrCmp "$1" "" bongo_cat_install - IfFileExists "$1\BongoCat.exe" 0 bongo_cat_install - ${VersionCompare} "$0" "@CPACK_PACKAGE_VERSION@" $2 - StrCmp "$2" "2" bongo_cat_upgrade - Goto bongo_cat_launch_installed - -bongo_cat_check_legacy: - GetFullPathName $1 "$LOCALAPPDATA\..\LocalPrograms\BongoCat" - IfFileExists "$1\BongoCat.exe" 0 bongo_cat_install - ClearErrors - GetDLLVersion "$1\BongoCat.exe" $6 $7 - IfErrors bongo_cat_install - IntOp $0 $6 >> 16 - IntOp $8 $6 & 0x0000FFFF - IntOp $9 $7 >> 16 - StrCpy $0 "$0.$8.$9" - ${VersionCompare} "$0" "@CPACK_PACKAGE_VERSION@" $2 - StrCmp "$2" "2" bongo_cat_upgrade_legacy - -bongo_cat_launch_installed: - ClearErrors - Exec '"$1\BongoCat.exe"' - IfErrors bongo_cat_launch_failed - Quit - -bongo_cat_launch_failed: - MessageBox MB_OK|MB_ICONSTOP \ - "The installed BongoCat could not be started." - Quit - -bongo_cat_upgrade: - ; 0.1.0 treats an unknown update argument as a normal launch. Waiting for - ; that process would block forever when the application was not running. - ${VersionCompare} "$0" "0.1.1" $2 - StrCmp "$2" "2" bongo_cat_upgrade_paths - StrCpy $BONGO_CAT_UPDATE_SHUTDOWN "1" -bongo_cat_upgrade_paths: - StrCpy $BONGO_CAT_UPGRADE_DIR "$1" - StrCpy $INSTDIR "$1" - Goto bongo_cat_install - -bongo_cat_upgrade_legacy: - StrCpy $BONGO_CAT_UPGRADE_DIR "$1" - StrCpy $INSTDIR "$1" - Goto bongo_cat_install - -bongo_cat_install: - SetShellVarContext current -FunctionEnd]=] - BONGO_CAT_NSIS_TEMPLATE) -bongo_cat_replace_nsis_function(BONGO_CAT_NSIS_TEMPLATE "un.onInit" - [=[Function un.onInit - SetShellVarContext current - StrCpy $BONGO_CAT_KEEP_USER_DATA "1" -FunctionEnd]=] - BONGO_CAT_NSIS_TEMPLATE) -file(WRITE "${BONGO_CAT_CPACK_TEMPLATE_DIR}/NSIS.template.in" - "${BONGO_CAT_NSIS_TEMPLATE}") -list(PREPEND CMAKE_MODULE_PATH "${BONGO_CAT_CPACK_TEMPLATE_DIR}") diff --git a/packaging/windows/BongoCat.iss.in b/packaging/windows/BongoCat.iss.in new file mode 100644 index 00000000..f8236555 --- /dev/null +++ b/packaging/windows/BongoCat.iss.in @@ -0,0 +1,46 @@ +#ifndef PayloadDir + #error PayloadDir must point to the staged CMake Runtime component +#endif + +[Setup] +AppId=BongoCat +AppName=@BONGO_CAT_PACKAGE_PRODUCT@ +AppVersion=@PROJECT_VERSION@ +AppPublisher=vladelaina +AppPublisherURL=@PROJECT_HOMEPAGE_URL@ +AppSupportURL=@PROJECT_HOMEPAGE_URL@ +DefaultDirName={code:GetInstallDir} +DefaultGroupName=BongoCat +PrivilegesRequired=lowest +DisableProgramGroupPage=yes +DisableWelcomePage=yes +DisableDirPage=auto +UsePreviousAppDir=yes +OutputDir=@CMAKE_BINARY_DIR@/dist +OutputBaseFilename=@BONGO_CAT_PACKAGE_NAME@-setup +SetupIconFile=@CMAKE_CURRENT_SOURCE_DIR@/resources/icons/icon.ico +UninstallDisplayIcon={app}\BongoCat.exe +Compression=zip +WizardStyle=modern +CloseApplications=yes +CloseApplicationsFilter=BongoCat.exe +RestartApplications=no +SetupLogging=yes +#if "@BONGO_CAT_PACKAGE_ARCH@" == "x64" +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +#endif + +[Files] +Source: "{#PayloadDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +Name: "{userdesktop}\BongoCat"; Filename: "{app}\BongoCat.exe"; Parameters: "--nearby-root=""{userdesktop}"""; WorkingDir: "{userdesktop}" +Name: "{userprograms}\BongoCat\BongoCat"; Filename: "{app}\BongoCat.exe"; Parameters: "--nearby-root=""{userprograms}\BongoCat"""; WorkingDir: "{userprograms}\BongoCat" +Name: "{userprograms}\BongoCat\Uninstall"; Filename: "{uninstallexe}" + +[Run] +Filename: "{app}\BongoCat.exe"; Description: "Launch BongoCat"; Flags: nowait postinstall skipifsilent + +[Code] +#include "install-lifecycle.iss" diff --git a/packaging/windows/build-installer.ps1 b/packaging/windows/build-installer.ps1 new file mode 100644 index 00000000..9cfa0db5 --- /dev/null +++ b/packaging/windows/build-installer.ps1 @@ -0,0 +1,28 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$BuildDir, + [Parameter(Mandatory = $true)][string]$Configuration, + [Parameter(Mandatory = $true)][string]$PackageName +) +$ErrorActionPreference = 'Stop' +$iscc = & "$PSScriptRoot/find-inno.ps1" +# A fresh staging directory avoids shipping files left by an older build. +$stage = Join-Path $BuildDir ('inno-stage-' + [Guid]::NewGuid().ToString('N')) +try { + & cmake --install $BuildDir --config $Configuration --component Runtime --prefix $stage + if ($LASTEXITCODE -ne 0) { throw 'Could not stage the installer payload.' } + & $iscc "/DPayloadDir=$stage" (Join-Path $BuildDir 'BongoCat.iss') + if ($LASTEXITCODE -ne 0) { throw 'Inno Setup packaging failed.' } + $output = Join-Path $BuildDir "dist/$PackageName-setup.exe" + $hash = (Get-FileHash -LiteralPath $output -Algorithm SHA256).Hash.ToLowerInvariant() + "$hash $PackageName-setup.exe" | Set-Content -LiteralPath "$output.sha256" -Encoding ascii +} finally { + # Only remove the unique stage directory directly below the build directory. + $stageFull = [IO.Path]::GetFullPath($stage) + $buildFull = [IO.Path]::GetFullPath($BuildDir).TrimEnd('\', '/') + if ((Split-Path $stageFull -Parent) -eq $buildFull -and + (Split-Path $stageFull -Leaf) -match '^inno-stage-[0-9a-f]{32}$' -and + (Test-Path -LiteralPath $stageFull)) { + Remove-Item -LiteralPath $stageFull -Recurse -Force + } +} diff --git a/packaging/windows/find-inno.ps1 b/packaging/windows/find-inno.ps1 new file mode 100644 index 00000000..751b4689 --- /dev/null +++ b/packaging/windows/find-inno.ps1 @@ -0,0 +1,54 @@ +[CmdletBinding()] +param([switch]$PassThru) +$ErrorActionPreference = 'Stop' +$candidates = @() +$registeredVersions = @{} +if ($env:BONGO_CAT_ISCC) { $candidates += $env:BONGO_CAT_ISCC.Trim().Trim('"') } +$command = Get-Command ISCC.exe -ErrorAction SilentlyContinue +if ($command) { $candidates += $command.Source } +foreach ($base in @(${env:ProgramFiles(x86)}, $env:ProgramFiles, "$env:LOCALAPPDATA\Programs")) { + if ($base) { $candidates += Join-Path $base 'Inno Setup 6\ISCC.exe' } +} +foreach ($key in @( + 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\Inno Setup 6_is1', + 'HKCU:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\Inno Setup 6_is1', + 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\Inno Setup 6_is1', + 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\Inno Setup 6_is1')) { + $entry = Get-ItemProperty -LiteralPath $key -ErrorAction SilentlyContinue + if ($entry -and $entry.InstallLocation) { + $path = [IO.Path]::GetFullPath((Join-Path $entry.InstallLocation.Trim().Trim('"') 'ISCC.exe')) + $candidates += $path + $registeredVersions[$path] = [string]$entry.DisplayVersion + } +} +$rejected = @() +foreach ($candidate in ($candidates | Select-Object -Unique)) { + if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue } + $path = (Resolve-Path -LiteralPath $candidate).Path + $fileVersion = (Get-Item -LiteralPath $path).VersionInfo + $version = [version]::new($fileVersion.FileMajorPart, $fileVersion.FileMinorPart, + $fileVersion.FileBuildPart, $fileVersion.FilePrivatePart) + $versionSource = 'FileVersion' + # Some official builds report 0.0.0.0 in ISCC.exe (and ISCmplr.dll). + # Use DisplayVersion only from a registration for this exact compiler path. + if ($version.Major -eq 0 -and $registeredVersions.ContainsKey($path)) { + $parsed = $null + if ([version]::TryParse($registeredVersions[$path], [ref]$parsed)) { + $version = $parsed + $versionSource = 'Registry DisplayVersion' + } + } + if ($version.Major -eq 6 -and $version.Minor -ge 3) { + if ($PassThru) { + return [PSCustomObject]@{ + Path = $path + Version = $version + VersionSource = $versionSource + } + } + return $path + } + $rejected += "$path (version $version, $versionSource)" +} +$details = if ($rejected.Count) { " Found but rejected: $($rejected -join '; ')." } else { '' } +throw "Inno Setup 6.3 or newer (6.x) is required for packaging. Install from https://jrsoftware.org/isdl.php or set BONGO_CAT_ISCC to ISCC.exe. Normal application builds do not require Inno Setup.$details" diff --git a/packaging/windows/inno-version.txt b/packaging/windows/inno-version.txt new file mode 100644 index 00000000..133cad28 --- /dev/null +++ b/packaging/windows/inno-version.txt @@ -0,0 +1 @@ +6.4.3 diff --git a/packaging/windows/install-lifecycle.iss.in b/packaging/windows/install-lifecycle.iss.in new file mode 100644 index 00000000..8c149d34 --- /dev/null +++ b/packaging/windows/install-lifecycle.iss.in @@ -0,0 +1,147 @@ +{ Installation discovery, upgrade migration, and uninstall data policy. } +const + UninstallBase = 'Software\Microsoft\Windows\CurrentVersion\Uninstall\'; +var + ExistingDir: String; + RemoveUserData: Boolean; + +function CompareVersions(Left, Right: String): Integer; +var + LeftVersion, RightVersion: Int64; +begin + if not StrToVersion(Left, LeftVersion) then + RaiseException('Invalid installed version: ' + Left); + if not StrToVersion(Right, RightVersion) then + RaiseException('Invalid package version: ' + Right); + Result := ComparePackedVersion(LeftVersion, RightVersion); +end; + +function RegisteredDir(Root: Integer; Key: String): String; +begin + Result := ''; + RegQueryStringValue(Root, UninstallBase + Key, 'InstallLocation', Result); + if (Result = '') and (Key = 'BongoCat') then + RegQueryStringValue(Root, 'Software\BongoCat\BongoCat', '', Result); + Result := RemoveBackslashUnlessRoot(RemoveQuotes(Result)); + if not FileExists(Result + '\BongoCat.exe') then Result := ''; +end; + +function FindExistingDir: String; +begin + { Prefer Inno's registration after the first migration, in either view. } + Result := RegisteredDir(HKCU32, 'BongoCat_is1'); + if (Result = '') and IsWin64 then Result := RegisteredDir(HKCU64, 'BongoCat_is1'); + if Result = '' then Result := RegisteredDir(HKCU32, 'BongoCat'); + if (Result = '') and IsWin64 then Result := RegisteredDir(HKCU64, 'BongoCat'); + if Result = '' then begin + Result := ExpandConstant('{localappdata}\Programs\BongoCat'); + if not FileExists(Result + '\BongoCat.exe') then Result := ''; + end; + if Result = '' then begin + Result := ExpandConstant('{localappdata}\..\LocalPrograms\BongoCat'); + if not FileExists(Result + '\BongoCat.exe') then Result := ''; + end; +end; + +function GetInstallDir(Param: String): String; +begin + if ExistingDir <> '' then Result := ExistingDir + else Result := ExpandConstant('{localappdata}\Programs\BongoCat'); +end; + +function InitializeSetup: Boolean; +var + Version: String; +begin + ExistingDir := FindExistingDir; + Result := True; + { Equal versions must be allowed: a same-version NSIS -> Inno migration + still has to replace the installer registration. Never downgrade. } + if (ExistingDir <> '') and + GetVersionNumbersString(ExistingDir + '\BongoCat.exe', Version) then + if CompareVersions(Version, '@PROJECT_VERSION@') > 0 then begin + SuppressibleMsgBox('A newer version of BongoCat is already installed.', mbError, MB_OK, IDOK); + Result := False; + end; +end; + +procedure InitializeWizard; +begin + if ExistingDir <> '' then begin + WizardForm.DirEdit.Text := ExistingDir; + WizardForm.DirEdit.Enabled := False; + WizardForm.DirBrowseButton.Enabled := False; + end; +end; + +function ShouldSkipPage(PageID: Integer): Boolean; +begin + Result := (PageID = wpSelectDir) and (ExistingDir <> ''); +end; + +function PrepareToInstall(var NeedsRestart: Boolean): String; +var + App, Version: String; + ExitCode: Integer; +begin + Result := ''; + if (ExistingDir <> '') and + (CompareText(RemoveBackslashUnlessRoot(ExpandConstant('{app}')), + RemoveBackslashUnlessRoot(ExistingDir)) <> 0) then begin + Result := 'An upgrade must use the existing BongoCat installation directory.'; + Exit; + end; + App := ExpandConstant('{app}\BongoCat.exe'); + if GetVersionNumbersString(App, Version) then begin + if CompareVersions(Version, '@PROJECT_VERSION@') > 0 then begin + Result := 'A newer version of BongoCat is already installed in this directory.'; + Exit; + end; + { 0.1.0 treats unknown switches as a normal launch and would wait forever. + Inno Restart Manager handles that version and any shutdown failures. } + if CompareVersions(Version, '0.1.1') >= 0 then + if not Exec(App, '--shutdown-for-update', ExpandConstant('{app}'), + SW_HIDE, ewWaitUntilTerminated, ExitCode) then + Log('Update shutdown unavailable; using Restart Manager.'); + end; +end; + +procedure RemoveLegacyRegistration(Root: Integer); +var + Path: String; +begin + Path := RegisteredDir(Root, 'BongoCat'); + if (Path <> '') and + (CompareText(Path, RemoveBackslashUnlessRoot(ExpandConstant('{app}'))) = 0) then begin + if not RegDeleteKeyIncludingSubkeys(Root, UninstallBase + 'BongoCat') then + Log('Could not remove the legacy NSIS uninstall registration.'); + RegDeleteKeyIncludingSubkeys(Root, 'Software\BongoCat\BongoCat'); + end; +end; + +procedure CurStepChanged(CurStep: TSetupStep); +begin + if CurStep = ssPostInstall then begin + { Only retire NSIS after successful installation. Do not run its uninstaller: + old releases may delete user files. Inno owns only its installed payload. } + RemoveLegacyRegistration(HKCU32); + if IsWin64 then RemoveLegacyRegistration(HKCU64); + DeleteFile(ExpandConstant('{app}\Uninstall.exe')); + DeleteFile(ExpandConstant('{userprograms}\BongoCat\Uninstall BongoCat.lnk')); + end; +end; + +function InitializeUninstall: Boolean; +begin + RemoveUserData := False; + if not UninstallSilent then + RemoveUserData := MsgBox('Also delete settings, imported models, and other BongoCat user data?' + + #13#10 + 'Choose No to keep your data.', mbConfirmation, MB_YESNO or MB_DEFBUTTON2) = IDYES; + Result := True; +end; + +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +begin + if (CurUninstallStep = usPostUninstall) and RemoveUserData then + DelTree(ExpandConstant('{localappdata}\BongoCat'), True, True, True); +end; diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 index 59d9ee6a..821dcd8d 100644 --- a/scripts/build-windows.ps1 +++ b/scripts/build-windows.ps1 @@ -23,7 +23,6 @@ Remove-Item Env:PATH -ErrorAction SilentlyContinue $env:Path = $canonicalPath $env:VSLANG = '1033' $env:MSBUILDDISABLENODEREUSE = '1' -$requiredNsisCompilerVersion = 'v3.12' $root = [IO.Path]::GetFullPath((Split-Path $PSScriptRoot -Parent)) if (-not $BuildDir) { $BuildDir = Join-Path $root 'build-cubism' } if (-not [IO.Path]::IsPathRooted($BuildDir)) { @@ -81,75 +80,20 @@ function Write-GitHubBuildAnnotations { } } -function Test-NsisCompiler { - param([string]$Path) - if (-not $Path -or - -not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $false } - try { - $versionOutput = @(& $Path /VERSION 2>&1) - $versionStatus = $LASTEXITCODE - } catch { - return $false - } - if ($versionStatus -ne 0) { return $false } - $versionText = ($versionOutput | ForEach-Object { $_.ToString() }) -join "`n" - return $versionText.Trim() -eq $script:requiredNsisCompilerVersion -} - if (-not (Get-Command cmake -ErrorAction SilentlyContinue)) { Write-Host 'Error: CMake was not found in PATH.' exit 1 } -if ($Package) { - $makensis = Get-Command makensis.exe -ErrorAction SilentlyContinue - $makensisPath = if ($makensis -and - (Test-NsisCompiler $makensis.Source)) { $makensis.Source } else { $null } - if (-not $makensisPath) { - $nsisCandidates = @() - foreach ($programFiles in @(${env:ProgramFiles(x86)}, $env:ProgramFiles)) { - if ($programFiles) { - $nsisCandidates += Join-Path $programFiles 'NSIS\makensis.exe' - $nsisCandidates += Join-Path $programFiles 'NSIS\Bin\makensis.exe' - } - } - $uninstallKeys = @( - 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', - 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', - 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*') - foreach ($key in $uninstallKeys) { - Get-ItemProperty -Path $key -Name InstallLocation ` - -ErrorAction SilentlyContinue | ForEach-Object { - if ($_.InstallLocation) { - # Some uninstall registry entries store the path with - # surrounding quotes (for example, `"C:\Program Files\NSIS"`). - # Strip those quotes before passing it to Join-Path; - # otherwise PowerShell interprets the drive as `"C`. - $installLocation = ([string]$_.InstallLocation).Trim().Trim('"') - if ($installLocation) { - $nsisCandidates += Join-Path $installLocation 'makensis.exe' - $nsisCandidates += Join-Path $installLocation 'Bin\makensis.exe' - } - } - } - } - $nsisPath = $nsisCandidates | - Select-Object -Unique | - Where-Object { Test-NsisCompiler $_ } | - Select-Object -First 1 - if ($nsisPath) { - $env:Path = "$(Split-Path $nsisPath -Parent);$env:Path" - $makensisPath = $nsisPath - } - } - if (-not $makensisPath) { - Write-Host 'Package build requires a working NSIS compiler (makensis.exe).' - Write-Host "NSIS v$($requiredNsisCompilerVersion.TrimStart('v')) was not found or failed its /VERSION check." - Write-Host 'Install or repair NSIS from https://nsis.sourceforge.io/Download and run again.' - Write-Host 'The normal application build does not require NSIS.' +if ($Package -or $Target -contains 'package-installer') { + try { + $isccPath = & (Join-Path $root 'packaging/windows/find-inno.ps1') + $env:Path = "$(Split-Path $isccPath -Parent);$env:Path" + Write-Host "Inno Setup compiler: $isccPath" + } catch { + Write-Host $_.Exception.Message exit 1 } - Write-Host "NSIS compiler: $makensisPath" } if ($Clean -and (Test-Path -LiteralPath $BuildDir)) { @@ -315,8 +259,8 @@ if ($Package) { $packageStatus = $LASTEXITCODE if ($packageStatus -ne 0) { Write-Host 'Package generation failed.' - Write-Host 'Ensure NSIS is installed and available in PATH for the installer.' - Write-Host "CPack configuration: $(Join-Path $BuildDir 'CPackConfig.cmake')" + Write-Host 'Ensure Inno Setup 6.3 or newer is installed (ISCC.exe).' + Write-Host "Packaging build directory: $BuildDir" exit $packageStatus } $packageNameFile = Join-Path $BuildDir 'bongocat-package-name.txt' diff --git a/src/runtime/update/update_channel.c b/src/runtime/update/update_channel.c index 06bee423..0a64f5ae 100644 --- a/src/runtime/update/update_channel.c +++ b/src/runtime/update/update_channel.c @@ -29,21 +29,27 @@ bool bongo_cat_update_platform_installed(void) { const DWORD views[] = { RRF_SUBKEY_WOW6464KEY, RRF_SUBKEY_WOW6432KEY }; - for (size_t index = 0; index < _countof(views); ++index) { - wchar_t install_root[2048] = {0}; - DWORD size = sizeof(install_root); - LSTATUS result = RegGetValueW(HKEY_CURRENT_USER, - L"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\BongoCat", - L"InstallLocation", RRF_RT_REG_SZ | views[index], NULL, - install_root, &size); - if (result != ERROR_SUCCESS || !install_root[0]) continue; - size_t length = wcslen(install_root); - while (length && (install_root[length - 1] == L'\\' || - install_root[length - 1] == L'/')) install_root[--length] = L'\0'; - static const wchar_t executable[] = L"\\BongoCat.exe"; - if (length + _countof(executable) > _countof(install_root)) continue; - memcpy(install_root + length, executable, sizeof(executable)); - if (_wcsicmp(running, install_root) == 0) return true; + const wchar_t *keys[] = { + L"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\BongoCat_is1", + L"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\BongoCat" + }; + for (size_t key = 0; key < _countof(keys); ++key) { + for (size_t index = 0; index < _countof(views); ++index) { + wchar_t install_root[2048] = {0}; + DWORD size = sizeof(install_root); + LSTATUS result = RegGetValueW(HKEY_CURRENT_USER, + keys[key], + L"InstallLocation", RRF_RT_REG_SZ | views[index], NULL, + install_root, &size); + if (result != ERROR_SUCCESS || !install_root[0]) continue; + size_t length = wcslen(install_root); + while (length && (install_root[length - 1] == L'\\' || + install_root[length - 1] == L'/')) install_root[--length] = L'\0'; + static const wchar_t executable[] = L"\\BongoCat.exe"; + if (length + _countof(executable) > _countof(install_root)) continue; + memcpy(install_root + length, executable, sizeof(executable)); + if (_wcsicmp(running, install_root) == 0) return true; + } } return false; } From fbcaa31395a5f01d2704897ec93ac35c287b5440 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 17 Sep 2026 16:17:29 +0800 Subject: [PATCH 308/334] fix: github action --- packaging/windows/BongoCat.iss.in | 2 ++ packaging/windows/inno-version.txt | 2 +- src/ui/rendering/ui_native_theme.c | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packaging/windows/BongoCat.iss.in b/packaging/windows/BongoCat.iss.in index f8236555..e2fc9398 100644 --- a/packaging/windows/BongoCat.iss.in +++ b/packaging/windows/BongoCat.iss.in @@ -6,6 +6,8 @@ AppId=BongoCat AppName=@BONGO_CAT_PACKAGE_PRODUCT@ AppVersion=@PROJECT_VERSION@ +VersionInfoVersion=@PROJECT_VERSION@ +VersionInfoTextVersion=@PROJECT_VERSION@ AppPublisher=vladelaina AppPublisherURL=@PROJECT_HOMEPAGE_URL@ AppSupportURL=@PROJECT_HOMEPAGE_URL@ diff --git a/packaging/windows/inno-version.txt b/packaging/windows/inno-version.txt index 133cad28..1d420242 100644 --- a/packaging/windows/inno-version.txt +++ b/packaging/windows/inno-version.txt @@ -1 +1 @@ -6.4.3 +6.7.1 \ No newline at end of file diff --git a/src/ui/rendering/ui_native_theme.c b/src/ui/rendering/ui_native_theme.c index b669db35..5b646500 100644 --- a/src/ui/rendering/ui_native_theme.c +++ b/src/ui/rendering/ui_native_theme.c @@ -56,7 +56,8 @@ static HWND native_window(SDL_Window *window) { static void initialize(void) { if (initialized) return; initialized = true; - HMODULE theme = LoadLibraryW(L"uxtheme.dll"); + HMODULE theme = LoadLibraryExW(L"uxtheme.dll", NULL, + LOAD_LIBRARY_SEARCH_SYSTEM32); load_named(theme, "SetWindowTheme", &set_window_theme, sizeof(set_window_theme)); if (windows_build() < 18362) return; From 2aacf0be65f83ec818186a4e3ee2948a479dde49 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 17 Sep 2026 17:23:45 +0800 Subject: [PATCH 309/334] fix: github action --- .github/workflows/ci.yml | 8 +++ .github/workflows/release.yml | 8 +++ packaging/windows/build-installer.ps1 | 71 ++++++++++++++++++++------- 3 files changed, 69 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e23b02f6..5317d3fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -406,6 +406,14 @@ jobs: "portable=$portable" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 "file=$setup" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + - name: Upload Windows installer failure log + if: failure() && runner.os == 'Windows' + uses: actions/upload-artifact@v7 + with: + name: installer-log-${{ matrix.platform }}-${{ github.run_attempt }} + path: build-store/installer.log + if-no-files-found: ignore + - name: Build Microsoft Store MSIX if: matrix.store_arch == 'x64' shell: pwsh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 366a3553..7998af92 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -182,6 +182,14 @@ jobs: Copy-Item -LiteralPath $portable, $portableChecksum, $setup, ` $setupChecksum -Destination release-output + - name: Upload Windows installer failure log + if: failure() && runner.os == 'Windows' + uses: actions/upload-artifact@v7 + with: + name: installer-log-${{ matrix.platform }}-${{ github.run_attempt }} + path: build-release/installer.log + if-no-files-found: ignore + - name: Package Unix release if: runner.os != 'Windows' timeout-minutes: 5 diff --git a/packaging/windows/build-installer.ps1 b/packaging/windows/build-installer.ps1 index 9cfa0db5..26dd311d 100644 --- a/packaging/windows/build-installer.ps1 +++ b/packaging/windows/build-installer.ps1 @@ -5,24 +5,59 @@ param( [Parameter(Mandatory = $true)][string]$PackageName ) $ErrorActionPreference = 'Stop' -$iscc = & "$PSScriptRoot/find-inno.ps1" -# A fresh staging directory avoids shipping files left by an older build. -$stage = Join-Path $BuildDir ('inno-stage-' + [Guid]::NewGuid().ToString('N')) +$BuildDir = [IO.Path]::GetFullPath($BuildDir) +$log = Join-Path $BuildDir 'installer.log' +Set-Content -LiteralPath $log -Value 'BongoCat installer packaging' -Encoding utf8 + +function Invoke-PackagingCommand { + param([string]$Executable, [string[]]$Arguments) + # Windows PowerShell wraps native stderr as ErrorRecord objects. Keep + # collecting output until the process exits, then check its actual status. + $ErrorActionPreference = 'Continue' + & $Executable @Arguments 2>&1 | ForEach-Object { + $line = $_.ToString() + Add-Content -LiteralPath $log -Value $line -Encoding utf8 -ErrorAction Stop + Write-Host $line + } + $status = $LASTEXITCODE + if ($status -ne 0) { + throw "$(Split-Path $Executable -Leaf) failed (exit $status). Log: $log" + } +} + try { - & cmake --install $BuildDir --config $Configuration --component Runtime --prefix $stage - if ($LASTEXITCODE -ne 0) { throw 'Could not stage the installer payload.' } - & $iscc "/DPayloadDir=$stage" (Join-Path $BuildDir 'BongoCat.iss') - if ($LASTEXITCODE -ne 0) { throw 'Inno Setup packaging failed.' } - $output = Join-Path $BuildDir "dist/$PackageName-setup.exe" - $hash = (Get-FileHash -LiteralPath $output -Algorithm SHA256).Hash.ToLowerInvariant() - "$hash $PackageName-setup.exe" | Set-Content -LiteralPath "$output.sha256" -Encoding ascii -} finally { - # Only remove the unique stage directory directly below the build directory. - $stageFull = [IO.Path]::GetFullPath($stage) - $buildFull = [IO.Path]::GetFullPath($BuildDir).TrimEnd('\', '/') - if ((Split-Path $stageFull -Parent) -eq $buildFull -and - (Split-Path $stageFull -Leaf) -match '^inno-stage-[0-9a-f]{32}$' -and - (Test-Path -LiteralPath $stageFull)) { - Remove-Item -LiteralPath $stageFull -Recurse -Force + $iscc = & "$PSScriptRoot/find-inno.ps1" + Write-Host "Inno Setup compiler: $iscc" + # A fresh staging directory avoids shipping files left by an older build. + $stage = Join-Path $BuildDir ('inno-stage-' + [Guid]::NewGuid().ToString('N')) + try { + Invoke-PackagingCommand -Executable 'cmake' -Arguments @( + '--install', $BuildDir, '--config', $Configuration, + '--component', 'Runtime', '--prefix', $stage) + Invoke-PackagingCommand -Executable $iscc -Arguments @( + "/DPayloadDir=$stage", (Join-Path $BuildDir 'BongoCat.iss')) + $output = Join-Path $BuildDir "dist/$PackageName-setup.exe" + $hash = (Get-FileHash -LiteralPath $output -Algorithm SHA256).Hash.ToLowerInvariant() + "$hash $PackageName-setup.exe" | Set-Content -LiteralPath "$output.sha256" -Encoding ascii + } finally { + # Only remove the unique stage directory directly below the build directory. + $stageFull = [IO.Path]::GetFullPath($stage) + $buildFull = [IO.Path]::GetFullPath($BuildDir).TrimEnd('\', '/') + if ((Split-Path $stageFull -Parent) -eq $buildFull -and + (Split-Path $stageFull -Leaf) -match '^inno-stage-[0-9a-f]{32}$' -and + (Test-Path -LiteralPath $stageFull)) { + Remove-Item -LiteralPath $stageFull -Recurse -Force + } + } +} catch { + $summary = $_.Exception.Message + Add-Content -LiteralPath $log -Value $summary -Encoding utf8 + Write-Host 'Installer failure details:' + Get-Content -LiteralPath $log -Tail 30 | ForEach-Object { Write-Host $_ } + if ($env:GITHUB_ACTIONS -eq 'true') { + $detail = (Get-Content -LiteralPath $log -Tail 12) -join "`n" + $detail = $detail.Replace('%', '%25').Replace("`r", '%0D').Replace("`n", '%0A') + Write-Host "::error title=Inno Setup packaging failed::$detail" } + throw } From a4674453a8fc4e2a44c3c2eb8d2013952b0349a2 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 17 Sep 2026 17:49:36 +0800 Subject: [PATCH 310/334] fix: github action --- packaging/windows/build-installer.ps1 | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packaging/windows/build-installer.ps1 b/packaging/windows/build-installer.ps1 index 26dd311d..863b6a0b 100644 --- a/packaging/windows/build-installer.ps1 +++ b/packaging/windows/build-installer.ps1 @@ -37,7 +37,17 @@ try { Invoke-PackagingCommand -Executable $iscc -Arguments @( "/DPayloadDir=$stage", (Join-Path $BuildDir 'BongoCat.iss')) $output = Join-Path $BuildDir "dist/$PackageName-setup.exe" - $hash = (Get-FileHash -LiteralPath $output -Algorithm SHA256).Hash.ToLowerInvariant() + # MSBuild launches Windows PowerShell with the runner's inherited module + # paths; Get-FileHash may be unavailable there. Use the .NET stream API. + $sha256 = [Security.Cryptography.SHA256]::Create() + $stream = $null + try { + $stream = [IO.File]::OpenRead($output) + $hash = [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() + } finally { + if ($null -ne $stream) { $stream.Dispose() } + $sha256.Dispose() + } "$hash $PackageName-setup.exe" | Set-Content -LiteralPath "$output.sha256" -Encoding ascii } finally { # Only remove the unique stage directory directly below the build directory. From a79961be71ce99e18d31419e296f32f05e2401a0 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 17 Sep 2026 18:32:24 +0800 Subject: [PATCH 311/334] chore: change version to 1.9.0 --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 7a92cd89..2f95b3dc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -set(BONGO_CAT_VERSION "1.8.0") +set(BONGO_CAT_VERSION "1.9.0") project(BongoCat VERSION ${BONGO_CAT_VERSION} DESCRIPTION "BongoCat" HOMEPAGE_URL "https://github.com/vladelaina/BongoCat" LANGUAGES C CXX) include(cmake/Version.cmake) include(FetchContent) From a0cde05aebeeeb7f3403a1d5e17c068190c751ca Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 17 Sep 2026 23:41:40 +0800 Subject: [PATCH 312/334] feat: a series of optimizations --- CMakeLists.txt | 4 +- cmake/Archive.cmake | 19 ++ cmake/Dependencies.cmake | 2 + cmake/PlatformRuntime.cmake | 3 + cmake/RuntimeSources.cmake | 3 + cmake/Tests.cmake | 4 +- include/bongo_cat/app.h | 6 + include/bongo_cat/json.h | 4 + src/core/model_behavior.c | 7 +- src/core/model_json.c | 61 +++++++ src/live2d/cubism_model.hpp | 11 ++ src/live2d/cubism_model_bounds.cpp | 30 +++- src/live2d/cubism_model_load.cpp | 17 ++ src/live2d/cubism_model_renderer.cpp | 106 ++++++++++- src/live2d/cubism_model_update.cpp | 1 + src/live2d/cubism_model_validate.cpp | 3 +- src/live2d/cubism_motion_playback.cpp | 3 +- src/live2d/cubism_parameter_overrides.cpp | 3 +- src/live2d/live2d_stub.c | 12 +- src/platform/windows/windows_borderless.c | 32 ++++ src/platform/windows/windows_borderless.h | 1 + src/platform/windows/windows_pointer.c | 9 +- src/platform/windows/windows_snapshot.c | 82 +++++++++ src/platform/windows/windows_snapshot.h | 19 ++ src/platform/windows/windows_snapshot_image.c | 85 +++++++++ src/platform/windows/windows_snapshot_input.c | 68 +++++++ .../windows/windows_snapshot_internal.h | 27 +++ src/runtime/input/mouse.c | 1 + src/runtime/lifecycle/app_input.c | 5 + src/runtime/lifecycle/app_loop.c | 10 +- src/runtime/lifecycle/frame_clock.c | 1 + src/runtime/lifecycle/shutdown.c | 1 + .../model/import/model_import_archive.c | 168 ++++++++++++++++++ .../model/import/model_import_archive.h | 12 ++ .../model/import/model_import_manifest.c | 32 ++-- .../model/import/model_import_manifest.h | 2 +- .../model/import/model_import_progressive.c | 22 ++- .../model/import/model_import_session.c | 12 +- .../import/mver/model_import_mver_assets.c | 22 +-- .../import/mver/model_import_mver_audio.c | 6 +- .../import/mver/model_import_mver_copy.c | 5 - .../import/mver/model_import_mver_discovery.c | 48 +++-- .../import/mver/model_import_mver_effect.c | 11 +- .../import/mver/model_import_mver_manifest.c | 80 +-------- .../import/mver/model_import_mver_manifest.h | 4 +- .../import/mver/model_import_mver_motion.c | 19 +- .../nearby/model_import_nearby_internal.h | 2 +- src/runtime/model/model_files.c | 1 + src/runtime/model/model_update.c | 2 + src/runtime/runtime.h | 4 + src/runtime/shell/modal_frame.c | 1 + src/runtime/shell/window.c | 52 ++++-- src/runtime/shell/window_display.c | 5 + src/runtime/shell/window_drag.c | 2 + src/runtime/shell/window_geometry.c | 16 -- src/runtime/shell/window_hit.c | 15 +- src/runtime/shell/window_menu_actions.c | 2 + src/runtime/shell/window_menu_preview.c | 11 +- src/runtime/shell/window_scale.c | 77 ++++++++ src/runtime/shell/window_snapshot.c | 123 +++++++++++++ src/runtime/shell/window_snapshot.h | 19 ++ src/runtime/shell/window_wheel.c | 38 ++-- src/runtime/shell/window_wheel_test.c | 12 +- .../model_import/test_model_import_archive.c | 115 ++++++++++++ tests/model_import/test_mver_container.c | 66 +++++++ tests/model_import/test_mver_import.c | 1 + .../model_import/test_mver_import_internal.h | 1 + tests/model_import/test_mver_manifest.c | 44 ++++- tests/model_import/test_mver_missing_motion.c | 114 ++++++++++++ 69 files changed, 1565 insertions(+), 241 deletions(-) create mode 100644 cmake/Archive.cmake create mode 100644 src/core/model_json.c create mode 100644 src/platform/windows/windows_snapshot.c create mode 100644 src/platform/windows/windows_snapshot.h create mode 100644 src/platform/windows/windows_snapshot_image.c create mode 100644 src/platform/windows/windows_snapshot_input.c create mode 100644 src/platform/windows/windows_snapshot_internal.h create mode 100644 src/runtime/model/import/model_import_archive.c create mode 100644 src/runtime/model/import/model_import_archive.h create mode 100644 src/runtime/shell/window_scale.c create mode 100644 src/runtime/shell/window_snapshot.c create mode 100644 src/runtime/shell/window_snapshot.h create mode 100644 tests/model_import/test_model_import_archive.c diff --git a/CMakeLists.txt b/CMakeLists.txt index 2f95b3dc..bdb2f612 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -75,7 +75,7 @@ add_library(bongo_cat_core STATIC src/core/input_state.c src/core/mver_pointer.c src/core/mouse_tracking.c src/core/sha256.c src/core/utf8.c src/core/model_catalog.c - src/core/model_behavior.c + src/core/model_behavior.c src/core/model_json.c src/core/path.c src/core/path_io.c src/core/json.c src/core/shortcut.c src/core/sound_shortcut.c src/core/update_release.c src/core/update_version.c) @@ -97,7 +97,7 @@ target_include_directories(bongo_cat_runtime PRIVATE ${BONGO_CAT_RUNTIME_INTERNAL_INCLUDE_DIRS}) target_include_directories(bongo_cat_runtime SYSTEM PRIVATE ${BONGO_CAT_STB_INCLUDE_DIR} ${BONGO_CAT_NUKLEAR_INCLUDE_DIR}) -target_link_libraries(bongo_cat_runtime PRIVATE bongo_cat_warnings) +target_link_libraries(bongo_cat_runtime PRIVATE bongo_cat_warnings bongo_cat_archive) target_link_libraries(bongo_cat_runtime PUBLIC bongo_cat_core SDL3::SDL3-static OpenGL::GL ${BONGO_CAT_MINIAUDIO_TARGET}) if(MSVC) set_source_files_properties(src/media/stb_image_impl.c src/media/image_resize.c src/ui/backend/nuklear_impl.c diff --git a/cmake/Archive.cmake b/cmake/Archive.cmake new file mode 100644 index 00000000..eab8e39a --- /dev/null +++ b/cmake/Archive.cmake @@ -0,0 +1,19 @@ +if(BONGO_CAT_FETCH_DEPS) + FetchContent_Declare(miniz + URL https://codeload.github.com/richgel999/miniz/tar.gz/refs/tags/3.1.0 + URL_HASH SHA256=09569fc19d060ac9f5999ba9356728c2494ebe6a24ac0eb0a6b6ae3d396cfea6 + SOURCE_SUBDIR bongocat-unused) + FetchContent_MakeAvailable(miniz) + # Build only the library, without changing the parent's build options. + file(WRITE "${miniz_BINARY_DIR}/miniz_export.h" + "#pragma once\n#define MINIZ_EXPORT\n") + add_library(bongo_cat_archive STATIC + "${miniz_SOURCE_DIR}/miniz.c" "${miniz_SOURCE_DIR}/miniz_zip.c" + "${miniz_SOURCE_DIR}/miniz_tinfl.c" "${miniz_SOURCE_DIR}/miniz_tdef.c") + target_include_directories(bongo_cat_archive SYSTEM PUBLIC + "${miniz_SOURCE_DIR}" "${miniz_BINARY_DIR}") +else() + find_package(miniz CONFIG REQUIRED) + add_library(bongo_cat_archive INTERFACE) + target_link_libraries(bongo_cat_archive INTERFACE miniz::miniz) +endif() diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake index 05a87d33..631f0813 100644 --- a/cmake/Dependencies.cmake +++ b/cmake/Dependencies.cmake @@ -128,3 +128,5 @@ else() set(BONGO_CAT_MINIAUDIO_TARGET bongo_cat_miniaudio_system) endif() endif() + +include("${CMAKE_CURRENT_LIST_DIR}/Archive.cmake") diff --git a/cmake/PlatformRuntime.cmake b/cmake/PlatformRuntime.cmake index f3ef26b3..56cfde97 100644 --- a/cmake/PlatformRuntime.cmake +++ b/cmake/PlatformRuntime.cmake @@ -10,6 +10,9 @@ if(WIN32) src/platform/windows/windows_capture.c src/platform/windows/windows_transparency.c src/platform/windows/windows_pointer.c + src/platform/windows/windows_snapshot.c + src/platform/windows/windows_snapshot_image.c + src/platform/windows/windows_snapshot_input.c src/platform/windows/windows_dialog.c src/platform/windows/windows_input.c src/platform/windows/windows_input_receiver.c diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index 3cf135f4..d397b29f 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -100,6 +100,7 @@ set(BONGO_CAT_RUNTIME_MODEL_SOURCES set(BONGO_CAT_RUNTIME_IMPORT_SOURCES # Shared import workflow. src/runtime/model/import/model_import_app.c + src/runtime/model/import/model_import_archive.c src/runtime/model/import/model_import_adapter.c src/runtime/model/import/model_import_digest.c src/runtime/model/import/model_import_digest_api.c @@ -168,6 +169,8 @@ set(BONGO_CAT_RUNTIME_SHELL_SOURCES src/runtime/shell/window_menu_behavior.c src/runtime/shell/window_menu_behavior_test.c src/runtime/shell/window_menu_preview.c + src/runtime/shell/window_scale.c + src/runtime/shell/window_snapshot.c src/runtime/shell/window_wheel.c src/runtime/shell/window_wheel_test.c) diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index 08d51585..f5806f07 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -129,6 +129,7 @@ if(BUILD_TESTING) tests/model_import/test_mver_audio.c tests/model_import/test_mver_import.c tests/model_import/test_model_import_source.c + tests/model_import/test_model_import_archive.c tests/model_import/test_mver_manifest.c tests/model_import/test_tauri_portable.c tests/model_import/test_mver_container.c @@ -146,7 +147,8 @@ if(BUILD_TESTING) ${BONGO_CAT_RUNTIME_INTERNAL_INCLUDE_DIRS}) target_include_directories(bongo_cat_mver_import_tests SYSTEM PRIVATE ${BONGO_CAT_NUKLEAR_INCLUDE_DIR} ${BONGO_CAT_STB_INCLUDE_DIR}) - target_link_libraries(bongo_cat_mver_import_tests PRIVATE bongo_cat_runtime) + target_link_libraries(bongo_cat_mver_import_tests PRIVATE + bongo_cat_runtime bongo_cat_archive) target_compile_definitions(bongo_cat_mver_import_tests PRIVATE BONGO_CAT_NATIVE_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") if(MSVC) diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index 7fa9423e..0ff15ea4 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -170,6 +170,12 @@ typedef struct BongoCatApp { float resize_scale_start, resize_scale_target; int resize_base_width, resize_base_height; bool resize_pending; + bool resize_render_target_pending; + /* Native preview ownership and gesture lifetime belong to window_snapshot.c. */ + void *window_snapshot; + bool snapshot_blocked; + uint64_t snapshot_deadline_ns; + float snapshot_pointer_x, snapshot_pointer_y; bool wheel_animation_active; bool wheel_gesture_active; int resize_pixel_width, resize_pixel_height; diff --git a/include/bongo_cat/json.h b/include/bongo_cat/json.h index e4b9dbac..7ca8f379 100644 --- a/include/bongo_cat/json.h +++ b/include/bongo_cat/json.h @@ -10,6 +10,10 @@ extern "C" { yyjson_doc *bongo_cat_json_read_file(const char *path, yyjson_read_flag flags, yyjson_read_err *error); +/* Model compatibility is applied in memory; authored files are never rewritten. */ +yyjson_doc *bongo_cat_model_json_parse(const char *data, size_t size, + bool *normalized); +yyjson_doc *bongo_cat_model_json_read(const char *path, bool *normalized); bool bongo_cat_json_write_file(const char *path, const yyjson_mut_doc *document, yyjson_write_flag flags, yyjson_write_err *error); diff --git a/src/core/model_behavior.c b/src/core/model_behavior.c index bd6a69c2..50902e93 100644 --- a/src/core/model_behavior.c +++ b/src/core/model_behavior.c @@ -127,13 +127,10 @@ BongoCatResult bongo_cat_behaviors_load(BongoCatBehaviorCatalog *catalog, char path[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(path, sizeof(path), model->directory, model->setting_file)) return BONGO_CAT_ERROR_FORMAT; - yyjson_read_err json_error = {0}; - FILE *file = bongo_cat_file_open(path, "rb"); - yyjson_doc *document = file ? yyjson_read_fp(file, 0, NULL, &json_error) : NULL; - if (file) fclose(file); + yyjson_doc *document = bongo_cat_model_json_read(path, NULL); if (!document) { bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, "Cannot read model setting: %s", - json_error.msg ? json_error.msg : "cannot open file"); + path); return BONGO_CAT_ERROR_FORMAT; } yyjson_val *references = yyjson_obj_get(yyjson_doc_get_root(document), "FileReferences"); diff --git a/src/core/model_json.c b/src/core/model_json.c new file mode 100644 index 00000000..6c19af74 --- /dev/null +++ b/src/core/model_json.c @@ -0,0 +1,61 @@ +#include "bongo_cat/json.h" +#include "bongo_cat/file.h" +#include "bongo_cat/path.h" + +#include +#include + +static size_t skip_space(const char *data, size_t size, size_t pos) { + while (pos < size && (data[pos] == ' ' || data[pos] == '\t' || + data[pos] == '\r' || data[pos] == '\n')) pos++; + return pos; +} + +yyjson_doc *bongo_cat_model_json_parse(const char *data, size_t size, + bool *normalized) { + if (normalized) *normalized = false; + if (!data || !size || size > 4u * 1024u * 1024u) return NULL; + yyjson_doc *document = yyjson_read(data, size, 0); + if (document) return document; + yyjson_read_flag flags = YYJSON_READ_ALLOW_COMMENTS | + YYJSON_READ_ALLOW_TRAILING_COMMAS | YYJSON_READ_ALLOW_BOM; + yyjson_read_err error = {0}; + document = yyjson_read_opts((char *)data, size, flags, NULL, &error); + if (!document && error.pos < size && data[error.pos] == ']') { + size_t second = skip_space(data, size, error.pos + 1); + size_t third = skip_space(data, size, second + 1); + /* Some hand-edited Mver manifests have one extra '] }' pair. */ + if (second < size && third < size && data[second] == '}' && data[third] == '}') { + char *copy = malloc(size); + if (!copy) return NULL; + memcpy(copy, data, size); + copy[error.pos] = copy[second] = ' '; + document = yyjson_read_opts(copy, size, flags, NULL, NULL); + free(copy); + yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; + if (!yyjson_is_obj(root) || + !yyjson_is_obj(yyjson_obj_get(root, "FileReferences"))) { + yyjson_doc_free(document); + document = NULL; + } + } + } + if (normalized) *normalized = document != NULL; + return document; +} + +yyjson_doc *bongo_cat_model_json_read(const char *path, bool *normalized) { + if (normalized) *normalized = false; + uint64_t size; + if (!bongo_cat_path_file_size(path, &size) || !size || + size > 4u * 1024u * 1024u) return NULL; + char *data = malloc((size_t)size); + if (!data) return NULL; + FILE *file = bongo_cat_file_open(path, "rb"); + bool loaded = file && fread(data, 1, (size_t)size, file) == (size_t)size; + if (file && fclose(file) != 0) loaded = false; + yyjson_doc *document = loaded + ? bongo_cat_model_json_parse(data, (size_t)size, normalized) : NULL; + free(data); + return document; +} diff --git a/src/live2d/cubism_model.hpp b/src/live2d/cubism_model.hpp index b1db2b2d..c91b5aca 100644 --- a/src/live2d/cubism_model.hpp +++ b/src/live2d/cubism_model.hpp @@ -99,6 +99,7 @@ class NativeModel final : public Csm::CubismUserModel { float max_y = 0.0f; bool valid = false; }; + struct DrawableBounds { ModelBounds bounds; float area; }; bool load_model(BongoCatError *error); void load_expressions(); void load_effects(); @@ -135,6 +136,7 @@ class NativeModel final : public Csm::CubismUserModel { void release_textures(); void release_renderer(); bool create_renderer(BongoCatError *error); + void update_mask_buffers(); void bind_textures(); std::vector read(const std::string &path, size_t maximum = (size_t)-1) const; @@ -151,10 +153,13 @@ class NativeModel final : public Csm::CubismUserModel { std::vector expression_names_; std::vector textures_; std::vector texture_alpha_; + mutable std::vector> triangle_alpha_; + mutable std::vector bounds_scratch_; std::vector parameter_snapshot_; std::vector part_snapshot_; std::vector parameter_override_values_; std::vector parameter_baseline_values_; + std::vector parameter_save_scratch_; std::vector parameter_overrides_; std::vector motion_preview_parameters_; std::vector motion_preview_parts_; @@ -168,6 +173,11 @@ class NativeModel final : public Csm::CubismUserModel { int height_ = 354; int renderer_width_ = 0; int renderer_height_ = 0; + int mask_texture_limit_ = 0; + int mask_buffer_size_ = 0; + int mask_layout_divisions_ = 1; + int mask_last_width_ = 0; + int mask_last_height_ = 0; int viewport_x_ = 0; int viewport_y_ = 0; int viewport_width_ = 612; @@ -187,6 +197,7 @@ class NativeModel final : public Csm::CubismUserModel { bool parameter_overrides_applied_ = false; std::vector idle_motion_keys_; std::vector motion_runs_; + std::vector motion_finished_scratch_; ViewerLookUpdater *viewer_look_ = nullptr; int last_idle_motion_ = -1; float opacity_snapshot_ = -1.0f; diff --git a/src/live2d/cubism_model_bounds.cpp b/src/live2d/cubism_model_bounds.cpp index e7c05efd..f81c6c36 100644 --- a/src/live2d/cubism_model_bounds.cpp +++ b/src/live2d/cubism_model_bounds.cpp @@ -28,8 +28,11 @@ bool NativeModel::canvas_size(int *width, int *height) const { NativeModel::ModelBounds NativeModel::capture_visible_bounds() const { ModelBounds bounds; if (!_model) return bounds; - struct DrawableBounds { ModelBounds bounds; float area; }; - std::vector drawables; + auto &drawables = bounds_scratch_; + drawables.clear(); + const size_t drawable_count = (size_t)_model->GetDrawableCount(); + drawables.reserve(drawable_count); + triangle_alpha_.resize(drawable_count); size_t largest = 0; for (int i = 0; i < _model->GetDrawableCount(); ++i) { if (!_model->GetDrawableDynamicFlagIsVisible(i) || @@ -43,6 +46,11 @@ NativeModel::ModelBounds NativeModel::capture_visible_bounds() const { const auto *uvs = _model->GetDrawableVertexUvs(i); const auto *indices = _model->GetDrawableVertexIndices(i); int index_count = _model->GetDrawableVertexIndexCount(i); + if (!indices || !uvs || index_count < 3) continue; + auto &triangle_alpha = triangle_alpha_[(size_t)i]; + const size_t triangle_count = (size_t)index_count / 3; + const bool sample_alpha = triangle_alpha.size() != triangle_count; + if (sample_alpha) triangle_alpha.assign(triangle_count, 0); ModelBounds drawable = {FLT_MAX, FLT_MAX, -FLT_MAX, -FLT_MAX, true}; float visible_area = 0.0f; auto alpha = [&](float u, float v) { @@ -54,16 +62,20 @@ NativeModel::ModelBounds NativeModel::capture_visible_bounds() const { for (int j = 0; indices && uvs && j + 2 < index_count; j += 3) { int a = indices[j], b = indices[j + 1], c = indices[j + 2]; if (a >= count || b >= count || c >= count) continue; + // Cubism UVs and mesh indices are static; only positions deform. + if (sample_alpha) { + float u = (uvs[a].X + uvs[b].X + uvs[c].X) / 3.0f; + float v = (uvs[a].Y + uvs[b].Y + uvs[c].Y) / 3.0f; + triangle_alpha[(size_t)j / 3] = (unsigned char)std::max({alpha(u, v), + alpha((uvs[a].X + uvs[b].X) * 0.5f, (uvs[a].Y + uvs[b].Y) * 0.5f), + alpha((uvs[b].X + uvs[c].X) * 0.5f, (uvs[b].Y + uvs[c].Y) * 0.5f), + alpha((uvs[c].X + uvs[a].X) * 0.5f, (uvs[c].Y + uvs[a].Y) * 0.5f)}); + } + int opacity = triangle_alpha[(size_t)j / 3]; + if (opacity <= 8) continue; float ax = vertices[a * 2], ay = vertices[a * 2 + 1]; float bx = vertices[b * 2], by = vertices[b * 2 + 1]; float cx = vertices[c * 2], cy = vertices[c * 2 + 1]; - float u = (uvs[a].X + uvs[b].X + uvs[c].X) / 3.0f; - float v = (uvs[a].Y + uvs[b].Y + uvs[c].Y) / 3.0f; - int opacity = std::max({alpha(u, v), - alpha((uvs[a].X + uvs[b].X) * 0.5f, (uvs[a].Y + uvs[b].Y) * 0.5f), - alpha((uvs[b].X + uvs[c].X) * 0.5f, (uvs[b].Y + uvs[c].Y) * 0.5f), - alpha((uvs[c].X + uvs[a].X) * 0.5f, (uvs[c].Y + uvs[a].Y) * 0.5f)}); - if (opacity <= 8) continue; float area = std::fabs((bx - ax) * (cy - ay) - (by - ay) * (cx - ax)); visible_area += area * opacity / 255.0f; drawable.min_x = std::min({drawable.min_x, ax, bx, cx}); diff --git a/src/live2d/cubism_model_load.cpp b/src/live2d/cubism_model_load.cpp index 740101a0..d2a73acd 100644 --- a/src/live2d/cubism_model_load.cpp +++ b/src/live2d/cubism_model_load.cpp @@ -2,6 +2,7 @@ #include "cubism_viewer_look.hpp" #include "bongo_cat/file.h" #include "bongo_cat/image.h" +#include "bongo_cat/json.h" #include #include @@ -88,6 +89,22 @@ bool NativeModel::load(const char *directory, const char *setting_file, bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, "Cannot read model setting: %s", setting_file); return false; } + bool normalized = false; + yyjson_doc *document = bongo_cat_model_json_parse( + reinterpret_cast(json.data()), json.size(), &normalized); + if (document && normalized) { + size_t size = 0; + char *canonical = yyjson_write(document, 0, &size); + if (!canonical) { + yyjson_doc_free(document); + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Cannot prepare model setting: %s", setting_file); + return false; + } + json.assign(canonical, canonical + size); + std::free(canonical); + } + yyjson_doc_free(document); if (!validate_model_setting_json(json, setting_file, error)) return false; if (progress) progress(userdata, .10f); setting_ = new(std::nothrow) diff --git a/src/live2d/cubism_model_renderer.cpp b/src/live2d/cubism_model_renderer.cpp index c2d8ec83..f46095fc 100644 --- a/src/live2d/cubism_model_renderer.cpp +++ b/src/live2d/cubism_model_renderer.cpp @@ -2,9 +2,32 @@ #include "bongo_cat/gl_api.h" #include +#include +#include +#include namespace bongo_cat { +static size_t mask_combination_count(Csm::csmInt32 object_count, + const Csm::csmInt32 *mask_counts, const Csm::csmInt32 *const *masks) { + std::set> combinations; + for (Csm::csmInt32 i = 0; i < object_count; ++i) { + if (mask_counts[i] <= 0) continue; + std::vector ids(masks[i], masks[i] + mask_counts[i]); + // Cubism shares a clipping context for the same IDs in any order. + std::sort(ids.begin(), ids.end()); + combinations.insert(std::move(ids)); + } + // Include hidden objects so expressions cannot exceed the allocated capacity. + return combinations.size(); +} + +static Csm::csmInt32 mask_buffer_count(size_t count) { + if (count <= (size_t)Csm::Rendering::ClippingMaskMaxCountOnDefault) return 1; + const size_t capacity = Csm::Rendering::ClippingMaskMaxCountOnMultiRenderTexture; + return (Csm::csmInt32)(1 + (count - 1) / capacity); +} + void NativeModel::bind_textures() { auto *renderer = GetRenderer(); if (!renderer) return; @@ -19,6 +42,7 @@ void NativeModel::release_textures() { glDeleteTextures((GLsizei)textures_.size(), textures_.data()); textures_.clear(); texture_alpha_.clear(); + triangle_alpha_.clear(); } void NativeModel::release_renderer() { @@ -28,6 +52,10 @@ void NativeModel::release_renderer() { #endif renderer_width_ = 0; renderer_height_ = 0; + mask_texture_limit_ = 0; + mask_buffer_size_ = 0; + mask_layout_divisions_ = 1; + mask_last_width_ = mask_last_height_ = 0; } void NativeModel::release_render_resources() { @@ -35,6 +63,53 @@ void NativeModel::release_render_resources() { release_renderer(); } +void NativeModel::update_mask_buffers() { + if (width_ == mask_last_width_ && height_ == mask_last_height_) return; + auto *renderer = GetRenderer(); + if (!renderer || !_model || mask_texture_limit_ <= 0) return; + mask_last_width_ = width_; + mask_last_height_ = height_; + const bool drawable_masks = _model->IsUsingMasking(); + const bool offscreen_masks = _model->IsUsingMaskingForOffscreen(); + if (!drawable_masks && !offscreen_masks) return; + + // Account for the actual atlas subdivision, including hidden masks. + const int extent = std::min(std::max(width_, height_), mask_texture_limit_); + const int requested = std::max(512, mask_layout_divisions_ * extent); + const int size = std::min(mask_texture_limit_, ((requested + 511) / 512) * 512); + if (size == mask_buffer_size_) return; + // Grow immediately, but avoid grow/shrink churn near a size boundary. + if (size < mask_buffer_size_ && requested > mask_buffer_size_ - 768) return; + + GLint previous_texture = 0; + glGetIntegerv(GL_TEXTURE_BINDING_2D, &previous_texture); + auto prepare = [size, &previous_texture]( + Csm::Rendering::CubismRenderTarget_OpenGLES2 *buffer) { + const bool was_bound = previous_texture != 0 && + (GLuint)previous_texture == buffer->GetColorBuffer(); + // Allocate now so the SDK cannot replace the filtering on first draw. + buffer->CreateRenderTarget((Csm::csmUint32)size, (Csm::csmUint32)size); + if (was_bound) previous_texture = (GLint)buffer->GetColorBuffer(); + glBindTexture(GL_TEXTURE_2D, buffer->GetColorBuffer()); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + }; + if (drawable_masks) { + renderer->SetDrawableClippingMaskBufferSize((float)size, (float)size); + for (int i = 0; i < renderer->GetDrawableRenderTextureCount(); ++i) + prepare(renderer->GetDrawableMaskBuffer(i)); + } + if (offscreen_masks) { + renderer->SetOffscreenClippingMaskBufferSize((float)size, (float)size); + for (int i = 0; i < renderer->GetOffscreenRenderTextureCount(); ++i) + prepare(renderer->GetOffscreenMaskBuffer(i)); + } + glBindTexture(GL_TEXTURE_2D, (GLuint)previous_texture); + mask_buffer_size_ = size; +} + bool NativeModel::create_renderer(BongoCatError *error) { if (!SDL_GL_GetCurrentContext()) { bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, @@ -49,9 +124,36 @@ bool NativeModel::create_renderer(BongoCatError *error) { #ifdef CSM_TARGET_MAC_GL CoreProfileBinding binding(core_buffers_); #endif - CreateRenderer((Csm::csmUint32)width_, (Csm::csmUint32)height_); + const size_t drawable_combinations = mask_combination_count( + _model->GetDrawableCount(), _model->GetDrawableMaskCounts(), + _model->GetDrawableMasks()); + const size_t offscreen_combinations = mask_combination_count( + _model->GetOffscreenCount(), _model->GetOffscreenMaskCounts(), + _model->GetOffscreenMasks()); + const Csm::csmInt32 buffer_count = std::max( + mask_buffer_count(drawable_combinations), mask_buffer_count(offscreen_combinations)); + CreateRenderer((Csm::csmUint32)width_, (Csm::csmUint32)height_, + buffer_count); auto *renderer = GetRenderer(); - if (renderer) bind_textures(); + if (renderer) { + GLint texture_limit = 0; + glGetIntegerv(GL_MAX_TEXTURE_SIZE, &texture_limit); + mask_texture_limit_ = std::min(4096, (int)texture_limit); + const size_t per_buffer = (std::max(drawable_combinations, + offscreen_combinations) + (size_t)buffer_count - 1) / (size_t)buffer_count; + mask_layout_divisions_ = per_buffer <= 4 ? 1 : (per_buffer <= 16 ? 2 : 3); + const uint64_t total_buffers = (uint64_t)buffer_count * + ((drawable_combinations > 0 ? 1u : 0u) + + (offscreen_combinations > 0 ? 1u : 0u)); + // Target 32 MiB of RGBA8 masks per model, retaining a 256px floor. + // This excludes model atlases, blend targets, and driver overhead. + constexpr uint64_t mask_budget = 32ull * 1024 * 1024; + while (mask_texture_limit_ > 256 && total_buffers * 4 * + (uint64_t)mask_texture_limit_ * (uint64_t)mask_texture_limit_ > mask_budget) + mask_texture_limit_ = std::max(256, mask_texture_limit_ - 256); + bind_textures(); + update_mask_buffers(); + } GLenum renderer_error = glGetError(); if (renderer && renderer_error == GL_NO_ERROR) return true; bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, diff --git a/src/live2d/cubism_model_update.cpp b/src/live2d/cubism_model_update.cpp index bf3bab48..1f16c128 100644 --- a/src/live2d/cubism_model_update.cpp +++ b/src/live2d/cubism_model_update.cpp @@ -115,6 +115,7 @@ void NativeModel::draw() { #ifdef CSM_TARGET_MAC_GL CoreProfileBinding binding(core_buffers_); #endif + update_mask_buffers(); auto *manager = Csm::Rendering::CubismOffscreenManager_OpenGLES2::GetInstance(); manager->BeginFrameProcess(); Csm::CubismMatrix44 projection; diff --git a/src/live2d/cubism_model_validate.cpp b/src/live2d/cubism_model_validate.cpp index 82f154ce..a92b5460 100644 --- a/src/live2d/cubism_model_validate.cpp +++ b/src/live2d/cubism_model_validate.cpp @@ -24,7 +24,8 @@ bool safe_path_value(yyjson_val *value) { bool optional_path(yyjson_val *object, const char *key) { yyjson_val *value = yyjson_obj_get(object, key); - return !value || safe_path_value(value); + return !value || (yyjson_is_str(value) && !yyjson_get_str(value)[0]) || + safe_path_value(value); } bool optional_number(yyjson_val *object, const char *key) { diff --git a/src/live2d/cubism_motion_playback.cpp b/src/live2d/cubism_motion_playback.cpp index 592e9774..a60292c0 100644 --- a/src/live2d/cubism_motion_playback.cpp +++ b/src/live2d/cubism_motion_playback.cpp @@ -59,7 +59,8 @@ void NativeModel::stop_motion_runs(const std::string &key) { void NativeModel::expire_motion_runs() { if (motion_runs_.empty()) return; - std::vector finished(motion_runs_.size()); + auto &finished = motion_finished_scratch_; + finished.resize(motion_runs_.size()); for (size_t i = 0; i < motion_runs_.size(); ++i) finished[i] = _motionManager->IsFinished(motion_runs_[i].handle); bool restored_persistent_state = false; diff --git a/src/live2d/cubism_parameter_overrides.cpp b/src/live2d/cubism_parameter_overrides.cpp index 1d8964c9..9b0f0959 100644 --- a/src/live2d/cubism_parameter_overrides.cpp +++ b/src/live2d/cubism_parameter_overrides.cpp @@ -48,7 +48,8 @@ void NativeModel::save_parameters() { /* Cubism's saved state is the motion baseline. Never let frame-end input overrides become the starting values for a later motion frame. */ const int count = _model->GetParameterCount(); - std::vector current((size_t)count); + auto ¤t = parameter_save_scratch_; + current.resize((size_t)count); for (int i = 0; i < count; ++i) { current[(size_t)i] = _model->GetParameterValue(i); _model->SetParameterValue(i, parameter_baseline_values_[(size_t)i]); diff --git a/src/live2d/live2d_stub.c b/src/live2d/live2d_stub.c index 6da0a11e..7e655582 100644 --- a/src/live2d/live2d_stub.c +++ b/src/live2d/live2d_stub.c @@ -1,5 +1,7 @@ #include "bongo_cat/model.h" #include "model_import_manifest.h" +#include "bongo_cat/json.h" +#include "bongo_cat/path.h" #include #include @@ -41,8 +43,16 @@ BongoCatResult bongo_cat_live2d_load(BongoCatLive2D *live2d, const char *model_d (void)preset; (void)render_options; if (!live2d || !model_dir || !setting_file) return BONGO_CAT_ERROR_ARGUMENT; if (progress) progress(userdata, 0.1f); - if (!bongo_cat_import_manifest_valid(model_dir, setting_file, error)) + char path[BONGO_CAT_PATH_CAP]; + yyjson_doc *document = bongo_cat_path_join(path, sizeof(path), model_dir, + setting_file) ? bongo_cat_model_json_read(path, NULL) : NULL; + bool valid = bongo_cat_import_manifest_document_valid(model_dir, document, true); + yyjson_doc_free(document); + if (!valid) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Model manifest or required assets are invalid: %s", setting_file); return BONGO_CAT_ERROR_FORMAT; + } live2d->loaded = true; if (progress) progress(userdata, 1.0f); return BONGO_CAT_OK; diff --git a/src/platform/windows/windows_borderless.c b/src/platform/windows/windows_borderless.c index eaad98fe..a90f47a8 100644 --- a/src/platform/windows/windows_borderless.c +++ b/src/platform/windows/windows_borderless.c @@ -10,6 +10,7 @@ static const wchar_t original_proc_property[] = L"BongoCat.BorderlessWindowProc" static const wchar_t click_through_property[] = L"BongoCat.ClickThrough"; static const wchar_t menu_binding_property[] = L"BongoCat.MenuBinding"; static const wchar_t drag_binding_property[] = L"BongoCat.DragBinding"; +static const wchar_t preserve_screen_property[] = L"BongoCat.PreserveScreenPixels"; #define BONGO_CAT_MENU_PREVIEW_TIMER ((UINT_PTR)0xBC4E) #define BONGO_CAT_DRAG_MODAL_TIMER ((UINT_PTR)0xBC50) #define BONGO_CAT_DRAG_FRAME_INTERVAL_MS 16 @@ -56,6 +57,26 @@ static LRESULT CALLBACK borderless_window_proc(HWND window, UINT message, WNDPROC original = (WNDPROC)GetPropW(window, original_proc_property); WindowsMenuBinding *menu = GetPropW(window, menu_binding_property); WindowsDragBinding *drag = GetPropW(window, drag_binding_property); + if (message == WM_NCCALCSIZE && wparam && lparam && + GetPropW(window, preserve_screen_property)) { + NCCALCSIZE_PARAMS *sizes = (NCCALCSIZE_PARAMS *)lparam; + RECT previous_client = sizes->rgrc[2]; + /* SDL must still calculate the new client rectangle and update its + border bookkeeping. Its redraw/alignment flags are replaced only + for our synchronous move-and-resize transaction. */ + CallWindowProcW(original ? original : DefWindowProcW, + window, message, wparam, lparam); + RECT overlap; + if (IntersectRect(&overlap, &previous_client, &sizes->rgrc[0])) { + /* Both rectangles use screen coordinates. Equal source and + destination preserve the old frame in place, rather than + shifting it with the new window origin before the GL swap. */ + sizes->rgrc[1] = overlap; + sizes->rgrc[2] = overlap; + return WVR_VALIDRECTS; + } + return WVR_REDRAW; + } if (bongo_cat_windows_capture_handle_message(window, message, wparam)) { return 0; } else if (message == WM_NCHITTEST) { @@ -179,6 +200,17 @@ void bongo_cat_windows_borderless_uninstall(HWND window) { SetWindowLongPtrW(window, GWLP_WNDPROC, (LONG_PTR)original); RemovePropW(window, original_proc_property); RemovePropW(window, click_through_property); + RemovePropW(window, preserve_screen_property); +} + +bool bongo_cat_windows_borderless_preserve_screen(HWND window, bool enabled) { + if (!window) return false; + if (!enabled) { + RemovePropW(window, preserve_screen_property); + return true; + } + return GetPropW(window, original_proc_property) && + SetPropW(window, preserve_screen_property, (HANDLE)1); } void bongo_cat_windows_borderless_set_click_through(HWND window, diff --git a/src/platform/windows/windows_borderless.h b/src/platform/windows/windows_borderless.h index 62180506..11644a65 100644 --- a/src/platform/windows/windows_borderless.h +++ b/src/platform/windows/windows_borderless.h @@ -7,6 +7,7 @@ void bongo_cat_windows_borderless_install(HWND window); void bongo_cat_windows_borderless_uninstall(HWND window); +bool bongo_cat_windows_borderless_preserve_screen(HWND window, bool enabled); bool bongo_cat_windows_borderless_hit_transparent(bool forced, bool pointer_transparent, bool right_button_down); void bongo_cat_windows_borderless_set_click_through(HWND window, diff --git a/src/platform/windows/windows_pointer.c b/src/platform/windows/windows_pointer.c index b69fd3a8..6809ddfd 100644 --- a/src/platform/windows/windows_pointer.c +++ b/src/platform/windows/windows_pointer.c @@ -73,10 +73,17 @@ bool bongo_cat_platform_set_geometry(BongoCatPlatform *platform, !(SDL_GetWindowFlags(platform->window) & SDL_WINDOW_RESIZABLE) && !SDL_SetWindowResizable(platform->window, true)) return false; + UINT flags = SWP_NOZORDER | SWP_NOACTIVATE; + if (!position_changed) flags |= SWP_NOMOVE; + if (!size_changed) flags |= SWP_NOSIZE; + bool preserve_screen = size_changed && position_changed && + bongo_cat_windows_borderless_preserve_screen(window, true); bool changed = SetWindowPos(window, NULL, position_changed ? x : current_x, position_changed ? y : current_y, size_changed ? width : current_width, size_changed ? height : current_height, - SWP_NOZORDER | SWP_NOACTIVATE) != 0; + flags) != 0; + if (preserve_screen) + bongo_cat_windows_borderless_preserve_screen(window, false); if (!changed) return false; /* Geometry changes retain DWM alpha composition. Reapplying it here invalidates the surface on every animation frame. */ diff --git a/src/platform/windows/windows_snapshot.c b/src/platform/windows/windows_snapshot.c new file mode 100644 index 00000000..c21da96d --- /dev/null +++ b/src/platform/windows/windows_snapshot.c @@ -0,0 +1,82 @@ +#include "windows_snapshot_internal.h" +#include "windows_capture.h" + +#include + +static HWND native_window(SDL_Window *window) { + return (HWND)SDL_GetPointerProperty(SDL_GetWindowProperties(window), + SDL_PROP_WINDOW_WIN32_HWND_POINTER, NULL); +} + +void bongo_cat_windows_snapshot_destroy(BongoCatWindowsSnapshot *s) { + if (!s) return; + if (s->suppressed) { + SDL_SetWindowOpacity(s->source, s->opacity); + bongo_cat_windows_capture_restore_transparency(s->source_handle); + } + if (s->handle) { + ShowWindow(s->handle, SW_HIDE); + bongo_cat_windows_snapshot_unbind_input(s); + } + if (s->texture) SDL_DestroyTexture(s->texture); + if (s->renderer) SDL_DestroyRenderer(s->renderer); + if (s->window) SDL_DestroyWindow(s->window); + SDL_free(s->pixels); + SDL_free(s); +} + +BongoCatWindowsSnapshot *bongo_cat_windows_snapshot_create(SDL_Window *source, + float opacity) { + BongoCatWindowsSnapshot *s = SDL_calloc(1, sizeof(*s)); + if (!s) return NULL; + s->source = source; + s->source_handle = native_window(source); + s->opacity = opacity; + int x = GetSystemMetrics(SM_XVIRTUALSCREEN), y = GetSystemMetrics(SM_YVIRTUALSCREEN); + int width = GetSystemMetrics(SM_CXVIRTUALSCREEN), height = GetSystemMetrics(SM_CYVIRTUALSCREEN); + s->desktop = (RECT){x, y, x + width, y + height}; + /* Bound the temporary desktop surface as well as the captured texture. */ + if (width <= 0 || height <= 0 || (size_t)width * height > SNAPSHOT_MAX_PIXELS || + !bongo_cat_windows_snapshot_capture(s)) goto failed; + s->window = SDL_CreateWindow("BongoCat interaction preview", width, height, + SDL_WINDOW_HIDDEN | SDL_WINDOW_BORDERLESS | SDL_WINDOW_TRANSPARENT | + SDL_WINDOW_UTILITY | SDL_WINDOW_NOT_FOCUSABLE); + if (!s->window) goto failed; + s->handle = native_window(s->window); + if (!s->handle) goto failed; + /* A fixed desktop-sized surface means DWM only sees image changes, never + a sequence of native window moves/resizes during the gesture. */ + LONG_PTR style = GetWindowLongPtrW(s->handle, GWL_EXSTYLE); + SetWindowLongPtrW(s->handle, GWL_EXSTYLE, style | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE); + if (!SetWindowPos(s->handle, s->source_handle, x, y, width, height, SWP_NOACTIVATE)) + goto failed; + s->renderer = SDL_CreateRenderer(s->window, "direct3d11"); + if (!s->renderer) goto failed; + SDL_SetRenderVSync(s->renderer, 1); + s->texture = SDL_CreateTexture(s->renderer, SDL_PIXELFORMAT_RGBA32, + SDL_TEXTUREACCESS_STATIC, s->width, s->height); + if (!s->texture || !SDL_UpdateTexture(s->texture, NULL, s->pixels, s->width * 4)) + goto failed; + /* The framebuffer already contains premultiplied alpha. Replace pixels + directly, multiplying both RGB and alpha by the window opacity. */ + if (!SDL_SetTextureBlendMode(s->texture, SDL_BLENDMODE_NONE) || + !SDL_SetTextureScaleMode(s->texture, SDL_SCALEMODE_LINEAR)) goto failed; + Uint8 alpha = (Uint8)(SDL_clamp(opacity, 0.0f, 1.0f) * 255.0f + 0.5f); + SDL_SetTextureColorMod(s->texture, alpha, alpha, alpha); + SDL_SetTextureAlphaMod(s->texture, alpha); + if (!bongo_cat_windows_snapshot_bind_input(s)) goto failed; + if (!bongo_cat_windows_capture_restore_transparency(s->handle) || + !bongo_cat_windows_snapshot_present(s)) goto failed; + ShowWindow(s->handle, SW_SHOWNOACTIVATE); + SetWindowPos(s->handle, s->source_handle, 0, 0, 0, 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); + s->has_frame = false; + if (!bongo_cat_windows_snapshot_present(s)) goto failed; + bongo_cat_windows_snapshot_pointer(s); + if (!SDL_SetWindowOpacity(source, 0.0f)) goto failed; + s->suppressed = true; + return s; +failed: + bongo_cat_windows_snapshot_destroy(s); + return NULL; +} diff --git a/src/platform/windows/windows_snapshot.h b/src/platform/windows/windows_snapshot.h new file mode 100644 index 00000000..e6628081 --- /dev/null +++ b/src/platform/windows/windows_snapshot.h @@ -0,0 +1,19 @@ +#ifndef BONGO_CAT_WINDOWS_SNAPSHOT_H +#define BONGO_CAT_WINDOWS_SNAPSHOT_H + +#include + +typedef struct BongoCatWindowsSnapshot BongoCatWindowsSnapshot; + +/* Capture requires the source OpenGL context to be current. */ +BongoCatWindowsSnapshot *bongo_cat_windows_snapshot_create(SDL_Window *source, + float opacity); +bool bongo_cat_windows_snapshot_present(BongoCatWindowsSnapshot *snapshot); +void bongo_cat_windows_snapshot_geometry(BongoCatWindowsSnapshot *snapshot, + const SDL_FRect *rect); +void bongo_cat_windows_snapshot_pointer(BongoCatWindowsSnapshot *snapshot); +bool bongo_cat_windows_snapshot_hit(BongoCatWindowsSnapshot *snapshot, + float x, float y); +void bongo_cat_windows_snapshot_destroy(BongoCatWindowsSnapshot *snapshot); + +#endif diff --git a/src/platform/windows/windows_snapshot_image.c b/src/platform/windows/windows_snapshot_image.c new file mode 100644 index 00000000..2ce61394 --- /dev/null +++ b/src/platform/windows/windows_snapshot_image.c @@ -0,0 +1,85 @@ +#include "windows_snapshot_internal.h" + +#include + +void bongo_cat_windows_snapshot_geometry(BongoCatWindowsSnapshot *s, + const SDL_FRect *rect) { + if (!s) return; + s->custom_destination = rect != NULL; + if (rect) s->destination = *rect; +} + +bool bongo_cat_windows_snapshot_present(BongoCatWindowsSnapshot *s) { + RECT rect; + if (!s || !GetWindowRect(s->source_handle, &rect)) return false; + SDL_FRect image = s->custom_destination ? s->destination : + (SDL_FRect){(float)rect.left, (float)rect.top, + (float)(rect.right - rect.left), (float)(rect.bottom - rect.top)}; + if (s->has_frame && image.x == s->presented.x && image.y == s->presented.y && + image.w == s->presented.w && image.h == s->presented.h) return true; + int pixel_width, pixel_height; + if (!SDL_GetRenderOutputSize(s->renderer, &pixel_width, &pixel_height)) return false; + float sx = (float)pixel_width / (s->desktop.right - s->desktop.left); + float sy = (float)pixel_height / (s->desktop.bottom - s->desktop.top); + SDL_FRect destination = {(image.x - s->desktop.left) * sx, + (image.y - s->desktop.top) * sy, image.w * sx, image.h * sy}; + SDL_SetRenderDrawColor(s->renderer, 0, 0, 0, 0); + if (!SDL_RenderClear(s->renderer) || + !SDL_RenderTextureRotated(s->renderer, s->texture, NULL, &destination, + 0.0, NULL, SDL_FLIP_VERTICAL) || !SDL_RenderPresent(s->renderer)) + return false; + /* Keep the native hit-test footprint local to the picture, while the + swapchain and window origin remain fixed for the whole interaction. */ + HRGN region = CreateRectRgn((int)SDL_floorf(image.x - s->desktop.left), + (int)SDL_floorf(image.y - s->desktop.top), + (int)SDL_ceilf(image.x + image.w - s->desktop.left), + (int)SDL_ceilf(image.y + image.h - s->desktop.top)); + if (!region) return false; + if (!SetWindowRgn(s->handle, region, FALSE)) { + DeleteObject(region); + return false; + } + s->presented = image; + s->has_frame = true; + return true; +} + +bool bongo_cat_windows_snapshot_capture(BongoCatWindowsSnapshot *s) { + if (!SDL_GetWindowSizeInPixels(s->source, &s->width, &s->height) || + s->width <= 0 || s->height <= 0 || + (size_t)s->width * s->height > SNAPSHOT_MAX_PIXELS) return false; + s->pixels = SDL_malloc((size_t)s->width * s->height * 4); + if (!s->pixels) return false; + typedef void (APIENTRY *BindBufferFn)(GLenum, GLuint); + BindBufferFn bind_buffer = (BindBufferFn)SDL_GL_GetProcAddress("glBindBuffer"); + if (!bind_buffer) return false; + GLint pack_buffer; + glGetIntegerv(GL_PIXEL_PACK_BUFFER_BINDING, &pack_buffer); + bind_buffer(GL_PIXEL_PACK_BUFFER, 0); + GLint buffer, alignment, row_length, skip_pixels, skip_rows; + glGetIntegerv(GL_READ_BUFFER, &buffer); + glGetIntegerv(GL_PACK_ALIGNMENT, &alignment); + glGetIntegerv(GL_PACK_ROW_LENGTH, &row_length); + glGetIntegerv(GL_PACK_SKIP_PIXELS, &skip_pixels); + glGetIntegerv(GL_PACK_SKIP_ROWS, &skip_rows); + glReadBuffer(GL_FRONT); + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glPixelStorei(GL_PACK_ROW_LENGTH, 0); + glPixelStorei(GL_PACK_SKIP_PIXELS, 0); + glPixelStorei(GL_PACK_SKIP_ROWS, 0); + glReadPixels(0, 0, s->width, s->height, GL_RGBA, GL_UNSIGNED_BYTE, s->pixels); + GLenum error = glGetError(); + glPixelStorei(GL_PACK_SKIP_PIXELS, skip_pixels); + glPixelStorei(GL_PACK_SKIP_ROWS, skip_rows); + glPixelStorei(GL_PACK_ROW_LENGTH, row_length); + glPixelStorei(GL_PACK_ALIGNMENT, alignment); + glReadBuffer((GLenum)buffer); + bind_buffer(GL_PIXEL_PACK_BUFFER, (GLuint)pack_buffer); + if (error != GL_NO_ERROR) return false; + /* Some drivers cannot expose a transparent front buffer. Never replace + a live pet with an empty capture in that case. */ + size_t pixels = (size_t)s->width * s->height; + for (size_t i = 0; i < pixels; ++i) + if (s->pixels[i * 4 + 3] > 8) return true; + return false; +} diff --git a/src/platform/windows/windows_snapshot_input.c b/src/platform/windows/windows_snapshot_input.c new file mode 100644 index 00000000..d30a11db --- /dev/null +++ b/src/platform/windows/windows_snapshot_input.c @@ -0,0 +1,68 @@ +#include "windows_snapshot_internal.h" + +#include + +static const wchar_t snapshot_property[] = L"BongoCat.InteractionSnapshot"; + +bool bongo_cat_windows_snapshot_hit(BongoCatWindowsSnapshot *s, float x, float y) { + RECT rect; + if (!s || !GetWindowRect(s->source_handle, &rect) || + rect.right <= rect.left || rect.bottom <= rect.top || + x < 0 || y < 0 || x >= rect.right - rect.left || y >= rect.bottom - rect.top) + return false; + SDL_FRect destination = s->custom_destination ? s->destination : + (SDL_FRect){(float)rect.left, (float)rect.top, + (float)(rect.right - rect.left), (float)(rect.bottom - rect.top)}; + x += rect.left - destination.x; + y += rect.top - destination.y; + if (x < 0 || y < 0 || x >= destination.w || y >= destination.h) return false; + int px = SDL_clamp((int)(x * s->width / destination.w), 0, s->width - 1); + int py = s->height - 1 - SDL_clamp( + (int)(y * s->height / destination.h), 0, s->height - 1); + return s->pixels[((size_t)py * s->width + px) * 4 + 3] > 8; +} + +void bongo_cat_windows_snapshot_pointer(BongoCatWindowsSnapshot *s) { + POINT point; + if (!s || !GetCursorPos(&point) || !ScreenToClient(s->source_handle, &point)) return; + bool hit = bongo_cat_windows_snapshot_hit(s, (float)point.x, (float)point.y); + LONG_PTR style = GetWindowLongPtrW(s->handle, GWL_EXSTYLE); + LONG_PTR next = hit ? style & ~WS_EX_TRANSPARENT : style | WS_EX_TRANSPARENT; + if (next != style) SetWindowLongPtrW(s->handle, GWL_EXSTYLE, next); +} + +static LRESULT CALLBACK snapshot_proc(HWND window, UINT message, + WPARAM wparam, LPARAM lparam) { + BongoCatWindowsSnapshot *s = GetPropW(window, snapshot_property); + if (!s) return DefWindowProcW(window, message, wparam, lparam); + if (message == WM_NCHITTEST) { + POINT point = {GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam)}; + ScreenToClient(s->source_handle, &point); + return bongo_cat_windows_snapshot_hit(s, (float)point.x, (float)point.y) + ? HTCLIENT : HTTRANSPARENT; + } + if (message == WM_MOUSEACTIVATE) return MA_NOACTIVATE; + if (message >= WM_MOUSEFIRST && message <= WM_MOUSELAST) { + /* Wheel coordinates are already screen-relative. Other mouse messages + must look as though SDL received them on the real pet window. */ + if (message != WM_MOUSEWHEEL && message != WM_MOUSEHWHEEL) { + POINT point = {GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam)}; + MapWindowPoints(window, s->source_handle, &point, 1); + lparam = MAKELPARAM(point.x, point.y); + } + return SendMessageW(s->source_handle, message, wparam, lparam); + } + return CallWindowProcW(s->original, window, message, wparam, lparam); +} + +bool bongo_cat_windows_snapshot_bind_input(BongoCatWindowsSnapshot *s) { + if (!SetPropW(s->handle, snapshot_property, s)) return false; + s->original = (WNDPROC)GetWindowLongPtrW(s->handle, GWLP_WNDPROC); + return SetWindowLongPtrW(s->handle, GWLP_WNDPROC, (LONG_PTR)snapshot_proc) != 0; +} + +void bongo_cat_windows_snapshot_unbind_input(BongoCatWindowsSnapshot *s) { + if (s->original) + SetWindowLongPtrW(s->handle, GWLP_WNDPROC, (LONG_PTR)s->original); + RemovePropW(s->handle, snapshot_property); +} diff --git a/src/platform/windows/windows_snapshot_internal.h b/src/platform/windows/windows_snapshot_internal.h new file mode 100644 index 00000000..44a5d968 --- /dev/null +++ b/src/platform/windows/windows_snapshot_internal.h @@ -0,0 +1,27 @@ +#ifndef BONGO_CAT_WINDOWS_SNAPSHOT_INTERNAL_H +#define BONGO_CAT_WINDOWS_SNAPSHOT_INTERNAL_H + +#include "windows_snapshot.h" +#include + +#define SNAPSHOT_MAX_PIXELS (16u * 1024u * 1024u) + +struct BongoCatWindowsSnapshot { + SDL_Window *source, *window; + SDL_Renderer *renderer; + SDL_Texture *texture; + HWND source_handle, handle; + WNDPROC original; + unsigned char *pixels; + int width, height; + RECT desktop; + SDL_FRect destination, presented; + float opacity; + bool suppressed, has_frame, custom_destination; +}; + +bool bongo_cat_windows_snapshot_capture(BongoCatWindowsSnapshot *snapshot); +bool bongo_cat_windows_snapshot_bind_input(BongoCatWindowsSnapshot *snapshot); +void bongo_cat_windows_snapshot_unbind_input(BongoCatWindowsSnapshot *snapshot); + +#endif diff --git a/src/runtime/input/mouse.c b/src/runtime/input/mouse.c index 92661f6c..335a63ba 100644 --- a/src/runtime/input/mouse.c +++ b/src/runtime/input/mouse.c @@ -14,6 +14,7 @@ void bongo_cat_app_track_hover(BongoCatApp *app, double x, double y) { } static bool hover_opacity(BongoCatApp *app, float opacity) { + bongo_cat_window_snapshot_end(app); if (bongo_cat_platform_set_opacity(&app->platform, opacity)) return true; SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Hover hiding disabled after opacity update failed: %s", SDL_GetError()); diff --git a/src/runtime/lifecycle/app_input.c b/src/runtime/lifecycle/app_input.c index 02431485..c57e7f47 100644 --- a/src/runtime/lifecycle/app_input.c +++ b/src/runtime/lifecycle/app_input.c @@ -45,6 +45,11 @@ void bongo_cat_app_drain_input(BongoCatApp *app, bool allow_shortcuts) { } if (strcmp(event.name, "CapsLock") == 0) bongo_cat_input_schedule_release(&app->input, &event, 100); + if (keyboard || event.kind == BONGO_CAT_INPUT_GAMEPAD_BUTTON || + event.kind == BONGO_CAT_INPUT_GAMEPAD_AXIS || + (!app->window_drag_active && (event.kind == BONGO_CAT_INPUT_MOUSE_DOWN || + event.kind == BONGO_CAT_INPUT_MOUSE_UP))) + bongo_cat_window_snapshot_end(app); if (allow_shortcuts && !bongo_cat_preferences_shortcuts_blocked(app->preferences)) bongo_cat_app_shortcuts(app, &event); diff --git a/src/runtime/lifecycle/app_loop.c b/src/runtime/lifecycle/app_loop.c index 17c42b61..42c3dbb3 100644 --- a/src/runtime/lifecycle/app_loop.c +++ b/src/runtime/lifecycle/app_loop.c @@ -14,8 +14,10 @@ static void handle_event(BongoCatApp *app, const SDL_Event *event) { if (bongo_cat_preferences_event(app->preferences, event)) return; if (!bongo_cat_window_event(app, event)) app->running = false; if (event->type >= SDL_EVENT_GAMEPAD_AXIS_MOTION && - event->type <= SDL_EVENT_GAMEPAD_TOUCHPAD_UP) + event->type <= SDL_EVENT_GAMEPAD_TOUCHPAD_UP) { + bongo_cat_window_snapshot_end(app); bongo_cat_gamepad_event(app, event); + } } static void update_model(BongoCatApp *app, uint64_t now) { @@ -26,6 +28,10 @@ static void update_model(BongoCatApp *app, uint64_t now) { } static bool render(BongoCatApp *app, bool present) { + if (present && app->window_snapshot) { + bongo_cat_window_snapshot_present(app); + return app->window_snapshot != NULL; + } uint64_t now = SDL_GetTicksNS(); if (app->render_retry_ns > now) return false; if (!SDL_GL_MakeCurrent(app->window, app->gl_context)) { @@ -126,6 +132,7 @@ void bongo_cat_app_render_now(BongoCatApp *app) { bool bongo_cat_app_capture_pending_model_cover(BongoCatApp *app) { if (!app || !app->window || !bongo_cat_model_cover_pending(app)) return false; + if (app->window_snapshot) return false; uint64_t now = SDL_GetTicksNS(); if (!bongo_cat_model_cover_capture_due(app, now)) return false; if (app->window_minimized) { @@ -192,6 +199,7 @@ void bongo_cat_app_loop(BongoCatApp *app) { if (take_update_shutdown(app)) continue; now = SDL_GetTicksNS(); bongo_cat_window_update_wheel_animation(app, now); + bongo_cat_window_snapshot_update(app, now); bongo_cat_multi_pet_update(app, now); bongo_cat_random_expression_update(app, now); bongo_cat_audio_update(app->audio); diff --git a/src/runtime/lifecycle/frame_clock.c b/src/runtime/lifecycle/frame_clock.c index cd1f5d7e..d2efd6bf 100644 --- a/src/runtime/lifecycle/frame_clock.c +++ b/src/runtime/lifecycle/frame_clock.c @@ -40,6 +40,7 @@ int bongo_cat_window_wait_timeout(const BongoCatApp *app, uint64_t now) { if (wait_ms > wheel_wait) wait_ms = wheel_wait; } if (app->hover_fade_active && wait_ms > 8) wait_ms = 8; + if (app->window_snapshot && wait_ms > 16) wait_ms = 16; if (app->session.window.visible && !app->window_minimized && (app->click_through_applied || (app->settings.window.pass_through && app->settings.window.always_on_top && app->settings.window.hide_on_hover)) && diff --git a/src/runtime/lifecycle/shutdown.c b/src/runtime/lifecycle/shutdown.c index 1a1ed4e0..080c60c4 100644 --- a/src/runtime/lifecycle/shutdown.c +++ b/src/runtime/lifecycle/shutdown.c @@ -16,6 +16,7 @@ void bongo_cat_app_shutdown(BongoCatApp *app, const char *stage, "[runtime] Shutdown started: stage=%s exit_code=%d", stage, exit_code); bongo_cat_app_capture_behavior_state(app); + bongo_cat_window_snapshot_discard(app); bongo_cat_config_store_flush(app); bongo_cat_multi_pet_shutdown(app); bongo_cat_model_refresh_shutdown(app); diff --git a/src/runtime/model/import/model_import_archive.c b/src/runtime/model/import/model_import_archive.c new file mode 100644 index 00000000..71f32954 --- /dev/null +++ b/src/runtime/model/import/model_import_archive.c @@ -0,0 +1,168 @@ +#include "model_import_archive.h" +#include "model_import_path.h" +#include "model_storage.h" +#include "bongo_cat/path.h" +#include +#include +#include +#include + +#define ARCHIVE_BYTES_LIMIT (UINT64_C(2) * 1024 * 1024 * 1024) +#define ARCHIVE_ENTRY_LIMIT 20000 + +bool bongo_cat_import_is_archive(const char *source) { + return source && bongo_cat_import_has_suffix_ci(source, ".zip") && + !bongo_cat_path_is_dir(source); +} + +static size_t read_archive(void *userdata, mz_uint64 offset, void *buffer, + size_t size) { + SDL_IOStream *stream = userdata; + if (offset > INT64_MAX || SDL_SeekIO(stream, (Sint64)offset, + SDL_IO_SEEK_SET) < 0) return 0; + return SDL_ReadIO(stream, buffer, size); +} + +typedef struct ArchiveOutput { + SDL_IOStream *stream; + uint64_t remaining; + bool failed; +} ArchiveOutput; + +static size_t write_archive(void *userdata, mz_uint64 offset, + const void *buffer, size_t size) { + (void)offset; + ArchiveOutput *output = userdata; + if (size > output->remaining) return 0; + size_t written = SDL_WriteIO(output->stream, buffer, size); + output->remaining -= written; + if (written != size) output->failed = true; + return written; +} + +/* Reject aliases and special Windows paths on every platform, so an archive + has the same meaning wherever it is imported. Never materialize links. */ +static bool safe_path(char *path) { + for (char *p = path; *p; ++p) if (*p == '\\') *p = '/'; + if (!path[0] || path[0] == '/') return false; + unsigned depth = 0; + for (char *part = path; *part;) { + char *end = strchr(part, '/'); + size_t length = end ? (size_t)(end - part) : strlen(part); + if (!length || ++depth > 24 || part[length - 1] == '.' || + part[length - 1] == ' ') return false; + for (size_t i = 0; i < length; ++i) + if ((unsigned char)part[i] < 32 || strchr(":*?\"<>|", part[i])) + return false; + size_t stem = 0; + while (stem < length && part[stem] != '.') ++stem; + if ((stem == 3 && (!SDL_strncasecmp(part, "CON", 3) || + !SDL_strncasecmp(part, "PRN", 3) || + !SDL_strncasecmp(part, "AUX", 3) || + !SDL_strncasecmp(part, "NUL", 3))) || + (stem == 4 && (!SDL_strncasecmp(part, "COM", 3) || + !SDL_strncasecmp(part, "LPT", 3)) && + part[3] >= '0' && part[3] <= '9')) return false; + part = end ? end + 1 : part + length; + } + return true; +} + +static BongoCatResult extract_entries(mz_zip_archive *zip, const char *root) { + mz_uint count = mz_zip_reader_get_num_files(zip); + if (!count || count > ARCHIVE_ENTRY_LIMIT) return BONGO_CAT_ERROR_FORMAT; + uint64_t total = 0; + for (mz_uint i = 0; i < count; ++i) { + mz_zip_archive_file_stat stat; + char name[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; + if (!mz_zip_reader_file_stat(zip, i, &stat)) + return BONGO_CAT_ERROR_FORMAT; + mz_uint length = mz_zip_reader_get_filename(zip, i, NULL, 0); + if (!length || length > sizeof(name) || + mz_zip_reader_get_filename(zip, i, name, sizeof(name)) != length) + return BONGO_CAT_ERROR_FORMAT; + unsigned kind = (stat.m_external_attr >> 16) & 0170000; + if (!length || length > sizeof(name) || strlen(name) + 1 != length || + !safe_path(name) || stat.m_is_encrypted || !stat.m_is_supported || + (kind && kind != 0100000 && kind != 0040000) || + stat.m_uncomp_size > ARCHIVE_BYTES_LIMIT - total || + !bongo_cat_path_join(target, sizeof(target), root, name)) + return BONGO_CAT_ERROR_FORMAT; + total += stat.m_uncomp_size; + if (stat.m_is_directory) { + if (!bongo_cat_path_create_directory(target)) return BONGO_CAT_ERROR_IO; + continue; + } + char parent[BONGO_CAT_PATH_CAP]; + if (bongo_cat_path_is_file(target) || bongo_cat_path_is_dir(target)) + return BONGO_CAT_ERROR_FORMAT; + if (!bongo_cat_import_parent_path(target, parent, sizeof(parent)) || + !bongo_cat_path_create_directory(parent)) return BONGO_CAT_ERROR_IO; + SDL_IOStream *output = SDL_IOFromFile(target, "wb"); + if (!output) return BONGO_CAT_ERROR_IO; + ArchiveOutput destination = {output, stat.m_uncomp_size, false}; + bool extracted = mz_zip_reader_extract_to_callback(zip, i, + write_archive, &destination, 0) != 0; + bool closed = SDL_CloseIO(output); + if (!closed || destination.failed) return BONGO_CAT_ERROR_IO; + if (!extracted || destination.remaining) return BONGO_CAT_ERROR_FORMAT; + } + return BONGO_CAT_OK; +} + +void bongo_cat_import_archive_cleanup(const char *temporary) { + if (temporary && temporary[0] && + !bongo_cat_model_remove_tree(temporary, NULL)) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Cannot remove model archive temporary directory: %s", temporary); +} + +BongoCatResult bongo_cat_import_archive_extract(const char *source, + char directory[BONGO_CAT_PATH_CAP], char temporary[BONGO_CAT_PATH_CAP], + BongoCatError *error) { + directory[0] = temporary[0] = '\0'; + SDL_IOStream *input = SDL_IOFromFile(source, "rb"); + if (!input) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, "Cannot open ZIP: %s", source); + return BONGO_CAT_ERROR_IO; + } + mz_zip_archive zip = {0}; + zip.m_pRead = read_archive; + zip.m_pIO_opaque = input; + Sint64 size = SDL_GetIOSize(input); + BongoCatResult result = BONGO_CAT_ERROR_FORMAT; + if (size > 0 && mz_zip_reader_init(&zip, (mz_uint64)size, 0)) { + char *base = SDL_GetPrefPath("BongoCat", "archive-import"); + char token[80], name[BONGO_CAT_PATH_CAP]; + snprintf(token, sizeof(token), "%016llx-%016llx", + (unsigned long long)SDL_GetPerformanceCounter(), + (unsigned long long)SDL_GetTicksNS()); + int written = snprintf(name, sizeof(name), "%s", bongo_cat_path_name(source)); + result = BONGO_CAT_ERROR_IO; + if (base && written >= 4 && (size_t)written < sizeof(name)) { + name[written - 4] = '\0'; + if (!safe_path(name)) snprintf(name, sizeof(name), "model"); + char candidate[BONGO_CAT_PATH_CAP]; + if (bongo_cat_path_join(candidate, sizeof(candidate), base, token) && + !bongo_cat_path_is_dir(candidate) && + !bongo_cat_path_is_file(candidate) && + bongo_cat_path_create_directory(candidate)) { + snprintf(temporary, BONGO_CAT_PATH_CAP, "%s", candidate); + if (bongo_cat_path_join(directory, BONGO_CAT_PATH_CAP, temporary, name) && + bongo_cat_path_create_directory(directory)) + result = extract_entries(&zip, directory); + } + } + SDL_free(base); + mz_zip_reader_end(&zip); + } + SDL_CloseIO(input); + if (result != BONGO_CAT_OK) { + bongo_cat_import_archive_cleanup(temporary); + directory[0] = temporary[0] = '\0'; + bongo_cat_error_set(error, result, + "Cannot extract ZIP (damaged, encrypted, unsafe, too large, or inaccessible): %s", + source); + } + return result; +} diff --git a/src/runtime/model/import/model_import_archive.h b/src/runtime/model/import/model_import_archive.h new file mode 100644 index 00000000..80108b62 --- /dev/null +++ b/src/runtime/model/import/model_import_archive.h @@ -0,0 +1,12 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_ARCHIVE_H +#define BONGO_CAT_MODEL_IMPORT_ARCHIVE_H + +#include "model_import.h" + +bool bongo_cat_import_is_archive(const char *source); +BongoCatResult bongo_cat_import_archive_extract(const char *source, + char directory[BONGO_CAT_PATH_CAP], char temporary[BONGO_CAT_PATH_CAP], + BongoCatError *error); +void bongo_cat_import_archive_cleanup(const char *temporary); + +#endif diff --git a/src/runtime/model/import/model_import_manifest.c b/src/runtime/model/import/model_import_manifest.c index 13a44d86..4eec4df8 100644 --- a/src/runtime/model/import/model_import_manifest.c +++ b/src/runtime/model/import/model_import_manifest.c @@ -36,37 +36,43 @@ static bool referenced_texture(const char *root, const char *relative) { } static bool optional_reference(const char *root, yyjson_val *refs, - const char *name) { + const char *name, bool allow_missing) { yyjson_val *value = yyjson_obj_get(refs, name); if (!value) return true; const char *relative = yyjson_get_str(value); - return relative && referenced_file(root, relative); + return relative && (allow_missing ? (!relative[0] || safe_reference(relative)) + : referenced_file(root, relative)); } -static bool behavior_references(const char *root, yyjson_val *refs) { +static bool behavior_references(const char *root, yyjson_val *refs, + bool allow_missing) { yyjson_val *expressions = yyjson_obj_get(refs, "Expressions"); if (expressions && !yyjson_is_arr(expressions)) return false; size_t index, count; yyjson_val *item; - yyjson_arr_foreach(expressions, index, count, item) - if (!referenced_file(root, yyjson_get_str(yyjson_obj_get(item, "File")))) + yyjson_arr_foreach(expressions, index, count, item) { + const char *file = yyjson_get_str(yyjson_obj_get(item, "File")); + if (!(allow_missing ? safe_reference(file) : referenced_file(root, file))) return false; + } yyjson_val *motions = yyjson_obj_get(refs, "Motions"); if (motions && !yyjson_is_obj(motions)) return false; size_t group_index, group_count; yyjson_val *key, *group; yyjson_obj_foreach(motions, group_index, group_count, key, group) { if (!yyjson_is_arr(group)) return false; yyjson_arr_foreach(group, index, count, item) { - if (!referenced_file(root, yyjson_get_str(yyjson_obj_get(item, "File")))) + const char *file = yyjson_get_str(yyjson_obj_get(item, "File")); + if (!(allow_missing ? safe_reference(file) : referenced_file(root, file))) return false; const char *sound = yyjson_get_str(yyjson_obj_get(item, "Sound")); - if (sound && !safe_reference(sound)) return false; + if (sound && !(allow_missing && !sound[0]) && !safe_reference(sound)) + return false; } } return true; } bool bongo_cat_import_manifest_document_valid(const char *root, - yyjson_doc *document) { + yyjson_doc *document, bool allow_missing_optional) { yyjson_val *manifest = document ? yyjson_doc_get_root(document) : NULL; yyjson_val *refs = yyjson_is_obj(manifest) ? yyjson_obj_get(manifest, "FileReferences") : NULL; @@ -78,9 +84,11 @@ bool bongo_cat_import_manifest_document_valid(const char *root, size_t index, maximum; yyjson_val *texture; yyjson_arr_foreach(textures, index, maximum, texture) valid = valid && referenced_texture(root, yyjson_get_str(texture)); - valid = valid && optional_reference(root, refs, "Physics") && - optional_reference(root, refs, "Pose") && - optional_reference(root, refs, "DisplayInfo") && behavior_references(root, refs); + valid = valid && optional_reference(root, refs, "Physics", allow_missing_optional) && + optional_reference(root, refs, "Pose", allow_missing_optional) && + optional_reference(root, refs, "DisplayInfo", allow_missing_optional) && + (!allow_missing_optional || optional_reference(root, refs, "UserData", true)) && + behavior_references(root, refs, allow_missing_optional); return valid; } @@ -91,7 +99,7 @@ bool bongo_cat_import_manifest_valid(const char *root, const char *setting, FILE *file = bongo_cat_file_open(path, "rb"); yyjson_doc *document = file ? yyjson_read_fp(file, 0, NULL, NULL) : NULL; if (file) fclose(file); - bool valid = bongo_cat_import_manifest_document_valid(root, document); + bool valid = bongo_cat_import_manifest_document_valid(root, document, false); yyjson_doc_free(document); if (!valid && error) bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, "Model manifest or referenced assets are invalid: %s", path); diff --git a/src/runtime/model/import/model_import_manifest.h b/src/runtime/model/import/model_import_manifest.h index e7e644c7..dd055f46 100644 --- a/src/runtime/model/import/model_import_manifest.h +++ b/src/runtime/model/import/model_import_manifest.h @@ -5,7 +5,7 @@ #include bool bongo_cat_import_manifest_document_valid(const char *root, - yyjson_doc *document); + yyjson_doc *document, bool allow_missing_optional); /* Validates a Live2D manifest and every referenced local asset. */ bool bongo_cat_import_manifest_valid(const char *root, const char *setting, diff --git a/src/runtime/model/import/model_import_progressive.c b/src/runtime/model/import/model_import_progressive.c index c52d20dd..3e111ff1 100644 --- a/src/runtime/model/import/model_import_progressive.c +++ b/src/runtime/model/import/model_import_progressive.c @@ -1,4 +1,5 @@ #include "model_import.h" +#include "model_import_archive.h" #include "model_import_probe.h" #include "bongo_cat/path.h" @@ -125,7 +126,7 @@ static BongoCatResult fail_source(BongoCatImportBatchStats *stats, return result; } -BongoCatResult bongo_cat_import_session_install_progressive( +static BongoCatResult install_progressive_directory( BongoCatImportSession *session, const char *source, BongoCatImportReceiptCallback callback, void *userdata, BongoCatImportBatchStats *stats, BongoCatError *error) { @@ -201,3 +202,22 @@ BongoCatResult bongo_cat_import_session_install_progressive( if (first_failure != BONGO_CAT_OK && error) *error = first_error; return first_failure; } + +BongoCatResult bongo_cat_import_session_install_progressive( + BongoCatImportSession *session, const char *source, + BongoCatImportReceiptCallback callback, void *userdata, + BongoCatImportBatchStats *stats, BongoCatError *error) { + if (!stats) return BONGO_CAT_ERROR_ARGUMENT; + if (!session || !source || !bongo_cat_import_is_archive(source)) + return install_progressive_directory(session, source, callback, + userdata, stats, error); + *stats = (BongoCatImportBatchStats){0}; + char directory[BONGO_CAT_PATH_CAP], temporary[BONGO_CAT_PATH_CAP]; + BongoCatResult result = bongo_cat_import_archive_extract(source, + directory, temporary, error); + if (result != BONGO_CAT_OK) return fail_source(stats, source, result); + result = install_progressive_directory(session, directory, callback, + userdata, stats, error); + bongo_cat_import_archive_cleanup(temporary); + return result; +} diff --git a/src/runtime/model/import/model_import_session.c b/src/runtime/model/import/model_import_session.c index 58d058e9..1441cb1c 100644 --- a/src/runtime/model/import/model_import_session.c +++ b/src/runtime/model/import/model_import_session.c @@ -1,4 +1,5 @@ #include "model_import.h" +#include "model_import_archive.h" #include "model_import_lock.h" #include "model_import_session_internal.h" #include "model_storage.h" @@ -131,8 +132,15 @@ BongoCatResult bongo_cat_import_session_install( bongo_cat_import_storage_lock(); SDL_Log("[runtime] Model import storage lock: stage=acquired path=%s", source); - BongoCatResult result = session_install_unlocked(session, source, receipt, - error); + char directory[BONGO_CAT_PATH_CAP], temporary[BONGO_CAT_PATH_CAP] = ""; + BongoCatResult result = BONGO_CAT_OK; + bool archive = bongo_cat_import_is_archive(source); + if (archive) result = bongo_cat_import_archive_extract(source, directory, + temporary, error); + if (result == BONGO_CAT_OK) + result = session_install_unlocked(session, archive ? directory : source, + receipt, error); + bongo_cat_import_archive_cleanup(temporary); bongo_cat_import_storage_unlock(); SDL_Log("[runtime] Model import storage lock: stage=released result=%d " "path=%s", (int)result, source); diff --git a/src/runtime/model/import/mver/model_import_mver_assets.c b/src/runtime/model/import/mver/model_import_mver_assets.c index b05fd47c..92f6a164 100644 --- a/src/runtime/model/import/mver/model_import_mver_assets.c +++ b/src/runtime/model/import/mver/model_import_mver_assets.c @@ -11,11 +11,6 @@ typedef BongoCatMverKeyNames KeyNames; -static bool child_is_dir(const char *root, const char *name) { - char path[BONGO_CAT_PATH_CAP]; - return bongo_cat_path_join(path, sizeof(path), root, name) && bongo_cat_path_is_dir(path); -} - static bool asset_file(const BongoCatImportCandidate *candidate, const char *group, const char *name, char *output, size_t capacity) { char directory[BONGO_CAT_PATH_CAP]; @@ -156,7 +151,9 @@ static bool process_matrix(const BongoCatImportCandidate *candidate, yyjson_val yyjson_val *before, yyjson_val *after, yyjson_val *keyboard_matrix, const char *hand_name, const char *key_group, const char *target, BongoCatError *error) { + if (!matrix || yyjson_is_null(matrix)) return true; if (!yyjson_is_arr(matrix)) return false; + if (!yyjson_arr_size(matrix)) return true; char resources[BONGO_CAT_PATH_CAP], output_dir[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(resources, sizeof(resources), target, "resources") || !bongo_cat_path_join(output_dir, sizeof(output_dir), resources, key_group) || @@ -170,7 +167,8 @@ static bool process_matrix(const BongoCatImportCandidate *candidate, yyjson_val yyjson_arr_foreach(matrix, index, count, keys) { char hand[BONGO_CAT_PATH_CAP], filename[32]; snprintf(filename, sizeof(filename), "%zu.png", index); - if (!asset_file(candidate, hand_name, filename, hand, sizeof(hand))) { + if (!asset_file(candidate, hand_name, filename, hand, sizeof(hand)) || + !bongo_cat_image_info(hand, NULL, NULL)) { size_t missing_index, missing_count; yyjson_val *missing; yyjson_arr_foreach(keys, missing_index, missing_count, missing) { int modifier = (yyjson_is_int(missing) || yyjson_is_uint(missing)) @@ -193,7 +191,8 @@ static bool process_matrix(const BongoCatImportCandidate *candidate, yyjson_val if (keyboard_index(keyboard_matrix, code, &keyboard_row)) { snprintf(filename, sizeof(filename), "%zu.png", keyboard_row); if (asset_file(candidate, "keyboard", filename, keyboard, - sizeof(keyboard))) keyboard_path = keyboard; + sizeof(keyboard)) && bongo_cat_image_info(keyboard, NULL, NULL)) + keyboard_path = keyboard; } int modifier = bongo_cat_mver_modifier_index(code); size_t occurrence = modifier >= 0 ? modifier_seen[modifier]++ : 0; @@ -215,8 +214,6 @@ bool bongo_cat_import_mver_assets(const BongoCatImportCandidate *candidate, const char *target, BongoCatError *error) { if (candidate->format != BONGO_CAT_IMPORT_MVER && candidate->format != BONGO_CAT_IMPORT_MVER_PATCH) return true; - const char *left = candidate->mode == BONGO_CAT_MODE_STANDARD ? "hand" : "lefthand"; - if (!child_is_dir(candidate->assets, left)) return false; FILE *file = bongo_cat_file_open(candidate->config, "rb"); yyjson_doc *document = file ? yyjson_read_fp(file, YYJSON_READ_JSON5 | YYJSON_READ_ALLOW_INVALID_UNICODE, NULL, NULL) : NULL; @@ -234,15 +231,14 @@ bool bongo_cat_import_mver_assets(const BongoCatImportCandidate *candidate, yyjson_val *keyboard = yyjson_obj_get(mode, "keyboard"); if (candidate->mode == BONGO_CAT_MODE_STANDARD) { yyjson_val *hand = yyjson_obj_get(mode, "hand"); - if (yyjson_is_arr(hand)) ok = process_matrix(candidate, hand, NULL, NULL, + ok = process_matrix(candidate, hand, NULL, NULL, keyboard, "hand", "left-keys", target, error); if (ok) ok = copy_standard_pointer_assets(candidate, target); } else { yyjson_val *left_keys = yyjson_obj_get(mode, "lefthand"); yyjson_val *right_keys = yyjson_obj_get(mode, "righthand"); - if (yyjson_is_arr(left_keys) && yyjson_is_arr(right_keys)) - ok = process_matrix(candidate, left_keys, NULL, right_keys, keyboard, - "lefthand", "left-keys", target, error) && + ok = process_matrix(candidate, left_keys, NULL, right_keys, keyboard, + "lefthand", "left-keys", target, error) && process_matrix(candidate, right_keys, left_keys, NULL, keyboard, "righthand", "right-keys", target, error); } diff --git a/src/runtime/model/import/mver/model_import_mver_audio.c b/src/runtime/model/import/mver/model_import_mver_audio.c index 64e73376..1049c751 100644 --- a/src/runtime/model/import/mver/model_import_mver_audio.c +++ b/src/runtime/model/import/mver/model_import_mver_audio.c @@ -36,7 +36,7 @@ bool bongo_cat_mver_add_audio(void *raw_output, void *raw_items, yyjson_mut_doc *output = raw_output; yyjson_mut_val *items = raw_items; yyjson_val *config = raw_config, *rows = raw_rows; - if (!rows) return true; + if (!rows || yyjson_is_null(rows)) return true; if (!yyjson_is_arr(rows)) return false; char target_resources[BONGO_CAT_PATH_CAP], target_sounds[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(target_resources, sizeof(target_resources), target, "resources") || @@ -49,9 +49,11 @@ bool bongo_cat_mver_add_audio(void *raw_output, void *raw_items, yyjson_arr_foreach(rows, index, count, row) { char shortcut[BONGO_CAT_SHORTCUT_CAP], source[BONGO_CAT_PATH_CAP]; char relative[BONGO_CAT_PATH_CAP], destination[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_mver_sound_chord(row, shortcut, sizeof(shortcut))) return false; + if (yyjson_is_null(row) || (yyjson_is_arr(row) && !yyjson_arr_size(row))) + continue; if (!sound_source(candidate, index, source, sizeof(source), relative, sizeof(relative))) continue; + if (!bongo_cat_mver_sound_chord(row, shortcut, sizeof(shortcut))) return false; const char *name = bongo_cat_path_name(source); if (!bongo_cat_path_join(destination, sizeof(destination), target_sounds, name) || !bongo_cat_path_copy_file(source, destination)) return false; diff --git a/src/runtime/model/import/mver/model_import_mver_copy.c b/src/runtime/model/import/mver/model_import_mver_copy.c index c3dd849b..a946c187 100644 --- a/src/runtime/model/import/mver/model_import_mver_copy.c +++ b/src/runtime/model/import/mver/model_import_mver_copy.c @@ -1,5 +1,4 @@ #include "model_import_mver_copy.h" -#include "model_import_mver_manifest.h" #include "model_import_path.h" #include "bongo_cat/path.h" @@ -80,10 +79,6 @@ static BongoCatPathVisit copy_child(void *userdata, const char *dirname, name)) return BONGO_CAT_PATH_FAILURE; if (!bongo_cat_path_is_dir(source)) { if (context->depth == 0) return BONGO_CAT_PATH_CONTINUE; - if (bongo_cat_import_has_suffix_ci(name, ".model3.json")) - return bongo_cat_import_mver_manifest_copy(dirname, name, - source, target, context->error) - ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; return bongo_cat_path_copy_file(source, target) ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; } diff --git a/src/runtime/model/import/mver/model_import_mver_discovery.c b/src/runtime/model/import/mver/model_import_mver_discovery.c index e127f2b0..341190f6 100644 --- a/src/runtime/model/import/mver/model_import_mver_discovery.c +++ b/src/runtime/model/import/mver/model_import_mver_discovery.c @@ -24,13 +24,22 @@ static bool numbered_asset(const char *directory, const char *group) { } static bool mver_shape(const char *image_root) { - char mode[BONGO_CAT_PATH_CAP]; - if (bongo_cat_path_join(mode, sizeof(mode), image_root, "standard") && - numbered_asset(mode, "hand")) return true; - const char *names[] = {"keyboard", "gamepad"}; - for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) - if (bongo_cat_path_join(mode, sizeof(mode), image_root, names[i]) && - numbered_asset(mode, "lefthand") && numbered_asset(mode, "righthand")) return true; + char mode[BONGO_CAT_PATH_CAP], model[BONGO_CAT_PATH_CAP]; + char setting[BONGO_CAT_PATH_CAP]; + const char *names[] = {"standard", "keyboard", "gamepad"}; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) { + if (!bongo_cat_path_join(mode, sizeof(mode), image_root, names[i])) + continue; + if (i == 0 ? numbered_asset(mode, "hand") : + numbered_asset(mode, "lefthand") && numbered_asset(mode, "righthand")) + return true; + /* Live2D packages may omit all optional hand sprites. */ + if (bongo_cat_path_find_suffix(mode, ".model3.json", setting, + sizeof(setting)) || + (bongo_cat_path_join(model, sizeof(model), mode, "cat_model") && + bongo_cat_path_find_suffix(model, ".model3.json", setting, + sizeof(setting)))) return true; + } return false; } @@ -63,15 +72,16 @@ static bool find_package(const char *source, char *package, size_t capacity, return false; } -static bool mode_config_valid(yyjson_val *mode, BongoCatModelMode value, - const char *directory) { +static bool optional_matrix(yyjson_val *mode, const char *name) { + yyjson_val *value = yyjson_obj_get(mode, name); + return !value || yyjson_is_null(value) || yyjson_is_arr(value); +} + +static bool mode_config_valid(yyjson_val *mode, BongoCatModelMode value) { if (!yyjson_is_obj(mode)) return false; if (value == BONGO_CAT_MODE_STANDARD) - return yyjson_is_arr(yyjson_obj_get(mode, "hand")) && - numbered_asset(directory, "hand"); - return yyjson_is_arr(yyjson_obj_get(mode, "lefthand")) && - yyjson_is_arr(yyjson_obj_get(mode, "righthand")) && - numbered_asset(directory, "lefthand") && numbered_asset(directory, "righthand"); + return optional_matrix(mode, "hand"); + return optional_matrix(mode, "lefthand") && optional_matrix(mode, "righthand"); } static bool mode_uses_live2d(yyjson_val *mode) { @@ -102,15 +112,15 @@ static bool add_mode(BongoCatImportDiscovery *discovery, const char *source, if (!bongo_cat_path_join(mode_root, sizeof(mode_root), image_root, name)) return false; yyjson_val *mode_config = yyjson_obj_get(root, name); if (!bongo_cat_path_is_dir(mode_root) || !yyjson_is_obj(mode_config)) return true; - if (!mode_config_valid(mode_config, mode, mode_root)) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Mver mode has invalid input mappings or numbered assets: %s", mode_root); - return false; - } /* Mver distributions bundle fallback Live2D files for modes that are configured to use static sprites. They are runtime templates, not authored model variants, so they must not become import candidates. */ if (!mode_uses_live2d(mode_config)) return true; + if (!mode_config_valid(mode_config, mode)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Mver mode has invalid input mappings: %s", mode_root); + return false; + } if (discovery->count >= BONGO_CAT_IMPORT_CANDIDATE_CAP) return false; BongoCatImportCandidate *candidate = &discovery->candidates[discovery->count]; if (!find_mode_model(mode_root, candidate->directory, sizeof(candidate->directory), diff --git a/src/runtime/model/import/mver/model_import_mver_effect.c b/src/runtime/model/import/mver/model_import_mver_effect.c index 2b068ee3..c73dbe4e 100644 --- a/src/runtime/model/import/mver/model_import_mver_effect.c +++ b/src/runtime/model/import/mver/model_import_mver_effect.c @@ -22,7 +22,8 @@ static bool clear_binding(yyjson_mut_doc *output, yyjson_mut_val *items, yyjson_val *root, const BongoCatImportCandidate *candidate) { yyjson_val *decoration = yyjson_obj_get(root, "decoration"); yyjson_val *row = yyjson_obj_get(decoration, "emoticonClear"); - if (!row) return true; + if (!row || yyjson_is_null(row) || + (yyjson_is_arr(row) && !yyjson_arr_size(row))) return true; char shortcut[BONGO_CAT_SHORTCUT_CAP]; if (!bongo_cat_mver_chord(candidate, row, shortcut, sizeof(shortcut))) return false; yyjson_mut_val *item = yyjson_mut_arr_add_obj(output, items); @@ -50,11 +51,13 @@ bool bongo_cat_mver_effects(void *raw_output, void *raw_items, void *raw_root, yyjson_arr_foreach(rows, index, count, row) { char shortcut[BONGO_CAT_SHORTCUT_CAP], name[32]; char source[BONGO_CAT_PATH_CAP], destination[BONGO_CAT_PATH_CAP]; + if (yyjson_is_null(row) || (yyjson_is_arr(row) && !yyjson_arr_size(row))) + continue; snprintf(name, sizeof(name), "%zu.png", index); + if (!effect_source(candidate, name, source, sizeof(source)) || + !bongo_cat_image_info(source, NULL, NULL)) continue; if (!bongo_cat_mver_chord(candidate, row, shortcut, sizeof(shortcut))) return false; - if (!effect_source(candidate, name, source, sizeof(source))) continue; - if (!bongo_cat_image_info(source, NULL, NULL) || - !bongo_cat_path_join(destination, sizeof(destination), target_effects, name) || + if (!bongo_cat_path_join(destination, sizeof(destination), target_effects, name) || !bongo_cat_path_copy_file(source, destination)) return false; yyjson_mut_val *item = yyjson_mut_arr_add_obj(output, items); char relative[BONGO_CAT_PATH_CAP]; diff --git a/src/runtime/model/import/mver/model_import_mver_manifest.c b/src/runtime/model/import/mver/model_import_mver_manifest.c index 7b1a1621..f7561a31 100644 --- a/src/runtime/model/import/mver/model_import_mver_manifest.c +++ b/src/runtime/model/import/mver/model_import_mver_manifest.c @@ -1,65 +1,11 @@ #include "model_import_mver_manifest.h" #include "../model_import_manifest.h" -#include "bongo_cat/file.h" #include "bongo_cat/json.h" #include "bongo_cat/path.h" -#include -#include -#include - -static size_t skip_space(const char *data, size_t size, size_t pos) { - while (pos < size && (data[pos] == ' ' || data[pos] == '\t' || - data[pos] == '\r' || data[pos] == '\n')) pos++; - return pos; -} - -static yyjson_doc *repair_closures(char *data, size_t size, - const yyjson_read_err *parse_error, yyjson_read_flag flags) { - size_t first = parse_error->pos; - /* Legacy hand-edited manifests contain an extra '] }' before the - actual FileReferences closing brace. Only remove this exact form - at the parser's error position, then reparse the entire document. */ - if (first >= size || data[first] != ']') return NULL; - size_t second = skip_space(data, size, first + 1); - size_t third = skip_space(data, size, second + 1); - if (second >= size || third >= size || data[second] != '}' || - data[third] != '}') return NULL; - data[first] = data[second] = ' '; - yyjson_doc *document = yyjson_read_opts(data, size, flags, NULL, NULL); - yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; - if (!yyjson_is_obj(root) || - !yyjson_is_obj(yyjson_obj_get(root, "FileReferences"))) { - yyjson_doc_free(document); - return NULL; - } - return document; -} - yyjson_doc *bongo_cat_import_mver_manifest_read(const char *path, bool *repaired) { - if (repaired) *repaired = false; - uint64_t size; - if (!bongo_cat_path_file_size(path, &size) || !size || - size > 4u * 1024u * 1024u) return NULL; - char *data = malloc((size_t)size); - if (!data) return NULL; - FILE *file = bongo_cat_file_open(path, "rb"); - bool loaded = file && fread(data, 1, (size_t)size, file) == (size_t)size; - if (file && fclose(file) != 0) loaded = false; - if (!loaded) { free(data); return NULL; } - yyjson_doc *document = yyjson_read_opts(data, (size_t)size, 0, NULL, NULL); - if (!document) { - yyjson_read_flag flags = YYJSON_READ_ALLOW_COMMENTS | - YYJSON_READ_ALLOW_TRAILING_COMMAS; - yyjson_read_err parse_error = {0}; - document = yyjson_read_opts(data, (size_t)size, flags, NULL, &parse_error); - if (!document) document = repair_closures(data, (size_t)size, - &parse_error, flags); - if (repaired) *repaired = document != NULL; - } - free(data); - return document; + return bongo_cat_model_json_read(path, repaired); } bool bongo_cat_import_mver_manifest_valid(const char *root, @@ -67,29 +13,7 @@ bool bongo_cat_import_mver_manifest_valid(const char *root, char path[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(path, sizeof(path), root, setting)) return false; yyjson_doc *document = bongo_cat_import_mver_manifest_read(path, NULL); - bool valid = bongo_cat_import_manifest_document_valid(root, document); + bool valid = bongo_cat_import_manifest_document_valid(root, document, true); yyjson_doc_free(document); return valid; } - -bool bongo_cat_import_mver_manifest_copy(const char *root, const char *name, - const char *source, const char *target, BongoCatError *error) { - bool repaired = false; - yyjson_doc *document = bongo_cat_import_mver_manifest_read(source, &repaired); - if (!repaired) { - yyjson_doc_free(document); - return bongo_cat_path_copy_file(source, target); - } - bool valid = bongo_cat_import_manifest_document_valid(root, document); - yyjson_mut_doc *output = valid ? yyjson_doc_mut_copy(document, NULL) : NULL; - yyjson_doc_free(document); - bool ok = output && bongo_cat_json_write_file(target, output, - YYJSON_WRITE_PRETTY, NULL); - yyjson_mut_doc_free(output); - if (!ok) bongo_cat_error_set(error, - valid ? BONGO_CAT_ERROR_IO : BONGO_CAT_ERROR_FORMAT, - "Cannot normalize Mver model manifest: %s", source); - else SDL_Log("Normalized Mver model manifest during import: %s (%s)", - source, name); - return ok; -} diff --git a/src/runtime/model/import/mver/model_import_mver_manifest.h b/src/runtime/model/import/mver/model_import_mver_manifest.h index cd97c91c..18dfb1f7 100644 --- a/src/runtime/model/import/mver/model_import_mver_manifest.h +++ b/src/runtime/model/import/mver/model_import_mver_manifest.h @@ -4,12 +4,10 @@ #include "bongo_cat/common.h" #include -/* Compatibility is limited to Mver sources; installed JSON remains strict. */ +/* Compatibility is applied when reading, without modifying model files. */ yyjson_doc *bongo_cat_import_mver_manifest_read(const char *path, bool *repaired); bool bongo_cat_import_mver_manifest_valid(const char *root, const char *setting); -bool bongo_cat_import_mver_manifest_copy(const char *root, const char *name, - const char *source, const char *target, BongoCatError *error); #endif diff --git a/src/runtime/model/import/mver/model_import_mver_motion.c b/src/runtime/model/import/mver/model_import_mver_motion.c index 0e1f547e..4b86a24b 100644 --- a/src/runtime/model/import/mver/model_import_mver_motion.c +++ b/src/runtime/model/import/mver/model_import_mver_motion.c @@ -8,20 +8,27 @@ static bool add_rows(yyjson_mut_doc *output, yyjson_mut_val *items, yyjson_val *rows, const BongoCatImportCandidate *candidate, - const char *kind, const char *field, const char *group, size_t available, + const char *kind, const char *field, const char *group, yyjson_val *available, const BongoCatMverLabels *labels, BongoCatError *error) { - if (!rows) return true; + if (!rows || yyjson_is_null(rows)) return true; if (!yyjson_is_arr(rows)) { bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, "Mver %s bindings exceed the matching Live2D manifest entries", kind); return false; } size_t limit = yyjson_arr_size(rows); - if (limit > available) limit = available; + if (limit > yyjson_arr_size(available)) limit = yyjson_arr_size(available); size_t index, count; yyjson_val *row; yyjson_arr_foreach(rows, index, count, row) { if (index >= limit) break; + if (yyjson_is_null(row) || (yyjson_is_arr(row) && !yyjson_arr_size(row))) + continue; + const char *file = yyjson_get_str(yyjson_obj_get( + yyjson_arr_get(available, index), "File")); + char path[BONGO_CAT_PATH_CAP]; + if (!file || !bongo_cat_path_join(path, sizeof(path), candidate->directory, + file) || !bongo_cat_path_is_file(path)) continue; char shortcut[BONGO_CAT_SHORTCUT_CAP]; if (!bongo_cat_mver_chord(candidate, row, shortcut, sizeof(shortcut))) { bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, @@ -56,9 +63,8 @@ static bool add_motion_group(yyjson_mut_doc *output, yyjson_mut_val *items, const BongoCatMverLabels *labels, BongoCatError *error) { yyjson_val *available = yyjson_obj_get(motions, group); if (yyjson_is_arr(configured) && !yyjson_is_arr(available)) return true; - size_t count = yyjson_is_arr(available) ? yyjson_arr_size(available) : 0; return add_rows(output, items, configured, candidate, "motion", field, - group, count, labels, error); + group, available, labels, error); } bool bongo_cat_mver_add_behaviors(void *raw_output, void *raw_items, @@ -73,8 +79,7 @@ bool bongo_cat_mver_add_behaviors(void *raw_output, void *raw_items, bool ok = document && add_rows(output, items, yyjson_obj_get(mode, "l2d_expression"), candidate, "expression", "l2d_expression", NULL, - yyjson_is_arr(expressions) ? yyjson_arr_size(expressions) : 0, - labels, error) && + expressions, labels, error) && add_motion_group(output, items, yyjson_obj_get(mode, "l2d_motion"), motions, "l2d_motion", "CAT_motion", candidate, labels, error) && add_motion_group(output, items, diff --git a/src/runtime/model/import/nearby/model_import_nearby_internal.h b/src/runtime/model/import/nearby/model_import_nearby_internal.h index 79983120..9b539c57 100644 --- a/src/runtime/model/import/nearby/model_import_nearby_internal.h +++ b/src/runtime/model/import/nearby/model_import_nearby_internal.h @@ -4,7 +4,7 @@ #include "model_import.h" #define BONGO_CAT_NEARBY_CACHE_MARKER ".bongo-cat-cache.json" -#define BONGO_CAT_NEARBY_CACHE_SCHEMA 6 +#define BONGO_CAT_NEARBY_CACHE_SCHEMA 7 #define BONGO_CAT_ADAPTER_CACHE_DIRECTORY "model-adapters" bool bongo_cat_nearby_signature(const BongoCatImportCandidate *candidate, diff --git a/src/runtime/model/model_files.c b/src/runtime/model/model_files.c index 4456097c..ae865c4a 100644 --- a/src/runtime/model/model_files.c +++ b/src/runtime/model/model_files.c @@ -118,6 +118,7 @@ bool bongo_cat_app_select_model_with_error(BongoCatApp *app, "Model is not installed: %s", id); return false; } + bongo_cat_window_snapshot_end(app); if (app->loaded_model[0] && strcmp(app->loaded_model, entry->id) == 0) { commit_model(app, entry, false, false); return true; diff --git a/src/runtime/model/model_update.c b/src/runtime/model/model_update.c index d19ac3a4..6b4acb77 100644 --- a/src/runtime/model/model_update.c +++ b/src/runtime/model/model_update.c @@ -24,6 +24,7 @@ void bongo_cat_random_expression_reset(BongoCatApp *app) { } void bongo_cat_random_expression_update(BongoCatApp *app, uint64_t now) { + if (app && app->window_snapshot) return; if (!app || !app->settings.window.random_expression || !app->live2d) { bongo_cat_random_expression_reset(app); return; @@ -68,6 +69,7 @@ void bongo_cat_random_expression_update(BongoCatApp *app, uint64_t now) { } bool bongo_cat_app_step_live2d(BongoCatApp *app, float elapsed_seconds) { + if (app && app->window_snapshot) return false; if (!app || !app->live2d || elapsed_seconds <= 0.0f) return false; if (elapsed_seconds > 0.25f) elapsed_seconds = 0.25f; unsigned steps = 1; diff --git a/src/runtime/runtime.h b/src/runtime/runtime.h index ba56309d..4eace0b3 100644 --- a/src/runtime/runtime.h +++ b/src/runtime/runtime.h @@ -4,6 +4,7 @@ #include "bongo_cat/app.h" #include "bongo_cat/memory_policy.h" #include "update_service.h" +#include "window_snapshot.h" #include BongoCatResult bongo_cat_window_create(BongoCatApp *app, BongoCatError *error); @@ -67,6 +68,9 @@ bool bongo_cat_window_content_size(BongoCatApp *app, bool bongo_cat_window_apply_geometry(BongoCatApp *app, int x, int y, float scale, int width, int height); bool bongo_cat_window_set_scale(BongoCatApp *app, float scale); +bool bongo_cat_window_apply_scale_centered(BongoCatApp *app, float scale, + int base_width, int base_height, float base_scale, + float center_x, float center_y); void bongo_cat_window_clamp_to_display(BongoCatApp *app); void bongo_cat_window_drag_to(BongoCatApp *app, int x, int y); void bongo_cat_window_drag_bounds_refresh(BongoCatApp *app); diff --git a/src/runtime/shell/modal_frame.c b/src/runtime/shell/modal_frame.c index 2a3851bc..1a658587 100644 --- a/src/runtime/shell/modal_frame.c +++ b/src/runtime/shell/modal_frame.c @@ -25,6 +25,7 @@ void bongo_cat_modal_frame_tick(void *userdata) { app->last_frame_ns = now; state->tick_count++; bongo_cat_app_drain_input(app, true); + bongo_cat_window_snapshot_update(app, SDL_GetTicksNS()); now = SDL_GetTicksNS(); bongo_cat_app_update_hover(app, now); bongo_cat_app_update_hover_fade(app, now); diff --git a/src/runtime/shell/window.c b/src/runtime/shell/window.c index 1399faf8..6a6a152d 100644 --- a/src/runtime/shell/window.c +++ b/src/runtime/shell/window.c @@ -157,13 +157,18 @@ bool bongo_cat_window_event(BongoCatApp *app, const SDL_Event *event) { bongo_cat_window_drag_end(app); } if (event->type == SDL_EVENT_WINDOW_RESIZED) { - app->session.window.width = event->window.data1; - app->session.window.height = event->window.data2; - bongo_cat_window_content_size(app, event->window.data1, - event->window.data2, &app->session.window.content_width, - &app->session.window.content_height); - bongo_cat_window_clamp_to_display(app); - app->dirty = true; + /* Queued notifications may describe an earlier animation frame. */ + int width = app->session.window.width, height = app->session.window.height; + SDL_GetWindowSize(app->window, &width, &height); + if (width != app->session.window.width || height != app->session.window.height) { + app->session.window.width = width; + app->session.window.height = height; + bongo_cat_window_content_size(app, width, + height, &app->session.window.content_width, + &app->session.window.content_height); + bongo_cat_window_clamp_to_display(app); + app->dirty = true; + } } if (event->type == SDL_EVENT_WINDOW_EXPOSED || event->type == SDL_EVENT_WINDOW_SHOWN || @@ -172,7 +177,8 @@ bool bongo_cat_window_event(BongoCatApp *app, const SDL_Event *event) { expose is not proof that the window is currently restored. */ app->window_minimized = (SDL_GetWindowFlags(app->window) & SDL_WINDOW_MINIMIZED) != 0; - bongo_cat_app_reset_pointer_tracking(app); + if (event->type != SDL_EVENT_WINDOW_EXPOSED) + bongo_cat_app_reset_pointer_tracking(app); /* DWM can discard the transparent redirection surface after an Explorer/display refresh. Repaint even when the model is idle so the restored alpha surface is submitted immediately. */ @@ -184,17 +190,27 @@ bool bongo_cat_window_event(BongoCatApp *app, const SDL_Event *event) { } if (event->type == SDL_EVENT_WINDOW_RESIZED || event->type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED) { - SDL_GetWindowSizeInPixels(app->window, - &app->resize_pixel_width, &app->resize_pixel_height); - app->resize_pending = true; - bongo_cat_window_mark_hit_dirty(app); + int width = 0, height = 0; + if (SDL_GetWindowSizeInPixels(app->window, &width, &height) && + (width != app->resize_pixel_width || height != app->resize_pixel_height)) { + app->resize_pixel_width = width; + app->resize_pixel_height = height; + app->resize_pending = true; + app->dirty = true; + bongo_cat_window_mark_hit_dirty(app); + } } else if (event->type == SDL_EVENT_WINDOW_MOVED) { - app->session.window.x = event->window.data1; - app->session.window.y = event->window.data2; - app->session.window.position_known = true; - app->pointer_known = false; - if (!app->window_drag_active) bongo_cat_window_clamp_to_display(app); - bongo_cat_window_mark_hit_dirty(app); + int x = 0, y = 0; + if (SDL_GetWindowPosition(app->window, &x, &y) && + (!app->session.window.position_known || + x != app->session.window.x || y != app->session.window.y)) { + app->session.window.x = x; + app->session.window.y = y; + app->session.window.position_known = true; + app->pointer_known = false; + if (!app->window_drag_active) bongo_cat_window_clamp_to_display(app); + bongo_cat_window_mark_hit_dirty(app); + } } else if (event->type == SDL_EVENT_WINDOW_DISPLAY_CHANGED || event->type == SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED) { bongo_cat_app_reset_pointer_tracking(app); diff --git a/src/runtime/shell/window_display.c b/src/runtime/shell/window_display.c index 9e71299f..668be24d 100644 --- a/src/runtime/shell/window_display.c +++ b/src/runtime/shell/window_display.c @@ -202,6 +202,10 @@ void bongo_cat_window_drag_to(BongoCatApp *app, int x, int y) { app->session.window.x = next.x; app->session.window.y = next.y; app->session.window.position_known = true; bongo_cat_window_mark_hit_dirty(app); + if (app->window_snapshot) { + bongo_cat_window_snapshot_geometry(app, NULL); + app->dirty = true; + } } bool bongo_cat_window_recover_to_display(BongoCatApp *app) { @@ -218,6 +222,7 @@ void bongo_cat_window_display_event(BongoCatApp *app, const SDL_Event *event) { if (!app || !event) return; if (event->type < SDL_EVENT_DISPLAY_FIRST || event->type > SDL_EVENT_DISPLAY_LAST) return; + bongo_cat_window_snapshot_end(app); if (app->window_drag_active && app->settings.window.keep_in_screen) bongo_cat_window_drag_bounds_refresh(app); bongo_cat_app_reset_pointer_tracking(app); diff --git a/src/runtime/shell/window_drag.c b/src/runtime/shell/window_drag.c index 7e90ad05..61beecdd 100644 --- a/src/runtime/shell/window_drag.c +++ b/src/runtime/shell/window_drag.c @@ -53,6 +53,7 @@ void bongo_cat_window_drag_motion(BongoCatApp *app, if (x * x + y * y < 9.0f) return; app->drag_candidate = false; app->window_drag_active = true; + bongo_cat_window_snapshot_begin(app); if (use_pointer_drag(app)) { if (app->settings.window.keep_in_screen) bongo_cat_window_drag_bounds_refresh(app); @@ -75,6 +76,7 @@ void bongo_cat_window_drag_end(BongoCatApp *app) { app->drag_candidate = false; bongo_cat_window_clamp_to_display(app); app->window_drag_active = false; + bongo_cat_window_snapshot_end(app); bongo_cat_window_drag_bounds_clear(app); if (was_active && app->settings.model.mouse_centered) { float pointer_x = 0.0f, pointer_y = 0.0f; diff --git a/src/runtime/shell/window_geometry.c b/src/runtime/shell/window_geometry.c index 22ff77dc..307bb101 100644 --- a/src/runtime/shell/window_geometry.c +++ b/src/runtime/shell/window_geometry.c @@ -101,22 +101,6 @@ bool bongo_cat_window_apply_geometry(BongoCatApp *app, int x, int y, return true; } -bool bongo_cat_window_set_scale(BongoCatApp *app, float scale) { - if (!app || !app->window) return false; - int x, y, width, height; - if (!SDL_GetWindowPosition(app->window, &x, &y) || - !SDL_GetWindowSize(app->window, &width, &height)) return false; - float actual; - int next_width, next_height; - if (!bongo_cat_window_scaled_size(width, height, - app->session.window.scale_percent, scale, - &actual, &next_width, &next_height)) return false; - if (actual == app->session.window.scale_percent && - next_width == width && next_height == height) return false; - return bongo_cat_window_apply_geometry(app, x, y, - actual, next_width, next_height); -} - void bongo_cat_window_resize_by_pointer(BongoCatApp *app, const SDL_Event *event) { bool shift = bongo_cat_input_shift_down(&app->input); #ifndef _WIN32 diff --git a/src/runtime/shell/window_hit.c b/src/runtime/shell/window_hit.c index 70a16e52..841542fc 100644 --- a/src/runtime/shell/window_hit.c +++ b/src/runtime/shell/window_hit.c @@ -4,6 +4,7 @@ #include bool bongo_cat_window_visible_at_pointer(BongoCatApp *app, float x, float y) { + if (app->window_snapshot) return bongo_cat_window_snapshot_hit(app, x, y); int width, height, pixel_width, pixel_height; if (!SDL_GetWindowSize(app->window, &width, &height) || !SDL_GetWindowSizeInPixels(app->window, &pixel_width, &pixel_height) || @@ -68,6 +69,7 @@ void bongo_cat_window_mark_hit_dirty(BongoCatApp *app) { void bongo_cat_window_set_visible(BongoCatApp *app, bool visible) { if (!app || !app->window) return; app->session.window.visible = visible; + if (!visible) bongo_cat_window_snapshot_discard(app); if (!visible) { app->startup_visibility_pending = false; #if defined(__linux__) @@ -135,6 +137,7 @@ void bongo_cat_window_schedule_hit_check(BongoCatApp *app) { void bongo_cat_window_sync_click_through(BongoCatApp *app) { if (!app || !app->window) return; bool forced = app->settings.window.pass_through || app->hover_hidden; + if (forced && app->window_snapshot) bongo_cat_window_snapshot_end(app); if (!forced && !bongo_cat_platform_dynamic_hit_supported()) { app->pointer_transparent = false; app->pointer_hit_dirty = false; @@ -164,14 +167,22 @@ void bongo_cat_window_sync_click_through(BongoCatApp *app) { } void bongo_cat_window_apply_pending_resize(BongoCatApp *app) { - if (!app || !app->resize_pending) return; - app->model_pointer_anchor_ready = false; + if (!app) return; + if (app->window_snapshot) return; if (app->wheel_animation_active) { + if (!app->resize_pending) return; + app->resize_pending = false; + app->resize_render_target_pending = true; + /* Keep the normalized gaze anchor stable throughout the gesture. */ bongo_cat_live2d_reshape(app->live2d, app->resize_pixel_width, app->resize_pixel_height); return; } + if (!app->resize_pending && !app->resize_render_target_pending) return; app->resize_pending = false; + if (!app->resize_render_target_pending) + app->model_pointer_anchor_ready = false; + app->resize_render_target_pending = false; bongo_cat_live2d_resize(app->live2d, app->resize_pixel_width, app->resize_pixel_height); bongo_cat_window_mark_hit_dirty(app); diff --git a/src/runtime/shell/window_menu_actions.c b/src/runtime/shell/window_menu_actions.c index 6a6f4f5a..a5a42504 100644 --- a/src/runtime/shell/window_menu_actions.c +++ b/src/runtime/shell/window_menu_actions.c @@ -91,6 +91,8 @@ void bongo_cat_window_show_context_menu(BongoCatApp *app) { void bongo_cat_window_menu_action(BongoCatApp *app, BongoCatMenuAction action) { + if (action < BONGO_CAT_MENU_SCALE_50 || action > BONGO_CAT_MENU_SCALE_200) + bongo_cat_window_snapshot_end(app); if (action == BONGO_CAT_MENU_PREFERENCES) { if (app->secondary_pet) bongo_cat_multi_pet_request_preferences(app); diff --git a/src/runtime/shell/window_menu_preview.c b/src/runtime/shell/window_menu_preview.c index aeac2d1a..262db90d 100644 --- a/src/runtime/shell/window_menu_preview.c +++ b/src/runtime/shell/window_menu_preview.c @@ -46,6 +46,7 @@ void bongo_cat_window_menu_preview(void *userdata, BongoCatMenuAction action) { if (state->last != BONGO_CAT_MENU_NONE && previous_group != next_group) bongo_cat_window_menu_restore(state, BONGO_CAT_MENU_NONE); if (!previewable(action)) { + bongo_cat_window_snapshot_end(app); if (previous_group == 0) bongo_cat_window_menu_restore(state, BONGO_CAT_MENU_NONE); state->last = action; @@ -53,11 +54,14 @@ void bongo_cat_window_menu_preview(void *userdata, BongoCatMenuAction action) { } state->last = action; if (action >= BONGO_CAT_MENU_SCALE_50 && - action <= BONGO_CAT_MENU_SCALE_200) + action <= BONGO_CAT_MENU_SCALE_200) { bongo_cat_window_set_scale(app, (float)(50 + 10 * (action - BONGO_CAT_MENU_SCALE_50))); - else if (action >= BONGO_CAT_MENU_OPACITY_10 && + /* The shared scale path already presents the resized frame. */ + return; + } else if (action >= BONGO_CAT_MENU_OPACITY_10 && action <= BONGO_CAT_MENU_OPACITY_100) { + bongo_cat_window_snapshot_end(app); app->session.window.opacity_percent = (float)(10 * (action - BONGO_CAT_MENU_OPACITY_10 + 1)); if (!app->hover_hidden) { @@ -70,6 +74,7 @@ void bongo_cat_window_menu_preview(void *userdata, BongoCatMenuAction action) { bongo_cat_modal_frame_tick(&state->modal_frame); return; } else { + bongo_cat_window_snapshot_end(app); if (next_group == 5 && bongo_cat_live2d_expression(app->live2d) != state->expression) bongo_cat_live2d_set_expression(app->live2d, state->expression); @@ -124,6 +129,7 @@ void bongo_cat_window_menu_restore(void *userdata, BongoCatMenuAction selected) } if (!keep_opacity && SDL_fabsf(app->session.window.opacity_percent - state->opacity) > .01f) { + bongo_cat_window_snapshot_end(app); app->session.window.opacity_percent = state->opacity; if (!app->hover_hidden) { bongo_cat_app_cancel_hover_fade(app); @@ -140,6 +146,7 @@ void bongo_cat_window_menu_restore(void *userdata, BongoCatMenuAction selected) } if (committed_expression) bongo_cat_app_capture_behavior_state(app); + bongo_cat_window_snapshot_end(app); if (changed) bongo_cat_app_render_now(app); } diff --git a/src/runtime/shell/window_scale.c b/src/runtime/shell/window_scale.c new file mode 100644 index 00000000..fae5ad66 --- /dev/null +++ b/src/runtime/shell/window_scale.c @@ -0,0 +1,77 @@ +#include "runtime.h" + +static int round_position(double value) { + return (int)(value + (value < 0.0 ? -0.5 : 0.5)); +} + +bool bongo_cat_window_set_scale(BongoCatApp *app, float scale) { + if (!app || !app->window) return false; + int x, y, width, height; + if (!SDL_GetWindowPosition(app->window, &x, &y) || + !SDL_GetWindowSize(app->window, &width, &height)) return false; + float actual; + int next_width, next_height; + if (!bongo_cat_window_scaled_size(width, height, + app->session.window.scale_percent, scale, + &actual, &next_width, &next_height)) return false; + if (actual == app->session.window.scale_percent && + next_width == width && next_height == height) return false; + return bongo_cat_window_apply_scale_centered(app, scale, + width, height, app->session.window.scale_percent, + x + width * 0.5f, y + height * 0.5f); +} + +bool bongo_cat_window_apply_scale_centered(BongoCatApp *app, float scale, + int base_width, int base_height, float base_scale, + float center_x, float center_y) { + float actual; + int width, height; + if (!app || !app->window || !bongo_cat_window_scaled_size(base_width, base_height, + base_scale, scale, &actual, &width, &height)) return false; + int x = round_position(center_x - width * 0.5); + int y = round_position(center_y - height * 0.5); + SDL_FRect image = {0, 0, base_width * actual / base_scale, + base_height * actual / base_scale}; + image.x = center_x - image.w * 0.5f; + image.y = center_y - image.h * 0.5f; + if (app->settings.window.keep_in_screen) { + SDL_DisplayID display = SDL_GetDisplayForWindow(app->window); + SDL_Rect bounds; + if (display && SDL_GetDisplayUsableBounds(display, &bounds)) { + x = SDL_clamp(x, bounds.x, SDL_max(bounds.x, bounds.x + bounds.w - width)); + y = SDL_clamp(y, bounds.y, SDL_max(bounds.y, bounds.y + bounds.h - height)); + image.x = SDL_clamp(image.x, (float)bounds.x, + SDL_max((float)bounds.x, bounds.x + bounds.w - image.w)); + image.y = SDL_clamp(image.y, (float)bounds.y, + SDL_max((float)bounds.y, bounds.y + bounds.h - image.h)); + } + } + if (x == app->session.window.x && y == app->session.window.y && + width == app->session.window.width && height == app->session.window.height) { + app->session.window.scale_percent = actual; + if (app->window_snapshot) { + bongo_cat_window_snapshot_geometry(app, &image); + bongo_cat_window_snapshot_present(app); + } + return true; + } + bongo_cat_window_snapshot_begin(app); + bongo_cat_window_snapshot_geometry(app, &image); + if (!bongo_cat_window_apply_geometry(app, x, y, actual, width, height)) { + bongo_cat_window_snapshot_end(app); + return false; + } + /* Uniform scaling preserves the normalized gaze anchor. Finish drawing + the resized surface before returning to event/model processing, where + the compositor could otherwise display the moved previous frame. */ + app->resize_render_target_pending = true; + SDL_Window *previous_window = SDL_GL_GetCurrentWindow(); + SDL_GLContext previous_context = SDL_GL_GetCurrentContext(); + bongo_cat_app_render_now(app); + if (previous_window && previous_context && + (previous_window != app->window || previous_context != app->gl_context) && + !SDL_GL_MakeCurrent(previous_window, previous_context)) + SDL_LogError(SDL_LOG_CATEGORY_VIDEO, + "Cannot restore OpenGL context after window scaling: %s", SDL_GetError()); + return true; +} diff --git a/src/runtime/shell/window_snapshot.c b/src/runtime/shell/window_snapshot.c new file mode 100644 index 00000000..8034b159 --- /dev/null +++ b/src/runtime/shell/window_snapshot.c @@ -0,0 +1,123 @@ +#include "runtime.h" + +#ifdef _WIN32 +#include "windows_snapshot.h" +#endif + +#define SNAPSHOT_IDLE_NS 180000000ull + +void bongo_cat_window_snapshot_begin(BongoCatApp *app) { + if (!app) return; + app->snapshot_deadline_ns = SDL_GetTicksNS() + SNAPSHOT_IDLE_NS; + if (app->window_snapshot || app->snapshot_blocked) return; +#ifdef _WIN32 + if (!app->window || !app->live2d || !app->session.window.visible || + app->startup_visibility_pending || app->window_minimized || app->hover_hidden || + app->hover_fade_active || + app->settings.window.pass_through || + !(SDL_GetWindowFlags(app->window) & SDL_WINDOW_TRANSPARENT)) return; + SDL_Window *previous_window = SDL_GL_GetCurrentWindow(); + SDL_GLContext previous_context = SDL_GL_GetCurrentContext(); + if (!SDL_GL_MakeCurrent(app->window, app->gl_context)) return; + /* Capture a complete, up-to-date frame before changing native geometry. */ + bongo_cat_app_render_now(app); + if (!app->render_retry_ns) { + app->window_snapshot = bongo_cat_windows_snapshot_create(app->window, + app->platform.window_opacity); + if (!app->window_snapshot) { + app->snapshot_blocked = true; + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Interaction snapshot unavailable; using live rendering: %s", SDL_GetError()); + } + } + SDL_GetGlobalMouseState(&app->snapshot_pointer_x, &app->snapshot_pointer_y); + if (previous_window && previous_context) + SDL_GL_MakeCurrent(previous_window, previous_context); +#endif +} + +void bongo_cat_window_snapshot_discard(BongoCatApp *app) { + if (!app || !app->window_snapshot) return; +#ifdef _WIN32 + bongo_cat_windows_snapshot_destroy(app->window_snapshot); +#endif + app->window_snapshot = NULL; + app->last_frame_ns = SDL_GetTicksNS(); + app->dirty = true; + bongo_cat_window_mark_hit_dirty(app); +} + +void bongo_cat_window_snapshot_end(BongoCatApp *app) { + if (!app || !app->window_snapshot) return; + void *snapshot = app->window_snapshot; + app->window_snapshot = NULL; + app->snapshot_blocked = true; + app->last_frame_ns = SDL_GetTicksNS(); + app->dirty = true; + /* Keep the preview visible until the final real frame is submitted. */ + SDL_Window *previous_window = SDL_GL_GetCurrentWindow(); + SDL_GLContext previous_context = SDL_GL_GetCurrentContext(); + bongo_cat_app_render_now(app); +#ifdef _WIN32 + bongo_cat_windows_snapshot_destroy(snapshot); +#else + (void)snapshot; +#endif + if (previous_window && previous_context) + SDL_GL_MakeCurrent(previous_window, previous_context); + bongo_cat_window_mark_hit_dirty(app); +} + +void bongo_cat_window_snapshot_update(BongoCatApp *app, uint64_t now) { + if (!app) return; + if (!app->window_snapshot) { + if (!app->window_drag_active && !app->wheel_animation_active && + now >= app->snapshot_deadline_ns) app->snapshot_blocked = false; + return; + } +#ifdef _WIN32 + bongo_cat_windows_snapshot_pointer(app->window_snapshot); +#endif + bool finish = !app->session.window.visible || app->window_minimized || + app->settings.window.pass_through || app->hover_hidden; + if (!app->window_drag_active) { + float x, y; + SDL_GetGlobalMouseState(&x, &y); + float dx = x - app->snapshot_pointer_x, dy = y - app->snapshot_pointer_y; + finish = finish || dx * dx + dy * dy >= 4.0f || + (!app->wheel_animation_active && now >= app->snapshot_deadline_ns); + } + if (finish) bongo_cat_window_snapshot_end(app); + else if (app->window_drag_active && + !(SDL_GetGlobalMouseState(NULL, NULL) & SDL_BUTTON_LMASK)) + bongo_cat_window_drag_end(app); +} + +void bongo_cat_window_snapshot_geometry(BongoCatApp *app, const SDL_FRect *rect) { +#ifdef _WIN32 + if (app) bongo_cat_windows_snapshot_geometry(app->window_snapshot, rect); +#else + (void)app; (void)rect; +#endif +} + +void bongo_cat_window_snapshot_present(BongoCatApp *app) { + if (!app || !app->window_snapshot) return; +#ifdef _WIN32 + if (!bongo_cat_windows_snapshot_present(app->window_snapshot)) { + app->snapshot_blocked = true; + bongo_cat_window_snapshot_discard(app); + return; + } +#endif + app->dirty = false; +} + +bool bongo_cat_window_snapshot_hit(BongoCatApp *app, float x, float y) { +#ifdef _WIN32 + return app && bongo_cat_windows_snapshot_hit(app->window_snapshot, x, y); +#else + (void)app; (void)x; (void)y; + return false; +#endif +} diff --git a/src/runtime/shell/window_snapshot.h b/src/runtime/shell/window_snapshot.h new file mode 100644 index 00000000..59f8adda --- /dev/null +++ b/src/runtime/shell/window_snapshot.h @@ -0,0 +1,19 @@ +#ifndef BONGO_CAT_WINDOW_SNAPSHOT_H +#define BONGO_CAT_WINDOW_SNAPSHOT_H + +#include "bongo_cat/app.h" +#include + +/* Begin also refreshes the idle deadline of an existing preview. */ +void bongo_cat_window_snapshot_begin(BongoCatApp *app); +/* Draw the final live frame before releasing the preview. */ +void bongo_cat_window_snapshot_end(BongoCatApp *app); +/* Release without rendering, for hiding, shutdown or presentation failure. */ +void bongo_cat_window_snapshot_discard(BongoCatApp *app); +void bongo_cat_window_snapshot_update(BongoCatApp *app, uint64_t now); +void bongo_cat_window_snapshot_present(BongoCatApp *app); +/* NULL follows the native window rectangle (drag); otherwise use subpixels. */ +void bongo_cat_window_snapshot_geometry(BongoCatApp *app, const SDL_FRect *rect); +bool bongo_cat_window_snapshot_hit(BongoCatApp *app, float x, float y); + +#endif diff --git a/src/runtime/shell/window_wheel.c b/src/runtime/shell/window_wheel.c index d82c4366..30708c32 100644 --- a/src/runtime/shell/window_wheel.c +++ b/src/runtime/shell/window_wheel.c @@ -3,8 +3,8 @@ #include "bongo_cat/preferences.h" #define WHEEL_OPACITY_STEP 5.0f -#define WHEEL_SCALE_STEP 5.0f -#define WHEEL_SCALE_TARGET_LEAD 10.0f +#define WHEEL_SCALE_STEP 2.0f +#define WHEEL_SCALE_TARGET_LEAD (2.0f * WHEEL_SCALE_STEP) #define WHEEL_OPACITY_RESPONSE_SECONDS 0.05f #define WHEEL_SCALE_RESPONSE_SECONDS 0.025f #define WHEEL_OPACITY_SPEED_PER_SECOND 100.0f @@ -65,17 +65,6 @@ int bongo_cat_window_wheel_round_position(float value) { return (int)(value + (value < 0.0f ? -0.5f : 0.5f)); } -static void clamp_position(BongoCatApp *app, int width, int height, int *x, int *y) { - if (!app->settings.window.keep_in_screen) return; - SDL_DisplayID display = SDL_GetDisplayForWindow(app->window); - SDL_Rect bounds; - if (!display || !SDL_GetDisplayUsableBounds(display, &bounds)) return; - int max_x = SDL_max(bounds.x, bounds.x + bounds.w - width); - int max_y = SDL_max(bounds.y, bounds.y + bounds.h - height); - *x = SDL_clamp(*x, bounds.x, max_x); - *y = SDL_clamp(*y, bounds.y, max_y); -} - void bongo_cat_window_wheel(BongoCatApp *app, const SDL_MouseWheelEvent *event) { if (!app || !app->window || !event || !wheel_targets_window(app, event)) return; @@ -104,6 +93,7 @@ void bongo_cat_window_wheel(BongoCatApp *app, const SDL_MouseWheelEvent *event) app->wheel_scale_target = SDL_clamp(app->wheel_scale_target + delta * WHEEL_SCALE_STEP, minimum, maximum); } else { + bongo_cat_window_snapshot_end(app); app->wheel_opacity_target = SDL_clamp(app->wheel_opacity_target + delta * WHEEL_OPACITY_STEP, 10.0f, 100.0f); } @@ -139,18 +129,15 @@ static void apply_scale(BongoCatApp *app, float scale) { app->wheel_base_height, app->wheel_geometry_scale, scale, &actual, &next_width, &next_height)) return; if (actual != scale) app->wheel_scale_target = actual; - if (next_width == app->session.window.width && + if (!app->window_snapshot && next_width == app->session.window.width && next_height == app->session.window.height) { app->session.window.scale_percent = actual; return; } - int next_x = bongo_cat_window_wheel_round_position( - app->wheel_center_x - next_width * 0.5f); - int next_y = bongo_cat_window_wheel_round_position( - app->wheel_center_y - next_height * 0.5f); - clamp_position(app, next_width, next_height, &next_x, &next_y); - if (!bongo_cat_window_apply_geometry(app, next_x, next_y, - actual, next_width, next_height)) { + if (!bongo_cat_window_apply_scale_centered(app, actual, + app->wheel_base_width, app->wheel_base_height, + app->wheel_geometry_scale, app->wheel_center_x, + app->wheel_center_y)) { app->wheel_scale_target = old; app->wheel_animation_active = false; app->wheel_gesture_active = false; @@ -174,7 +161,9 @@ void bongo_cat_window_update_wheel_animation(BongoCatApp *app, uint64_t now) { WHEEL_SCALE_RESPONSE_SECONDS, WHEEL_SCALE_SPEED_PER_SECOND, 0.05f); bool changed = SDL_fabsf(opacity - app->session.window.opacity_percent) > 0.001f || SDL_fabsf(scale - app->session.window.scale_percent) > 0.001f; - if (SDL_fabsf(opacity - app->session.window.opacity_percent) > 0.001f) { + bool opacity_changed = + SDL_fabsf(opacity - app->session.window.opacity_percent) > 0.001f; + if (opacity_changed) { app->session.window.opacity_percent = opacity; if (!app->hover_hidden) { bongo_cat_app_cancel_hover_fade(app); @@ -188,7 +177,10 @@ void bongo_cat_window_update_wheel_animation(BongoCatApp *app, uint64_t now) { bool recent_input = now >= app->wheel_input_ns && now - app->wheel_input_ns < BONGO_CAT_WHEEL_GESTURE_IDLE_NS; app->wheel_animation_active = !reached || recent_input; - if (changed || !app->wheel_animation_active) app->dirty = true; + /* Geometry marks actual pixel changes dirty; subpixel scale bookkeeping + alone does not need another model draw and buffer swap. */ + if (opacity_changed || (!app->wheel_animation_active && + app->resize_render_target_pending)) app->dirty = true; if (changed) bongo_cat_preferences_invalidate(app->preferences); } diff --git a/src/runtime/shell/window_wheel_test.c b/src/runtime/shell/window_wheel_test.c index 94f38b33..23e96877 100644 --- a/src/runtime/shell/window_wheel_test.c +++ b/src/runtime/shell/window_wheel_test.c @@ -54,7 +54,7 @@ bool bongo_cat_window_wheel_self_test(BongoCatApp *app) { for (int i = 1; i <= 12; ++i) bongo_cat_window_update_wheel_animation(app, started + i * 16666667ull); bool responsive = SDL_fabsf( - app->session.window.scale_percent - 105.0f) < 0.1f; + app->session.window.scale_percent - 102.0f) < 0.1f; bongo_cat_window_update_wheel_animation(app, started + 250000000ull); bool scale = responsive && !app->wheel_animation_active && @@ -65,15 +65,15 @@ bool bongo_cat_window_wheel_self_test(BongoCatApp *app) { original_width, original_height); wheel.y = 1000.0f; for (int i = 0; i < 2; ++i) bongo_cat_window_wheel(app, &wheel); - bool burst = app->wheel_scale_target >= 109.5f && - app->wheel_scale_target <= 110.1f; + bool burst = app->wheel_scale_target >= 103.5f && + app->wheel_scale_target <= 104.1f; bongo_cat_window_cancel_wheel_animation(app); bongo_cat_window_apply_geometry(app, original_x, original_y, 100.0f, original_width, original_height); wheel.y = 3.0f; bongo_cat_window_wheel(app, &wheel); - bool aggregated = app->wheel_scale_target >= 104.5f && - app->wheel_scale_target <= 105.1f; + bool aggregated = app->wheel_scale_target >= 101.5f && + app->wheel_scale_target <= 102.1f; bongo_cat_window_cancel_wheel_animation(app); bongo_cat_window_apply_geometry(app, original_x, original_y, 100.0f, original_width, original_height); @@ -93,7 +93,7 @@ bool bongo_cat_window_wheel_self_test(BongoCatApp *app) { for (int i = 1; i <= 30; ++i) bongo_cat_window_update_wheel_animation(app, started + i * 16666667ull); bool flipped = SDL_fabsf( - app->session.window.scale_percent - 95.0f) < 0.1f; + app->session.window.scale_percent - 98.0f) < 0.1f; wheel.direction = SDL_MOUSEWHEEL_NORMAL; bongo_cat_window_cancel_wheel_animation(app); app->session.window.scale_percent = 500.0f; diff --git a/tests/model_import/test_model_import_archive.c b/tests/model_import/test_model_import_archive.c new file mode 100644 index 00000000..8e165524 --- /dev/null +++ b/tests/model_import/test_model_import_archive.c @@ -0,0 +1,115 @@ +#include "model_import_archive.h" +#include "model_storage.h" +#include "test_mver_import_internal.h" +#include "test_mver_support.h" +#include "bongo_cat/path.h" +#include +#include +#include +#include + +static bool save_zip(mz_zip_archive *zip, const char *path) { + void *data = NULL; + size_t size = 0; + bool ok = mz_zip_writer_finalize_heap_archive(zip, &data, &size) != 0; + mz_zip_writer_end(zip); + if (ok) ok = SDL_SaveFile(path, data, size); + mz_free(data); + return ok; +} + +typedef struct ZipTree { + mz_zip_archive *zip; + const char *root; + const char *prefix; +} ZipTree; + +static BongoCatPathVisit zip_item(void *userdata, const char *directory, + const char *name) { + ZipTree *tree = userdata; + char path[BONGO_CAT_PATH_CAP], relative[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), directory, name)) + return BONGO_CAT_PATH_FAILURE; + if (bongo_cat_path_is_dir(path)) + return bongo_cat_path_enumerate(path, zip_item, tree) + ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; + int written = snprintf(relative, sizeof(relative), "%s%s", tree->prefix, + path + strlen(tree->root) + 1); + if (written < 0 || (size_t)written >= sizeof(relative)) + return BONGO_CAT_PATH_FAILURE; + size_t size = 0; + void *data = SDL_LoadFile(path, &size); + bool ok = data && mz_zip_writer_add_mem(tree->zip, relative, data, + size, MZ_BEST_SPEED); + SDL_free(data); + return ok ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; +} + +void test_model_import_archive(void) { + char root[BONGO_CAT_PATH_CAP], package[BONGO_CAT_PATH_CAP]; + char archive[BONGO_CAT_PATH_CAP], models[BONGO_CAT_PATH_CAP]; + char directory[BONGO_CAT_PATH_CAP], temporary[BONGO_CAT_PATH_CAP]; + char *cwd = SDL_GetCurrentDirectory(); + CHECK(cwd != NULL); + if (!cwd) return; + snprintf(root, sizeof(root), "%s/archive-test-%llu", cwd, + (unsigned long long)SDL_GetTicksNS()); + SDL_free(cwd); + CHECK(child(package, sizeof(package), root, "fixture", true)); + CHECK(mver_fixture(package)); + CHECK(child(models, sizeof(models), root, "installed", true)); + CHECK(child(archive, sizeof(archive), root, "example.ZIP", false)); + CHECK(bongo_cat_import_is_archive(archive)); + CHECK(!bongo_cat_import_is_archive(package)); + BongoCatError error = {0}; + for (int nested = 0; nested < 2; ++nested) { + mz_zip_archive zip = {0}; + CHECK(mz_zip_writer_init_heap(&zip, 0, 0)); + ZipTree tree = {&zip, package, nested ? "outer/model/" : ""}; + CHECK(bongo_cat_path_enumerate(package, zip_item, &tree)); + CHECK(save_zip(&zip, archive)); + CHECK(bongo_cat_import_archive_extract(archive, directory, temporary, + &error) == BONGO_CAT_OK); + CHECK(strcmp(bongo_cat_path_name(directory), "example") == 0); + bongo_cat_import_archive_cleanup(temporary); + CHECK(!bongo_cat_path_is_dir(temporary)); + BongoCatImportSession *session = bongo_cat_import_session_create(models, &error); + CHECK(session != NULL); + BongoCatImportBatchStats stats; + CHECK(bongo_cat_import_session_install_progressive(session, archive, + NULL, NULL, &stats, &error) == BONGO_CAT_OK); + CHECK(stats.succeeded_count > 0 && stats.failed_count == 0); + BongoCatImportReceipt receipt; + CHECK(bongo_cat_import_session_install(session, archive, &receipt, + &error) == BONGO_CAT_OK); + CHECK(receipt.count > 0 && receipt.installed_count == 0); + bongo_cat_import_session_destroy(session); + } + const char *unsafe[] = {"../escape", "safe/../../escape", "\\absolute", + "C:/escape", "safe\\..\\escape", "file:stream", "NUL.txt", "dir./file"}; + for (size_t i = 0; i < sizeof(unsafe) / sizeof(unsafe[0]); ++i) { + mz_zip_archive zip = {0}; + CHECK(mz_zip_writer_init_heap(&zip, 0, 0)); + CHECK(mz_zip_writer_add_mem(&zip, unsafe[i], "x", 1, 0)); + CHECK(save_zip(&zip, archive)); + CHECK(bongo_cat_import_archive_extract(archive, directory, temporary, + &error) == BONGO_CAT_ERROR_FORMAT); + CHECK(!directory[0] && !temporary[0]); + } + CHECK(write_text(archive, "not a zip")); + CHECK(bongo_cat_import_archive_extract(archive, directory, temporary, + &error) == BONGO_CAT_ERROR_FORMAT); + CHECK(!directory[0] && !temporary[0]); + mz_zip_archive empty_model = {0}; + CHECK(mz_zip_writer_init_heap(&empty_model, 0, 0)); + CHECK(mz_zip_writer_add_mem(&empty_model, "readme.txt", "text", 4, 0)); + CHECK(save_zip(&empty_model, archive)); + BongoCatImportSession *session = bongo_cat_import_session_create(models, &error); + CHECK(session != NULL); + BongoCatImportBatchStats stats; + CHECK(bongo_cat_import_session_install_progressive(session, archive, + NULL, NULL, &stats, &error) == BONGO_CAT_ERROR_FORMAT); + CHECK(stats.succeeded_count == 0 && stats.failed_count == 1); + bongo_cat_import_session_destroy(session); + CHECK(bongo_cat_model_remove_tree(root, NULL)); +} diff --git a/tests/model_import/test_mver_container.c b/tests/model_import/test_mver_container.c index a43f9a41..1b5142e6 100644 --- a/tests/model_import/test_mver_container.c +++ b/tests/model_import/test_mver_container.c @@ -24,7 +24,73 @@ static void progressive_receipt(void *userdata, log->installed += receipt->installed_count; } +static void import_without_hand_images(void) { + char *temporary = SDL_GetCurrentDirectory(); + BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); + CHECK(temporary && discovery); + if (!temporary || !discovery) { + SDL_free(temporary); + free(discovery); + return; + } + char root[BONGO_CAT_PATH_CAP], package[BONGO_CAT_PATH_CAP]; + char models[BONGO_CAT_PATH_CAP], stored[BONGO_CAT_PATH_CAP]; + char path[BONGO_CAT_PATH_CAP], copied[BONGO_CAT_PATH_CAP]; + snprintf(root, sizeof(root), "%s/bongocat-no-hand-%llu", temporary, + (unsigned long long)SDL_GetTicksNS()); + CHECK(SDL_CreateDirectory(root)); + CHECK(child(package, sizeof(package), root, "source", true)); + CHECK(mver_fixture(package)); + BongoCatError error = {0}; + CHECK(child(path, sizeof(path), package, "img/standard/hand/0.png", false)); + CHECK(SDL_RemovePath(path)); + CHECK(bongo_cat_import_mver_discover_exact(package, discovery, &error) == 1); + CHECK(discovery->count == 1); + CHECK(child(path, sizeof(path), package, "img/standard/hand", false)); + CHECK(bongo_cat_model_remove_tree(path, NULL)); + CHECK(child(models, sizeof(models), root, "models", true)); + BongoCatImportReceipt receipt = {0}; + CHECK(bongo_cat_import_install(package, models, &receipt, &error) == BONGO_CAT_OK); + CHECK(receipt.count == 1 && receipt.installed_count == 1); + CHECK(child(stored, sizeof(stored), models, receipt.ids[0], false)); + memset(discovery, 0, sizeof(*discovery)); + CHECK(bongo_cat_import_mver_discover_exact(stored, discovery, &error) == 1); + CHECK(discovery->count == 1); + CHECK(child(path, sizeof(path), root, "adapter", false)); + CHECK(bongo_cat_import_prepare_adapter(&discovery->candidates[0], path, &error)); + CHECK(child(path, sizeof(path), root, "adapter/resources/left-keys", false)); + CHECK(!bongo_cat_path_find_suffix(path, ".png", copied, sizeof(copied))); + CHECK(child(path, sizeof(path), package, "img/standard/hand", false)); + CHECK(!bongo_cat_path_is_dir(path)); + CHECK(child(path, sizeof(path), stored, "img/standard/hand", false)); + CHECK(!bongo_cat_path_is_dir(path)); + static const char *const preserved[] = { + "config.json", "img/standard/cat_model/cat.model3.json", + "img/standard/cat_model/cat.moc3", "img/standard/cat_model/texture.png" + }; + for (size_t i = 0; i < sizeof(preserved) / sizeof(preserved[0]); ++i) { + CHECK(child(path, sizeof(path), package, preserved[i], false)); + CHECK(child(copied, sizeof(copied), stored, preserved[i], false)); + size_t source_size = 0, copied_size = 0; + void *source_data = SDL_LoadFile(path, &source_size); + void *copied_data = SDL_LoadFile(copied, &copied_size); + CHECK(source_data && copied_data && source_size == copied_size && + memcmp(source_data, copied_data, source_size) == 0); + SDL_free(source_data); + SDL_free(copied_data); + } + CHECK(child(path, sizeof(path), package, "img/standard/cat_model/texture.png", false)); + CHECK(SDL_RemovePath(path)); + memset(discovery, 0, sizeof(*discovery)); + CHECK(bongo_cat_import_mver_discover_exact(package, discovery, &error) == -1); + CHECK(error.code == BONGO_CAT_ERROR_FORMAT); + CHECK(bongo_cat_model_remove_tree(root, NULL)); + SDL_free(temporary); + free(discovery); +} + void test_mver_container_discovery(void) { + import_without_hand_images(); char root[BONGO_CAT_PATH_CAP], package[BONGO_CAT_PATH_CAP]; char backup[BONGO_CAT_PATH_CAP]; char mode[BONGO_CAT_PATH_CAP]; diff --git a/tests/model_import/test_mver_import.c b/tests/model_import/test_mver_import.c index 2eb914c3..4d592273 100644 --- a/tests/model_import/test_mver_import.c +++ b/tests/model_import/test_mver_import.c @@ -237,6 +237,7 @@ int main(void) { failures += test_model_import_identity(); test_mver_container_discovery(); test_model_import_source(); + test_model_import_archive(); test_mver_manifest(); test_tauri_portable(); failures += test_slim_package(); diff --git a/tests/model_import/test_mver_import_internal.h b/tests/model_import/test_mver_import_internal.h index 2077dc2c..ab619d5a 100644 --- a/tests/model_import/test_mver_import_internal.h +++ b/tests/model_import/test_mver_import_internal.h @@ -15,6 +15,7 @@ void test_mver_container_discovery(void); void test_mver_audio(void); void test_mver_pointer_modes(void); void test_model_import_source(void); +void test_model_import_archive(void); void test_mver_manifest(void); void test_tauri_portable(void); diff --git a/tests/model_import/test_mver_manifest.c b/tests/model_import/test_mver_manifest.c index c619f77a..d8e5e2b2 100644 --- a/tests/model_import/test_mver_manifest.c +++ b/tests/model_import/test_mver_manifest.c @@ -9,6 +9,7 @@ #include #include +#include #include static const char *broken = @@ -47,7 +48,26 @@ void test_mver_manifest(void) { CHECK(receipt.count == 1 && receipt.installed_count == 1); CHECK(child(stored, sizeof(stored), models, receipt.ids[0], false)); CHECK(child(model, sizeof(model), stored, "img/standard/cat_model", false)); - CHECK(bongo_cat_import_manifest_valid(model, "cat.model3.json", &error)); + CHECK(!bongo_cat_import_manifest_valid(model, "cat.model3.json", NULL)); + CHECK(bongo_cat_import_mver_manifest_valid(model, "cat.model3.json")); + char installed_manifest[BONGO_CAT_PATH_CAP]; + CHECK(child(installed_manifest, sizeof(installed_manifest), model, + "cat.model3.json", false)); + size_t installed_length = 0; + char *installed_json = SDL_LoadFile(installed_manifest, &installed_length); + CHECK(installed_json && installed_length == strlen(broken) && + memcmp(installed_json, broken, installed_length) == 0); + SDL_free(installed_json); + BongoCatBehaviorCatalog *behaviors = calloc(1, sizeof(*behaviors)); + BongoCatModelEntry *entry = calloc(1, sizeof(*entry)); + CHECK(behaviors && entry); + if (behaviors && entry) { + snprintf(entry->directory, sizeof(entry->directory), "%s", model); + snprintf(entry->setting_file, sizeof(entry->setting_file), "cat.model3.json"); + CHECK(bongo_cat_behaviors_load(behaviors, entry, &error) == BONGO_CAT_OK); + } + free(behaviors); + free(entry); CHECK(bongo_cat_import_install(package, models, &duplicate, &error) == BONGO_CAT_OK); CHECK(duplicate.count == 1 && duplicate.installed_count == 0 && strcmp(receipt.ids[0], duplicate.ids[0]) == 0); @@ -61,6 +81,13 @@ void test_mver_manifest(void) { document = bongo_cat_import_mver_manifest_read(path, &repaired); CHECK(document && repaired); yyjson_doc_free(document); + static const char bom_manifest[] = "\xEF\xBB\xBF" + "{\"Version\":3,\"FileReferences\":{\"Moc\":\"cat.moc3\"," + "\"Textures\":[\"texture.png\"]}}"; + document = bongo_cat_model_json_parse(bom_manifest, + sizeof(bom_manifest) - 1, &repaired); + CHECK(document && repaired); + yyjson_doc_free(document); CHECK(write_text(path, "{\"Version\":3,\"FileReferences\":{" "\"Moc\":\"cat.moc3\",\"Textures\":[\"missing.png\"]]}}}")); CHECK(child(model, sizeof(model), package, "img/standard/cat_model", false)); @@ -81,6 +108,21 @@ void test_mver_manifest(void) { document = bongo_cat_import_mver_manifest_read(path, &repaired); CHECK(document && !repaired); yyjson_doc_free(document); + CHECK(write_text(path, "{\"Version\":3,\"FileReferences\":{" + "\"Moc\":\"cat.moc3\",\"Textures\":[\"texture.png\"]," + "\"Physics\":\"missing.physics3.json\",\"Pose\":\"\"," + "\"DisplayInfo\":\"missing.cdi3.json\",\"UserData\":\"missing.userdata3.json\"," + "\"Expressions\":[{\"Name\":\"missing\",\"File\":\"missing.exp3.json\"}]," + "\"Motions\":{\"Idle\":[{\"File\":\"missing.motion3.json\",\"Sound\":\"\"}]}}}")); + CHECK(!bongo_cat_import_manifest_valid(model, "cat.model3.json", NULL)); + CHECK(bongo_cat_import_mver_manifest_valid(model, "cat.model3.json")); + CHECK(write_text(path, "{\"Version\":3,\"FileReferences\":{" + "\"Moc\":\"cat.moc3\",\"Textures\":[\"texture.png\"]," + "\"UserData\":\"../outside.json\"}}")); + CHECK(!bongo_cat_import_mver_manifest_valid(model, "cat.model3.json")); + CHECK(write_text(path, "{\"Version\":3,\"FileReferences\":{" + "\"Moc\":\"missing.moc3\",\"Textures\":[\"texture.png\"]}}")); + CHECK(!bongo_cat_import_mver_manifest_valid(model, "cat.model3.json")); CHECK(bongo_cat_model_remove_tree(root, NULL)); SDL_free(temporary); } diff --git a/tests/model_import/test_mver_missing_motion.c b/tests/model_import/test_mver_missing_motion.c index f5eb5a0c..fa0efc03 100644 --- a/tests/model_import/test_mver_missing_motion.c +++ b/tests/model_import/test_mver_missing_motion.c @@ -1,10 +1,15 @@ #include "test_mver_support.h" #include "model_import.h" +#include "model_import_mver.h" #include "model_storage.h" +#include "bongo_cat/json.h" +#include "bongo_cat/path.h" #include #include +#include +#include static int failures; #define CHECK(value) do { if (!(value)) { \ @@ -12,11 +17,120 @@ static int failures; failures++; \ } } while (0) +static void optional_mode_inputs(const char *temporary) { + const char *names[] = {"standard", "keyboard", "gamepad"}; + BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); + CHECK(discovery != NULL); + if (!discovery) return; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) { + char root[BONGO_CAT_PATH_CAP], standard[BONGO_CAT_PATH_CAP]; + char mode[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; + char adapter[BONGO_CAT_PATH_CAP], config[512]; + snprintf(root, sizeof(root), "%s/bongocat-optional-%s-%llu", temporary, + names[i], (unsigned long long)SDL_GetTicksNS()); + CHECK(mver_fixture(root)); + CHECK(child(standard, sizeof(standard), root, "img/standard", false)); + CHECK(child(mode, sizeof(mode), root, "img", false)); + CHECK(child(path, sizeof(path), mode, names[i], false)); + if (i) CHECK(SDL_RenamePath(standard, path)); + snprintf(mode, sizeof(mode), "%s", path); + CHECK(child(path, sizeof(path), mode, "hand", false)); + CHECK(bongo_cat_model_remove_tree(path, NULL)); + const char *disabled = i ? "standard" : "keyboard"; + CHECK(child(path, sizeof(path), root, "img", false)); + CHECK(child(standard, sizeof(standard), path, disabled, true)); + snprintf(config, sizeof(config), "{\"%s\":{\"l2d\":true," + "\"hand\":null,\"lefthand\":null,\"sounds\":null," + "\"l2d_expression\":null,\"l2d_motion\":null}," + "\"%s\":{\"l2d\":false,\"hand\":42,\"lefthand\":42}}", names[i], disabled); + CHECK(child(path, sizeof(path), root, "config.json", false)); + CHECK(write_text(path, config)); + memset(discovery, 0, sizeof(*discovery)); + BongoCatError error = {0}; + CHECK(bongo_cat_import_mver_discover_exact(root, discovery, &error) == 1); + CHECK(discovery->count == 1); + CHECK(child(adapter, sizeof(adapter), root, "adapter", false)); + CHECK(bongo_cat_import_prepare_adapter(&discovery->candidates[0], adapter, &error)); + size_t length = 0; + char *original = SDL_LoadFile(path, &length); + CHECK(original && length == strlen(config) && memcmp(original, config, length) == 0); + SDL_free(original); + CHECK(bongo_cat_model_remove_tree(root, NULL)); + } + free(discovery); +} + +static void empty_optional_bindings(const char *temporary) { + char root[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; + char adapter[BONGO_CAT_PATH_CAP]; + snprintf(root, sizeof(root), "%s/bongocat-empty-bindings-%llu", temporary, + (unsigned long long)SDL_GetTicksNS()); + CHECK(mver_fixture(root)); + CHECK(child(path, sizeof(path), root, "config.json", false)); + CHECK(write_text(path, "{\"decoration\":{\"emoticonKeep\":true,\"emoticonClear\":[]}," + "\"standard\":{\"l2d\":true,\"hand\":[[65]],\"keyboard\":[[65]]," + "\"l2d_expression\":[[],[999999],[65]]," + "\"l2d_motion\":[null,[999999],[66]],\"face\":[[],null,[67],[999999]]," + "\"sounds\":[[],null,[68],[999999]]}}")); + CHECK(child(path, sizeof(path), root, "img/standard/cat_model/cat.model3.json", false)); + CHECK(write_text(path, "{\"Version\":3,\"FileReferences\":{\"Moc\":\"cat.moc3\"," + "\"Textures\":[\"texture.png\"],\"Expressions\":[" + "{\"Name\":\"a\",\"File\":\"a.exp3.json\"}," + "{\"Name\":\"b\",\"File\":\"b.exp3.json\"}," + "{\"Name\":\"c\",\"File\":\"c.exp3.json\"}]," + "\"Motions\":{\"CAT_motion\":[{\"File\":\"a.motion3.json\"}," + "{\"File\":\"b.motion3.json\"},{\"File\":\"c.motion3.json\"}]}}}")); + CHECK(child(path, sizeof(path), root, "img/standard/cat_model/c.exp3.json", false)); + CHECK(write_text(path, "{\"Type\":\"Live2D Expression\",\"Parameters\":[]}")); + CHECK(child(path, sizeof(path), root, "img/standard/cat_model/c.motion3.json", false)); + CHECK(write_text(path, "{\"Version\":3,\"Meta\":{\"Duration\":1},\"Curves\":[]}")); + CHECK(child(path, sizeof(path), root, "img/standard/keyboard", true)); + CHECK(child(path, sizeof(path), root, "img/standard/keyboard/0.png", false)); + CHECK(write_text(path, "unreadable optional image")); + CHECK(child(path, sizeof(path), root, "img/standard/face", true)); + CHECK(child(path, sizeof(path), root, "img/standard/face/2.png", false)); + CHECK(bongo_cat_path_copy_file(BONGO_CAT_NATIVE_SOURCE_DIR + "/resources/assets/bongocat.png", path)); + CHECK(child(path, sizeof(path), root, "img/standard/face/3.png", false)); + CHECK(write_text(path, "unreadable optional image")); + CHECK(child(path, sizeof(path), root, "img/standard/sounds", true)); + CHECK(child(path, sizeof(path), root, "img/standard/sounds/2.flac", false)); + CHECK(bongo_cat_path_copy_file(BONGO_CAT_NATIVE_SOURCE_DIR + "/resources/assets/models/standard/live2d_motion1.flac", path)); + BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); + CHECK(discovery != NULL); + if (discovery) { + BongoCatError error = {0}; + CHECK(bongo_cat_import_mver_discover_exact(root, discovery, &error) == 1); + CHECK(child(adapter, sizeof(adapter), root, "adapter", false)); + CHECK(bongo_cat_import_prepare_adapter(&discovery->candidates[0], adapter, &error)); + CHECK(child(path, sizeof(path), adapter, BONGO_CAT_MODEL_ADAPTER_FILE, false)); + yyjson_doc *document = bongo_cat_json_read_file(path, 0, NULL); + yyjson_val *bindings = yyjson_obj_get(yyjson_doc_get_root(document), "bindings"); + CHECK(document && yyjson_arr_size(bindings) == 4); + CHECK(yyjson_get_int(yyjson_obj_get(yyjson_arr_get(bindings, 0), "index")) == 2); + CHECK(yyjson_get_int(yyjson_obj_get(yyjson_arr_get(bindings, 1), "index")) == 2); + yyjson_doc_free(document); + CHECK(child(path, sizeof(path), adapter, "resources/left-keys/KeyA.png", false)); + CHECK(bongo_cat_path_is_file(path)); + CHECK(child(path, sizeof(path), root, "img/standard/hand/0.png", false)); + CHECK(write_text(path, "unreadable optional hand")); + CHECK(child(adapter, sizeof(adapter), root, "adapter-without-hand", false)); + CHECK(bongo_cat_import_prepare_adapter(&discovery->candidates[0], adapter, &error)); + CHECK(child(path, sizeof(path), adapter, "resources/left-keys/KeyA.png", false)); + CHECK(!bongo_cat_path_is_file(path)); + free(discovery); + } + CHECK(bongo_cat_model_remove_tree(root, NULL)); +} + int test_mver_missing_motion_groups(void) { failures = 0; char *temporary = SDL_GetCurrentDirectory(); CHECK(temporary != NULL); if (!temporary) return failures; + optional_mode_inputs(temporary); + empty_optional_bindings(temporary); unsigned long long stamp = (unsigned long long)SDL_GetTicksNS(); char source[BONGO_CAT_PATH_CAP], data[BONGO_CAT_PATH_CAP]; char path[BONGO_CAT_PATH_CAP]; From f455f7c3b903ee98d2b9c0c5119c5d508c623f95 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Thu, 17 Sep 2026 23:58:53 +0800 Subject: [PATCH 313/334] fix: github action --- tests/core/test_hover_fade.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/core/test_hover_fade.c b/tests/core/test_hover_fade.c index 369aed8a..5ccebb1d 100644 --- a/tests/core/test_hover_fade.c +++ b/tests/core/test_hover_fade.c @@ -34,6 +34,10 @@ bool bongo_cat_window_visible_at_pointer(BongoCatApp *app, float x, float y) { } void bongo_cat_window_schedule_pointer_hit(BongoCatApp *app) { (void)app; } void bongo_cat_window_sync_click_through(BongoCatApp *app) { (void)app; } +void bongo_cat_window_snapshot_end(BongoCatApp *app) { (void)app; } +void bongo_cat_window_snapshot_update(BongoCatApp *app, uint64_t now) { + (void)app; (void)now; +} void bongo_cat_app_drain_input(BongoCatApp *app, bool shortcuts) { (void)app; (void)shortcuts; } From f2a1f0f4f36f1ea35e466021821666fafd111c36 Mon Sep 17 00:00:00 2001 From: yuanziyi2022 Date: Fri, 18 Sep 2026 00:40:17 +0800 Subject: [PATCH 314/334] chore: refresh group QR code (2026-09-18) --- docs/wechat.png | Bin 181337 -> 65647 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/wechat.png b/docs/wechat.png index 1375811ca066e8d0a3730ba65d2c0d155d6b5bb6..0928295bb802a7363ed012be39a7da31b58a88d4 100644 GIT binary patch literal 65647 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

MugJuOQ+k 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�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 VRl}F?^QuJn%gbJs$&|Y8^MA)15EK9a From c611b500335134bb22626d355ade6fc0e7687258 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 18 Sep 2026 01:19:09 +0800 Subject: [PATCH 315/334] fix: where the model the model was misaligned --- cmake/Tests.cmake | 9 ++++ src/render/overlay_assets.c | 17 ++++++- tests/media/test_overlay_layout.c | 84 +++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 tests/media/test_overlay_layout.c diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake index f5806f07..a5ed46f8 100644 --- a/cmake/Tests.cmake +++ b/cmake/Tests.cmake @@ -33,6 +33,15 @@ if(BUILD_TESTING) target_link_libraries(bongo_cat_image_filter_tests PRIVATE bongo_cat_runtime bongo_cat_warnings) add_test(NAME image-filter COMMAND bongo_cat_image_filter_tests) + if(BONGO_CAT_CUBISM_ENABLED) + add_executable(bongo_cat_overlay_layout_tests tests/media/test_overlay_layout.c) + target_include_directories(bongo_cat_overlay_layout_tests PRIVATE tests/support) + target_include_directories(bongo_cat_overlay_layout_tests SYSTEM PRIVATE + ${BONGO_CAT_STB_INCLUDE_DIR}) + target_link_libraries(bongo_cat_overlay_layout_tests PRIVATE + bongo_cat_runtime bongo_cat_warnings) + add_test(NAME overlay-layout COMMAND bongo_cat_overlay_layout_tests) + endif() if(APPLE) add_test(NAME macos-bundle-signature COMMAND /usr/bin/codesign --verify --strict --deep --verbose=2 "$") diff --git a/src/render/overlay_assets.c b/src/render/overlay_assets.c index 40f37763..658985a1 100644 --- a/src/render/overlay_assets.c +++ b/src/render/overlay_assets.c @@ -59,9 +59,10 @@ BongoCatResult bongo_cat_overlay_load(BongoCatOverlay *value, bongo_cat_overlay_clear_textures(value); bongo_cat_mver_pointer_overlay_clear(value->mver_pointer); GLuint background = 0; + int background_width = 0, background_height = 0; BongoCatError background_error = {0}; if (bongo_cat_path_is_file(path)) background = bongo_cat_image_texture(path, - NULL, NULL, &background_error); + &background_width, &background_height, &background_error); BongoCatError pointer_error = {0}; if (!model_pointer_preferred && !bongo_cat_mver_pointer_overlay_load(value->mver_pointer, directory, @@ -83,6 +84,20 @@ BongoCatResult bongo_cat_overlay_load(BongoCatOverlay *value, if (render_options && render_options->mver_projection) { value->reference_width = render_options->reference_width; value->reference_height = render_options->reference_height; + /* Some Mver packages enlarge the window while retaining the original + full-canvas sprites. Keep their shared sprite coordinates in memory. + Do not infer a canvas from arbitrary partial images or pointer art. */ + if (background_width == 612 && + (background_height == 352 || background_height == 354) && + !bongo_cat_overlay_mver_pointer_enabled(value) && + value->reference_width > background_width && + value->reference_width % background_width == 0 && + value->reference_height % background_height == 0 && + value->reference_width / background_width == + value->reference_height / background_height) { + value->reference_width = background_width; + value->reference_height = background_height; + } } #else (void)render_options; diff --git a/tests/media/test_overlay_layout.c b/tests/media/test_overlay_layout.c new file mode 100644 index 00000000..162f6b7a --- /dev/null +++ b/tests/media/test_overlay_layout.c @@ -0,0 +1,84 @@ +#include "bongo_cat/overlay.h" +#include "bongo_cat/path.h" +#include "test.h" +#include +#include +#include +#include +#include + +int bongo_cat_test_failures; + +static void check_pixel(bool filled) { + unsigned char pixel[4] = {0}; + glReadPixels(28, 3, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + CHECK(filled ? pixel[0] > 250 : pixel[0] == 0); + CHECK(glGetError() == GL_NO_ERROR); +} + +static void layout_case(BongoCatOverlay *overlay, const char *root, + const char *background, int width, int height, int reference_width, + int reference_height, bool mver, bool filled) { + size_t bytes = (size_t)width * height * 4; + unsigned char *pixels = malloc(bytes); + CHECK(pixels != NULL); + if (!pixels) return; + memset(pixels, 255, bytes); + CHECK(stbi_write_png(background, width, height, 4, pixels, width * 4)); + free(pixels); + BongoCatLive2DRenderOptions options = {0}; + options.mver_projection = mver; + options.reference_width = reference_width; + options.reference_height = reference_height; + BongoCatError error = {0}; + CHECK(bongo_cat_overlay_load(overlay, root, true, &options, &error) == BONGO_CAT_OK); + glClear(GL_COLOR_BUFFER_BIT); + bongo_cat_overlay_draw_background(overlay, false); + check_pixel(filled); + /* Effects must use the same logical canvas as the background. */ + CHECK(bongo_cat_overlay_effect(overlay, background)); + glClear(GL_COLOR_BUFFER_BIT); + bongo_cat_overlay_draw_effect(overlay, false); + check_pixel(filled); +} + +int main(void) { + CHECK(SDL_Init(SDL_INIT_VIDEO)); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); + SDL_Window *window = SDL_CreateWindow("Overlay layout test", 32, 32, + SDL_WINDOW_OPENGL | SDL_WINDOW_HIDDEN); + SDL_GLContext context = window ? SDL_GL_CreateContext(window) : NULL; + CHECK(context != NULL); + if (!context) { SDL_Quit(); return 1; } + char root[BONGO_CAT_PATH_CAP], resources[BONGO_CAT_PATH_CAP]; + char background[BONGO_CAT_PATH_CAP]; + SDL_snprintf(root, sizeof(root), "%soverlay-layout-%llu", SDL_GetBasePath(), + (unsigned long long)SDL_GetTicksNS()); + CHECK(bongo_cat_path_join(resources, sizeof(resources), root, "resources")); + CHECK(bongo_cat_path_join(background, sizeof(background), resources, "background.png")); + CHECK(SDL_CreateDirectory(resources)); + BongoCatError error = {0}; + BongoCatOverlay *overlay = bongo_cat_overlay_create(&error); + CHECK(overlay != NULL); + glViewport(0, 0, 32, 32); + glClearColor(0, 0, 0, 0); + if (overlay) { + layout_case(overlay, root, background, 612, 354, 1224, 708, true, true); + layout_case(overlay, root, background, 612, 352, 1836, 1056, true, true); + layout_case(overlay, root, background, 612, 354, 612, 354, true, true); + layout_case(overlay, root, background, 612, 354, 1400, 1400, true, false); + layout_case(overlay, root, background, 306, 177, 612, 354, true, false); + layout_case(overlay, root, background, 612, 354, 1224, 354, true, false); + layout_case(overlay, root, background, 612, 354, 1224, 708, false, true); + bongo_cat_overlay_destroy(overlay); + } + CHECK(SDL_RemovePath(background)); + CHECK(SDL_RemovePath(resources)); + CHECK(SDL_RemovePath(root)); + SDL_GL_DestroyContext(context); + SDL_DestroyWindow(window); + SDL_Quit(); + return bongo_cat_test_failures ? 1 : 0; +} From fe7b49d69d6c068d07b742df4a0aad0877c3d646 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 18 Sep 2026 01:33:13 +0800 Subject: [PATCH 316/334] fix: github action --- src/runtime/model/import/model_import_report.c | 2 +- tests/model_import/test_mver_missing_motion.c | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/runtime/model/import/model_import_report.c b/src/runtime/model/import/model_import_report.c index 4e4d7148..2a090a81 100644 --- a/src/runtime/model/import/model_import_report.c +++ b/src/runtime/model/import/model_import_report.c @@ -40,7 +40,7 @@ static bool add_stat(yyjson_mut_doc *output, yyjson_mut_val *stats, yyjson_val *mode, const BongoCatImportCandidate *candidate, const char *key, const char *directory, bool sound) { yyjson_val *rows = yyjson_obj_get(mode, key); - if (!rows) return true; + if (!rows || yyjson_is_null(rows)) return true; if (!yyjson_is_arr(rows)) return false; size_t declared = yyjson_arr_size(rows), available = 0; for (size_t i = 0; i < declared; ++i) diff --git a/tests/model_import/test_mver_missing_motion.c b/tests/model_import/test_mver_missing_motion.c index fa0efc03..b0e85fc5 100644 --- a/tests/model_import/test_mver_missing_motion.c +++ b/tests/model_import/test_mver_missing_motion.c @@ -51,6 +51,16 @@ static void optional_mode_inputs(const char *temporary) { CHECK(discovery->count == 1); CHECK(child(adapter, sizeof(adapter), root, "adapter", false)); CHECK(bongo_cat_import_prepare_adapter(&discovery->candidates[0], adapter, &error)); + char report_path[BONGO_CAT_PATH_CAP]; + CHECK(child(report_path, sizeof(report_path), adapter, + ".bongo-cat-import-report.json", false)); + yyjson_doc *report = bongo_cat_json_read_file(report_path, 0, NULL); + yyjson_val *assets = yyjson_obj_get(yyjson_doc_get_root(report), "assets"); + CHECK(report && yyjson_is_obj(assets)); + CHECK(!yyjson_obj_get(assets, "hand") && + !yyjson_obj_get(assets, "lefthand") && + !yyjson_obj_get(assets, "sounds")); + yyjson_doc_free(report); size_t length = 0; char *original = SDL_LoadFile(path, &length); CHECK(original && length == strlen(config) && memcmp(original, config, length) == 0); From eb739332ab409c51c9383274d96c62bb7a2b943e Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 18 Sep 2026 15:28:01 +0800 Subject: [PATCH 317/334] remove: support --- .../preferences/preferences_about_community.c | 28 +------------------ 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/src/ui/preferences/preferences_about_community.c b/src/ui/preferences/preferences_about_community.c index c4cc117a..6cf28ba8 100644 --- a/src/ui/preferences/preferences_about_community.c +++ b/src/ui/preferences/preferences_about_community.c @@ -117,30 +117,6 @@ static void community_link(BongoCatPreferences *value, if (hit(context, bounds)) open_url(urls[index]); } -static void coffee(BongoCatPreferences *value, struct nk_context *context, - struct nk_command_buffer *canvas, struct nk_rect bounds, - BongoCatUIPalette p) { - const char *label = tr(value, "native.support.coffee", - "Buy the developer a coffee?"); - float width = value->ui.caption_font->width(value->ui.caption_font->userdata, - value->ui.caption_font->height, label, nk_strlen(label)) + 48.0f; - struct nk_rect pill = nk_rect(bounds.x + (bounds.w - width) * .5f, - bounds.y, width, 36); - bool hover = nk_input_is_mouse_hovering_rect(&context->input, pill); - if (p.effects) bongo_cat_ui_paint_shadow(context, pill, 18, 0, - hover ? 9.0f : 7.0f, hover ? 22.0f : 18.0f, 0, - nk_rgba(p.pink.r, p.pink.g, p.pink.b, hover ? 77 : 61)); - nk_fill_rect(canvas, pill, 18, p.pink); - bongo_cat_preferences_icon_draw(value, canvas, - BONGO_CAT_UI_ICON_COFFEE, - nk_rect(pill.x + 16, pill.y + 10, 16, 16), nk_rgb(255, 255, 255)); - centered(canvas, nk_rect(pill.x + 32, pill.y, pill.w - 38, pill.h), label, - value->ui.caption_font, nk_rgb(255, 255, 255)); - if (hover) bongo_cat_ui_cursor_hover_rect(context, pill, - BONGO_CAT_UI_CURSOR_POINTER); - if (hit(context, pill)) open_url("https://bongocat.pet/support"); -} - void bongo_cat_preferences_about_projects_heading( BongoCatPreferences *value, struct nk_context *context, struct nk_rect bounds) { @@ -183,7 +159,7 @@ void bongo_cat_preferences_about_projects_heading( void bongo_cat_preferences_about_community( BongoCatPreferences *value, struct nk_context *context) { struct nk_rect bounds; - nk_layout_row_dynamic(context, 220, 1); + nk_layout_row_dynamic(context, 164, 1); if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return; BongoCatUIPalette p = bongo_cat_ui_palette( bongo_cat_ui_dark(context)); @@ -195,6 +171,4 @@ void bongo_cat_preferences_about_community( community_link(value, context, canvas, nk_rect(bounds.x + bounds.w * .5f - 289 + i * 298, bounds.y + 48, 280, 68), i, p); - coffee(value, context, canvas, - nk_rect(bounds.x, bounds.y + 136, bounds.w, 36), p); } From 9ad81d342ae019e83016e3a04ba2b7eb39ffb86b Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 18 Sep 2026 16:17:19 +0800 Subject: [PATCH 318/334] Create wechat.md --- docs/wechat.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 docs/wechat.md diff --git a/docs/wechat.md b/docs/wechat.md new file mode 100644 index 00000000..d082cbad --- /dev/null +++ b/docs/wechat.md @@ -0,0 +1,3 @@ + + + From 71df6830a510a6f254f06c34872332e70bc63257 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 18 Sep 2026 16:18:12 +0800 Subject: [PATCH 319/334] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 31d1ad2f..fd391755 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ - +

From cab9ed0048567d83f25e5711e45918da92bc7920 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Fri, 18 Sep 2026 16:12:16 +0800 Subject: [PATCH 320/334] remove: wechat.png --- docs/wechat.png | Bin 65647 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 docs/wechat.png diff --git a/docs/wechat.png b/docs/wechat.png deleted file mode 100644 index 0928295bb802a7363ed012be39a7da31b58a88d4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 65647 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 Date: Sat, 19 Sep 2026 01:18:44 +0800 Subject: [PATCH 321/334] feat: update setting about --- CMakeLists.txt | 7 +- cmake/AboutWebP.cmake | 43 + cmake/RuntimeSources.cmake | 18 +- resources/assets/licenses/libwebp.txt | 30 + resources/assets/licenses/nanosvg.txt | 18 + resources/assets/locales/de-DE.json | 18 + resources/assets/locales/en-US.json | 18 + resources/assets/locales/es-ES.json | 18 + resources/assets/locales/fr-FR.json | 18 + resources/assets/locales/ja-JP.json | 18 + resources/assets/locales/ko-KR.json | 18 + resources/assets/locales/pt-BR.json | 18 + resources/assets/locales/ru-RU.json | 18 + resources/assets/locales/zh-CN.json | 18 + resources/assets/locales/zh-Hant.json | 18 + src/ui/backend/ui_backend.c | 59 +- src/ui/backend/ui_backend.h | 2 +- .../{ => about}/preferences_about.c | 3 +- src/ui/preferences/about/preferences_about.h | 41 + .../{ => about}/preferences_about_community.c | 78 +- .../{ => about}/preferences_about_community.h | 0 .../about/preferences_about_contributors.c | 235 ++ .../about/preferences_about_feed.c | 153 + .../about/preferences_about_feed.h | 21 + .../{ => about}/preferences_about_footer.c | 15 +- .../{ => about}/preferences_about_footer.h | 0 .../about/preferences_about_internal.h | 25 + .../about/preferences_about_lifecycle.c | 104 + .../about/preferences_about_online.c | 251 ++ .../about/preferences_about_online.h | 34 + .../{ => about}/preferences_about_page.c | 7 +- .../preferences/about/preferences_about_svg.c | 49 + .../preferences/about/preferences_about_svg.h | 13 + .../about/preferences_about_text.c | 52 + .../about/preferences_about_wechat.c | 107 + src/ui/preferences/preferences.c | 9 + src/ui/preferences/preferences_assets.c | 2 + src/ui/preferences/preferences_lifecycle.c | 9 + src/ui/preferences/preferences_model_glyphs.c | 2 + src/ui/preferences/preferences_present.c | 8 +- src/ui/preferences/preferences_render.c | 1 + src/ui/preferences/preferences_state.h | 2 + tests/ui/test_preferences_lifecycle.c | 146 + vendor/nanosvg/LICENSE.txt | 18 + vendor/nanosvg/nanosvg.h | 3278 +++++++++++++++++ vendor/nanosvg/nanosvgrast.h | 1472 ++++++++ 46 files changed, 6432 insertions(+), 60 deletions(-) create mode 100644 cmake/AboutWebP.cmake create mode 100644 resources/assets/licenses/libwebp.txt create mode 100644 resources/assets/licenses/nanosvg.txt rename src/ui/preferences/{ => about}/preferences_about.c (99%) create mode 100644 src/ui/preferences/about/preferences_about.h rename src/ui/preferences/{ => about}/preferences_about_community.c (69%) rename src/ui/preferences/{ => about}/preferences_about_community.h (100%) create mode 100644 src/ui/preferences/about/preferences_about_contributors.c create mode 100644 src/ui/preferences/about/preferences_about_feed.c create mode 100644 src/ui/preferences/about/preferences_about_feed.h rename src/ui/preferences/{ => about}/preferences_about_footer.c (95%) rename src/ui/preferences/{ => about}/preferences_about_footer.h (100%) create mode 100644 src/ui/preferences/about/preferences_about_internal.h create mode 100644 src/ui/preferences/about/preferences_about_lifecycle.c create mode 100644 src/ui/preferences/about/preferences_about_online.c create mode 100644 src/ui/preferences/about/preferences_about_online.h rename src/ui/preferences/{ => about}/preferences_about_page.c (66%) create mode 100644 src/ui/preferences/about/preferences_about_svg.c create mode 100644 src/ui/preferences/about/preferences_about_svg.h create mode 100644 src/ui/preferences/about/preferences_about_text.c create mode 100644 src/ui/preferences/about/preferences_about_wechat.c create mode 100644 vendor/nanosvg/LICENSE.txt create mode 100644 vendor/nanosvg/nanosvg.h create mode 100644 vendor/nanosvg/nanosvgrast.h diff --git a/CMakeLists.txt b/CMakeLists.txt index bdb2f612..946fe9f4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -set(BONGO_CAT_VERSION "1.9.0") +set(BONGO_CAT_VERSION "1.10.0") project(BongoCat VERSION ${BONGO_CAT_VERSION} DESCRIPTION "BongoCat" HOMEPAGE_URL "https://github.com/vladelaina/BongoCat" LANGUAGES C CXX) include(cmake/Version.cmake) include(FetchContent) @@ -100,10 +100,10 @@ target_include_directories(bongo_cat_runtime SYSTEM PRIVATE target_link_libraries(bongo_cat_runtime PRIVATE bongo_cat_warnings bongo_cat_archive) target_link_libraries(bongo_cat_runtime PUBLIC bongo_cat_core SDL3::SDL3-static OpenGL::GL ${BONGO_CAT_MINIAUDIO_TARGET}) if(MSVC) - set_source_files_properties(src/media/stb_image_impl.c src/media/image_resize.c src/ui/backend/nuklear_impl.c + set_source_files_properties(src/media/stb_image_impl.c src/media/image_resize.c src/ui/backend/nuklear_impl.c src/ui/preferences/about/preferences_about_svg.c PROPERTIES COMPILE_OPTIONS "/W0") else() - set_source_files_properties(src/media/stb_image_impl.c src/media/image_resize.c src/ui/backend/nuklear_impl.c + set_source_files_properties(src/media/stb_image_impl.c src/media/image_resize.c src/ui/backend/nuklear_impl.c src/ui/preferences/about/preferences_about_svg.c PROPERTIES COMPILE_OPTIONS "-w") endif() if(BONGO_CAT_CUBISM_ENABLED) @@ -115,6 +115,7 @@ target_compile_definitions(bongo_cat_runtime PRIVATE BONGO_CAT_DEV_LOCALE_ROOT="${CMAKE_CURRENT_SOURCE_DIR}/resources/assets/locales") include(cmake/PlatformRuntime.cmake) +include(cmake/AboutWebP.cmake) add_executable(bongo_cat src/main.c) target_link_libraries(bongo_cat PRIVATE bongo_cat_runtime bongo_cat_warnings) diff --git a/cmake/AboutWebP.cmake b/cmake/AboutWebP.cmake new file mode 100644 index 00000000..17985d18 --- /dev/null +++ b/cmake/AboutWebP.cmake @@ -0,0 +1,43 @@ +# The live /co SVG embeds PNG, JPEG and WebP avatars. Decode all in memory +# with C libraries; only the decoder is linked, no command-line tools installed. +if(BONGO_CAT_FETCH_DEPS) + # Keep the decoder static without changing the parent's library policy. + function(bongo_cat_add_about_webp) + if(POLICY CMP0168) + # Download directly on CMake 3.30+, without an MSBuild sub-project. + cmake_policy(SET CMP0168 NEW) + endif() + set(BUILD_SHARED_LIBS OFF) + set(WEBP_LINK_STATIC ON CACHE BOOL "" FORCE) + set(WEBP_USE_THREAD OFF CACHE BOOL "" FORCE) + set(WEBP_BUILD_FUZZTEST OFF CACHE BOOL "" FORCE) + set(WEBP_BUILD_ANIM_UTILS OFF CACHE BOOL "" FORCE) + set(WEBP_BUILD_CWEBP OFF CACHE BOOL "" FORCE) + set(WEBP_BUILD_DWEBP OFF CACHE BOOL "" FORCE) + set(WEBP_BUILD_GIF2WEBP OFF CACHE BOOL "" FORCE) + set(WEBP_BUILD_IMG2WEBP OFF CACHE BOOL "" FORCE) + set(WEBP_BUILD_VWEBP OFF CACHE BOOL "" FORCE) + set(WEBP_BUILD_WEBPINFO OFF CACHE BOOL "" FORCE) + set(WEBP_BUILD_WEBPMUX OFF CACHE BOOL "" FORCE) + set(WEBP_BUILD_EXTRAS OFF CACHE BOOL "" FORCE) + set(WEBP_BUILD_LIBWEBPMUX OFF CACHE BOOL "" FORCE) + FetchContent_Declare(about_webp + URL https://storage.googleapis.com/downloads.webmproject.org/releases/webp/libwebp-1.6.0.tar.gz + URL_HASH SHA256=e4ab7009bf0629fd11982d4c2aa83964cf244cffba7347ecd39019a9e38c4564 + DOWNLOAD_EXTRACT_TIMESTAMP FALSE + # Populate only; manually add with EXCLUDE_FROM_ALL for CMake 3.24 too. + SOURCE_SUBDIR bongo-download-only) + FetchContent_MakeAvailable(about_webp) + if(NOT TARGET webpdecoder) + add_subdirectory("${about_webp_SOURCE_DIR}" "${about_webp_BINARY_DIR}" EXCLUDE_FROM_ALL) + endif() + target_include_directories(bongo_cat_runtime SYSTEM PRIVATE "${about_webp_SOURCE_DIR}/src") + target_link_libraries(bongo_cat_runtime PRIVATE webpdecoder) + endfunction() + bongo_cat_add_about_webp() +else() + find_path(BONGO_CAT_WEBP_INCLUDE_DIR webp/decode.h REQUIRED) + find_library(BONGO_CAT_WEBP_LIBRARY NAMES webpdecoder webp REQUIRED) + target_include_directories(bongo_cat_runtime SYSTEM PRIVATE "${BONGO_CAT_WEBP_INCLUDE_DIR}") + target_link_libraries(bongo_cat_runtime PRIVATE "${BONGO_CAT_WEBP_LIBRARY}") +endif() diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake index d397b29f..bd607530 100644 --- a/cmake/RuntimeSources.cmake +++ b/cmake/RuntimeSources.cmake @@ -215,12 +215,22 @@ set(BONGO_CAT_UI_THEME_SOURCES src/ui/theme/ui_catime_tabs.c src/ui/theme/ui_catime_theme.c) +set(BONGO_CAT_UI_PREFERENCES_ABOUT_SOURCES + src/ui/preferences/about/preferences_about.c + src/ui/preferences/about/preferences_about_community.c + src/ui/preferences/about/preferences_about_contributors.c + src/ui/preferences/about/preferences_about_feed.c + src/ui/preferences/about/preferences_about_footer.c + src/ui/preferences/about/preferences_about_lifecycle.c + src/ui/preferences/about/preferences_about_online.c + src/ui/preferences/about/preferences_about_page.c + src/ui/preferences/about/preferences_about_svg.c + src/ui/preferences/about/preferences_about_text.c + src/ui/preferences/about/preferences_about_wechat.c) + set(BONGO_CAT_UI_PREFERENCES_SOURCES + ${BONGO_CAT_UI_PREFERENCES_ABOUT_SOURCES} src/ui/preferences/preferences.c - src/ui/preferences/preferences_about.c - src/ui/preferences/preferences_about_community.c - src/ui/preferences/preferences_about_footer.c - src/ui/preferences/preferences_about_page.c src/ui/preferences/preferences_assets.c src/ui/preferences/preferences_behavior_dialog.c src/ui/preferences/preferences_behavior_rename.c diff --git a/resources/assets/licenses/libwebp.txt b/resources/assets/licenses/libwebp.txt new file mode 100644 index 00000000..7a6f9954 --- /dev/null +++ b/resources/assets/licenses/libwebp.txt @@ -0,0 +1,30 @@ +Copyright (c) 2010, Google Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of Google nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/resources/assets/licenses/nanosvg.txt b/resources/assets/licenses/nanosvg.txt new file mode 100644 index 00000000..f896f2eb --- /dev/null +++ b/resources/assets/licenses/nanosvg.txt @@ -0,0 +1,18 @@ +Copyright (c) 2013-14 Mikko Mononen memon@inside.org + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not +claim that you wrote the original software. If you use this software +in a product, an acknowledgment in the product documentation would be +appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be +misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. + diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index 505eab75..45af3e89 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -195,6 +195,24 @@ } }, "native": { + "about": { + "wechat": "WeChat", + "wechatHint": "Hover to scan and chat", + "loading": "Loading...", + "contributorsFailed": "Cannot load contributors. Click to retry.", + "qrUnavailable": "QR code unavailable", + "reload": "Reload", + "qrFailed": "QR code unavailable. Click to retry.", + "contributorsTitle": "Open source & community contributions", + "contributorsText": "Every step of BongoCat comes from the power of open source. Thank you to all community contributors for making desktop companionship freer and simpler.", + "joinTitle": "Want to appear in the contributors list?", + "joinText": "Whether you contribute code, improve documentation, report bugs or translate, every contribution makes BongoCat better.", + "code": "Code", + "docs": "Documentation", + "bugs": "Bug reports", + "translations": "Translations", + "joinGithub": "Contribute on GitHub" + }, "multiplePets": "Mehrere gleichzeitig anzeigen", "multiplePetsLimit": "Bis zu 8 Desktop-Begleiter k\u00f6nnen gleichzeitig angezeigt werden", "removeDesktopPet": "Diesen Desktop-Begleiter schlie\u00dfen", diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index 259848c8..348dcb03 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -195,6 +195,24 @@ } }, "native": { + "about": { + "wechat": "WeChat", + "wechatHint": "Hover to scan and chat", + "loading": "Loading...", + "contributorsFailed": "Cannot load contributors. Click to retry.", + "qrUnavailable": "QR code unavailable", + "reload": "Reload", + "qrFailed": "QR code unavailable. Click to retry.", + "contributorsTitle": "Open source & community contributions", + "contributorsText": "Every step of BongoCat comes from the power of open source. Thank you to all community contributors for making desktop companionship freer and simpler.", + "joinTitle": "Want to appear in the contributors list?", + "joinText": "Whether you contribute code, improve documentation, report bugs or translate, every contribution makes BongoCat better.", + "code": "Code", + "docs": "Documentation", + "bugs": "Bug reports", + "translations": "Translations", + "joinGithub": "Contribute on GitHub" + }, "multiplePets": "Display multiple", "multiplePetsLimit": "Up to 8 desktop pets can be displayed at once", "removeDesktopPet": "Close this desktop pet", diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index 75fd6455..209a7150 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -195,6 +195,24 @@ } }, "native": { + "about": { + "wechat": "WeChat", + "wechatHint": "Hover to scan and chat", + "loading": "Loading...", + "contributorsFailed": "Cannot load contributors. Click to retry.", + "qrUnavailable": "QR code unavailable", + "reload": "Reload", + "qrFailed": "QR code unavailable. Click to retry.", + "contributorsTitle": "Open source & community contributions", + "contributorsText": "Every step of BongoCat comes from the power of open source. Thank you to all community contributors for making desktop companionship freer and simpler.", + "joinTitle": "Want to appear in the contributors list?", + "joinText": "Whether you contribute code, improve documentation, report bugs or translate, every contribution makes BongoCat better.", + "code": "Code", + "docs": "Documentation", + "bugs": "Bug reports", + "translations": "Translations", + "joinGithub": "Contribute on GitHub" + }, "multiplePets": "Mostrar varias a la vez", "multiplePetsLimit": "Se pueden mostrar hasta 8 mascotas a la vez", "removeDesktopPet": "Cerrar esta mascota de escritorio", diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index 7b9e5097..78b51342 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -195,6 +195,24 @@ } }, "native": { + "about": { + "wechat": "WeChat", + "wechatHint": "Hover to scan and chat", + "loading": "Loading...", + "contributorsFailed": "Cannot load contributors. Click to retry.", + "qrUnavailable": "QR code unavailable", + "reload": "Reload", + "qrFailed": "QR code unavailable. Click to retry.", + "contributorsTitle": "Open source & community contributions", + "contributorsText": "Every step of BongoCat comes from the power of open source. Thank you to all community contributors for making desktop companionship freer and simpler.", + "joinTitle": "Want to appear in the contributors list?", + "joinText": "Whether you contribute code, improve documentation, report bugs or translate, every contribution makes BongoCat better.", + "code": "Code", + "docs": "Documentation", + "bugs": "Bug reports", + "translations": "Translations", + "joinGithub": "Contribute on GitHub" + }, "multiplePets": "Afficher plusieurs modèles", "multiplePetsLimit": "Jusqu'\u00e0 8 mascottes peuvent \u00eatre affich\u00e9es \u00e0 la fois", "removeDesktopPet": "Fermer cette mascotte de bureau", diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index 4d68e57a..8b63a54a 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -195,6 +195,24 @@ } }, "native": { + "about": { + "wechat": "WeChat", + "wechatHint": "Hover to scan and chat", + "loading": "Loading...", + "contributorsFailed": "Cannot load contributors. Click to retry.", + "qrUnavailable": "QR code unavailable", + "reload": "Reload", + "qrFailed": "QR code unavailable. Click to retry.", + "contributorsTitle": "Open source & community contributions", + "contributorsText": "Every step of BongoCat comes from the power of open source. Thank you to all community contributors for making desktop companionship freer and simpler.", + "joinTitle": "Want to appear in the contributors list?", + "joinText": "Whether you contribute code, improve documentation, report bugs or translate, every contribution makes BongoCat better.", + "code": "Code", + "docs": "Documentation", + "bugs": "Bug reports", + "translations": "Translations", + "joinGithub": "Contribute on GitHub" + }, "multiplePets": "複数を同時に表示", "multiplePetsLimit": "\u4e00\u5ea6\u306b\u8868\u793a\u3067\u304d\u308b\u30c7\u30b9\u30af\u30c8\u30c3\u30d7\u30da\u30c3\u30c8\u306f 8 \u4f53\u307e\u3067\u3067\u3059", "removeDesktopPet": "\u3053\u306e\u30c7\u30b9\u30af\u30c8\u30c3\u30d7\u30da\u30c3\u30c8\u3092\u9589\u3058\u308b", diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index 8acdce90..d5eb4dbc 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -195,6 +195,24 @@ } }, "native": { + "about": { + "wechat": "WeChat", + "wechatHint": "Hover to scan and chat", + "loading": "Loading...", + "contributorsFailed": "Cannot load contributors. Click to retry.", + "qrUnavailable": "QR code unavailable", + "reload": "Reload", + "qrFailed": "QR code unavailable. Click to retry.", + "contributorsTitle": "Open source & community contributions", + "contributorsText": "Every step of BongoCat comes from the power of open source. Thank you to all community contributors for making desktop companionship freer and simpler.", + "joinTitle": "Want to appear in the contributors list?", + "joinText": "Whether you contribute code, improve documentation, report bugs or translate, every contribution makes BongoCat better.", + "code": "Code", + "docs": "Documentation", + "bugs": "Bug reports", + "translations": "Translations", + "joinGithub": "Contribute on GitHub" + }, "multiplePets": "여러 개 동시에 표시", "multiplePetsLimit": "\ub370\uc2a4\ud06c\ud1b1 \ud3ab\uc740 \ud55c \ubc88\uc5d0 \ucd5c\ub300 8\uac1c\uae4c\uc9c0 \ud45c\uc2dc\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4", "removeDesktopPet": "\uc774 \ub370\uc2a4\ud06c\ud1b1 \ud3ab \ub2eb\uae30", diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index 658ff72d..c095a445 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -195,6 +195,24 @@ } }, "native": { + "about": { + "wechat": "WeChat", + "wechatHint": "Hover to scan and chat", + "loading": "Loading...", + "contributorsFailed": "Cannot load contributors. Click to retry.", + "qrUnavailable": "QR code unavailable", + "reload": "Reload", + "qrFailed": "QR code unavailable. Click to retry.", + "contributorsTitle": "Open source & community contributions", + "contributorsText": "Every step of BongoCat comes from the power of open source. Thank you to all community contributors for making desktop companionship freer and simpler.", + "joinTitle": "Want to appear in the contributors list?", + "joinText": "Whether you contribute code, improve documentation, report bugs or translate, every contribution makes BongoCat better.", + "code": "Code", + "docs": "Documentation", + "bugs": "Bug reports", + "translations": "Translations", + "joinGithub": "Contribute on GitHub" + }, "multiplePets": "Exibir vários", "multiplePetsLimit": "At\u00e9 8 mascotes podem ser exibidos ao mesmo tempo", "removeDesktopPet": "Fechar este mascote da \u00e1rea de trabalho", diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index 2cfb14d2..9760c35c 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -195,6 +195,24 @@ } }, "native": { + "about": { + "wechat": "WeChat", + "wechatHint": "Hover to scan and chat", + "loading": "Loading...", + "contributorsFailed": "Cannot load contributors. Click to retry.", + "qrUnavailable": "QR code unavailable", + "reload": "Reload", + "qrFailed": "QR code unavailable. Click to retry.", + "contributorsTitle": "Open source & community contributions", + "contributorsText": "Every step of BongoCat comes from the power of open source. Thank you to all community contributors for making desktop companionship freer and simpler.", + "joinTitle": "Want to appear in the contributors list?", + "joinText": "Whether you contribute code, improve documentation, report bugs or translate, every contribution makes BongoCat better.", + "code": "Code", + "docs": "Documentation", + "bugs": "Bug reports", + "translations": "Translations", + "joinGithub": "Contribute on GitHub" + }, "multiplePets": "Показывать несколько", "multiplePetsLimit": "\u041e\u0434\u043d\u043e\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e \u043c\u043e\u0436\u043d\u043e \u043e\u0442\u043e\u0431\u0440\u0430\u0437\u0438\u0442\u044c \u0434\u043e 8 \u043f\u0438\u0442\u043e\u043c\u0446\u0435\u0432", "removeDesktopPet": "\u0417\u0430\u043a\u0440\u044b\u0442\u044c \u044d\u0442\u043e\u0433\u043e \u043f\u0438\u0442\u043e\u043c\u0446\u0430", diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index 62c05641..05e979f0 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -195,6 +195,24 @@ } }, "native": { + "about": { + "wechat": "微信群", + "wechatHint": "悬停扫码,一起交流", + "loading": "正在加载...", + "contributorsFailed": "贡献者加载失败,点击重试。", + "qrUnavailable": "二维码暂时无法加载", + "reload": "重新加载", + "qrFailed": "二维码暂时无法加载,点击重新加载", + "contributorsTitle": "开源共建与 社区贡献", + "contributorsText": "BongoCat 的每一步都源于开源的力量。感谢所有社区贡献者的无私付出,是你们让桌面陪伴变得更加自由与纯粹。", + "joinTitle": "想出现在贡献者名单中吗?", + "joinText": "无论是提交代码、改善文档、报告 Bug 还是贡献多语言翻译,你的每一次参与都在让 BongoCat 变得更好。", + "code": "代码贡献", + "docs": "文档改进", + "bugs": "Bug 反馈", + "translations": "翻译国际化", + "joinGithub": "参与 GitHub 贡献" + }, "multiplePets": "同时显示多个", "multiplePetsLimit": "\u6700\u591a\u53ef\u540c\u65f6\u663e\u793a 8 \u4e2a\u684c\u5ba0", "removeDesktopPet": "\u5173\u95ed\u6b64\u684c\u5ba0", diff --git a/resources/assets/locales/zh-Hant.json b/resources/assets/locales/zh-Hant.json index 584620e9..d68ccad8 100644 --- a/resources/assets/locales/zh-Hant.json +++ b/resources/assets/locales/zh-Hant.json @@ -195,6 +195,24 @@ } }, "native": { + "about": { + "wechat": "微信群組", + "wechatHint": "懸停掃碼,一起交流", + "loading": "正在載入...", + "contributorsFailed": "貢獻者載入失敗,點擊重試。", + "qrUnavailable": "QR 碼暫時無法載入", + "reload": "重新載入", + "qrFailed": "二維碼暫時無法載入,點擊重新載入", + "contributorsTitle": "開源共建與 社群貢獻", + "contributorsText": "BongoCat 的每一步都源於開源的力量。感謝所有社群貢獻者的無私付出,是你們讓桌面陪伴變得更加自由與純粹。", + "joinTitle": "想出現在貢獻者名單中嗎?", + "joinText": "無論是提交程式碼、改善文件、回報 Bug 還是貢獻多語言翻譯,你的每一次參與都在讓 BongoCat 變得更好。", + "code": "程式碼貢獻", + "docs": "文件改進", + "bugs": "Bug 回報", + "translations": "翻譯國際化", + "joinGithub": "參與 GitHub 貢獻" + }, "multiplePets": "同時顯示多個", "multiplePetsLimit": "\u6700\u591a\u53ef\u540c\u6642\u986f\u793a 8 \u500b\u684c\u5bf5", "removeDesktopPet": "\u95dc\u9589\u6b64\u684c\u5bf5", diff --git a/src/ui/backend/ui_backend.c b/src/ui/backend/ui_backend.c index acf3b99e..d5355bf5 100644 --- a/src/ui/backend/ui_backend.c +++ b/src/ui/backend/ui_backend.c @@ -139,7 +139,17 @@ void bongo_cat_ui_destroy(BongoCatUIBackend *ui) { release(ui, true); } void bongo_cat_ui_abandon(BongoCatUIBackend *ui) { release(ui, false); } -static void convert(BongoCatUIBackend *ui) { +static bool grow_buffer(void **data, size_t *capacity, size_t limit) { + if (*capacity >= limit) return false; + size_t next = NK_MIN(*capacity * 2, limit); + void *grown = realloc(*data, next); + if (!grown) return false; + *data = grown; + *capacity = next; + return true; +} + +static bool convert(BongoCatUIBackend *ui) { static const struct nk_draw_vertex_layout_element layout[] = { {NK_VERTEX_POSITION, NK_FORMAT_FLOAT, offsetof(UIVertex, position)}, {NK_VERTEX_TEXCOORD, NK_FORMAT_FLOAT, offsetof(UIVertex, uv)}, @@ -158,10 +168,22 @@ static void convert(BongoCatUIBackend *ui) { config.shape_AA = NK_ANTI_ALIASING_ON; config.line_AA = NK_ANTI_ALIASING_ON; struct nk_buffer vertices, elements; - nk_buffer_init_fixed(&vertices, ui->vertices, ui->vertex_capacity); - nk_buffer_init_fixed(&elements, ui->elements, ui->element_capacity); - ui->last_convert_result = nk_convert(&ui->context, - &ui->commands, &vertices, &elements, &config); + /* Bound transient storage and stay below the 16-bit vertex index limit. + Never upload partially converted geometry when either buffer fills. */ + for (;;) { + nk_buffer_clear(&ui->commands); + nk_buffer_init_fixed(&vertices, ui->vertices, ui->vertex_capacity); + nk_buffer_init_fixed(&elements, ui->elements, ui->element_capacity); + ui->last_convert_result = (int)nk_convert(&ui->context, + &ui->commands, &vertices, &elements, &config); + if (ui->last_convert_result == NK_CONVERT_SUCCESS) break; + int full = NK_CONVERT_VERTEX_BUFFER_FULL | NK_CONVERT_ELEMENT_BUFFER_FULL; + if (ui->last_convert_result & ~full) return false; + if ((ui->last_convert_result & NK_CONVERT_VERTEX_BUFFER_FULL) && + !grow_buffer(&ui->vertices, &ui->vertex_capacity, 1024u * 1024u)) return false; + if ((ui->last_convert_result & NK_CONVERT_ELEMENT_BUFFER_FULL) && + !grow_buffer(&ui->elements, &ui->element_capacity, 512u * 1024u)) return false; + } ui->last_vertex_bytes = vertices.allocated; ui->last_element_bytes = elements.allocated; if (vertices.allocated >= sizeof(UIVertex)) { @@ -173,23 +195,38 @@ static void convert(BongoCatUIBackend *ui) { if (sample[i].color[3] > ui->max_alpha) ui->max_alpha = sample[i].color[3]; } } + ui->gl.bind_vertex_array(ui->vao); ui->gl.bind_buffer(GL_ARRAY_BUFFER, ui->vbo); ui->gl.buffer_data(GL_ARRAY_BUFFER, (GLsizeiptr)vertices.allocated, ui->vertices, GL_STREAM_DRAW); ui->gl.bind_buffer(GL_ELEMENT_ARRAY_BUFFER, ui->ebo); ui->gl.buffer_data(GL_ELEMENT_ARRAY_BUFFER, (GLsizeiptr)elements.allocated, ui->elements, GL_STREAM_DRAW); + return true; } -void bongo_cat_ui_render(BongoCatUIBackend *ui) { +bool bongo_cat_ui_render(BongoCatUIBackend *ui) { int pixel_width, pixel_height; float width = 0.0f, height = 0.0f; bongo_cat_ui_logical_size(ui, &width, &height); SDL_GetWindowSizeInPixels(ui->window, &pixel_width, &pixel_height); + if (width <= 0 || height <= 0 || pixel_width <= 0 || pixel_height <= 0) { + nk_clear(&ui->context); + nk_buffer_clear(&ui->commands); + return false; + } float projection[4][4] = {{2.0f / width, 0, 0, 0}, {0, -2.0f / height, 0, 0}, {0, 0, -1, 0}, {-1, 1, 0, 1}}; bongo_cat_gl_clear_errors(); - convert(ui); + if (!convert(ui)) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, "UI conversion rejected incomplete geometry (flags %d)", + ui->last_convert_result); + ui->last_vertex_bytes = ui->last_element_bytes = 0; + ui->last_draw_commands = ui->last_draw_elements = 0; + nk_clear(&ui->context); + nk_buffer_clear(&ui->commands); + return false; + } glViewport(0, 0, pixel_width, pixel_height); glEnable(GL_BLEND); ui->gl.blend_equation(GL_FUNC_ADD); @@ -204,7 +241,7 @@ void bongo_cat_ui_render(BongoCatUIBackend *ui) { ui->gl.active_texture(GL_TEXTURE0); ui->gl.bind_vertex_array(ui->vao); const struct nk_draw_command *command; - const nk_draw_index *offset = NULL; + size_t offset = 0; float sx = pixel_width / width, sy = pixel_height / height; ui->last_draw_commands = 0; ui->last_draw_elements = 0; @@ -217,8 +254,9 @@ void bongo_cat_ui_render(BongoCatUIBackend *ui) { (GLint)((height - command->clip_rect.y - command->clip_rect.h) * sy), (GLsizei)(command->clip_rect.w * sx), (GLsizei)(command->clip_rect.h * sy)); glDrawElements(GL_TRIANGLES, (GLsizei)command->elem_count, - sizeof(nk_draw_index) == 2 ? GL_UNSIGNED_SHORT : GL_UNSIGNED_INT, offset); - offset += command->elem_count; + sizeof(nk_draw_index) == 2 ? GL_UNSIGNED_SHORT : GL_UNSIGNED_INT, + (const void *)(uintptr_t)offset); + offset += (size_t)command->elem_count * sizeof(nk_draw_index); } nk_clear(&ui->context); nk_buffer_clear(&ui->commands); @@ -228,6 +266,7 @@ void bongo_cat_ui_render(BongoCatUIBackend *ui) { if (ui->last_draw_commands < 256) trim_commands(ui); glDisable(GL_SCISSOR_TEST); ui->last_gl_error = glGetError(); + return ui->last_gl_error == GL_NO_ERROR; } void bongo_cat_ui_trim_idle(BongoCatUIBackend *ui) { diff --git a/src/ui/backend/ui_backend.h b/src/ui/backend/ui_backend.h index 266ee514..e4184049 100644 --- a/src/ui/backend/ui_backend.h +++ b/src/ui/backend/ui_backend.h @@ -88,7 +88,7 @@ void bongo_cat_ui_input_begin(BongoCatUIBackend *ui); void bongo_cat_ui_input_end(BongoCatUIBackend *ui); void bongo_cat_ui_input_reset(BongoCatUIBackend *ui); bool bongo_cat_ui_event(BongoCatUIBackend *ui, const SDL_Event *event); -void bongo_cat_ui_render(BongoCatUIBackend *ui); +bool bongo_cat_ui_render(BongoCatUIBackend *ui); void bongo_cat_ui_trim_idle(BongoCatUIBackend *ui); bool bongo_cat_ui_resize_cache_capture(BongoCatUIBackend *ui); bool bongo_cat_ui_resize_cache_present(BongoCatUIBackend *ui); diff --git a/src/ui/preferences/preferences_about.c b/src/ui/preferences/about/preferences_about.c similarity index 99% rename from src/ui/preferences/preferences_about.c rename to src/ui/preferences/about/preferences_about.c index a43bd1bc..70491dc7 100644 --- a/src/ui/preferences/preferences_about.c +++ b/src/ui/preferences/about/preferences_about.c @@ -1,3 +1,4 @@ +#include "preferences_about_internal.h" #include "preferences_state.h" #include "preferences_about_community.h" #include "preferences_about_footer.h" @@ -147,7 +148,7 @@ static void hero_title(BongoCatPreferences *value, struct nk_context *context, text(canvas, link, developer, value->ui.caption_font, bongo_cat_ui_color_mix(p.accent, p.pink, amount)); link_cursor(context, link); if (hit(context, link)) - open_url("https://github.com/vladelaina/BongoCat"); + open_url("https://vladelaina.com"); } void bongo_cat_preferences_about_hero(BongoCatPreferences *value, struct nk_context *context) { diff --git a/src/ui/preferences/about/preferences_about.h b/src/ui/preferences/about/preferences_about.h new file mode 100644 index 00000000..6e319b48 --- /dev/null +++ b/src/ui/preferences/about/preferences_about.h @@ -0,0 +1,41 @@ +#ifndef BONGO_CAT_PREFERENCES_ABOUT_H +#define BONGO_CAT_PREFERENCES_ABOUT_H + +#include "preferences_about_online.h" +#include "nuklear_config.h" + +typedef struct BongoCatPreferences BongoCatPreferences; + +/* UI-owned state. Workers publish results through the request objects. */ +typedef struct BongoCatAboutState { + bool qr_attempted, qr_open; + bool qr_texture_dirty; + BongoCatAboutRequest *qr_request; + /* Retained for this settings session, including GL asset rebuilds. */ + unsigned char *qr_pixels; + unsigned int qr_texture, portraits[BONGO_ABOUT_CONTRIBUTOR_CAP]; + unsigned int wechat_icon_texture; + bool wechat_icon_attempted; + BongoCatAboutRequest *contributors_request; + BongoCatAboutFeed *contributors; + bool contributors_attempted; + bool portraits_loaded; + Uint32 event_type; + Uint64 qr_hide_at; + struct nk_rect qr_anchor; + struct nk_user_font detail_font; + struct nk_user_font title_font, description_font; + /* Stable addresses: Nuklear text commands retain font pointers until render. */ + struct nk_user_font css_fonts[64]; +} BongoCatAboutState; + +/* Preferences lifecycle and render-loop integration. */ +/* Starts each request once; retries explicitly reset the corresponding flag. */ +void bongo_cat_about_refresh(BongoCatPreferences *value); +void bongo_cat_about_overlays(BongoCatPreferences *value, struct nk_context *context); +void bongo_cat_about_clear(BongoCatPreferences *value, bool gl_ready); +void bongo_cat_about_assets_clear(BongoCatPreferences *value, bool gl_ready); +void bongo_cat_about_shutdown(BongoCatPreferences *value); +bool bongo_cat_about_event(BongoCatPreferences *value, const SDL_Event *event); + +#endif diff --git a/src/ui/preferences/preferences_about_community.c b/src/ui/preferences/about/preferences_about_community.c similarity index 69% rename from src/ui/preferences/preferences_about_community.c rename to src/ui/preferences/about/preferences_about_community.c index 6cf28ba8..ec675f37 100644 --- a/src/ui/preferences/preferences_about_community.c +++ b/src/ui/preferences/about/preferences_about_community.c @@ -1,3 +1,4 @@ +#include "preferences_about_internal.h" #include "preferences_about_community.h" #include "preferences_state.h" #include "ui_animation.h" @@ -84,37 +85,57 @@ static void community_link(BongoCatPreferences *value, struct nk_context *context, struct nk_command_buffer *canvas, struct nk_rect bounds, int index, BongoCatUIPalette p) { const char *labels[] = {"Discord", tr(value, - "native.support.qqGroup", "QQ Group")}; - const char *details[] = {"https://discord.gg/vf8jqnattk", "422616922"}; + "native.support.qqGroup", "QQ Group"), tr(value,"native.about.wechat","WeChat")}; + const char *details[] = {"https://discord.gg/vf8jqnattk", "422616922", + tr(value,"native.about.wechatHint","Hover to scan and chat")}; const char *urls[] = {"https://discord.gg/vf8jqnattk", "https://qm.qq.com/q/6ksRMCZIuA"}; - struct nk_color brand = index ? p.accent : nk_rgb(88, 101, 242); - bool hover = nk_input_is_mouse_hovering_rect(&context->input, bounds); + struct nk_color brand = index == 2 ? nk_rgb(7,193,96) : index ? p.accent : nk_rgb(88, 101, 242); + bool hover = nk_input_is_mouse_hovering_rect(&context->input, canvas->clip) && + nk_input_is_mouse_hovering_rect(&context->input, bounds); char id[32]; snprintf(id, sizeof(id), "community-hover-%d", index); float lift = bongo_cat_ui_animate_eased(context, id, hover ? 1.0f : 0.0f, 220.0f, BONGO_CAT_UI_EASE_SWIFT); bounds.y -= 2.0f * lift; - struct nk_color background = bongo_cat_ui_color_mix(p.surface_glass, - brand, hover ? .10f : .055f); + const struct nk_color light[] = {nk_rgb(242, 244, 253), nk_rgb(242, 248, 254), nk_rgb(237, 248, 241)}; + struct nk_color background = bongo_cat_ui_dark(context) + ? bongo_cat_ui_color_mix(p.surface_glass, brand, .055f) : light[index]; if (hover && p.effects) bongo_cat_ui_paint_shadow(context, bounds, 18, 0, 10, 24, 0, nk_rgba(brand.r, brand.g, brand.b, 28)); - nk_fill_rect(canvas, bounds, 18, background); - struct nk_rect mark = nk_rect(bounds.x + 12, bounds.y + 12, 44, 44); - nk_fill_rect(canvas, mark, 14, brand); - bongo_cat_preferences_icon_draw(value, canvas, + nk_fill_rect(canvas, bounds, 16, background); + struct nk_rect mark = nk_rect(bounds.x + 12, bounds.y + (bounds.h-36)*.5f, 36, 36); + nk_fill_rect(canvas, mark, 12, brand); + if(index == 2) { + bongo_cat_about_wechat_icon(value, context, nk_rect(mark.x + 5.5f, mark.y + 5.5f, 25, 25)); + } else bongo_cat_preferences_icon_draw(value, canvas, index ? BONGO_CAT_UI_ICON_QQ : BONGO_CAT_UI_ICON_DISCORD, - nk_rect(mark.x + 9.5f, mark.y + 9.5f, 25, 25), nk_rgb(255, 255, 255)); - text(canvas, nk_rect(bounds.x + 68, bounds.y + 13, bounds.w - 94, 22), - labels[index], value->ui.label_font, p.text); - text(canvas, nk_rect(bounds.x + 68, bounds.y + 37, bounds.w - 94, 18), - details[index], value->ui.caption_font, p.muted); - nk_stroke_line(canvas, bounds.x + bounds.w - 22, bounds.y + 31, - bounds.x + bounds.w - 17, bounds.y + 34, 1.5f, brand); - nk_stroke_line(canvas, bounds.x + bounds.w - 17, bounds.y + 34, - bounds.x + bounds.w - 22, bounds.y + 37, 1.5f, brand); + nk_rect(mark.x + 5.5f, mark.y + 5.5f, 25, 25), nk_rgb(255, 255, 255)); + text(canvas, nk_rect(bounds.x + 57, bounds.y + bounds.h*.5f-21, bounds.w - 77, 22), + labels[index], bongo_cat_about_font(value, 20), p.text); + const struct nk_user_font *detail_font = bongo_cat_about_font(value, 14.0f); + char detail[256]; + snprintf(detail, sizeof(detail), "%s", details[index]); + int length = nk_strlen(detail); + float detail_width = NK_MAX(0.0f, bounds.w - 80.0f); + if (detail_font->width(detail_font->userdata, detail_font->height, detail, length) > detail_width) { + float dots = detail_font->width(detail_font->userdata, detail_font->height, "...", 3); + while (length && detail_font->width(detail_font->userdata, detail_font->height, detail, length) + dots > detail_width) { + length--; + while (length && ((unsigned char)detail[length] & 0xc0) == 0x80) length--; + } + snprintf(detail + length, sizeof(detail) - (size_t)length, "..."); + } + text(canvas, nk_rect(bounds.x + 57, bounds.y + bounds.h*.5f+3, detail_width, 18), + detail, detail_font, p.muted); + float right = bounds.x + bounds.w - (bounds.h < 80 ? 20.0f : 10.0f); + float middle = bounds.y + bounds.h * .5f; + nk_stroke_line(canvas, right - 5, middle - 3, right, middle, 1.5f, brand); + nk_stroke_line(canvas, right, middle, right - 5, middle + 3, 1.5f, brand); if (hover) bongo_cat_ui_cursor_hover_rect(context, bounds, BONGO_CAT_UI_CURSOR_POINTER); - if (hit(context, bounds)) open_url(urls[index]); + if(index == 2) { + if(hover) bongo_cat_about_wechat(value,bounds); + } else if (hit(context, bounds)) open_url(urls[index]); } void bongo_cat_preferences_about_projects_heading( @@ -159,16 +180,21 @@ void bongo_cat_preferences_about_projects_heading( void bongo_cat_preferences_about_community( BongoCatPreferences *value, struct nk_context *context) { struct nk_rect bounds; - nk_layout_row_dynamic(context, 164, 1); + float viewport, height; + bongo_cat_ui_logical_size(&value->ui, &viewport, &height); + (void)height; + bool stacked = viewport <= 780.0f; + nk_layout_row_dynamic(context, stacked ? 334.0f : 194.0f, 1); if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return; BongoCatUIPalette p = bongo_cat_ui_palette( bongo_cat_ui_dark(context)); struct nk_command_buffer *canvas = nk_window_get_canvas(context); - centered(canvas, nk_rect(bounds.x, bounds.y, bounds.w, 30), + centered(canvas, nk_rect(bounds.x, bounds.y + 24, bounds.w, 30), tr(value, "native.support.community", "Community"), - value->ui.heading_font, p.text); - for (int i = 0; i < 2; ++i) + bongo_cat_about_font(value, 24), p.text); + for (int i = 0; i < 3; ++i) community_link(value, context, canvas, - nk_rect(bounds.x + bounds.w * .5f - 289 + i * 298, - bounds.y + 48, 280, 68), i, p); + nk_rect(bounds.x + (stacked?0:i*(bounds.w+12)/3), + bounds.y + 74 + (stacked?i*80:0), stacked?bounds.w:(bounds.w-24)/3, + stacked?68.0f:88.0f), i, p); } diff --git a/src/ui/preferences/preferences_about_community.h b/src/ui/preferences/about/preferences_about_community.h similarity index 100% rename from src/ui/preferences/preferences_about_community.h rename to src/ui/preferences/about/preferences_about_community.h diff --git a/src/ui/preferences/about/preferences_about_contributors.c b/src/ui/preferences/about/preferences_about_contributors.c new file mode 100644 index 00000000..885b84c2 --- /dev/null +++ b/src/ui/preferences/about/preferences_about_contributors.c @@ -0,0 +1,235 @@ +#include "preferences_state.h" +#include "preferences_about_internal.h" +#include +#include "ui_catime.h" +#include "ui_animation.h" +#include "ui_paint.h" +#include "ui_icons.h" +#include +#include +#include + +static void contributors_load(BongoCatPreferences *value) { + BongoCatAboutState *s = &value->about; + bongo_cat_about_refresh(value); + if (s->contributors && !s->portraits_loaded) { + glDeleteTextures(BONGO_ABOUT_CONTRIBUTOR_CAP, s->portraits); + memset(s->portraits, 0, sizeof(s->portraits)); + s->portraits_loaded = true; + for (int i = 0; i < s->contributors->count; i++) { + unsigned char *pixels = s->contributors->people[i].pixels; + if (!pixels) continue; + glGenTextures(1, &s->portraits[i]); + glBindTexture(GL_TEXTURE_2D, s->portraits[i]); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, BONGO_ABOUT_AVATAR_SIZE, + BONGO_ABOUT_AVATAR_SIZE, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels); + } + } +} + +void bongo_cat_about_contributors(BongoCatPreferences *value, struct nk_context *context) { + contributors_load(value); + float available = nk_window_get_content_region_size(context).x; + float viewport, viewport_height; + bongo_cat_ui_logical_size(&value->ui, &viewport, &viewport_height); + (void)viewport_height; + bool compact = viewport <= 560.0f; + float inset = compact ? 8.0f : 16.0f; + float avatar_size = compact ? 54.0f : 64.0f; + float gap = compact ? 18.0f : 24.0f; + float stride = avatar_size + gap; + float inner = available - 2 * inset; + const struct nk_user_font *body_font = bongo_cat_about_font(value, 16); + const struct nk_user_font *tag_font = bongo_cat_about_font(value, 14); + const struct nk_user_font *join_font = bongo_cat_about_font(value, compact ? 23.0f : 24.0f); + const char *description = bongo_cat_i18n_get(value->app->i18n, "native.about.contributorsText", + "Every step of BongoCat comes from open source. Thank you to all our contributors."); + const char *join_title = bongo_cat_i18n_get(value->app->i18n, "native.about.joinTitle", + "Want to appear in the contributors list?"); + const char *join_text = bongo_cat_i18n_get(value->app->i18n, "native.about.joinText", + "Code, documentation, bug reports and translations: every contribution makes BongoCat better."); + const char *join_button = bongo_cat_i18n_get(value->app->i18n, "native.about.joinGithub", "Contribute on GitHub"); + float natural_button_width = NK_MAX(220.0f, body_font->width(body_font->userdata, + body_font->height, join_button, nk_strlen(join_button)) + 80.0f); + bool wide = viewport > 768.0f && inner >= 344.0f + natural_button_width; + bool centered = viewport <= 768.0f; + float content_width = wide ? inner - natural_button_width - 24.0f : inner; + float title_height = compact ? 31.25f : 37.5f; + float desc_y = 44.0f + title_height + 16.0f; + float grid_y = desc_y + bongo_cat_about_paragraph(NULL, nk_rect(0, 0, inner, 0), + description, body_font, nk_rgb(0, 0, 0), true, 28.8f) + 32.0f; + int columns = NK_MAX(1, (int)((inner + gap) / stride)); + int total = value->about.contributors ? value->about.contributors->count : 0; + int rows = (NK_MAX(1, total) + columns - 1) / columns; + float grid_height = rows * stride - gap; + float join_y = grid_y + grid_height + 32.0f; + float heading_y = 28.0f + 13.0f + 10.4f; + float paragraph_y = heading_y + bongo_cat_about_text_height(join_title, join_font, content_width) + 6.0f; + float tags_y = paragraph_y + bongo_cat_about_paragraph(NULL, nk_rect(0, 0, content_width, 0), + join_text, body_font, nk_rgb(0, 0, 0), centered, 24.8f) + 18.0f; + const char *keys[] = {"native.about.code", "native.about.docs", "native.about.bugs", "native.about.translations"}; + const char *fallbacks[] = {"Code", "Documentation", "Bug reports", "Translations"}; + const char *marks[] = {"〈/〉", "▤", "♧", "文"}; + float mark_widths[4]; + const char *labels[4]; + struct nk_rect tags[4]; + float tag_x = 0, tag_y = tags_y; + for (int i = 0; i < 4; i++) { + labels[i] = bongo_cat_i18n_get(value->app->i18n, keys[i], fallbacks[i]); + mark_widths[i] = tag_font->width(tag_font->userdata, tag_font->height, marks[i], nk_strlen(marks[i])); + float tw = NK_MIN(content_width, tag_font->width(tag_font->userdata, tag_font->height, + labels[i], nk_strlen(labels[i])) + mark_widths[i] + 26.4f); + if (tag_x && tag_x + tw > content_width) { tag_x = 0; tag_y += 40.0f; } + tags[i] = nk_rect(tag_x, tag_y, tw, 32); + tag_x += tw + 8.0f; + } + if (centered) { + for (int first = 0; first < 4;) { + int last = first; + while (last + 1 < 4 && tags[last + 1].y == tags[first].y) last++; + float shift = (content_width - tags[last].x - tags[last].w) * .5f; + for (int i = first; i <= last; i++) tags[i].x += shift; + first = last + 1; + } + } + float content_height = tag_y + 32.0f; + float button_y = wide ? 28.0f + (content_height - 28.0f - 46.0f) * .5f : content_height + 24.0f; + struct nk_rect bounds; + nk_layout_row_dynamic(context, join_y + NK_MAX(content_height, button_y + 46.0f) + 44.0f, 1); + if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) + return; + BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); + struct nk_command_buffer *canvas = nk_window_get_canvas(context); + struct nk_rect avatars[BONGO_ABOUT_CONTRIBUTOR_CAP]; + for (int i = 0; i < total; i++) { + int row = i / columns, count = NK_MIN(columns, total - row * columns); + avatars[i] = nk_rect(bounds.x + (bounds.w - (count * stride - gap)) * .5f + (i % columns) * stride, + bounds.y + grid_y + row * stride, avatar_size, avatar_size); + } + value->about.title_font = *value->ui.heading_font; + value->about.title_font.height = compact ? 25.0f : 30.0f; + const struct nk_user_font *title_font = &value->about.title_font; + const char *title = bongo_cat_i18n_get(value->app->i18n, "native.about.contributorsTitle", + "Open source & community contributions"); + int title_length = nk_strlen(title); + float title_width = + title_font->width(title_font->userdata, title_font->height, title, title_length); + if (title_width > bounds.w - 32) { + value->about.title_font.height *= (bounds.w - 32) / title_width; + title_width = bounds.w - 32; + } + float title_x = bounds.x + (bounds.w - title_width) * .5f; + for (int i = 0; i < title_length;) { + nk_rune rune; + int bytes = nk_utf_decode(title + i, &rune, title_length - i); + if (bytes <= 0) + break; + float prefix = title_font->width(title_font->userdata, title_font->height, title, i); + float glyph = title_font->width(title_font->userdata, title_font->height, title + i, bytes); + struct nk_color color = + bongo_cat_ui_color_mix(p.accent, p.pink, title_width ? prefix / title_width : 0); + nk_draw_text(canvas, + nk_rect(title_x + prefix, bounds.y + 44, glyph + 2, title_font->height), + title + i, bytes, title_font, nk_rgba(0, 0, 0, 0), color); + i += bytes; + } + bongo_cat_about_paragraph(context, nk_rect(bounds.x + inset, bounds.y + desc_y, inner, grid_y - desc_y), + description, body_font, p.muted, true, 28.8f); + if (!total) { + struct nk_rect status = nk_rect(bounds.x + inset, bounds.y + grid_y, inner, avatar_size); + bool loading = value->about.contributors_request != NULL; + bongo_cat_about_text(value, context, status, + loading ? "native.about.loading" : "native.about.contributorsFailed", + loading ? "Loading..." : "Cannot load contributors. Click to retry.", + body_font, p.muted, true); + if (!loading && nk_input_is_mouse_hovering_rect(&context->input, canvas->clip) && + nk_input_is_mouse_hovering_rect(&context->input, status)) { + bongo_cat_ui_cursor_hover_rect(context, status, BONGO_CAT_UI_CURSOR_POINTER); + if (nk_input_is_mouse_click_in_rect(&context->input, NK_BUTTON_LEFT, status)) { + value->about.contributors_attempted = false; + value->render_dirty = true; + } + } + } + for (int i = 0; i < total; i++) { + struct nk_rect avatar = avatars[i]; + bool hover = nk_input_is_mouse_hovering_rect(&context->input, canvas->clip) && + nk_input_is_mouse_hovering_rect(&context->input, avatar); + char id[32]; + snprintf(id, sizeof(id), "contributor-%d", i); + float amount = + bongo_cat_ui_animate_eased(context, id, hover ? 1.0f : 0.0f, 400, BONGO_CAT_UI_EASE_STANDARD); + float growth = avatar_size * .075f * amount; + struct nk_rect raised = nk_rect(avatar.x - growth, avatar.y - growth - 6.0f * amount, + avatar_size + growth * 2, avatar_size + growth * 2); + if (hover && p.effects) + bongo_cat_ui_paint_shadow(context, raised, 36, 0, 8, 18, 0, nk_rgba(130, 80, 223, 65)); + nk_fill_circle(canvas, raised, p.field); + if (value->about.portraits[i]) { + struct nk_image image = nk_image_id((int)value->about.portraits[i]); + nk_draw_image(canvas, raised, &image, nk_rgb(255, 255, 255)); + } + nk_stroke_circle(canvas, raised, 2, bongo_cat_ui_color_mix(nk_rgb(255, 255, 255), p.pink, amount)); + if (hover) { + bongo_cat_ui_cursor_hover_rect(context, avatar, BONGO_CAT_UI_CURSOR_POINTER); + const char *profile = value->about.contributors->people[i].profile; + if (*profile && nk_input_is_mouse_click_in_rect(&context->input, NK_BUTTON_LEFT, avatar)) + SDL_OpenURL(profile); + } + } + float x = bounds.x + inset, y = bounds.y + join_y, w = inner; + for (int i = 0; i < 48; i++) { + float t = ((float)i + .5f) / 48.0f; + float alpha = NK_MIN(1.0f, NK_MIN(t, 1.0f - t) / .15f) * .25f; + struct nk_color c = bongo_cat_ui_color_mix(nk_rgb(130, 80, 223), nk_rgb(30, 150, 235), + 1.0f - fabsf(t * 2.0f - 1.0f)); + nk_fill_rect(canvas, nk_rect(x + w * (float)i / 48.0f, y, w / 48.0f + .5f, 1), 0, + bongo_cat_ui_color_alpha(c, alpha)); + } + bongo_cat_about_text(value, context, nk_rect(x, y + 28, content_width, 18), NULL, "OPEN SOURCE", + bongo_cat_about_font(value, 13), p.pink, centered); + bongo_cat_about_text(value, context, nk_rect(x, y + heading_y, content_width, paragraph_y - heading_y), + NULL, join_title, join_font, p.text, centered); + bongo_cat_about_paragraph(context, nk_rect(x, y + paragraph_y, content_width, tags_y - paragraph_y), + join_text, body_font, p.muted, centered, 24.8f); + struct nk_color colors[] = {p.pink, nk_rgb(122, 162, 247), p.pink, nk_rgb(39, 201, 63)}; + for (int i = 0; i < 4; i++) { + struct nk_rect tag = tags[i]; tag.x += x; tag.y += y; + nk_fill_rect(canvas, tag, 16, bongo_cat_ui_color_mix(p.surface, colors[i], .05f)); + nk_stroke_rect(canvas, tag, 16, 1, bongo_cat_ui_color_alpha(colors[i], .15f)); + bongo_cat_about_text(value, context, nk_rect(tag.x + 10, tag.y + 9, mark_widths[i] + 1, 16), NULL, + marks[i], tag_font, colors[i], true); + bongo_cat_about_text(value, context, nk_rect(tag.x + 16.4f + mark_widths[i], tag.y + 9, + tag.w - 26.4f - mark_widths[i] + 1, 16), NULL, + labels[i], tag_font, colors[i], true); + } + float button_width = NK_MIN(w, centered ? NK_MAX(280.0f, natural_button_width) : natural_button_width); + struct nk_rect button = nk_rect(x + (wide ? w - button_width : centered ? (w - button_width) * .5f : 0), + y + button_y, button_width, 46); + bool hover = nk_input_is_mouse_hovering_rect(&context->input, canvas->clip) && + nk_input_is_mouse_hovering_rect(&context->input, button); + float lift = bongo_cat_ui_animate_eased(context, "about-join-hover", hover ? 1.0f : 0.0f, + 400, BONGO_CAT_UI_EASE_STANDARD); + button.y -= lift * 3.0f; + if (p.effects) bongo_cat_ui_paint_shadow(context, button, 23, 0, 8, 20, 0, + bongo_cat_ui_color_alpha(p.pink, .04f + .18f * lift)); + nk_fill_rect(canvas, button, 23, bongo_cat_ui_color_mix(p.surface, p.pink, .08f * lift)); + nk_stroke_rect(canvas, button, 23, 1, bongo_cat_ui_color_alpha(p.pink, .35f + .3f * lift)); + bongo_cat_preferences_icon_draw(value, canvas, BONGO_CAT_UI_ICON_GITHUB, + nk_rect(button.x + 17, button.y + 13, 20, 20), p.pink); + bongo_cat_about_text(value, context, nk_rect(button.x + 43, button.y + 16, button.w - 74, 20), + "native.about.joinGithub", "Contribute on GitHub", body_font, p.pink, true); + float arrow = button.x + button.w - 23 + 4 * lift, ay = button.y + 23; + nk_stroke_line(canvas, arrow - 8, ay, arrow, ay, 1.3f, p.pink); + nk_stroke_line(canvas, arrow - 4, ay - 4, arrow, ay, 1.3f, p.pink); + nk_stroke_line(canvas, arrow - 4, ay + 4, arrow, ay, 1.3f, p.pink); + if (hover) { + bongo_cat_ui_cursor_hover_rect(context, button, BONGO_CAT_UI_CURSOR_POINTER); + if (nk_input_is_mouse_click_in_rect(&context->input, NK_BUTTON_LEFT, button)) + SDL_OpenURL("https://github.com/vladelaina/BongoCat"); + } +} diff --git a/src/ui/preferences/about/preferences_about_feed.c b/src/ui/preferences/about/preferences_about_feed.c new file mode 100644 index 00000000..ae847e94 --- /dev/null +++ b/src/ui/preferences/about/preferences_about_feed.c @@ -0,0 +1,153 @@ +#include "preferences_about_feed.h" +#include "preferences_about_svg.h" +#include +#include +#include +#include +#include + +typedef struct FeedParser { + BongoCatAboutFeed *feed; + SDL_AtomicInt *cancel; + bool in_link, in_title, failed; + BongoCatAboutContributor person; +} FeedParser; + +static int base64_digit(unsigned char c) { + if (c >= 'A' && c <= 'Z') return c - 'A'; + if (c >= 'a' && c <= 'z') return c - 'a' + 26; + if (c >= '0' && c <= '9') return c - '0' + 52; + return c == '+' ? 62 : c == '/' ? 63 : -1; +} + +static unsigned char *decode_uri(const char *uri, int *length) { + const char *data = NULL; + const char *prefixes[] = {"data:image/png;base64,", "data:image/jpeg;base64,", + "data:image/webp;base64,"}; + for (size_t i = 0; i < sizeof(prefixes) / sizeof(prefixes[0]); i++) + if (!strncmp(uri, prefixes[i], strlen(prefixes[i]))) data = uri + strlen(prefixes[i]); + if (!data) return NULL; + size_t size = strlen(data); + if (!size || size > 1024u * 1024u || size % 4) return NULL; + unsigned char *bytes = malloc(size / 4 * 3); + if (!bytes) return NULL; + int used = 0; + for (size_t i = 0; i < size; i += 4) { + int a = base64_digit((unsigned char)data[i]); + int b = base64_digit((unsigned char)data[i + 1]); + int c = data[i + 2] == '=' ? 0 : base64_digit((unsigned char)data[i + 2]); + int d = data[i + 3] == '=' ? 0 : base64_digit((unsigned char)data[i + 3]); + if (a < 0 || b < 0 || c < 0 || d < 0 || + ((data[i + 2] == '=' || data[i + 3] == '=') && i + 4 != size) || + (data[i + 2] == '=' && data[i + 3] != '=')) { free(bytes); return NULL; } + bytes[used++] = (unsigned char)((a << 2) | (b >> 4)); + if (data[i + 2] != '=') bytes[used++] = (unsigned char)((b << 4) | (c >> 2)); + if (data[i + 3] != '=') bytes[used++] = (unsigned char)((c << 6) | d); + } + *length = used; + return bytes; +} + +static unsigned char *portrait(const char *uri) { + int length = 0, width = 0, height = 0, channels = 0; + unsigned char *data = decode_uri(uri, &length); + if (!data) return NULL; + bool webp = length >= 12 && !memcmp(data, "RIFF", 4) && !memcmp(data + 8, "WEBP", 4); + bool known = webp ? WebPGetInfo(data, (size_t)length, &width, &height) != 0 : + stbi_info_from_memory(data, length, &width, &height, &channels) != 0; + if (!known || width <= 0 || height <= 0 || width > 4096 || height > 4096 || + (size_t)width * (size_t)height > 4u * 1024u * 1024u) { free(data); return NULL; } + unsigned char *source = webp ? WebPDecodeRGBA(data, (size_t)length, &width, &height) : + stbi_load_from_memory(data, length, &width, &height, &channels, 4); + free(data); + if (!source) return NULL; + const int size = BONGO_ABOUT_AVATAR_SIZE; + unsigned char *pixels = malloc((size_t)size * (size_t)size * 4); + if (pixels) for (int y = 0; y < size; y++) for (int x = 0; x < size; x++) { + unsigned char *out = pixels + (y * size + x) * 4; + int sx = x * width / size, sy = y * height / size; + memcpy(out, source + ((size_t)sy * (size_t)width + (size_t)sx) * 4, 4); + int dx = 2 * x + 1 - size, dy = 2 * y + 1 - size; + if (dx * dx + dy * dy > size * size) out[3] = 0; + } + if (webp) WebPFree(source); else stbi_image_free(source); + return pixels; +} + +static const char *attribute(const char **attrs, const char *name) { + for (int i = 0; attrs[i] && attrs[i + 1]; i += 2) + if (!strcmp(attrs[i], name)) return attrs[i + 1]; + return ""; +} + +static void xml_text(char *out, size_t capacity, const char *input) { + size_t used = 0; + while (*input && used + 1 < capacity) { + const char *entities[] = {"&", "<", ">", """, "'"}; + const char values[] = {'&', '<', '>', '"', '\''}; + bool entity = false; + for (size_t i = 0; i < 5; i++) if (!strncmp(input, entities[i], strlen(entities[i]))) { + out[used++] = values[i]; input += strlen(entities[i]); entity = true; break; + } + if (!entity) out[used++] = *input++; + } + out[used] = 0; +} + +static void start(void *user, const char *element, const char **attrs) { + FeedParser *p = user; + if (p->failed || SDL_GetAtomicInt(p->cancel)) return; + if (!strcmp(element, "a")) { + if (p->in_link) { p->failed = true; return; } + p->in_link = true; + memset(&p->person, 0, sizeof(p->person)); + const char *href = attribute(attrs, "href"); + if (!*href) href = attribute(attrs, "xlink:href"); + if (strlen(href) < sizeof(p->person.profile) && !strncmp(href, "https://", 8)) + xml_text(p->person.profile, sizeof(p->person.profile), href); + } else if (p->in_link && !strcmp(element, "title")) p->in_title = true; + else if (p->in_link && !strcmp(element, "image") && !p->person.pixels && + p->feed->count < BONGO_ABOUT_CONTRIBUTOR_CAP) { + const char *href = attribute(attrs, "href"); + if (!*href) href = attribute(attrs, "xlink:href"); + p->person.pixels = portrait(href); + } +} + +static void end(void *user, const char *element) { + FeedParser *p = user; + if (!strcmp(element, "title")) p->in_title = false; + if (!strcmp(element, "a")) { + if (!p->failed && p->person.pixels && p->feed->count < BONGO_ABOUT_CONTRIBUTOR_CAP) { + if (!p->person.name[0]) snprintf(p->person.name, sizeof(p->person.name), "Contributor"); + p->feed->people[p->feed->count++] = p->person; + p->person.pixels = NULL; + } + free(p->person.pixels); p->person.pixels = NULL; + p->in_link = p->in_title = false; + } +} + +static void content(void *user, const char *text) { + FeedParser *p = user; + if (p->in_link && p->in_title) xml_text(p->person.name, sizeof(p->person.name), text); +} + +BongoCatAboutFeed *bongo_cat_about_feed_parse(char *svg, SDL_AtomicInt *cancel) { + if (!svg || !strstr(svg, "count; i++) free(feed->people[i].pixels); + free(feed); +} diff --git a/src/ui/preferences/about/preferences_about_feed.h b/src/ui/preferences/about/preferences_about_feed.h new file mode 100644 index 00000000..b356a6d2 --- /dev/null +++ b/src/ui/preferences/about/preferences_about_feed.h @@ -0,0 +1,21 @@ +#ifndef BONGO_CAT_PREFERENCES_ABOUT_FEED_H +#define BONGO_CAT_PREFERENCES_ABOUT_FEED_H + +#include + +#define BONGO_ABOUT_CONTRIBUTOR_CAP 64 +#define BONGO_ABOUT_AVATAR_SIZE 128 +typedef struct BongoCatAboutContributor { + char name[256]; + char profile[1024]; + unsigned char *pixels; +} BongoCatAboutContributor; +typedef struct BongoCatAboutFeed { + int count; + BongoCatAboutContributor people[BONGO_ABOUT_CONTRIBUTOR_CAP]; +} BongoCatAboutFeed; +/* Parsing owns decoded avatar pixels until feed_free. */ +BongoCatAboutFeed *bongo_cat_about_feed_parse(char *svg, SDL_AtomicInt *cancel); +void bongo_cat_about_feed_free(BongoCatAboutFeed *feed); + +#endif diff --git a/src/ui/preferences/preferences_about_footer.c b/src/ui/preferences/about/preferences_about_footer.c similarity index 95% rename from src/ui/preferences/preferences_about_footer.c rename to src/ui/preferences/about/preferences_about_footer.c index 87dc540c..df265507 100644 --- a/src/ui/preferences/preferences_about_footer.c +++ b/src/ui/preferences/about/preferences_about_footer.c @@ -154,18 +154,17 @@ static void draw_links(BongoCatPreferences *value, struct nk_rect feedback, struct nk_rect logs, const char *feedback_label, const char *logs_label, BongoCatUIPalette p) { const struct nk_user_font *small_font = logs_font(value); - struct nk_color feedback_color = link_color(context, feedback, - "support-feedback-hover", p); + float feedback_hover = bongo_cat_ui_animate_eased(context, "support-feedback-hover", + nk_input_is_mouse_hovering_rect(&context->input, feedback) ? 1.0f : 0.0f, + 200, BONGO_CAT_UI_EASE_STANDARD); + struct nk_color feedback_color = bongo_cat_ui_color_mix(nk_rgb(111, 204, 225), + nk_rgb(78, 172, 192), feedback_hover); centered(canvas, feedback, feedback_label, value->ui.caption_font, feedback_color); link_cursor(context, feedback); - if (hit(context, feedback) && !SDL_OpenURL( - "https://github.com/vladelaina/BongoCat/issues")) - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "Cannot open URL: %s", SDL_GetError()); + if (hit(context, feedback) && !SDL_OpenURL("https://bongocat.pet/?feedback=1")) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Cannot open feedback page: %s", SDL_GetError()); - nk_stroke_line(canvas, logs.x - 10, logs.y + 11, - logs.x - 10, logs.y + logs.h - 11, 1, p.border_subtle); struct nk_color logs_color = link_color(context, logs, "support-logs-hover", p); float content_width = logs_content_width(small_font, logs_label); diff --git a/src/ui/preferences/preferences_about_footer.h b/src/ui/preferences/about/preferences_about_footer.h similarity index 100% rename from src/ui/preferences/preferences_about_footer.h rename to src/ui/preferences/about/preferences_about_footer.h diff --git a/src/ui/preferences/about/preferences_about_internal.h b/src/ui/preferences/about/preferences_about_internal.h new file mode 100644 index 00000000..baa704b5 --- /dev/null +++ b/src/ui/preferences/about/preferences_about_internal.h @@ -0,0 +1,25 @@ +#ifndef BONGO_CAT_PREFERENCES_ABOUT_INTERNAL_H +#define BONGO_CAT_PREFERENCES_ABOUT_INTERNAL_H + +#include "preferences_about.h" + +/* Shared text layout and rendering. */ +const struct nk_user_font *bongo_cat_about_font(BongoCatPreferences *value, float size); +float bongo_cat_about_text_height(const char *text, const struct nk_user_font *font, float width); +float bongo_cat_about_paragraph(struct nk_context *context, struct nk_rect bounds, + const char *text, const struct nk_user_font *font, struct nk_color color, + bool center, float leading); +void bongo_cat_about_text(BongoCatPreferences *value, struct nk_context *context, + struct nk_rect bounds, const char *key, const char *fallback, + const struct nk_user_font *font, struct nk_color color, bool center); + +/* Page sections and their UI helpers. */ +void bongo_cat_preferences_about_hero(BongoCatPreferences *value, struct nk_context *context); +void bongo_cat_preferences_about_projects(BongoCatPreferences *value, struct nk_context *context); +void bongo_cat_about_contributors(BongoCatPreferences *value, struct nk_context *context); +void bongo_cat_about_wechat(BongoCatPreferences *value, struct nk_rect anchor); +void bongo_cat_about_wechat_icon(BongoCatPreferences *value, struct nk_context *context, + struct nk_rect bounds); +void bongo_cat_about_ensure_event(BongoCatPreferences *value); + +#endif diff --git a/src/ui/preferences/about/preferences_about_lifecycle.c b/src/ui/preferences/about/preferences_about_lifecycle.c new file mode 100644 index 00000000..c45357af --- /dev/null +++ b/src/ui/preferences/about/preferences_about_lifecycle.c @@ -0,0 +1,104 @@ +#include "preferences_state.h" +#include "preferences_about_internal.h" +#include +#include +#include + +void bongo_cat_about_ensure_event(BongoCatPreferences *value) { + if (!value->about.event_type) + value->about.event_type = SDL_RegisterEvents(1); +} + +/* Collect CPU results even on other settings pages; GL uploads remain lazy. + Cancelled jobs keep their slot until completion, bounding rapid reopen work. */ +void bongo_cat_about_refresh(BongoCatPreferences *value) { + BongoCatAboutState *s = &value->about; + BongoCatAboutRequest **slots[] = {&s->qr_request, &s->contributors_request}; + for (int i = 0; i < 2; i++) { + BongoCatAboutRequest *job = *slots[i]; + if (!job || !SDL_GetAtomicInt(&job->done)) continue; + bool refresh = value->visible && job->refresh_needed && + !SDL_GetAtomicInt(&job->cancel); + int kind = job->kind; + if (value->visible && job->status == 200 && !SDL_GetAtomicInt(&job->cancel)) { + if (value->page == 3) value->render_dirty = true; + if (job->kind == BONGO_ABOUT_WECHAT) { + free(s->qr_pixels); + s->qr_pixels = job->qr_pixels; + job->qr_pixels = NULL; + s->qr_texture_dirty = true; + } else { + bongo_cat_about_feed_free(s->contributors); + s->contributors = job->feed; + job->feed = NULL; + s->portraits_loaded = false; + } + } + bongo_cat_about_request_free(job); + *slots[i] = NULL; + if (refresh) + *slots[i] = bongo_cat_about_request(kind, s->event_type, + SDL_GetWindowID(value->window), value->app->cache_root, true); + } + if (!value->visible) return; + bongo_cat_about_ensure_event(value); + if (!s->contributors_attempted && !s->contributors_request) { + s->contributors_attempted = true; + s->contributors_request = bongo_cat_about_request(BONGO_ABOUT_CONTRIBUTORS, + s->event_type, SDL_GetWindowID(value->window), value->app->cache_root, false); + } + if (!s->qr_attempted && !s->qr_request) { + s->qr_attempted = true; + s->qr_request = bongo_cat_about_request(BONGO_ABOUT_WECHAT, + s->event_type, SDL_GetWindowID(value->window), value->app->cache_root, false); + } +} + +bool bongo_cat_about_event(BongoCatPreferences *value, const SDL_Event *event) { + if (value->about.event_type && event->type == value->about.event_type) { + bongo_cat_about_refresh(value); + if (value->visible && value->page == 3) value->render_dirty = true; + return true; + } + return false; +} + +void bongo_cat_about_assets_clear(BongoCatPreferences *value, bool gl_ready) { + BongoCatAboutState *s = &value->about; + if (gl_ready) { + if (s->wechat_icon_texture) + glDeleteTextures(1, &s->wechat_icon_texture); + if (s->qr_texture) + glDeleteTextures(1, &s->qr_texture); + glDeleteTextures(BONGO_ABOUT_CONTRIBUTOR_CAP, s->portraits); + } + s->qr_texture = 0; + s->qr_texture_dirty = false; + s->wechat_icon_texture = 0; + s->wechat_icon_attempted = false; + memset(s->portraits, 0, sizeof(s->portraits)); + s->portraits_loaded = false; +} + +void bongo_cat_about_clear(BongoCatPreferences *value, bool gl_ready) { + BongoCatAboutState *s = &value->about; + /* Closing settings never waits for DNS/TLS. Shutdown joins remaining jobs. */ + if (s->qr_request) + SDL_SetAtomicInt(&s->qr_request->cancel, 1); + if (s->contributors_request) + SDL_SetAtomicInt(&s->contributors_request->cancel, 1); + bongo_cat_about_feed_free(s->contributors); + s->contributors = NULL; + free(s->qr_pixels); + s->qr_pixels = NULL; + s->contributors_attempted = false; + bongo_cat_about_assets_clear(value, gl_ready); + s->portraits_loaded = s->qr_attempted = s->qr_open = false; +} + +void bongo_cat_about_shutdown(BongoCatPreferences *value) { + bongo_cat_about_request_free(value->about.contributors_request); + value->about.contributors_request = NULL; + bongo_cat_about_request_free(value->about.qr_request); + value->about.qr_request = NULL; +} diff --git a/src/ui/preferences/about/preferences_about_online.c b/src/ui/preferences/about/preferences_about_online.c new file mode 100644 index 00000000..69376e37 --- /dev/null +++ b/src/ui/preferences/about/preferences_about_online.c @@ -0,0 +1,251 @@ +#include "preferences_about_online.h" +#include "preferences_about_svg.h" +#include "bongo_cat/file.h" +#include "bongo_cat/path.h" +#include +#include +#ifdef _WIN32 +#include +#include +#else +#include +#include +#endif + +#define RESPONSE_LIMIT (4u * 1024u * 1024u) +#define CACHE_TTL_NS (SDL_NS_PER_SECOND * 86400LL) + +static bool cache_path(BongoCatAboutRequest *job, char *path, size_t capacity) { + return job->cache_directory[0] && bongo_cat_path_join(path, capacity, + job->cache_directory, job->kind == BONGO_ABOUT_CONTRIBUTORS + ? "contributors-v1.svg" : "wechat-v1.svg"); +} + +/* SVG parsers modify their input. Keep the original bytes for the disk cache. */ +static bool decode_response(BongoCatAboutRequest *job) { + if (SDL_GetAtomicInt(&job->cancel) || !job->length || + memchr(job->response, 0, job->length) || !strstr(job->response, "response, "response, "length + 1); + if (!copy) return false; + memcpy(copy, job->response, job->length + 1); + bool valid; + if (job->kind == BONGO_ABOUT_CONTRIBUTORS) { + job->feed = bongo_cat_about_feed_parse(copy, &job->cancel); + valid = job->feed && job->feed->count; + if (!valid) { + bongo_cat_about_feed_free(job->feed); + job->feed = NULL; + } + } else { + job->qr_pixels = bongo_cat_about_qr_pixels(copy); + valid = job->qr_pixels != NULL; + } + free(copy); + return valid; +} + +static bool cache_read(BongoCatAboutRequest *job) { + char path[BONGO_CAT_PATH_CAP]; + if (!cache_path(job, path, sizeof(path))) return false; + SDL_PathInfo info; + if (!SDL_GetPathInfo(path, &info) || info.type != SDL_PATHTYPE_FILE || + !info.size || info.size > job->limit) return false; + FILE *file = bongo_cat_file_open(path, "rb"); + if (!file) return false; + size_t size = (size_t)info.size; + bool ok = fread(job->response, 1, size, file) == size; + if (fclose(file) != 0) ok = false; + job->length = size; + job->response[size] = 0; + if (!ok || !decode_response(job)) { + job->length = 0; + job->response[0] = 0; + return false; + } + SDL_Time now = 0; + job->refresh_needed = !SDL_GetCurrentTime(&now) || info.modify_time <= 0 || + now < info.modify_time || now - info.modify_time >= CACHE_TTL_NS; + job->status = 200; + return true; +} + +static void cache_write(BongoCatAboutRequest *job) { + char path[BONGO_CAT_PATH_CAP], temporary[BONGO_CAT_PATH_CAP + 64]; + if (SDL_GetAtomicInt(&job->cancel) || !cache_path(job, path, sizeof(path)) || + !bongo_cat_path_create_directory(job->cache_directory)) return; + /* A sibling temporary file preserves the previous cache on failed writes. */ + unsigned long long process_id; +#ifdef _WIN32 + process_id = (unsigned long long)GetCurrentProcessId(); +#else + process_id = (unsigned long long)getpid(); +#endif + int length = snprintf(temporary, sizeof(temporary), "%s.%llu.%llu.tmp", path, + process_id, + (unsigned long long)SDL_GetCurrentThreadID()); + if (length < 0 || (size_t)length >= sizeof(temporary)) return; + FILE *file = bongo_cat_file_open(temporary, "wb"); + if (!file) return; + bool ok = fwrite(job->response, 1, job->length, file) == job->length; + if (fclose(file) != 0) ok = false; + if (!ok || SDL_GetAtomicInt(&job->cancel) || + !bongo_cat_file_replace(temporary, path, false)) + bongo_cat_file_remove(temporary); +} + +static int complete(BongoCatAboutRequest *job) { + free(job->response); + job->response = NULL; + job->length = 0; + SDL_SetAtomicInt(&job->done, 1); + SDL_Event event; + memset(&event, 0, sizeof(event)); + event.type = job->event_type; + event.user.windowID = job->window_id; + SDL_PushEvent(&event); + return 0; +} + +static bool append(BongoCatAboutRequest *job, const void *data, size_t size) { + if (SDL_GetAtomicInt(&job->cancel) || size > job->limit - job->length) + return false; + memcpy(job->response + job->length, data, size); + job->length += size; + job->response[job->length] = 0; + return true; +} + +#ifndef _WIN32 +static size_t receive(char *data, size_t size, size_t count, void *user) { + if (size && count > RESPONSE_LIMIT / size) + return 0; + return append(user, data, size * count) ? size * count : 0; +} +static int progress(void *user, curl_off_t a, curl_off_t b, curl_off_t c, curl_off_t d) { + (void)a; + (void)b; + (void)c; + (void)d; + return SDL_GetAtomicInt(&((BongoCatAboutRequest *)user)->cancel); +} +#endif + +static int SDLCALL request_worker(void *user) { + BongoCatAboutRequest *job = user; + job->response = calloc(job->limit + 1, 1); + if (!job->response) return complete(job); + /* Publish stale data before starting the separate background refresh. */ + if (!job->network_only && cache_read(job)) return complete(job); + if (SDL_GetAtomicInt(&job->cancel)) return complete(job); + bool ok = false; +#ifdef _WIN32 + HINTERNET session = WinHttpOpen(L"BongoCat About/1.0", WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, + WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); + HINTERNET connection = NULL, request = NULL; + if (session) { + WinHttpSetTimeouts(session, 3000, 3000, 4000, 4000); + connection = + WinHttpConnect(session, L"bongocat.pet", + INTERNET_DEFAULT_HTTPS_PORT, 0); + } + if (connection) + request = WinHttpOpenRequest( + connection, L"GET", + job->kind == BONGO_ABOUT_CONTRIBUTORS ? L"/co" : L"/wechat", + NULL, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE); + if (request) { + DWORD redirect = WINHTTP_OPTION_REDIRECT_POLICY_NEVER; + WinHttpSetOption(request, WINHTTP_OPTION_REDIRECT_POLICY, &redirect, sizeof(redirect)); + ok = !SDL_GetAtomicInt(&job->cancel) && + WinHttpSendRequest(request, WINHTTP_NO_ADDITIONAL_HEADERS, 0, + WINHTTP_NO_REQUEST_DATA, 0, 0, 0) && + WinHttpReceiveResponse(request, NULL); + DWORD status = 0, bytes = sizeof(status); + if (ok) + ok = WinHttpQueryHeaders(request, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + NULL, &status, &bytes, NULL) != FALSE; + job->status = (int)status; + Uint64 deadline = SDL_GetTicks() + 15000; + while (ok && !SDL_GetAtomicInt(&job->cancel)) { + char buffer[4096]; + DWORD read = 0; + ok = SDL_GetTicks() < deadline && + WinHttpReadData(request, buffer, sizeof(buffer), &read); + if (!ok || !read) + break; + ok = append(job, buffer, read); + } + } + if (request) + WinHttpCloseHandle(request); + if (connection) + WinHttpCloseHandle(connection); + if (session) + WinHttpCloseHandle(session); +#else + bool initialized = curl_global_init(CURL_GLOBAL_DEFAULT) == CURLE_OK; + CURL *curl = initialized ? curl_easy_init() : NULL; + if (curl) { + curl_easy_setopt(curl, CURLOPT_URL, + job->kind == BONGO_ABOUT_CONTRIBUTORS ? "https://bongocat.pet/co" + : "https://bongocat.pet/wechat"); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "BongoCat About/1.0"); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, 3000L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, 15000L); + curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, receive); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, job); + curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); + curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, progress); + curl_easy_setopt(curl, CURLOPT_XFERINFODATA, job); + ok = curl_easy_perform(curl) == CURLE_OK; + long status = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status); + job->status = (int)status; + curl_easy_cleanup(curl); + } + /* curl is shared with the update service; process lifetime owns its global state. */ +#endif + if (!ok || SDL_GetAtomicInt(&job->cancel)) + job->status = 0; + if (job->status == 200) { + if (decode_response(job)) cache_write(job); + else job->status = 0; + } + return complete(job); +} + +BongoCatAboutRequest *bongo_cat_about_request(int kind, Uint32 event_type, + Uint32 window_id, const char *cache_root, bool network_only) { + BongoCatAboutRequest *job = calloc(1, sizeof(*job)); + if (!job) + return NULL; + job->limit = kind == BONGO_ABOUT_CONTRIBUTORS ? RESPONSE_LIMIT : 64u * 1024u; + job->kind = kind; + job->event_type = event_type; + job->window_id = window_id; + job->network_only = network_only; + if (cache_root && cache_root[0] && + !bongo_cat_path_join(job->cache_directory, sizeof(job->cache_directory), + cache_root, "about")) job->cache_directory[0] = 0; + job->thread = SDL_CreateThread(request_worker, "bongocat-about", job); + if (!job->thread) { + free(job->response); + free(job); + return NULL; + } + return job; +} + +void bongo_cat_about_request_free(BongoCatAboutRequest *job) { + if (!job) + return; + SDL_SetAtomicInt(&job->cancel, 1); + if (job->thread) SDL_WaitThread(job->thread, NULL); + bongo_cat_about_feed_free(job->feed); + free(job->qr_pixels); + free(job->response); + free(job); +} diff --git a/src/ui/preferences/about/preferences_about_online.h b/src/ui/preferences/about/preferences_about_online.h new file mode 100644 index 00000000..fd5a0ae5 --- /dev/null +++ b/src/ui/preferences/about/preferences_about_online.h @@ -0,0 +1,34 @@ +#ifndef BONGO_CAT_ABOUT_ONLINE_H +#define BONGO_CAT_ABOUT_ONLINE_H + +#include "preferences_about_feed.h" +#include "bongo_cat/common.h" +#include +#include + +/* One bounded cache read or network request per job. Worker results are published + by done. A stale cache succeeds with refresh_needed, then the UI launches one + network_only job while keeping the cached content visible. */ +enum { BONGO_ABOUT_WECHAT, BONGO_ABOUT_CONTRIBUTORS }; +typedef struct BongoCatAboutRequest { + SDL_Thread *thread; + SDL_AtomicInt done; + SDL_AtomicInt cancel; + int kind; + size_t limit; + BongoCatAboutFeed *feed; + unsigned char *qr_pixels; + char *response; + size_t length; + int status; + char cache_directory[BONGO_CAT_PATH_CAP]; + bool network_only, refresh_needed; + Uint32 event_type; + Uint32 window_id; +} BongoCatAboutRequest; + +BongoCatAboutRequest *bongo_cat_about_request(int kind, Uint32 event_type, + Uint32 window_id, const char *cache_root, bool network_only); +void bongo_cat_about_request_free(BongoCatAboutRequest *request); + +#endif diff --git a/src/ui/preferences/preferences_about_page.c b/src/ui/preferences/about/preferences_about_page.c similarity index 66% rename from src/ui/preferences/preferences_about_page.c rename to src/ui/preferences/about/preferences_about_page.c index b755bbdd..801fb3e4 100644 --- a/src/ui/preferences/preferences_about_page.c +++ b/src/ui/preferences/about/preferences_about_page.c @@ -1,15 +1,12 @@ +#include "preferences_about_internal.h" #include "preferences_about_community.h" #include "preferences_state.h" -void bongo_cat_preferences_about_hero(BongoCatPreferences *value, - struct nk_context *context); -void bongo_cat_preferences_about_projects(BongoCatPreferences *value, - struct nk_context *context); - void bongo_cat_preferences_page_about(BongoCatPreferences *value, struct nk_context *context) { bongo_cat_preferences_support_assets_load(value); bongo_cat_preferences_about_hero(value, context); bongo_cat_preferences_about_community(value, context); + bongo_cat_about_contributors(value, context); bongo_cat_preferences_about_projects(value, context); } diff --git a/src/ui/preferences/about/preferences_about_svg.c b/src/ui/preferences/about/preferences_about_svg.c new file mode 100644 index 00000000..29a8c890 --- /dev/null +++ b/src/ui/preferences/about/preferences_about_svg.c @@ -0,0 +1,49 @@ +#include "preferences_about_svg.h" +#include +#include +#define NANOSVG_IMPLEMENTATION +#include "../../../../vendor/nanosvg/nanosvg.h" +#define NANOSVGRAST_IMPLEMENTATION +#include "../../../../vendor/nanosvg/nanosvgrast.h" + +static unsigned char *svg_pixels(char *svg, int size) { + NSVGimage *image = nsvgParse(svg, "px", 96); + if (!image) + return NULL; + unsigned char *pixels = NULL; + if (isfinite(image->width) && isfinite(image->height) && image->width > 0 && + image->height > 0 && image->width <= 4096 && image->height <= 4096) { + NSVGrasterizer *raster = nsvgCreateRasterizer(); + pixels = calloc((size_t)size * (size_t)size, 4); + if (raster && pixels) { + float scale = (float)size / fmaxf(image->width, image->height); + nsvgRasterize(raster, image, ((float)size - image->width * scale) * .5f, + ((float)size - image->height * scale) * .5f, scale, pixels, size, size, size * 4); + } else { + free(pixels); + pixels = NULL; + } + nsvgDeleteRasterizer(raster); + } + nsvgDelete(image); + return pixels; +} + +unsigned char *bongo_cat_about_qr_pixels(char *svg) { + return svg_pixels(svg, 240); +} + +unsigned char *bongo_cat_about_wechat_pixels(int size) { + /* Exact two-bubble WeChat paths from the supplied 1.html, rasterized once. */ + char svg[] = "" + "" + ""; + if (size < 25 || size > 100) return NULL; + return svg_pixels(svg, size); +} + +int bongo_cat_about_svg_parse_xml(char *input, + void (*start)(void *, const char *, const char **), + void (*end)(void *, const char *), void (*content)(void *, const char *), void *user) { + return nsvg__parseXML(input, start, end, content, user); +} diff --git a/src/ui/preferences/about/preferences_about_svg.h b/src/ui/preferences/about/preferences_about_svg.h new file mode 100644 index 00000000..06d0650b --- /dev/null +++ b/src/ui/preferences/about/preferences_about_svg.h @@ -0,0 +1,13 @@ +#ifndef BONGO_CAT_PREFERENCES_ABOUT_SVG_H +#define BONGO_CAT_PREFERENCES_ABOUT_SVG_H + +/* SVG input is modified in place. Pixel buffers are released with free(). */ +unsigned char *bongo_cat_about_qr_pixels(char *svg); +unsigned char *bongo_cat_about_wechat_pixels(int size); + +/* Bounded feed parsing uses the same XML tokenizer as SVG rasterization. */ +int bongo_cat_about_svg_parse_xml(char *input, + void (*start)(void *, const char *, const char **), + void (*end)(void *, const char *), void (*content)(void *, const char *), void *user); + +#endif diff --git a/src/ui/preferences/about/preferences_about_text.c b/src/ui/preferences/about/preferences_about_text.c new file mode 100644 index 00000000..9116130f --- /dev/null +++ b/src/ui/preferences/about/preferences_about_text.c @@ -0,0 +1,52 @@ +#include "preferences_state.h" +#include "preferences_about_internal.h" +#include + +const struct nk_user_font *bongo_cat_about_font(BongoCatPreferences *value, float size) { + int index = NK_CLAMP(0, (int)(size * 2.0f), 63); + struct nk_user_font *font = &value->about.css_fonts[index]; + *font = *(size >= 18.0f ? value->ui.heading_font : value->ui.caption_font); + font->height = size; + return font; +} + +/* The same wrapping routine measures and draws, keeping translated layouts aligned. */ +float bongo_cat_about_paragraph(struct nk_context *context, struct nk_rect bounds, + const char *text, const struct nk_user_font *font, struct nk_color color, + bool center, float leading) { + float y = bounds.y; + while (*text && (!context || y + font->height <= bounds.y + bounds.h + 1)) { + int length = 0, remaining = (int)strlen(text); + float width = 0; + while (length < remaining) { + nk_rune rune; + int bytes = nk_utf_decode(text + length, &rune, remaining - length); + if (bytes <= 0) break; + float next = font->width(font->userdata, font->height, text, length + bytes); + if (length && next > bounds.w) break; + length += bytes; + width = next; + if (rune == '\n') break; + } + if (!length) break; + if (context) nk_draw_text(nk_window_get_canvas(context), + nk_rect(bounds.x + (center ? (bounds.w - width) * .5f : 0), y, bounds.w, font->height), + text, length, font, nk_rgba(0, 0, 0, 0), color); + y += leading; + text += length; + while (*text == ' ' || *text == '\n') text++; + } + return y - bounds.y; +} + +float bongo_cat_about_text_height(const char *text, const struct nk_user_font *font, float width) { + return bongo_cat_about_paragraph(NULL, nk_rect(0, 0, width, 0), text, font, + nk_rgb(0, 0, 0), false, font->height + 6.0f); +} + +void bongo_cat_about_text(BongoCatPreferences *value, struct nk_context *context, + struct nk_rect bounds, const char *key, const char *fallback, + const struct nk_user_font *font, struct nk_color color, bool center) { + const char *label = key ? bongo_cat_i18n_get(value->app->i18n, key, fallback) : fallback; + bongo_cat_about_paragraph(context, bounds, label, font, color, center, font->height + 6.0f); +} diff --git a/src/ui/preferences/about/preferences_about_wechat.c b/src/ui/preferences/about/preferences_about_wechat.c new file mode 100644 index 00000000..b92f6901 --- /dev/null +++ b/src/ui/preferences/about/preferences_about_wechat.c @@ -0,0 +1,107 @@ +#include "preferences_state.h" +#include "preferences_about_internal.h" +#include "preferences_about_svg.h" +#include "ui_catime.h" +#include "ui_paint.h" +#include +#include + +void bongo_cat_about_wechat_icon(BongoCatPreferences *value, struct nk_context *context, + struct nk_rect bounds) { + BongoCatAboutState *s = &value->about; + if (!s->wechat_icon_attempted) { + s->wechat_icon_attempted = true; + float scale = NK_CLAMP(1.0f, value->ui.raster_scale, 4.0f); + int size = (int)(25.0f * scale + .5f); + unsigned char *pixels = bongo_cat_about_wechat_pixels(size); + if (pixels) { + glGenTextures(1, &s->wechat_icon_texture); + glBindTexture(GL_TEXTURE_2D, s->wechat_icon_texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, size, size, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels); + free(pixels); + } + } + if (s->wechat_icon_texture) { + struct nk_image icon = nk_image_id((int)s->wechat_icon_texture); + nk_draw_image(nk_window_get_canvas(context), bounds, &icon, nk_rgb(255, 255, 255)); + } +} + +void bongo_cat_about_wechat(BongoCatPreferences *value, struct nk_rect anchor) { + BongoCatAboutState *state = &value->about; + state->qr_open = true; + state->qr_anchor = anchor; + state->qr_hide_at = SDL_GetTicks() + 250; + bongo_cat_about_refresh(value); +} + +void bongo_cat_about_overlays(BongoCatPreferences *value, struct nk_context *context) { + bongo_cat_about_refresh(value); + BongoCatAboutState *s = &value->about; + if (!s->qr_open || value->page != 3) + return; + if (s->qr_texture_dirty) { + if (s->qr_texture) glDeleteTextures(1, &s->qr_texture); + s->qr_texture = 0; + s->qr_texture_dirty = false; + } + if (!s->qr_texture && s->qr_pixels) { + glGenTextures(1, &s->qr_texture); + glBindTexture(GL_TEXTURE_2D, s->qr_texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 240, 240, 0, GL_RGBA, + GL_UNSIGNED_BYTE, s->qr_pixels); + } + float width, height; + bongo_cat_ui_logical_size(&value->ui, &width, &height); + struct nk_rect anchor = s->qr_anchor; + float size = NK_MIN(200.0f, NK_MIN(width - 24.0f, height - 24.0f)); + bool above = anchor.y >= size + 22.0f; + float y = above ? anchor.y - size - 10.0f : anchor.y + anchor.h + 10.0f; + struct nk_rect panel = nk_rect(NK_CLAMP(12, anchor.x + anchor.w * .5f - size * .5f, width - size - 12), + NK_CLAMP(12, y, height - size - 12), size, size); + bool over = nk_input_is_mouse_hovering_rect(&context->input, panel); + if (over) + s->qr_hide_at = SDL_GetTicks() + 250; + if (SDL_GetTicks() >= s->qr_hide_at) { + s->qr_open = false; + return; + } + struct nk_command_buffer *canvas = nk_window_get_canvas(context); + nk_push_scissor(canvas, nk_rect(0, 0, width, height)); + if (bongo_cat_ui_palette(bongo_cat_ui_dark(context)).effects) + bongo_cat_ui_paint_shadow(context, panel, 10, 0, 2, 6, 0, nk_rgba(0, 0, 0, 8)); + nk_fill_rect(canvas, panel, 10, nk_rgb(255, 255, 255)); + nk_stroke_rect(canvas, panel, 10, 1, nk_rgba(0, 0, 0, 10)); + float arrow_x = NK_CLAMP(panel.x + 16, anchor.x + anchor.w * .5f, panel.x + panel.w - 16); + float edge = above ? panel.y + panel.h : panel.y; + float tip = edge + (above ? 6.0f : -6.0f); + nk_fill_triangle(canvas, arrow_x - 5, edge, arrow_x + 5, edge, arrow_x, tip, + nk_rgb(255, 255, 255)); + if (s->qr_texture) { + struct nk_image image = nk_image_id((int)s->qr_texture); + nk_draw_image(canvas, nk_rect(panel.x + 4, panel.y + 4, size - 8, size - 8), &image, + nk_rgb(255, 255, 255)); + } else { + bongo_cat_about_text(value, context, nk_rect(panel.x + 16, panel.y + size * .3f, size - 32, 52), + s->qr_request ? "native.about.loading" : "native.about.qrUnavailable", + s->qr_request ? "Loading..." : "QR code unavailable", + bongo_cat_about_font(value, 13), nk_rgb(85, 85, 85), true); + if (!s->qr_request) { + struct nk_rect retry = nk_rect(panel.x + (size - 88) * .5f, panel.y + size * .55f, 88, 30); + nk_fill_rect(canvas, retry, 6, nk_rgb(7, 193, 96)); + bongo_cat_about_text(value, context, nk_rect(retry.x, retry.y + 8, retry.w, 18), + "native.about.reload", "Reload", bongo_cat_about_font(value, 12), nk_rgb(255, 255, 255), true); + bongo_cat_ui_cursor_hover_rect(context, retry, BONGO_CAT_UI_CURSOR_POINTER); + if (over && nk_input_is_mouse_click_in_rect(&context->input, NK_BUTTON_LEFT, retry)) { + s->qr_attempted = false; + bongo_cat_about_wechat(value, s->qr_anchor); + } + } + } +} diff --git a/src/ui/preferences/preferences.c b/src/ui/preferences/preferences.c index 120ab54b..1b3de619 100644 --- a/src/ui/preferences/preferences.c +++ b/src/ui/preferences/preferences.c @@ -32,6 +32,7 @@ void bongo_cat_preferences_page_cache_clear(BongoCatPreferences *value, if (!value || previous_page == next_page) return; bool released = false; if (previous_page == 3 && next_page != 3) { + value->about.qr_open = false; bongo_cat_preferences_support_assets_clear(value); released = true; } @@ -73,6 +74,8 @@ bool bongo_cat_preferences_needs_frame(BongoCatPreferences *value) { value->render_dirty = true; } if (!value->window || !value->visible) return false; + if (value->about.qr_open && SDL_GetTicks() >= value->about.qr_hide_at) + value->render_dirty = true; if (value->behavior_dialog && value->app) { for (size_t i = 0; i < value->app->behaviors.count; ++i) { const BongoCatBehaviorEntry *entry = &value->app->behaviors.entries[i]; @@ -151,6 +154,11 @@ void bongo_cat_preferences_drag_tick(BongoCatPreferences *value) { static bool chrome_event(BongoCatPreferences *value, const SDL_Event *event) { if (event->type == SDL_EVENT_KEY_DOWN && event->key.key == SDLK_ESCAPE) { + if (value->about.qr_open) { + value->about.qr_open = false; + value->render_dirty = true; + return true; + } if (bongo_cat_preferences_behavior_dialog_active(value)) { bongo_cat_preferences_behavior_dialog_close(value); value->render_dirty = true; @@ -204,6 +212,7 @@ static bool chrome_event(BongoCatPreferences *value, const SDL_Event *event) { bool bongo_cat_preferences_event(BongoCatPreferences *value, const SDL_Event *event) { if (!value || !event) return false; + if (bongo_cat_about_event(value, event)) return true; if (bongo_cat_preferences_import_event(value->import_dialog, value->app, event)) { value->render_dirty = value->visible; return true; } diff --git a/src/ui/preferences/preferences_assets.c b/src/ui/preferences/preferences_assets.c index 75eb09fd..142cd188 100644 --- a/src/ui/preferences/preferences_assets.c +++ b/src/ui/preferences/preferences_assets.c @@ -97,6 +97,7 @@ void bongo_cat_preferences_support_assets_clear(BongoCatPreferences *value) { void bongo_cat_preferences_assets_clear(BongoCatPreferences *value) { if (!value) return; + bongo_cat_about_assets_clear(value, true); clear(&value->logo_texture); clear(&value->icon_texture); clear(&value->icon_texture_hidpi); @@ -106,6 +107,7 @@ void bongo_cat_preferences_assets_clear(BongoCatPreferences *value) { void bongo_cat_preferences_assets_abandon(BongoCatPreferences *value) { if (!value) return; + bongo_cat_about_assets_clear(value, false); value->logo_texture = 0; value->icon_texture = 0; value->icon_texture_hidpi = 0; diff --git a/src/ui/preferences/preferences_lifecycle.c b/src/ui/preferences/preferences_lifecycle.c index 5ba6b097..4ca433b0 100644 --- a/src/ui/preferences/preferences_lifecycle.c +++ b/src/ui/preferences/preferences_lifecycle.c @@ -28,6 +28,7 @@ static void release_window(BongoCatPreferences *value) { bongo_cat_pref_controls_reset(&value->ui.context); bongo_cat_ui_animations_reset(&value->ui.context); } + bongo_cat_about_clear(value, context_ready); if (context_ready) bongo_cat_preferences_assets_clear(value); else bongo_cat_preferences_assets_abandon(value); if (value->ui_initialized) { @@ -78,6 +79,7 @@ void bongo_cat_preferences_show(BongoCatPreferences *value) { if (value->ui_initialized) bongo_cat_ui_input_reset(&value->ui); value->shown_ns = requested_ns; value->visible = true; + bongo_cat_about_refresh(value); if (!opening) { SDL_StartTextInput(value->window); bongo_cat_preferences_live_resize_install(value); @@ -107,6 +109,11 @@ void bongo_cat_preferences_close(BongoCatPreferences *value) { if (value->chrome_dragging) SDL_CaptureMouse(false); value->chrome_dragging = false; value->visible = false; + SDL_Window *previous_window = SDL_GL_GetCurrentWindow(); + SDL_GLContext previous_context = SDL_GL_GetCurrentContext(); + bool about_gl_ready = SDL_GL_MakeCurrent(value->window, value->gl_context); + bongo_cat_about_clear(value, about_gl_ready); + SDL_GL_MakeCurrent(previous_window, previous_context); SDL_HideWindow(value->window); bongo_cat_preferences_release_idle_window(value); bongo_cat_config_store_flush(value->app); @@ -128,5 +135,7 @@ void bongo_cat_preferences_destroy(BongoCatPreferences *value) { value->import_dialog = NULL; bongo_cat_preferences_close(value); release_window(value); + bongo_cat_about_clear(value, false); + bongo_cat_about_shutdown(value); free(value); } diff --git a/src/ui/preferences/preferences_model_glyphs.c b/src/ui/preferences/preferences_model_glyphs.c index 76feaf41..4fee784c 100644 --- a/src/ui/preferences/preferences_model_glyphs.c +++ b/src/ui/preferences/preferences_model_glyphs.c @@ -77,6 +77,8 @@ void bongo_cat_preferences_model_glyphs(const BongoCatApp *app, size_t used = 0; while (used < capacity && ranges[used]) used++; if (used >= capacity || (used & 1)) return; + /* Symbols used by the HTML reference's contribution category badges. */ + add_text(ranges, capacity, &used, "〈/〉▤♧文"); for (size_t i = 0; i < app->models.count; ++i) { const BongoCatModelEntry *entry = &app->models.entries[i]; add_text(ranges, capacity, &used, diff --git a/src/ui/preferences/preferences_present.c b/src/ui/preferences/preferences_present.c index b9db1f2c..c1f74d7e 100644 --- a/src/ui/preferences/preferences_present.c +++ b/src/ui/preferences/preferences_present.c @@ -68,9 +68,13 @@ void bongo_cat_preferences_render(BongoCatPreferences *value) { value->transparent_window ? 0.0f : palette.background.b / 255.0f, value->transparent_window ? 0.0f : 1.0f); glClear(GL_COLOR_BUFFER_BIT); - bongo_cat_ui_render(&value->ui); + bool rendered = bongo_cat_ui_render(&value->ui); bongo_cat_preferences_smoke_frame(value); - if (!SDL_GL_SwapWindow(value->window)) { + if (!rendered) { + /* Keep the last complete front buffer instead of presenting corruption. */ + value->render_dirty = true; + value->render_retry_ns = now + 1000000000ull; + } else if (!SDL_GL_SwapWindow(value->window)) { value->render_dirty = true; value->render_retry_ns = now + 1000000000ull; SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, diff --git a/src/ui/preferences/preferences_render.c b/src/ui/preferences/preferences_render.c index 7fa663d9..0647a392 100644 --- a/src/ui/preferences/preferences_render.c +++ b/src/ui/preferences/preferences_render.c @@ -207,6 +207,7 @@ static bool draw_shell(BongoCatPreferences *value, struct nk_context *context, bongo_cat_preferences_notice_draw(value, context, width, height); bongo_cat_preferences_remove_dialog_draw(value->app, context); bongo_cat_preferences_behavior_dialog_draw(value, context); + bongo_cat_about_overlays(value, context); return close_requested; } bool bongo_cat_preferences_draw_frame(BongoCatPreferences *value, diff --git a/src/ui/preferences/preferences_state.h b/src/ui/preferences/preferences_state.h index 2730a071..3a8b2495 100644 --- a/src/ui/preferences/preferences_state.h +++ b/src/ui/preferences/preferences_state.h @@ -4,6 +4,7 @@ #include "preferences_internal.h" #include "preferences_scrollbar.h" #include "preferences_text_session.h" +#include "about/preferences_about.h" #include "ui_backend.h" #define BONGO_CAT_MODEL_LOAD_VISUAL_DURATION_NS 5000000000ull @@ -41,6 +42,7 @@ struct BongoCatPreferences { int catime_width, catime_height; int vlaina_width, vlaina_height; bool support_assets_loaded; + BongoCatAboutState about; struct nk_user_font support_logs_font; int page; int style_theme; diff --git a/tests/ui/test_preferences_lifecycle.c b/tests/ui/test_preferences_lifecycle.c index fe63d503..1fd032a7 100644 --- a/tests/ui/test_preferences_lifecycle.c +++ b/tests/ui/test_preferences_lifecycle.c @@ -3,9 +3,20 @@ #include "preferences_render_internal.h" #include "ui_catime.h" #include "runtime.h" +#include "bongo_cat/file.h" +#include "bongo_cat/path.h" #include #include +#include +#include +#ifdef _WIN32 +#include +#include +#else +#include +#include +#endif static int failures; #define CHECK(condition) do { if (!(condition)) { \ @@ -19,6 +30,130 @@ static void busy(BongoCatImportDialog *dialog, bool open, bool importing) { SDL_UnlockMutex(dialog->mutex); } +static void about_disk_cache(BongoCatApp *app) { + char root[BONGO_CAT_PATH_CAP], directory[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; + CHECK(bongo_cat_path_join(root, sizeof(root), app->cache_root, "about-cache-test")); + CHECK(bongo_cat_path_join(directory, sizeof(directory), root, "about")); + CHECK(bongo_cat_path_create_directory(directory)); + CHECK(bongo_cat_path_join(path, sizeof(path), directory, "wechat-v1.svg")); + const char svg[] = "" + ""; + Uint32 event_type = SDL_RegisterEvents(1); + for (int stale = 0; stale < 2; stale++) { + FILE *file = bongo_cat_file_open(path, "wb"); + CHECK(file != NULL); + if (!file) return; + CHECK(fwrite(svg, 1, sizeof(svg) - 1, file) == sizeof(svg) - 1); + CHECK(fflush(file) == 0); + if (stale) { + time_t old = time(NULL) - 2 * 86400; +#ifdef _WIN32 + struct __utimbuf64 times = {old, old}; + CHECK(_futime64(_fileno(file), ×) == 0); +#else + struct timespec times[2] = {{old, 0}, {old, 0}}; + CHECK(futimens(fileno(file), times) == 0); +#endif + } + CHECK(fclose(file) == 0); + SDL_PathInfo before = {0}, after = {0}; + CHECK(SDL_GetPathInfo(path, &before)); + BongoCatAboutRequest *job = bongo_cat_about_request(BONGO_ABOUT_WECHAT, + event_type, 0, root, false); + CHECK(job != NULL); + if (!job) return; + SDL_WaitThread(job->thread, NULL); + job->thread = NULL; + CHECK(SDL_GetAtomicInt(&job->done)); + CHECK(job->status == 200 && job->qr_pixels); + CHECK(job->refresh_needed == (stale != 0)); + CHECK(!job->response); + bongo_cat_about_request_free(job); + CHECK(SDL_GetPathInfo(path, &after)); + CHECK(before.modify_time == after.modify_time); + file = bongo_cat_file_open(path, "rb"); + CHECK(file != NULL); + if (file) { + char bytes[sizeof(svg)] = {0}; + CHECK(fread(bytes, 1, sizeof(svg) - 1, file) == sizeof(svg) - 1); + CHECK(strcmp(bytes, svg) == 0); + CHECK(fclose(file) == 0); + } + SDL_FlushEvent(event_type); + } + CHECK(bongo_cat_file_remove(path)); + CHECK(bongo_cat_path_remove(directory)); + CHECK(bongo_cat_path_remove(root)); +} + +static void about_refresh_failure(BongoCatApp *app) { + BongoCatPreferences *value = calloc(1, sizeof(*value)); + CHECK(value != NULL); + if (!value) return; + value->app = app; + value->visible = true; + value->about.event_type = SDL_RegisterEvents(1); + value->about.qr_attempted = value->about.contributors_attempted = true; + value->about.qr_pixels = calloc(240 * 240, 4); + value->about.contributors = calloc(1, sizeof(*value->about.contributors)); + unsigned char *pixels = value->about.qr_pixels; + BongoCatAboutFeed *feed = value->about.contributors; + CHECK(pixels && feed); + for (int cancelled = 0; cancelled < 2; cancelled++) { + BongoCatAboutRequest *job = calloc(1, sizeof(*job)); + CHECK(job != NULL); + if (!job) break; + job->kind = BONGO_ABOUT_WECHAT; + /* A failed refresh and a cancelled successful refresh must both + preserve the old cache without automatically retrying. */ + if (cancelled) { + job->status = 200; + job->qr_pixels = calloc(240 * 240, 4); + job->refresh_needed = true; + SDL_SetAtomicInt(&job->cancel, 1); + } + SDL_SetAtomicInt(&job->done, 1); + value->about.qr_request = job; + bongo_cat_about_refresh(value); + CHECK(value->about.qr_pixels == pixels && value->about.contributors == feed); + CHECK(!value->about.qr_request && !value->about.contributors_request); + CHECK(!value->about.qr_texture_dirty); + } + bongo_cat_about_clear(value, false); + free(value); +} + +static void about_session_cache(BongoCatPreferences *value) { + BongoCatAboutState *s = &value->about; + CHECK(s->contributors_attempted || (s->contributors_request && + SDL_GetAtomicInt(&s->contributors_request->cancel))); + CHECK(s->qr_attempted || (s->qr_request && SDL_GetAtomicInt(&s->qr_request->cancel))); + BongoCatAboutRequest *contributors_request = s->contributors_request; + BongoCatAboutRequest *qr_request = s->qr_request; + /* A second show only raises the already open window. */ + bongo_cat_preferences_show(value); + CHECK(s->contributors_request == contributors_request); + CHECK(s->qr_request == qr_request); + if (!s->contributors) s->contributors = calloc(1, sizeof(*s->contributors)); + if (!s->qr_pixels) s->qr_pixels = calloc(240 * 240, 4); + BongoCatAboutFeed *feed = s->contributors; + unsigned char *pixels = s->qr_pixels; + CHECK(feed && pixels); + s->qr_open = true; + SDL_GL_MakeCurrent(value->window, value->gl_context); + bongo_cat_preferences_page_cache_clear(value, 3, 0); + bongo_cat_preferences_page_cache_clear(value, 0, 3); + CHECK(!s->qr_open); + CHECK(s->contributors == feed && s->qr_pixels == pixels); + CHECK(s->contributors_request == contributors_request && s->qr_request == qr_request); + /* A DPI/theme asset rebuild must retain downloaded data and attempt flags. */ + bool qr_attempted = s->qr_attempted; + bongo_cat_about_assets_clear(value, true); + CHECK(s->qr_attempted == qr_attempted); + CHECK(s->contributors == feed && s->qr_pixels == pixels); + SDL_GL_MakeCurrent(value->app->window, value->app->gl_context); +} + static void close_over_scrolled_models(BongoCatPreferences *value) { if (bongo_cat_ui_native_chrome()) return; struct nk_context *context = &value->ui.context; @@ -58,12 +193,21 @@ int main(int argc, char **argv) { return 1; } BongoCatPreferences *value = app->preferences; + about_disk_cache(app); + about_refresh_failure(app); CHECK(value != NULL); if (value) { for (int cycle = 0; cycle < 3; ++cycle) { bongo_cat_preferences_show(value); CHECK(value->window && value->gl_context && value->ui_initialized); + about_session_cache(value); bongo_cat_preferences_close(value); + CHECK(!value->about.contributors && !value->about.qr_pixels); + CHECK(!value->about.contributors_attempted && !value->about.qr_attempted); + CHECK(!value->about.contributors_request || + SDL_GetAtomicInt(&value->about.contributors_request->cancel)); + CHECK(!value->about.qr_request || + SDL_GetAtomicInt(&value->about.qr_request->cancel)); CHECK(!value->window && !value->gl_context && !value->ui_initialized); CHECK(SDL_GL_GetCurrentContext() == app->gl_context); } @@ -73,11 +217,13 @@ int main(int argc, char **argv) { bongo_cat_preferences_close(value); bongo_cat_preferences_render(value); CHECK(value->window == window && !value->visible); + CHECK(!value->about.contributors_attempted && !value->about.qr_attempted); busy(value->import_dialog, false, true); bongo_cat_preferences_render(value); CHECK(value->window == window); bongo_cat_preferences_show(value); CHECK(value->window == window && value->visible); + about_session_cache(value); busy(value->import_dialog, false, false); bongo_cat_preferences_render(value); CHECK(value->window == window && value->visible); diff --git a/vendor/nanosvg/LICENSE.txt b/vendor/nanosvg/LICENSE.txt new file mode 100644 index 00000000..f896f2eb --- /dev/null +++ b/vendor/nanosvg/LICENSE.txt @@ -0,0 +1,18 @@ +Copyright (c) 2013-14 Mikko Mononen memon@inside.org + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not +claim that you wrote the original software. If you use this software +in a product, an acknowledgment in the product documentation would be +appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be +misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. + diff --git a/vendor/nanosvg/nanosvg.h b/vendor/nanosvg/nanosvg.h new file mode 100644 index 00000000..291ecbf8 --- /dev/null +++ b/vendor/nanosvg/nanosvg.h @@ -0,0 +1,3278 @@ +/* + * Copyright (c) 2013-14 Mikko Mononen memon@inside.org + * + * This software is provided 'as-is', without any express or implied + * warranty. In no event will the authors be held liable for any damages + * arising from the use of this software. + * + * Permission is granted to anyone to use this software for any purpose, + * including commercial applications, and to alter it and redistribute it + * freely, subject to the following restrictions: + * + * 1. The origin of this software must not be misrepresented; you must not + * claim that you wrote the original software. If you use this software + * in a product, an acknowledgment in the product documentation would be + * appreciated but is not required. + * 2. Altered source versions must be plainly marked as such, and must not be + * misrepresented as being the original software. + * 3. This notice may not be removed or altered from any source distribution. + * + * The SVG parser is based on Anti-Grain Geometry 2.4 SVG example + * Copyright (C) 2002-2004 Maxim Shemanarev (McSeem) (http://www.antigrain.com/) + * + * Arc calculation code based on canvg (https://code.google.com/p/canvg/) + * + * Bounding box calculation based on http://blog.hackers-cafe.net/2009/06/how-to-calculate-bezier-curves-bounding.html + * + */ + +#ifndef NANOSVG_H +#define NANOSVG_H + +#ifndef NANOSVG_CPLUSPLUS +#ifdef __cplusplus +extern "C" { +#endif +#endif + +// NanoSVG is a simple stupid single-header-file SVG parse. The output of the parser is a list of cubic bezier shapes. +// +// The library suits well for anything from rendering scalable icons in your editor application to prototyping a game. +// +// NanoSVG supports a wide range of SVG features, but something may be missing, feel free to create a pull request! +// +// The shapes in the SVG images are transformed by the viewBox and converted to specified units. +// That is, you should get the same looking data as your designed in your favorite app. +// +// NanoSVG can return the paths in few different units. For example if you want to render an image, you may choose +// to get the paths in pixels, or if you are feeding the data into a CNC-cutter, you may want to use millimeters. +// +// The units passed to NanoSVG should be one of: 'px', 'pt', 'pc' 'mm', 'cm', or 'in'. +// DPI (dots-per-inch) controls how the unit conversion is done. +// +// If you don't know or care about the units stuff, "px" and 96 should get you going. + + +/* Example Usage: + // Load SVG + NSVGimage* image; + image = nsvgParseFromFile("test.svg", "px", 96); + printf("size: %f x %f\n", image->width, image->height); + // Use... + for (NSVGshape *shape = image->shapes; shape != NULL; shape = shape->next) { + for (NSVGpath *path = shape->paths; path != NULL; path = path->next) { + for (int i = 0; i < path->npts-1; i += 3) { + float* p = &path->pts[i*2]; + drawCubicBez(p[0],p[1], p[2],p[3], p[4],p[5], p[6],p[7]); + } + } + } + // Delete + nsvgDelete(image); +*/ + +enum NSVGpaintType { + NSVG_PAINT_UNDEF = -1, + NSVG_PAINT_NONE = 0, + NSVG_PAINT_COLOR = 1, + NSVG_PAINT_LINEAR_GRADIENT = 2, + NSVG_PAINT_RADIAL_GRADIENT = 3 +}; + +enum NSVGspreadType { + NSVG_SPREAD_PAD = 0, + NSVG_SPREAD_REFLECT = 1, + NSVG_SPREAD_REPEAT = 2 +}; + +enum NSVGlineJoin { + NSVG_JOIN_MITER = 0, + NSVG_JOIN_ROUND = 1, + NSVG_JOIN_BEVEL = 2 +}; + +enum NSVGlineCap { + NSVG_CAP_BUTT = 0, + NSVG_CAP_ROUND = 1, + NSVG_CAP_SQUARE = 2 +}; + +enum NSVGfillRule { + NSVG_FILLRULE_NONZERO = 0, + NSVG_FILLRULE_EVENODD = 1 +}; + +enum NSVGflags { + NSVG_FLAGS_VISIBLE = 0x01 +}; + +enum NSVGpaintOrder { + NSVG_PAINT_FILL = 0x00, + NSVG_PAINT_MARKERS = 0x01, + NSVG_PAINT_STROKE = 0x02, +}; + +typedef struct NSVGgradientStop { + unsigned int color; + float offset; +} NSVGgradientStop; + +typedef struct NSVGgradient { + float xform[6]; + char spread; + float fx, fy; + int nstops; + NSVGgradientStop stops[1]; +} NSVGgradient; + +typedef struct NSVGpaint { + signed char type; + union { + unsigned int color; + NSVGgradient* gradient; + }; +} NSVGpaint; + +typedef struct NSVGpath +{ + float* pts; // Cubic bezier points: x0,y0, [cpx1,cpx1,cpx2,cpy2,x1,y1], ... + int npts; // Total number of bezier points. + char closed; // Flag indicating if shapes should be treated as closed. + float bounds[4]; // Tight bounding box of the shape [minx,miny,maxx,maxy]. + struct NSVGpath* next; // Pointer to next path, or NULL if last element. +} NSVGpath; + +typedef struct NSVGshape +{ + char id[64]; // Optional 'id' attr of the shape or its group + NSVGpaint fill; // Fill paint + NSVGpaint stroke; // Stroke paint + float opacity; // Opacity of the shape. + float strokeWidth; // Stroke width (scaled). + float strokeDashOffset; // Stroke dash offset (scaled). + float strokeDashArray[8]; // Stroke dash array (scaled). + char strokeDashCount; // Number of dash values in dash array. + char strokeLineJoin; // Stroke join type. + char strokeLineCap; // Stroke cap type. + float miterLimit; // Miter limit + char fillRule; // Fill rule, see NSVGfillRule. + unsigned char paintOrder; // Encoded paint order (3×2-bit fields) see NSVGpaintOrder + unsigned char flags; // Logical or of NSVG_FLAGS_* flags + float bounds[4]; // Tight bounding box of the shape [minx,miny,maxx,maxy]. + char fillGradient[64]; // Optional 'id' of fill gradient + char strokeGradient[64]; // Optional 'id' of stroke gradient + float xform[6]; // Root transformation for fill/stroke gradient + NSVGpath* paths; // Linked list of paths in the image. + struct NSVGshape* next; // Pointer to next shape, or NULL if last element. +} NSVGshape; + +typedef struct NSVGimage +{ + float width; // Width of the image. + float height; // Height of the image. + NSVGshape* shapes; // Linked list of shapes in the image. +} NSVGimage; + +// Parses SVG file from a file, returns SVG image as paths. +NSVGimage* nsvgParseFromFile(const char* filename, const char* units, float dpi); + +// Parses SVG file from a null terminated string, returns SVG image as paths. +// Important note: changes the string. +NSVGimage* nsvgParse(char* input, const char* units, float dpi); + +// Duplicates a path. +NSVGpath* nsvgDuplicatePath(NSVGpath* p); + +// Deletes an image. +void nsvgDelete(NSVGimage* image); + +#ifndef NANOSVG_CPLUSPLUS +#ifdef __cplusplus +} +#endif +#endif + +#ifdef NANOSVG_IMPLEMENTATION + +#include +#include +#include +#include + +#define NSVG_PI (3.14159265358979323846264338327f) +#define NSVG_KAPPA90 (0.5522847493f) // Length proportional to radius of a cubic bezier handle for 90deg arcs. + +#define NSVG_ALIGN_MIN 0 +#define NSVG_ALIGN_MID 1 +#define NSVG_ALIGN_MAX 2 +#define NSVG_ALIGN_NONE 0 +#define NSVG_ALIGN_MEET 1 +#define NSVG_ALIGN_SLICE 2 + +#define NSVG_NOTUSED(v) do { (void)(1 ? (void)0 : ( (void)(v) ) ); } while(0) +#define NSVG_RGB(r, g, b) (((unsigned int)r) | ((unsigned int)g << 8) | ((unsigned int)b << 16)) + +#ifdef _MSC_VER + #pragma warning (disable: 4996) // Switch off security warnings + #pragma warning (disable: 4100) // Switch off unreferenced formal parameter warnings + #ifdef __cplusplus + #define NSVG_INLINE inline + #else + #define NSVG_INLINE + #endif +#else + #define NSVG_INLINE inline +#endif + + +static int nsvg__isspace(char c) +{ + return strchr(" \t\n\v\f\r", c) != 0; +} + +static int nsvg__isdigit(char c) +{ + return c >= '0' && c <= '9'; +} + +static NSVG_INLINE float nsvg__minf(float a, float b) { return a < b ? a : b; } +static NSVG_INLINE float nsvg__maxf(float a, float b) { return a > b ? a : b; } + + +// Simple XML parser + +#define NSVG_XML_TAG 1 +#define NSVG_XML_CONTENT 2 +#define NSVG_XML_MAX_ATTRIBS 256 + +static void nsvg__parseContent(char* s, + void (*contentCb)(void* ud, const char* s), + void* ud) +{ + // Trim start white spaces + while (*s && nsvg__isspace(*s)) s++; + if (!*s) return; + + if (contentCb) + (*contentCb)(ud, s); +} + +static void nsvg__parseElement(char* s, + void (*startelCb)(void* ud, const char* el, const char** attr), + void (*endelCb)(void* ud, const char* el), + void* ud) +{ + const char* attr[NSVG_XML_MAX_ATTRIBS]; + int nattr = 0; + char* name; + int start = 0; + int end = 0; + char quote; + + // Skip white space after the '<' + while (*s && nsvg__isspace(*s)) s++; + + // Check if the tag is end tag + if (*s == '/') { + s++; + end = 1; + } else { + start = 1; + } + + // Skip comments, data and preprocessor stuff. + if (!*s || *s == '?' || *s == '!') + return; + + // Get tag name + name = s; + while (*s && !nsvg__isspace(*s)) s++; + if (*s) { *s++ = '\0'; } + + // Get attribs + while (!end && *s && nattr < NSVG_XML_MAX_ATTRIBS-3) { + char* name = NULL; + char* value = NULL; + + // Skip white space before the attrib name + while (*s && nsvg__isspace(*s)) s++; + if (!*s) break; + if (*s == '/') { + end = 1; + break; + } + name = s; + // Find end of the attrib name. + while (*s && !nsvg__isspace(*s) && *s != '=') s++; + if (*s) { *s++ = '\0'; } + // Skip until the beginning of the value. + while (*s && *s != '\"' && *s != '\'') s++; + if (!*s) break; + quote = *s; + s++; + // Store value and find the end of it. + value = s; + while (*s && *s != quote) s++; + if (*s) { *s++ = '\0'; } + + // Store only well formed attributes + if (name && value) { + attr[nattr++] = name; + attr[nattr++] = value; + } + } + + // List terminator + attr[nattr++] = 0; + attr[nattr++] = 0; + + // Call callbacks. + if (start && startelCb) + (*startelCb)(ud, name, attr); + if (end && endelCb) + (*endelCb)(ud, name); +} + +int nsvg__parseXML(char* input, + void (*startelCb)(void* ud, const char* el, const char** attr), + void (*endelCb)(void* ud, const char* el), + void (*contentCb)(void* ud, const char* s), + void* ud) +{ + char* s = input; + char* mark = s; + int state = NSVG_XML_CONTENT; + while (*s) { + if (*s == '<' && state == NSVG_XML_CONTENT) { + // Start of a tag + *s++ = '\0'; + nsvg__parseContent(mark, contentCb, ud); + mark = s; + state = NSVG_XML_TAG; + } else if (*s == '>' && state == NSVG_XML_TAG) { + // Start of a content or new tag. + *s++ = '\0'; + nsvg__parseElement(mark, startelCb, endelCb, ud); + mark = s; + state = NSVG_XML_CONTENT; + } else { + s++; + } + } + + return 1; +} + + +/* Simple SVG parser. */ + +#define NSVG_MAX_ATTR 128 + +enum NSVGgradientUnits { + NSVG_USER_SPACE = 0, + NSVG_OBJECT_SPACE = 1 +}; + +#define NSVG_MAX_DASHES 8 +#define NSVG_MAX_CLASSES 32 + +enum NSVGunits { + NSVG_UNITS_USER, + NSVG_UNITS_PX, + NSVG_UNITS_PT, + NSVG_UNITS_PC, + NSVG_UNITS_MM, + NSVG_UNITS_CM, + NSVG_UNITS_IN, + NSVG_UNITS_PERCENT, + NSVG_UNITS_EM, + NSVG_UNITS_EX +}; + +typedef struct NSVGcoordinate { + float value; + int units; +} NSVGcoordinate; + +typedef struct NSVGlinearData { + NSVGcoordinate x1, y1, x2, y2; +} NSVGlinearData; + +typedef struct NSVGradialData { + NSVGcoordinate cx, cy, r, fx, fy; +} NSVGradialData; + +typedef struct NSVGgradientData +{ + char id[64]; + char ref[64]; + signed char type; + union { + NSVGlinearData linear; + NSVGradialData radial; + }; + char spread; + char units; + float xform[6]; + int nstops; + NSVGgradientStop* stops; + struct NSVGgradientData* next; +} NSVGgradientData; + +typedef struct NSVGattrib +{ + char id[64]; + float xform[6]; + unsigned int fillColor; + unsigned int strokeColor; + float opacity; + float fillOpacity; + float strokeOpacity; + char fillGradient[64]; + char strokeGradient[64]; + float strokeWidth; + float strokeDashOffset; + float strokeDashArray[NSVG_MAX_DASHES]; + int strokeDashCount; + char strokeLineJoin; + char strokeLineCap; + float miterLimit; + char fillRule; + float fontSize; + unsigned int stopColor; + float stopOpacity; + float stopOffset; + char hasFill; + char hasStroke; + char visible; + unsigned char paintOrder; +} NSVGattrib; + +typedef struct NSVGstyleDeclaration +{ + char* className; + char* propertiesText; + struct NSVGstyleDeclaration* next; +} NSVGstyleDeclaration; + +typedef struct NSVGparser +{ + NSVGattrib attr[NSVG_MAX_ATTR]; + int attrHead; + float* pts; + int npts; + int cpts; + NSVGpath* plist; + NSVGimage* image; + NSVGstyleDeclaration* styles; + NSVGgradientData* gradients; + NSVGshape* shapesTail; + float viewMinx, viewMiny, viewWidth, viewHeight; + int alignX, alignY, alignType; + float dpi; + char pathFlag; + char defsFlag; + char styleFlag; +} NSVGparser; + +static void nsvg__xformIdentity(float* t) +{ + t[0] = 1.0f; t[1] = 0.0f; + t[2] = 0.0f; t[3] = 1.0f; + t[4] = 0.0f; t[5] = 0.0f; +} + +static void nsvg__xformSetTranslation(float* t, float tx, float ty) +{ + t[0] = 1.0f; t[1] = 0.0f; + t[2] = 0.0f; t[3] = 1.0f; + t[4] = tx; t[5] = ty; +} + +static void nsvg__xformSetScale(float* t, float sx, float sy) +{ + t[0] = sx; t[1] = 0.0f; + t[2] = 0.0f; t[3] = sy; + t[4] = 0.0f; t[5] = 0.0f; +} + +static void nsvg__xformSetSkewX(float* t, float a) +{ + t[0] = 1.0f; t[1] = 0.0f; + t[2] = tanf(a); t[3] = 1.0f; + t[4] = 0.0f; t[5] = 0.0f; +} + +static void nsvg__xformSetSkewY(float* t, float a) +{ + t[0] = 1.0f; t[1] = tanf(a); + t[2] = 0.0f; t[3] = 1.0f; + t[4] = 0.0f; t[5] = 0.0f; +} + +static void nsvg__xformSetRotation(float* t, float a) +{ + float cs = cosf(a), sn = sinf(a); + t[0] = cs; t[1] = sn; + t[2] = -sn; t[3] = cs; + t[4] = 0.0f; t[5] = 0.0f; +} + +static void nsvg__xformMultiply(float* t, float* s) +{ + float t0 = t[0] * s[0] + t[1] * s[2]; + float t2 = t[2] * s[0] + t[3] * s[2]; + float t4 = t[4] * s[0] + t[5] * s[2] + s[4]; + t[1] = t[0] * s[1] + t[1] * s[3]; + t[3] = t[2] * s[1] + t[3] * s[3]; + t[5] = t[4] * s[1] + t[5] * s[3] + s[5]; + t[0] = t0; + t[2] = t2; + t[4] = t4; +} + +static void nsvg__xformInverse(float* inv, float* t) +{ + double invdet, det = (double)t[0] * t[3] - (double)t[2] * t[1]; + if (det > -1e-6 && det < 1e-6) { + nsvg__xformIdentity(t); + return; + } + invdet = 1.0 / det; + inv[0] = (float)(t[3] * invdet); + inv[2] = (float)(-t[2] * invdet); + inv[4] = (float)(((double)t[2] * t[5] - (double)t[3] * t[4]) * invdet); + inv[1] = (float)(-t[1] * invdet); + inv[3] = (float)(t[0] * invdet); + inv[5] = (float)(((double)t[1] * t[4] - (double)t[0] * t[5]) * invdet); +} + +static void nsvg__xformPremultiply(float* t, float* s) +{ + float s2[6]; + memcpy(s2, s, sizeof(float)*6); + nsvg__xformMultiply(s2, t); + memcpy(t, s2, sizeof(float)*6); +} + +static void nsvg__xformPoint(float* dx, float* dy, float x, float y, float* t) +{ + *dx = x*t[0] + y*t[2] + t[4]; + *dy = x*t[1] + y*t[3] + t[5]; +} + +static void nsvg__xformVec(float* dx, float* dy, float x, float y, float* t) +{ + *dx = x*t[0] + y*t[2]; + *dy = x*t[1] + y*t[3]; +} + +#define NSVG_EPSILON (1e-12) + +static int nsvg__ptInBounds(float* pt, float* bounds) +{ + return pt[0] >= bounds[0] && pt[0] <= bounds[2] && pt[1] >= bounds[1] && pt[1] <= bounds[3]; +} + + +static double nsvg__evalBezier(double t, double p0, double p1, double p2, double p3) +{ + double it = 1.0-t; + return it*it*it*p0 + 3.0*it*it*t*p1 + 3.0*it*t*t*p2 + t*t*t*p3; +} + +static void nsvg__curveBounds(float* bounds, float* curve) +{ + int i, j, count; + double roots[2], a, b, c, b2ac, t, v; + float* v0 = &curve[0]; + float* v1 = &curve[2]; + float* v2 = &curve[4]; + float* v3 = &curve[6]; + + // Start the bounding box by end points + bounds[0] = nsvg__minf(v0[0], v3[0]); + bounds[1] = nsvg__minf(v0[1], v3[1]); + bounds[2] = nsvg__maxf(v0[0], v3[0]); + bounds[3] = nsvg__maxf(v0[1], v3[1]); + + // Bezier curve fits inside the convex hull of it's control points. + // If control points are inside the bounds, we're done. + if (nsvg__ptInBounds(v1, bounds) && nsvg__ptInBounds(v2, bounds)) + return; + + // Add bezier curve inflection points in X and Y. + for (i = 0; i < 2; i++) { + a = -3.0 * v0[i] + 9.0 * v1[i] - 9.0 * v2[i] + 3.0 * v3[i]; + b = 6.0 * v0[i] - 12.0 * v1[i] + 6.0 * v2[i]; + c = 3.0 * v1[i] - 3.0 * v0[i]; + count = 0; + if (fabs(a) < NSVG_EPSILON) { + if (fabs(b) > NSVG_EPSILON) { + t = -c / b; + if (t > NSVG_EPSILON && t < 1.0-NSVG_EPSILON) + roots[count++] = t; + } + } else { + b2ac = b*b - 4.0*c*a; + if (b2ac > NSVG_EPSILON) { + t = (-b + sqrt(b2ac)) / (2.0 * a); + if (t > NSVG_EPSILON && t < 1.0-NSVG_EPSILON) + roots[count++] = t; + t = (-b - sqrt(b2ac)) / (2.0 * a); + if (t > NSVG_EPSILON && t < 1.0-NSVG_EPSILON) + roots[count++] = t; + } + } + for (j = 0; j < count; j++) { + v = nsvg__evalBezier(roots[j], v0[i], v1[i], v2[i], v3[i]); + bounds[0+i] = nsvg__minf(bounds[0+i], (float)v); + bounds[2+i] = nsvg__maxf(bounds[2+i], (float)v); + } + } +} + +static unsigned char nsvg__encodePaintOrder(enum NSVGpaintOrder a, enum NSVGpaintOrder b, enum NSVGpaintOrder c) { + return (a & 0x03) | ((b & 0x03) << 2) | ((c & 0x03) << 4); +} + +static NSVGparser* nsvg__createParser(void) +{ + NSVGparser* p; + p = (NSVGparser*)malloc(sizeof(NSVGparser)); + if (p == NULL) goto error; + memset(p, 0, sizeof(NSVGparser)); + + p->image = (NSVGimage*)malloc(sizeof(NSVGimage)); + if (p->image == NULL) goto error; + memset(p->image, 0, sizeof(NSVGimage)); + + // Init style + nsvg__xformIdentity(p->attr[0].xform); + memset(p->attr[0].id, 0, sizeof p->attr[0].id); + p->attr[0].fillColor = NSVG_RGB(0,0,0); + p->attr[0].strokeColor = NSVG_RGB(0,0,0); + p->attr[0].opacity = 1; + p->attr[0].fillOpacity = 1; + p->attr[0].strokeOpacity = 1; + p->attr[0].stopOpacity = 1; + p->attr[0].strokeWidth = 1; + p->attr[0].strokeLineJoin = NSVG_JOIN_MITER; + p->attr[0].strokeLineCap = NSVG_CAP_BUTT; + p->attr[0].miterLimit = 4; + p->attr[0].fillRule = NSVG_FILLRULE_NONZERO; + p->attr[0].hasFill = 1; + p->attr[0].visible = 1; + p->attr[0].paintOrder = nsvg__encodePaintOrder(NSVG_PAINT_FILL, NSVG_PAINT_STROKE, NSVG_PAINT_MARKERS); + + return p; + +error: + if (p) { + if (p->image) free(p->image); + free(p); + } + return NULL; +} +static void nsvg__deleteStyles(NSVGstyleDeclaration* style) { + while (style) { + NSVGstyleDeclaration* next = style->next; + free(style->className); + free(style->propertiesText); + free(style); + style = next; + } +} + +static void nsvg__deletePaths(NSVGpath* path) +{ + while (path) { + NSVGpath *next = path->next; + if (path->pts != NULL) + free(path->pts); + free(path); + path = next; + } +} + +static void nsvg__deletePaint(NSVGpaint* paint) +{ + if (paint->type == NSVG_PAINT_LINEAR_GRADIENT || paint->type == NSVG_PAINT_RADIAL_GRADIENT) + free(paint->gradient); +} + +static void nsvg__deleteGradientData(NSVGgradientData* grad) +{ + NSVGgradientData* next; + while (grad != NULL) { + next = grad->next; + free(grad->stops); + free(grad); + grad = next; + } +} + +static void nsvg__deleteParser(NSVGparser* p) +{ + if (p != NULL) { + nsvg__deleteStyles(p->styles); + nsvg__deletePaths(p->plist); + nsvg__deleteGradientData(p->gradients); + nsvgDelete(p->image); + free(p->pts); + free(p); + } +} + +static void nsvg__resetPath(NSVGparser* p) +{ + p->npts = 0; +} + +static void nsvg__addPoint(NSVGparser* p, float x, float y) +{ + if (p->npts+1 > p->cpts) { + p->cpts = p->cpts ? p->cpts*2 : 8; + p->pts = (float*)realloc(p->pts, p->cpts*2*sizeof(float)); + if (!p->pts) return; + } + p->pts[p->npts*2+0] = x; + p->pts[p->npts*2+1] = y; + p->npts++; +} + +static void nsvg__moveTo(NSVGparser* p, float x, float y) +{ + if (p->npts > 0) { + p->pts[(p->npts-1)*2+0] = x; + p->pts[(p->npts-1)*2+1] = y; + } else { + nsvg__addPoint(p, x, y); + } +} + +static void nsvg__lineTo(NSVGparser* p, float x, float y) +{ + float px,py, dx,dy; + if (p->npts > 0) { + px = p->pts[(p->npts-1)*2+0]; + py = p->pts[(p->npts-1)*2+1]; + dx = x - px; + dy = y - py; + nsvg__addPoint(p, px + dx/3.0f, py + dy/3.0f); + nsvg__addPoint(p, x - dx/3.0f, y - dy/3.0f); + nsvg__addPoint(p, x, y); + } +} + +static void nsvg__cubicBezTo(NSVGparser* p, float cpx1, float cpy1, float cpx2, float cpy2, float x, float y) +{ + if (p->npts > 0) { + nsvg__addPoint(p, cpx1, cpy1); + nsvg__addPoint(p, cpx2, cpy2); + nsvg__addPoint(p, x, y); + } +} + +static NSVGattrib* nsvg__getAttr(NSVGparser* p) +{ + return &p->attr[p->attrHead]; +} + +static void nsvg__pushAttr(NSVGparser* p) +{ + if (p->attrHead < NSVG_MAX_ATTR-1) { + p->attrHead++; + memcpy(&p->attr[p->attrHead], &p->attr[p->attrHead-1], sizeof(NSVGattrib)); + } +} + +static void nsvg__popAttr(NSVGparser* p) +{ + if (p->attrHead > 0) + p->attrHead--; +} + +static float nsvg__actualOrigX(NSVGparser* p) +{ + return p->viewMinx; +} + +static float nsvg__actualOrigY(NSVGparser* p) +{ + return p->viewMiny; +} + +static float nsvg__actualWidth(NSVGparser* p) +{ + return p->viewWidth; +} + +static float nsvg__actualHeight(NSVGparser* p) +{ + return p->viewHeight; +} + +static float nsvg__actualLength(NSVGparser* p) +{ + float w = nsvg__actualWidth(p), h = nsvg__actualHeight(p); + return sqrtf(w*w + h*h) / sqrtf(2.0f); +} + +static float nsvg__convertToPixels(NSVGparser* p, NSVGcoordinate c, float orig, float length) +{ + NSVGattrib* attr = nsvg__getAttr(p); + switch (c.units) { + case NSVG_UNITS_USER: return c.value; + case NSVG_UNITS_PX: return c.value; + case NSVG_UNITS_PT: return c.value / 72.0f * p->dpi; + case NSVG_UNITS_PC: return c.value / 6.0f * p->dpi; + case NSVG_UNITS_MM: return c.value / 25.4f * p->dpi; + case NSVG_UNITS_CM: return c.value / 2.54f * p->dpi; + case NSVG_UNITS_IN: return c.value * p->dpi; + case NSVG_UNITS_EM: return c.value * attr->fontSize; + case NSVG_UNITS_EX: return c.value * attr->fontSize * 0.52f; // x-height of Helvetica. + case NSVG_UNITS_PERCENT: return orig + c.value / 100.0f * length; + default: return c.value; + } + return c.value; +} + +static NSVGgradientData* nsvg__findGradientData(NSVGparser* p, const char* id) +{ + NSVGgradientData* grad = p->gradients; + if (id == NULL || *id == '\0') + return NULL; + while (grad != NULL) { + if (strcmp(grad->id, id) == 0) + return grad; + grad = grad->next; + } + return NULL; +} + +static NSVGgradient* nsvg__createGradient(NSVGparser* p, const char* id, const float* localBounds, float *xform, signed char* paintType) +{ + NSVGgradientData* data = NULL; + NSVGgradientData* ref = NULL; + NSVGgradientStop* stops = NULL; + NSVGgradient* grad; + float ox, oy, sw, sh, sl; + int nstops = 0; + int refIter; + + data = nsvg__findGradientData(p, id); + if (data == NULL) return NULL; + + // TODO: use ref to fill in all unset values too. + ref = data; + refIter = 0; + while (ref != NULL) { + NSVGgradientData* nextRef = NULL; + if (stops == NULL && ref->stops != NULL) { + stops = ref->stops; + nstops = ref->nstops; + break; + } + nextRef = nsvg__findGradientData(p, ref->ref); + if (nextRef == ref) break; // prevent infite loops on malformed data + ref = nextRef; + refIter++; + if (refIter > 32) break; // prevent infite loops on malformed data + } + if (stops == NULL) return NULL; + + grad = (NSVGgradient*)malloc(sizeof(NSVGgradient) + sizeof(NSVGgradientStop)*(nstops-1)); + if (grad == NULL) return NULL; + + // The shape width and height. + if (data->units == NSVG_OBJECT_SPACE) { + ox = localBounds[0]; + oy = localBounds[1]; + sw = localBounds[2] - localBounds[0]; + sh = localBounds[3] - localBounds[1]; + } else { + ox = nsvg__actualOrigX(p); + oy = nsvg__actualOrigY(p); + sw = nsvg__actualWidth(p); + sh = nsvg__actualHeight(p); + } + sl = sqrtf(sw*sw + sh*sh) / sqrtf(2.0f); + + if (data->type == NSVG_PAINT_LINEAR_GRADIENT) { + float x1, y1, x2, y2, dx, dy; + x1 = nsvg__convertToPixels(p, data->linear.x1, ox, sw); + y1 = nsvg__convertToPixels(p, data->linear.y1, oy, sh); + x2 = nsvg__convertToPixels(p, data->linear.x2, ox, sw); + y2 = nsvg__convertToPixels(p, data->linear.y2, oy, sh); + // Calculate transform aligned to the line + dx = x2 - x1; + dy = y2 - y1; + grad->xform[0] = dy; grad->xform[1] = -dx; + grad->xform[2] = dx; grad->xform[3] = dy; + grad->xform[4] = x1; grad->xform[5] = y1; + } else { + float cx, cy, fx, fy, r; + cx = nsvg__convertToPixels(p, data->radial.cx, ox, sw); + cy = nsvg__convertToPixels(p, data->radial.cy, oy, sh); + fx = nsvg__convertToPixels(p, data->radial.fx, ox, sw); + fy = nsvg__convertToPixels(p, data->radial.fy, oy, sh); + r = nsvg__convertToPixels(p, data->radial.r, 0, sl); + // Calculate transform aligned to the circle + grad->xform[0] = r; grad->xform[1] = 0; + grad->xform[2] = 0; grad->xform[3] = r; + grad->xform[4] = cx; grad->xform[5] = cy; + grad->fx = fx / r; + grad->fy = fy / r; + } + + nsvg__xformMultiply(grad->xform, data->xform); + nsvg__xformMultiply(grad->xform, xform); + + grad->spread = data->spread; + memcpy(grad->stops, stops, nstops*sizeof(NSVGgradientStop)); + grad->nstops = nstops; + + *paintType = data->type; + + return grad; +} + +static float nsvg__getAverageScale(float* t) +{ + float sx = sqrtf(t[0]*t[0] + t[2]*t[2]); + float sy = sqrtf(t[1]*t[1] + t[3]*t[3]); + return (sx + sy) * 0.5f; +} + +static void nsvg__getLocalBounds(float* bounds, NSVGshape *shape, float* xform) +{ + NSVGpath* path; + float curve[4*2], curveBounds[4]; + int i, first = 1; + for (path = shape->paths; path != NULL; path = path->next) { + nsvg__xformPoint(&curve[0], &curve[1], path->pts[0], path->pts[1], xform); + for (i = 0; i < path->npts-1; i += 3) { + nsvg__xformPoint(&curve[2], &curve[3], path->pts[(i+1)*2], path->pts[(i+1)*2+1], xform); + nsvg__xformPoint(&curve[4], &curve[5], path->pts[(i+2)*2], path->pts[(i+2)*2+1], xform); + nsvg__xformPoint(&curve[6], &curve[7], path->pts[(i+3)*2], path->pts[(i+3)*2+1], xform); + nsvg__curveBounds(curveBounds, curve); + if (first) { + bounds[0] = curveBounds[0]; + bounds[1] = curveBounds[1]; + bounds[2] = curveBounds[2]; + bounds[3] = curveBounds[3]; + first = 0; + } else { + bounds[0] = nsvg__minf(bounds[0], curveBounds[0]); + bounds[1] = nsvg__minf(bounds[1], curveBounds[1]); + bounds[2] = nsvg__maxf(bounds[2], curveBounds[2]); + bounds[3] = nsvg__maxf(bounds[3], curveBounds[3]); + } + curve[0] = curve[6]; + curve[1] = curve[7]; + } + } +} + +static void nsvg__addShape(NSVGparser* p) +{ + NSVGattrib* attr = nsvg__getAttr(p); + float scale = 1.0f; + NSVGshape* shape; + NSVGpath* path; + int i; + + if (p->plist == NULL) + return; + + shape = (NSVGshape*)malloc(sizeof(NSVGshape)); + if (shape == NULL) goto error; + memset(shape, 0, sizeof(NSVGshape)); + + memcpy(shape->id, attr->id, sizeof shape->id); + memcpy(shape->fillGradient, attr->fillGradient, sizeof shape->fillGradient); + memcpy(shape->strokeGradient, attr->strokeGradient, sizeof shape->strokeGradient); + memcpy(shape->xform, attr->xform, sizeof shape->xform); + scale = nsvg__getAverageScale(attr->xform); + shape->strokeWidth = attr->strokeWidth * scale; + shape->strokeDashOffset = attr->strokeDashOffset * scale; + shape->strokeDashCount = (char)attr->strokeDashCount; + for (i = 0; i < attr->strokeDashCount; i++) + shape->strokeDashArray[i] = attr->strokeDashArray[i] * scale; + shape->strokeLineJoin = attr->strokeLineJoin; + shape->strokeLineCap = attr->strokeLineCap; + shape->miterLimit = attr->miterLimit; + shape->fillRule = attr->fillRule; + shape->opacity = attr->opacity; + shape->paintOrder = attr->paintOrder; + + shape->paths = p->plist; + p->plist = NULL; + + // Calculate shape bounds + shape->bounds[0] = shape->paths->bounds[0]; + shape->bounds[1] = shape->paths->bounds[1]; + shape->bounds[2] = shape->paths->bounds[2]; + shape->bounds[3] = shape->paths->bounds[3]; + for (path = shape->paths->next; path != NULL; path = path->next) { + shape->bounds[0] = nsvg__minf(shape->bounds[0], path->bounds[0]); + shape->bounds[1] = nsvg__minf(shape->bounds[1], path->bounds[1]); + shape->bounds[2] = nsvg__maxf(shape->bounds[2], path->bounds[2]); + shape->bounds[3] = nsvg__maxf(shape->bounds[3], path->bounds[3]); + } + + // Set fill + if (attr->hasFill == 0) { + shape->fill.type = NSVG_PAINT_NONE; + } else if (attr->hasFill == 1) { + shape->fill.type = NSVG_PAINT_COLOR; + shape->fill.color = attr->fillColor; + shape->fill.color |= (unsigned int)(attr->fillOpacity*255) << 24; + } else if (attr->hasFill == 2) { + shape->fill.type = NSVG_PAINT_UNDEF; + } + + // Set stroke + if (attr->hasStroke == 0) { + shape->stroke.type = NSVG_PAINT_NONE; + } else if (attr->hasStroke == 1) { + shape->stroke.type = NSVG_PAINT_COLOR; + shape->stroke.color = attr->strokeColor; + shape->stroke.color |= (unsigned int)(attr->strokeOpacity*255) << 24; + } else if (attr->hasStroke == 2) { + shape->stroke.type = NSVG_PAINT_UNDEF; + } + + // Set flags + shape->flags = (attr->visible ? NSVG_FLAGS_VISIBLE : 0x00); + + // Add to tail + if (p->image->shapes == NULL) + p->image->shapes = shape; + else + p->shapesTail->next = shape; + p->shapesTail = shape; + + return; + +error: + if (shape) free(shape); +} + +static void nsvg__addPath(NSVGparser* p, char closed) +{ + NSVGattrib* attr = nsvg__getAttr(p); + NSVGpath* path = NULL; + float bounds[4]; + float* curve; + int i; + + if (p->npts < 4) + return; + + if (closed) + nsvg__lineTo(p, p->pts[0], p->pts[1]); + + // Expect 1 + N*3 points (N = number of cubic bezier segments). + if ((p->npts % 3) != 1) + return; + + path = (NSVGpath*)malloc(sizeof(NSVGpath)); + if (path == NULL) goto error; + memset(path, 0, sizeof(NSVGpath)); + + path->pts = (float*)malloc(p->npts*2*sizeof(float)); + if (path->pts == NULL) goto error; + path->closed = closed; + path->npts = p->npts; + + // Transform path. + for (i = 0; i < p->npts; ++i) + nsvg__xformPoint(&path->pts[i*2], &path->pts[i*2+1], p->pts[i*2], p->pts[i*2+1], attr->xform); + + // Find bounds + for (i = 0; i < path->npts-1; i += 3) { + curve = &path->pts[i*2]; + nsvg__curveBounds(bounds, curve); + if (i == 0) { + path->bounds[0] = bounds[0]; + path->bounds[1] = bounds[1]; + path->bounds[2] = bounds[2]; + path->bounds[3] = bounds[3]; + } else { + path->bounds[0] = nsvg__minf(path->bounds[0], bounds[0]); + path->bounds[1] = nsvg__minf(path->bounds[1], bounds[1]); + path->bounds[2] = nsvg__maxf(path->bounds[2], bounds[2]); + path->bounds[3] = nsvg__maxf(path->bounds[3], bounds[3]); + } + } + + path->next = p->plist; + p->plist = path; + + return; + +error: + if (path != NULL) { + if (path->pts != NULL) free(path->pts); + free(path); + } +} + +// We roll our own string to float because the std library one uses locale and messes things up. +static double nsvg__atof(const char* s) +{ + char* cur = (char*)s; + char* end = NULL; + double res = 0.0, sign = 1.0; + long long intPart = 0, fracPart = 0; + char hasIntPart = 0, hasFracPart = 0; + + // Parse optional sign + if (*cur == '+') { + cur++; + } else if (*cur == '-') { + sign = -1; + cur++; + } + + // Parse integer part + if (nsvg__isdigit(*cur)) { + // Parse digit sequence + intPart = strtoll(cur, &end, 10); + if (cur != end) { + res = (double)intPart; + hasIntPart = 1; + cur = end; + } + } + + // Parse fractional part. + if (*cur == '.') { + cur++; // Skip '.' + if (nsvg__isdigit(*cur)) { + // Parse digit sequence + fracPart = strtoll(cur, &end, 10); + if (cur != end) { + res += (double)fracPart / pow(10.0, (double)(end - cur)); + hasFracPart = 1; + cur = end; + } + } + } + + // A valid number should have integer or fractional part. + if (!hasIntPart && !hasFracPart) + return 0.0; + + // Parse optional exponent + if (*cur == 'e' || *cur == 'E') { + long expPart = 0; + cur++; // skip 'E' + expPart = strtol(cur, &end, 10); // Parse digit sequence with sign + if (cur != end) { + res *= pow(10.0, (double)expPart); + } + } + + return res * sign; +} + + +static const char* nsvg__parseNumber(const char* s, char* it, const int size) +{ + const int last = size-1; + int i = 0; + + // sign + if (*s == '-' || *s == '+') { + if (i < last) it[i++] = *s; + s++; + } + // integer part + while (*s && nsvg__isdigit(*s)) { + if (i < last) it[i++] = *s; + s++; + } + if (*s == '.') { + // decimal point + if (i < last) it[i++] = *s; + s++; + // fraction part + while (*s && nsvg__isdigit(*s)) { + if (i < last) it[i++] = *s; + s++; + } + } + // exponent + if ((*s == 'e' || *s == 'E') && (s[1] != 'm' && s[1] != 'x')) { + if (i < last) it[i++] = *s; + s++; + if (*s == '-' || *s == '+') { + if (i < last) it[i++] = *s; + s++; + } + while (*s && nsvg__isdigit(*s)) { + if (i < last) it[i++] = *s; + s++; + } + } + it[i] = '\0'; + + return s; +} + +static const char* nsvg__getNextPathItemWhenArcFlag(const char* s, char* it) +{ + it[0] = '\0'; + while (*s && (nsvg__isspace(*s) || *s == ',')) s++; + if (!*s) return s; + if (*s == '0' || *s == '1') { + it[0] = *s++; + it[1] = '\0'; + return s; + } + return s; +} + +static const char* nsvg__getNextPathItem(const char* s, char* it) +{ + it[0] = '\0'; + // Skip white spaces and commas + while (*s && (nsvg__isspace(*s) || *s == ',')) s++; + if (!*s) return s; + if (*s == '-' || *s == '+' || *s == '.' || nsvg__isdigit(*s)) { + s = nsvg__parseNumber(s, it, 64); + } else { + // Parse command + it[0] = *s++; + it[1] = '\0'; + return s; + } + + return s; +} + +static unsigned int nsvg__parseColorHex(const char* str) +{ + unsigned int r=0, g=0, b=0; + if (sscanf(str, "#%2x%2x%2x", &r, &g, &b) == 3 ) // 2 digit hex + return NSVG_RGB(r, g, b); + if (sscanf(str, "#%1x%1x%1x", &r, &g, &b) == 3 ) // 1 digit hex, e.g. #abc -> 0xccbbaa + return NSVG_RGB(r*17, g*17, b*17); // same effect as (r<<4|r), (g<<4|g), .. + return NSVG_RGB(128, 128, 128); +} + +// Parse rgb color. The pointer 'str' must point at "rgb(" (4+ characters). +// This function returns gray (rgb(128, 128, 128) == '#808080') on parse errors +// for backwards compatibility. Note: other image viewers return black instead. + +static unsigned int nsvg__parseColorRGB(const char* str) +{ + int i; + unsigned int rgbi[3]; + float rgbf[3]; + // try decimal integers first + if (sscanf(str, "rgb(%u, %u, %u)", &rgbi[0], &rgbi[1], &rgbi[2]) != 3) { + // integers failed, try percent values (float, locale independent) + const char delimiter[3] = {',', ',', ')'}; + str += 4; // skip "rgb(" + for (i = 0; i < 3; i++) { + while (*str && (nsvg__isspace(*str))) str++; // skip leading spaces + if (*str == '+') str++; // skip '+' (don't allow '-') + if (!*str) break; + rgbf[i] = nsvg__atof(str); + + // Note 1: it would be great if nsvg__atof() returned how many + // bytes it consumed but it doesn't. We need to skip the number, + // the '%' character, spaces, and the delimiter ',' or ')'. + + // Note 2: The following code does not allow values like "33.%", + // i.e. a decimal point w/o fractional part, but this is consistent + // with other image viewers, e.g. firefox, chrome, eog, gimp. + + while (*str && nsvg__isdigit(*str)) str++; // skip integer part + if (*str == '.') { + str++; + if (!nsvg__isdigit(*str)) break; // error: no digit after '.' + while (*str && nsvg__isdigit(*str)) str++; // skip fractional part + } + if (*str == '%') str++; else break; + while (*str && nsvg__isspace(*str)) str++; + if (*str == delimiter[i]) str++; + else break; + } + if (i == 3) { + rgbi[0] = roundf(rgbf[0] * 2.55f); + rgbi[1] = roundf(rgbf[1] * 2.55f); + rgbi[2] = roundf(rgbf[2] * 2.55f); + } else { + rgbi[0] = rgbi[1] = rgbi[2] = 128; + } + } + // clip values as the CSS spec requires + for (i = 0; i < 3; i++) { + if (rgbi[i] > 255) rgbi[i] = 255; + } + return NSVG_RGB(rgbi[0], rgbi[1], rgbi[2]); +} + +typedef struct NSVGNamedColor { + const char* name; + unsigned int color; +} NSVGNamedColor; + +NSVGNamedColor nsvg__colors[] = { + + { "red", NSVG_RGB(255, 0, 0) }, + { "green", NSVG_RGB( 0, 128, 0) }, + { "blue", NSVG_RGB( 0, 0, 255) }, + { "yellow", NSVG_RGB(255, 255, 0) }, + { "cyan", NSVG_RGB( 0, 255, 255) }, + { "magenta", NSVG_RGB(255, 0, 255) }, + { "black", NSVG_RGB( 0, 0, 0) }, + { "grey", NSVG_RGB(128, 128, 128) }, + { "gray", NSVG_RGB(128, 128, 128) }, + { "white", NSVG_RGB(255, 255, 255) }, + +#ifdef NANOSVG_ALL_COLOR_KEYWORDS + { "aliceblue", NSVG_RGB(240, 248, 255) }, + { "antiquewhite", NSVG_RGB(250, 235, 215) }, + { "aqua", NSVG_RGB( 0, 255, 255) }, + { "aquamarine", NSVG_RGB(127, 255, 212) }, + { "azure", NSVG_RGB(240, 255, 255) }, + { "beige", NSVG_RGB(245, 245, 220) }, + { "bisque", NSVG_RGB(255, 228, 196) }, + { "blanchedalmond", NSVG_RGB(255, 235, 205) }, + { "blueviolet", NSVG_RGB(138, 43, 226) }, + { "brown", NSVG_RGB(165, 42, 42) }, + { "burlywood", NSVG_RGB(222, 184, 135) }, + { "cadetblue", NSVG_RGB( 95, 158, 160) }, + { "chartreuse", NSVG_RGB(127, 255, 0) }, + { "chocolate", NSVG_RGB(210, 105, 30) }, + { "coral", NSVG_RGB(255, 127, 80) }, + { "cornflowerblue", NSVG_RGB(100, 149, 237) }, + { "cornsilk", NSVG_RGB(255, 248, 220) }, + { "crimson", NSVG_RGB(220, 20, 60) }, + { "darkblue", NSVG_RGB( 0, 0, 139) }, + { "darkcyan", NSVG_RGB( 0, 139, 139) }, + { "darkgoldenrod", NSVG_RGB(184, 134, 11) }, + { "darkgray", NSVG_RGB(169, 169, 169) }, + { "darkgreen", NSVG_RGB( 0, 100, 0) }, + { "darkgrey", NSVG_RGB(169, 169, 169) }, + { "darkkhaki", NSVG_RGB(189, 183, 107) }, + { "darkmagenta", NSVG_RGB(139, 0, 139) }, + { "darkolivegreen", NSVG_RGB( 85, 107, 47) }, + { "darkorange", NSVG_RGB(255, 140, 0) }, + { "darkorchid", NSVG_RGB(153, 50, 204) }, + { "darkred", NSVG_RGB(139, 0, 0) }, + { "darksalmon", NSVG_RGB(233, 150, 122) }, + { "darkseagreen", NSVG_RGB(143, 188, 143) }, + { "darkslateblue", NSVG_RGB( 72, 61, 139) }, + { "darkslategray", NSVG_RGB( 47, 79, 79) }, + { "darkslategrey", NSVG_RGB( 47, 79, 79) }, + { "darkturquoise", NSVG_RGB( 0, 206, 209) }, + { "darkviolet", NSVG_RGB(148, 0, 211) }, + { "deeppink", NSVG_RGB(255, 20, 147) }, + { "deepskyblue", NSVG_RGB( 0, 191, 255) }, + { "dimgray", NSVG_RGB(105, 105, 105) }, + { "dimgrey", NSVG_RGB(105, 105, 105) }, + { "dodgerblue", NSVG_RGB( 30, 144, 255) }, + { "firebrick", NSVG_RGB(178, 34, 34) }, + { "floralwhite", NSVG_RGB(255, 250, 240) }, + { "forestgreen", NSVG_RGB( 34, 139, 34) }, + { "fuchsia", NSVG_RGB(255, 0, 255) }, + { "gainsboro", NSVG_RGB(220, 220, 220) }, + { "ghostwhite", NSVG_RGB(248, 248, 255) }, + { "gold", NSVG_RGB(255, 215, 0) }, + { "goldenrod", NSVG_RGB(218, 165, 32) }, + { "greenyellow", NSVG_RGB(173, 255, 47) }, + { "honeydew", NSVG_RGB(240, 255, 240) }, + { "hotpink", NSVG_RGB(255, 105, 180) }, + { "indianred", NSVG_RGB(205, 92, 92) }, + { "indigo", NSVG_RGB( 75, 0, 130) }, + { "ivory", NSVG_RGB(255, 255, 240) }, + { "khaki", NSVG_RGB(240, 230, 140) }, + { "lavender", NSVG_RGB(230, 230, 250) }, + { "lavenderblush", NSVG_RGB(255, 240, 245) }, + { "lawngreen", NSVG_RGB(124, 252, 0) }, + { "lemonchiffon", NSVG_RGB(255, 250, 205) }, + { "lightblue", NSVG_RGB(173, 216, 230) }, + { "lightcoral", NSVG_RGB(240, 128, 128) }, + { "lightcyan", NSVG_RGB(224, 255, 255) }, + { "lightgoldenrodyellow", NSVG_RGB(250, 250, 210) }, + { "lightgray", NSVG_RGB(211, 211, 211) }, + { "lightgreen", NSVG_RGB(144, 238, 144) }, + { "lightgrey", NSVG_RGB(211, 211, 211) }, + { "lightpink", NSVG_RGB(255, 182, 193) }, + { "lightsalmon", NSVG_RGB(255, 160, 122) }, + { "lightseagreen", NSVG_RGB( 32, 178, 170) }, + { "lightskyblue", NSVG_RGB(135, 206, 250) }, + { "lightslategray", NSVG_RGB(119, 136, 153) }, + { "lightslategrey", NSVG_RGB(119, 136, 153) }, + { "lightsteelblue", NSVG_RGB(176, 196, 222) }, + { "lightyellow", NSVG_RGB(255, 255, 224) }, + { "lime", NSVG_RGB( 0, 255, 0) }, + { "limegreen", NSVG_RGB( 50, 205, 50) }, + { "linen", NSVG_RGB(250, 240, 230) }, + { "maroon", NSVG_RGB(128, 0, 0) }, + { "mediumaquamarine", NSVG_RGB(102, 205, 170) }, + { "mediumblue", NSVG_RGB( 0, 0, 205) }, + { "mediumorchid", NSVG_RGB(186, 85, 211) }, + { "mediumpurple", NSVG_RGB(147, 112, 219) }, + { "mediumseagreen", NSVG_RGB( 60, 179, 113) }, + { "mediumslateblue", NSVG_RGB(123, 104, 238) }, + { "mediumspringgreen", NSVG_RGB( 0, 250, 154) }, + { "mediumturquoise", NSVG_RGB( 72, 209, 204) }, + { "mediumvioletred", NSVG_RGB(199, 21, 133) }, + { "midnightblue", NSVG_RGB( 25, 25, 112) }, + { "mintcream", NSVG_RGB(245, 255, 250) }, + { "mistyrose", NSVG_RGB(255, 228, 225) }, + { "moccasin", NSVG_RGB(255, 228, 181) }, + { "navajowhite", NSVG_RGB(255, 222, 173) }, + { "navy", NSVG_RGB( 0, 0, 128) }, + { "oldlace", NSVG_RGB(253, 245, 230) }, + { "olive", NSVG_RGB(128, 128, 0) }, + { "olivedrab", NSVG_RGB(107, 142, 35) }, + { "orange", NSVG_RGB(255, 165, 0) }, + { "orangered", NSVG_RGB(255, 69, 0) }, + { "orchid", NSVG_RGB(218, 112, 214) }, + { "palegoldenrod", NSVG_RGB(238, 232, 170) }, + { "palegreen", NSVG_RGB(152, 251, 152) }, + { "paleturquoise", NSVG_RGB(175, 238, 238) }, + { "palevioletred", NSVG_RGB(219, 112, 147) }, + { "papayawhip", NSVG_RGB(255, 239, 213) }, + { "peachpuff", NSVG_RGB(255, 218, 185) }, + { "peru", NSVG_RGB(205, 133, 63) }, + { "pink", NSVG_RGB(255, 192, 203) }, + { "plum", NSVG_RGB(221, 160, 221) }, + { "powderblue", NSVG_RGB(176, 224, 230) }, + { "purple", NSVG_RGB(128, 0, 128) }, + { "rosybrown", NSVG_RGB(188, 143, 143) }, + { "royalblue", NSVG_RGB( 65, 105, 225) }, + { "saddlebrown", NSVG_RGB(139, 69, 19) }, + { "salmon", NSVG_RGB(250, 128, 114) }, + { "sandybrown", NSVG_RGB(244, 164, 96) }, + { "seagreen", NSVG_RGB( 46, 139, 87) }, + { "seashell", NSVG_RGB(255, 245, 238) }, + { "sienna", NSVG_RGB(160, 82, 45) }, + { "silver", NSVG_RGB(192, 192, 192) }, + { "skyblue", NSVG_RGB(135, 206, 235) }, + { "slateblue", NSVG_RGB(106, 90, 205) }, + { "slategray", NSVG_RGB(112, 128, 144) }, + { "slategrey", NSVG_RGB(112, 128, 144) }, + { "snow", NSVG_RGB(255, 250, 250) }, + { "springgreen", NSVG_RGB( 0, 255, 127) }, + { "steelblue", NSVG_RGB( 70, 130, 180) }, + { "tan", NSVG_RGB(210, 180, 140) }, + { "teal", NSVG_RGB( 0, 128, 128) }, + { "thistle", NSVG_RGB(216, 191, 216) }, + { "tomato", NSVG_RGB(255, 99, 71) }, + { "turquoise", NSVG_RGB( 64, 224, 208) }, + { "violet", NSVG_RGB(238, 130, 238) }, + { "wheat", NSVG_RGB(245, 222, 179) }, + { "whitesmoke", NSVG_RGB(245, 245, 245) }, + { "yellowgreen", NSVG_RGB(154, 205, 50) }, +#endif +}; + +static unsigned int nsvg__parseColorName(const char* str) +{ + int i, ncolors = sizeof(nsvg__colors) / sizeof(NSVGNamedColor); + + for (i = 0; i < ncolors; i++) { + if (strcmp(nsvg__colors[i].name, str) == 0) { + return nsvg__colors[i].color; + } + } + + return NSVG_RGB(128, 128, 128); +} + +static unsigned int nsvg__parseColor(const char* str) +{ + size_t len = 0; + while(*str == ' ') ++str; + len = strlen(str); + if (len >= 1 && *str == '#') + return nsvg__parseColorHex(str); + else if (len >= 4 && str[0] == 'r' && str[1] == 'g' && str[2] == 'b' && str[3] == '(') + return nsvg__parseColorRGB(str); + return nsvg__parseColorName(str); +} + +static float nsvg__parseOpacity(const char* str) +{ + float val = nsvg__atof(str); + if (val < 0.0f) val = 0.0f; + if (val > 1.0f) val = 1.0f; + return val; +} + +static float nsvg__parseMiterLimit(const char* str) +{ + float val = nsvg__atof(str); + if (val < 0.0f) val = 0.0f; + return val; +} + +static int nsvg__parseUnits(const char* units) +{ + if (units[0] == 'p' && units[1] == 'x') + return NSVG_UNITS_PX; + else if (units[0] == 'p' && units[1] == 't') + return NSVG_UNITS_PT; + else if (units[0] == 'p' && units[1] == 'c') + return NSVG_UNITS_PC; + else if (units[0] == 'm' && units[1] == 'm') + return NSVG_UNITS_MM; + else if (units[0] == 'c' && units[1] == 'm') + return NSVG_UNITS_CM; + else if (units[0] == 'i' && units[1] == 'n') + return NSVG_UNITS_IN; + else if (units[0] == '%') + return NSVG_UNITS_PERCENT; + else if (units[0] == 'e' && units[1] == 'm') + return NSVG_UNITS_EM; + else if (units[0] == 'e' && units[1] == 'x') + return NSVG_UNITS_EX; + return NSVG_UNITS_USER; +} + +static int nsvg__isCoordinate(const char* s) +{ + // optional sign + if (*s == '-' || *s == '+') + s++; + // must have at least one digit, or start by a dot + return (nsvg__isdigit(*s) || *s == '.'); +} + +static NSVGcoordinate nsvg__parseCoordinateRaw(const char* str) +{ + NSVGcoordinate coord = {0, NSVG_UNITS_USER}; + char buf[64]; + coord.units = nsvg__parseUnits(nsvg__parseNumber(str, buf, 64)); + coord.value = nsvg__atof(buf); + return coord; +} + +static NSVGcoordinate nsvg__coord(float v, int units) +{ + NSVGcoordinate coord = {v, units}; + return coord; +} + +static float nsvg__parseCoordinate(NSVGparser* p, const char* str, float orig, float length) +{ + NSVGcoordinate coord = nsvg__parseCoordinateRaw(str); + return nsvg__convertToPixels(p, coord, orig, length); +} + +static int nsvg__parseTransformArgs(const char* str, float* args, int maxNa, int* na) +{ + const char* end; + const char* ptr; + char it[64]; + + *na = 0; + ptr = str; + while (*ptr && *ptr != '(') ++ptr; + if (*ptr == 0) + return 1; + end = ptr; + while (*end && *end != ')') ++end; + if (*end == 0) + return 1; + + while (ptr < end) { + if (*ptr == '-' || *ptr == '+' || *ptr == '.' || nsvg__isdigit(*ptr)) { + if (*na >= maxNa) return 0; + ptr = nsvg__parseNumber(ptr, it, 64); + args[(*na)++] = (float)nsvg__atof(it); + } else { + ++ptr; + } + } + return (int)(end - str); +} + + +static int nsvg__parseMatrix(float* xform, const char* str) +{ + float t[6]; + int na = 0; + int len = nsvg__parseTransformArgs(str, t, 6, &na); + if (na != 6) return len; + memcpy(xform, t, sizeof(float)*6); + return len; +} + +static int nsvg__parseTranslate(float* xform, const char* str) +{ + float args[2]; + float t[6]; + int na = 0; + int len = nsvg__parseTransformArgs(str, args, 2, &na); + if (na == 1) args[1] = 0.0; + + nsvg__xformSetTranslation(t, args[0], args[1]); + memcpy(xform, t, sizeof(float)*6); + return len; +} + +static int nsvg__parseScale(float* xform, const char* str) +{ + float args[2]; + int na = 0; + float t[6]; + int len = nsvg__parseTransformArgs(str, args, 2, &na); + if (na == 1) args[1] = args[0]; + nsvg__xformSetScale(t, args[0], args[1]); + memcpy(xform, t, sizeof(float)*6); + return len; +} + +static int nsvg__parseSkewX(float* xform, const char* str) +{ + float args[1]; + int na = 0; + float t[6]; + int len = nsvg__parseTransformArgs(str, args, 1, &na); + nsvg__xformSetSkewX(t, args[0]/180.0f*NSVG_PI); + memcpy(xform, t, sizeof(float)*6); + return len; +} + +static int nsvg__parseSkewY(float* xform, const char* str) +{ + float args[1]; + int na = 0; + float t[6]; + int len = nsvg__parseTransformArgs(str, args, 1, &na); + nsvg__xformSetSkewY(t, args[0]/180.0f*NSVG_PI); + memcpy(xform, t, sizeof(float)*6); + return len; +} + +static int nsvg__parseRotate(float* xform, const char* str) +{ + float args[3]; + int na = 0; + float m[6]; + float t[6]; + int len = nsvg__parseTransformArgs(str, args, 3, &na); + if (na == 1) + args[1] = args[2] = 0.0f; + nsvg__xformIdentity(m); + + if (na > 1) { + nsvg__xformSetTranslation(t, -args[1], -args[2]); + nsvg__xformMultiply(m, t); + } + + nsvg__xformSetRotation(t, args[0]/180.0f*NSVG_PI); + nsvg__xformMultiply(m, t); + + if (na > 1) { + nsvg__xformSetTranslation(t, args[1], args[2]); + nsvg__xformMultiply(m, t); + } + + memcpy(xform, m, sizeof(float)*6); + + return len; +} + +static void nsvg__parseTransform(float* xform, const char* str) +{ + float t[6]; + int len; + nsvg__xformIdentity(xform); + while (*str) + { + if (strncmp(str, "matrix", 6) == 0) + len = nsvg__parseMatrix(t, str); + else if (strncmp(str, "translate", 9) == 0) + len = nsvg__parseTranslate(t, str); + else if (strncmp(str, "scale", 5) == 0) + len = nsvg__parseScale(t, str); + else if (strncmp(str, "rotate", 6) == 0) + len = nsvg__parseRotate(t, str); + else if (strncmp(str, "skewX", 5) == 0) + len = nsvg__parseSkewX(t, str); + else if (strncmp(str, "skewY", 5) == 0) + len = nsvg__parseSkewY(t, str); + else{ + ++str; + continue; + } + if (len != 0) { + str += len; + } else { + ++str; + continue; + } + + nsvg__xformPremultiply(xform, t); + } +} + +static void nsvg__parseUrl(char* id, const char* str) +{ + int i = 0; + str += 4; // "url("; + if (*str && *str == '#') + str++; + while (i < 63 && *str && *str != ')') { + id[i] = *str++; + i++; + } + id[i] = '\0'; +} + +static char nsvg__parseLineCap(const char* str) +{ + if (strcmp(str, "butt") == 0) + return NSVG_CAP_BUTT; + else if (strcmp(str, "round") == 0) + return NSVG_CAP_ROUND; + else if (strcmp(str, "square") == 0) + return NSVG_CAP_SQUARE; + // TODO: handle inherit. + return NSVG_CAP_BUTT; +} + +static char nsvg__parseLineJoin(const char* str) +{ + if (strcmp(str, "miter") == 0) + return NSVG_JOIN_MITER; + else if (strcmp(str, "round") == 0) + return NSVG_JOIN_ROUND; + else if (strcmp(str, "bevel") == 0) + return NSVG_JOIN_BEVEL; + // TODO: handle inherit. + return NSVG_JOIN_MITER; +} + +static char nsvg__parseFillRule(const char* str) +{ + if (strcmp(str, "nonzero") == 0) + return NSVG_FILLRULE_NONZERO; + else if (strcmp(str, "evenodd") == 0) + return NSVG_FILLRULE_EVENODD; + // TODO: handle inherit. + return NSVG_FILLRULE_NONZERO; +} + +static unsigned char nsvg__parsePaintOrder(const char* str) +{ + if (strcmp(str, "normal") == 0 || strcmp(str, "fill stroke markers") == 0) + return nsvg__encodePaintOrder(NSVG_PAINT_FILL, NSVG_PAINT_STROKE, NSVG_PAINT_MARKERS); + else if (strcmp(str, "fill markers stroke") == 0) + return nsvg__encodePaintOrder(NSVG_PAINT_FILL, NSVG_PAINT_MARKERS, NSVG_PAINT_STROKE); + else if (strcmp(str, "markers fill stroke") == 0) + return nsvg__encodePaintOrder(NSVG_PAINT_MARKERS, NSVG_PAINT_FILL, NSVG_PAINT_STROKE); + else if (strcmp(str, "markers stroke fill") == 0) + return nsvg__encodePaintOrder(NSVG_PAINT_MARKERS, NSVG_PAINT_STROKE, NSVG_PAINT_FILL); + else if (strcmp(str, "stroke fill markers") == 0) + return nsvg__encodePaintOrder(NSVG_PAINT_STROKE, NSVG_PAINT_FILL, NSVG_PAINT_MARKERS); + else if (strcmp(str, "stroke markers fill") == 0) + return nsvg__encodePaintOrder(NSVG_PAINT_STROKE, NSVG_PAINT_MARKERS, NSVG_PAINT_FILL); + // TODO: handle inherit. + return nsvg__encodePaintOrder(NSVG_PAINT_FILL, NSVG_PAINT_STROKE, NSVG_PAINT_MARKERS); +} + +static const char* nsvg__getNextDashItem(const char* s, char* it) +{ + int n = 0; + it[0] = '\0'; + // Skip white spaces and commas + while (*s && (nsvg__isspace(*s) || *s == ',')) s++; + // Advance until whitespace, comma or end. + while (*s && (!nsvg__isspace(*s) && *s != ',')) { + if (n < 63) + it[n++] = *s; + s++; + } + it[n++] = '\0'; + return s; +} + +static int nsvg__parseStrokeDashArray(NSVGparser* p, const char* str, float* strokeDashArray) +{ + char item[64]; + int count = 0, i; + float sum = 0.0f; + + // Handle "none" + if (str[0] == 'n') + return 0; + + // Parse dashes + while (*str) { + str = nsvg__getNextDashItem(str, item); + if (!*item) break; + if (count < NSVG_MAX_DASHES) + strokeDashArray[count++] = fabsf(nsvg__parseCoordinate(p, item, 0.0f, nsvg__actualLength(p))); + } + + for (i = 0; i < count; i++) + sum += strokeDashArray[i]; + if (sum <= 1e-6f) + count = 0; + + return count; +} + +static void nsvg__parseStyle(NSVGparser* p, const char* str); + +// Apply any matching class styles for a "class" attribute value. We support only simple class +// selectors. The class attribute may contain multiple space-separated class names. +static void nsvg__applyClassStyles(NSVGparser* p, const char* value) +{ + const char* cur = value; + while (*cur) { + const char* classStart; + size_t classLen; + NSVGstyleDeclaration* style; + + while (*cur && nsvg__isspace(*cur)) + cur++; + if (!*cur) + break; + + classStart = cur; + while (*cur && !nsvg__isspace(*cur)) + cur++; + classLen = (size_t)(cur - classStart); + + for (style = p->styles; style != NULL; style = style->next) { + if (strncmp(style->className, classStart, classLen) == 0 && style->className[classLen] == '\0') { + nsvg__parseStyle(p, style->propertiesText); + } + } + } +} + +static int nsvg__parseAttr(NSVGparser* p, const char* name, const char* value) +{ + float xform[6]; + NSVGattrib* attr = nsvg__getAttr(p); + if (!attr) return 0; + + if (strcmp(name, "style") == 0) { + nsvg__parseStyle(p, value); + } else if (strcmp(name, "display") == 0) { + if (strcmp(value, "none") == 0) + attr->visible = 0; + // Don't reset ->visible on display:inline, one display:none hides the whole subtree + + } else if (strcmp(name, "fill") == 0) { + if (strcmp(value, "none") == 0) { + attr->hasFill = 0; + } else if (strncmp(value, "url(", 4) == 0) { + attr->hasFill = 2; + nsvg__parseUrl(attr->fillGradient, value); + } else { + attr->hasFill = 1; + attr->fillColor = nsvg__parseColor(value); + } + } else if (strcmp(name, "opacity") == 0) { + attr->opacity = nsvg__parseOpacity(value); + } else if (strcmp(name, "fill-opacity") == 0) { + attr->fillOpacity = nsvg__parseOpacity(value); + } else if (strcmp(name, "stroke") == 0) { + if (strcmp(value, "none") == 0) { + attr->hasStroke = 0; + } else if (strncmp(value, "url(", 4) == 0) { + attr->hasStroke = 2; + nsvg__parseUrl(attr->strokeGradient, value); + } else { + attr->hasStroke = 1; + attr->strokeColor = nsvg__parseColor(value); + } + } else if (strcmp(name, "stroke-width") == 0) { + attr->strokeWidth = nsvg__parseCoordinate(p, value, 0.0f, nsvg__actualLength(p)); + } else if (strcmp(name, "stroke-dasharray") == 0) { + attr->strokeDashCount = nsvg__parseStrokeDashArray(p, value, attr->strokeDashArray); + } else if (strcmp(name, "stroke-dashoffset") == 0) { + attr->strokeDashOffset = nsvg__parseCoordinate(p, value, 0.0f, nsvg__actualLength(p)); + } else if (strcmp(name, "stroke-opacity") == 0) { + attr->strokeOpacity = nsvg__parseOpacity(value); + } else if (strcmp(name, "stroke-linecap") == 0) { + attr->strokeLineCap = nsvg__parseLineCap(value); + } else if (strcmp(name, "stroke-linejoin") == 0) { + attr->strokeLineJoin = nsvg__parseLineJoin(value); + } else if (strcmp(name, "stroke-miterlimit") == 0) { + attr->miterLimit = nsvg__parseMiterLimit(value); + } else if (strcmp(name, "fill-rule") == 0) { + attr->fillRule = nsvg__parseFillRule(value); + } else if (strcmp(name, "font-size") == 0) { + attr->fontSize = nsvg__parseCoordinate(p, value, 0.0f, nsvg__actualLength(p)); + } else if (strcmp(name, "transform") == 0) { + nsvg__parseTransform(xform, value); + nsvg__xformPremultiply(attr->xform, xform); + } else if (strcmp(name, "stop-color") == 0) { + attr->stopColor = nsvg__parseColor(value); + } else if (strcmp(name, "stop-opacity") == 0) { + attr->stopOpacity = nsvg__parseOpacity(value); + } else if (strcmp(name, "offset") == 0) { + attr->stopOffset = nsvg__parseCoordinate(p, value, 0.0f, 1.0f); + } else if (strcmp(name, "paint-order") == 0) { + attr->paintOrder = nsvg__parsePaintOrder(value); + } else if (strcmp(name, "id") == 0) { + strncpy(attr->id, value, 63); + attr->id[63] = '\0'; + } else if (strcmp(name, "class") == 0) { + nsvg__applyClassStyles(p, value); + } + else { + return 0; + } + return 1; +} + +static int nsvg__parseNameValue(NSVGparser* p, const char* start, const char* end) +{ + const char* str; + const char* val; + char name[512]; + char value[512]; + int n; + + str = start; + while (str < end && *str != ':') ++str; + + val = str; + + // Right Trim + while (str > start && (*str == ':' || nsvg__isspace(*str))) --str; + ++str; + + n = (int)(str - start); + if (n > 511) n = 511; + if (n) memcpy(name, start, n); + name[n] = 0; + + while (val < end && (*val == ':' || nsvg__isspace(*val))) ++val; + + n = (int)(end - val); + if (n > 511) n = 511; + if (n) memcpy(value, val, n); + value[n] = 0; + + return nsvg__parseAttr(p, name, value); +} + +static void nsvg__parseStyle(NSVGparser* p, const char* str) +{ + const char* start; + const char* end; + + while (*str) { + // Left Trim + while(*str && nsvg__isspace(*str)) ++str; + start = str; + while(*str && *str != ';') ++str; + end = str; + + // Right Trim + while (end > start && (*end == ';' || nsvg__isspace(*end))) --end; + ++end; + + nsvg__parseNameValue(p, start, end); + if (*str) ++str; + } +} + +static void nsvg__parseAttribs(NSVGparser* p, const char** attr) +{ + int i; + for (i = 0; attr[i]; i += 2) + { + if (strcmp(attr[i], "style") == 0) + nsvg__parseStyle(p, attr[i + 1]); + else + nsvg__parseAttr(p, attr[i], attr[i + 1]); + } +} + +static int nsvg__getArgsPerElement(char cmd) +{ + switch (cmd) { + case 'v': + case 'V': + case 'h': + case 'H': + return 1; + case 'm': + case 'M': + case 'l': + case 'L': + case 't': + case 'T': + return 2; + case 'q': + case 'Q': + case 's': + case 'S': + return 4; + case 'c': + case 'C': + return 6; + case 'a': + case 'A': + return 7; + case 'z': + case 'Z': + return 0; + } + return -1; +} + +static void nsvg__pathMoveTo(NSVGparser* p, float* cpx, float* cpy, float* args, int rel) +{ + if (rel) { + *cpx += args[0]; + *cpy += args[1]; + } else { + *cpx = args[0]; + *cpy = args[1]; + } + nsvg__moveTo(p, *cpx, *cpy); +} + +static void nsvg__pathLineTo(NSVGparser* p, float* cpx, float* cpy, float* args, int rel) +{ + if (rel) { + *cpx += args[0]; + *cpy += args[1]; + } else { + *cpx = args[0]; + *cpy = args[1]; + } + nsvg__lineTo(p, *cpx, *cpy); +} + +static void nsvg__pathHLineTo(NSVGparser* p, float* cpx, float* cpy, float* args, int rel) +{ + if (rel) + *cpx += args[0]; + else + *cpx = args[0]; + nsvg__lineTo(p, *cpx, *cpy); +} + +static void nsvg__pathVLineTo(NSVGparser* p, float* cpx, float* cpy, float* args, int rel) +{ + if (rel) + *cpy += args[0]; + else + *cpy = args[0]; + nsvg__lineTo(p, *cpx, *cpy); +} + +static void nsvg__pathCubicBezTo(NSVGparser* p, float* cpx, float* cpy, + float* cpx2, float* cpy2, float* args, int rel) +{ + float x2, y2, cx1, cy1, cx2, cy2; + + if (rel) { + cx1 = *cpx + args[0]; + cy1 = *cpy + args[1]; + cx2 = *cpx + args[2]; + cy2 = *cpy + args[3]; + x2 = *cpx + args[4]; + y2 = *cpy + args[5]; + } else { + cx1 = args[0]; + cy1 = args[1]; + cx2 = args[2]; + cy2 = args[3]; + x2 = args[4]; + y2 = args[5]; + } + + nsvg__cubicBezTo(p, cx1,cy1, cx2,cy2, x2,y2); + + *cpx2 = cx2; + *cpy2 = cy2; + *cpx = x2; + *cpy = y2; +} + +static void nsvg__pathCubicBezShortTo(NSVGparser* p, float* cpx, float* cpy, + float* cpx2, float* cpy2, float* args, int rel) +{ + float x1, y1, x2, y2, cx1, cy1, cx2, cy2; + + x1 = *cpx; + y1 = *cpy; + if (rel) { + cx2 = *cpx + args[0]; + cy2 = *cpy + args[1]; + x2 = *cpx + args[2]; + y2 = *cpy + args[3]; + } else { + cx2 = args[0]; + cy2 = args[1]; + x2 = args[2]; + y2 = args[3]; + } + + cx1 = 2*x1 - *cpx2; + cy1 = 2*y1 - *cpy2; + + nsvg__cubicBezTo(p, cx1,cy1, cx2,cy2, x2,y2); + + *cpx2 = cx2; + *cpy2 = cy2; + *cpx = x2; + *cpy = y2; +} + +static void nsvg__pathQuadBezTo(NSVGparser* p, float* cpx, float* cpy, + float* cpx2, float* cpy2, float* args, int rel) +{ + float x1, y1, x2, y2, cx, cy; + float cx1, cy1, cx2, cy2; + + x1 = *cpx; + y1 = *cpy; + if (rel) { + cx = *cpx + args[0]; + cy = *cpy + args[1]; + x2 = *cpx + args[2]; + y2 = *cpy + args[3]; + } else { + cx = args[0]; + cy = args[1]; + x2 = args[2]; + y2 = args[3]; + } + + // Convert to cubic bezier + cx1 = x1 + 2.0f/3.0f*(cx - x1); + cy1 = y1 + 2.0f/3.0f*(cy - y1); + cx2 = x2 + 2.0f/3.0f*(cx - x2); + cy2 = y2 + 2.0f/3.0f*(cy - y2); + + nsvg__cubicBezTo(p, cx1,cy1, cx2,cy2, x2,y2); + + *cpx2 = cx; + *cpy2 = cy; + *cpx = x2; + *cpy = y2; +} + +static void nsvg__pathQuadBezShortTo(NSVGparser* p, float* cpx, float* cpy, + float* cpx2, float* cpy2, float* args, int rel) +{ + float x1, y1, x2, y2, cx, cy; + float cx1, cy1, cx2, cy2; + + x1 = *cpx; + y1 = *cpy; + if (rel) { + x2 = *cpx + args[0]; + y2 = *cpy + args[1]; + } else { + x2 = args[0]; + y2 = args[1]; + } + + cx = 2*x1 - *cpx2; + cy = 2*y1 - *cpy2; + + // Convert to cubix bezier + cx1 = x1 + 2.0f/3.0f*(cx - x1); + cy1 = y1 + 2.0f/3.0f*(cy - y1); + cx2 = x2 + 2.0f/3.0f*(cx - x2); + cy2 = y2 + 2.0f/3.0f*(cy - y2); + + nsvg__cubicBezTo(p, cx1,cy1, cx2,cy2, x2,y2); + + *cpx2 = cx; + *cpy2 = cy; + *cpx = x2; + *cpy = y2; +} + +static float nsvg__sqr(float x) { return x*x; } +static float nsvg__vmag(float x, float y) { return sqrtf(x*x + y*y); } + +static float nsvg__vecrat(float ux, float uy, float vx, float vy) +{ + return (ux*vx + uy*vy) / (nsvg__vmag(ux,uy) * nsvg__vmag(vx,vy)); +} + +static float nsvg__vecang(float ux, float uy, float vx, float vy) +{ + float r = nsvg__vecrat(ux,uy, vx,vy); + if (r < -1.0f) r = -1.0f; + if (r > 1.0f) r = 1.0f; + return ((ux*vy < uy*vx) ? -1.0f : 1.0f) * acosf(r); +} + +static void nsvg__pathArcTo(NSVGparser* p, float* cpx, float* cpy, float* args, int rel) +{ + // Ported from canvg (https://code.google.com/p/canvg/) + float rx, ry, rotx; + float x1, y1, x2, y2, cx, cy, dx, dy, d; + float x1p, y1p, cxp, cyp, s, sa, sb; + float ux, uy, vx, vy, a1, da; + float x, y, tanx, tany, a, px = 0, py = 0, ptanx = 0, ptany = 0, t[6]; + float sinrx, cosrx; + int fa, fs; + int i, ndivs; + float hda, kappa; + + rx = fabsf(args[0]); // y radius + ry = fabsf(args[1]); // x radius + rotx = args[2] / 180.0f * NSVG_PI; // x rotation angle + fa = fabsf(args[3]) > 1e-6 ? 1 : 0; // Large arc + fs = fabsf(args[4]) > 1e-6 ? 1 : 0; // Sweep direction + x1 = *cpx; // start point + y1 = *cpy; + if (rel) { // end point + x2 = *cpx + args[5]; + y2 = *cpy + args[6]; + } else { + x2 = args[5]; + y2 = args[6]; + } + + dx = x1 - x2; + dy = y1 - y2; + d = sqrtf(dx*dx + dy*dy); + if (d < 1e-6f || rx < 1e-6f || ry < 1e-6f) { + // The arc degenerates to a line + nsvg__lineTo(p, x2, y2); + *cpx = x2; + *cpy = y2; + return; + } + + sinrx = sinf(rotx); + cosrx = cosf(rotx); + + // Convert to center point parameterization. + // http://www.w3.org/TR/SVG11/implnote.html#ArcImplementationNotes + // 1) Compute x1', y1' + x1p = cosrx * dx / 2.0f + sinrx * dy / 2.0f; + y1p = -sinrx * dx / 2.0f + cosrx * dy / 2.0f; + d = nsvg__sqr(x1p)/nsvg__sqr(rx) + nsvg__sqr(y1p)/nsvg__sqr(ry); + if (d > 1) { + d = sqrtf(d); + rx *= d; + ry *= d; + } + // 2) Compute cx', cy' + s = 0.0f; + sa = nsvg__sqr(rx)*nsvg__sqr(ry) - nsvg__sqr(rx)*nsvg__sqr(y1p) - nsvg__sqr(ry)*nsvg__sqr(x1p); + sb = nsvg__sqr(rx)*nsvg__sqr(y1p) + nsvg__sqr(ry)*nsvg__sqr(x1p); + if (sa < 0.0f) sa = 0.0f; + if (sb > 0.0f) + s = sqrtf(sa / sb); + if (fa == fs) + s = -s; + cxp = s * rx * y1p / ry; + cyp = s * -ry * x1p / rx; + + // 3) Compute cx,cy from cx',cy' + cx = (x1 + x2)/2.0f + cosrx*cxp - sinrx*cyp; + cy = (y1 + y2)/2.0f + sinrx*cxp + cosrx*cyp; + + // 4) Calculate theta1, and delta theta. + ux = (x1p - cxp) / rx; + uy = (y1p - cyp) / ry; + vx = (-x1p - cxp) / rx; + vy = (-y1p - cyp) / ry; + a1 = nsvg__vecang(1.0f,0.0f, ux,uy); // Initial angle + da = nsvg__vecang(ux,uy, vx,vy); // Delta angle + +// if (vecrat(ux,uy,vx,vy) <= -1.0f) da = NSVG_PI; +// if (vecrat(ux,uy,vx,vy) >= 1.0f) da = 0; + + if (fs == 0 && da > 0) + da -= 2 * NSVG_PI; + else if (fs == 1 && da < 0) + da += 2 * NSVG_PI; + + // Approximate the arc using cubic spline segments. + t[0] = cosrx; t[1] = sinrx; + t[2] = -sinrx; t[3] = cosrx; + t[4] = cx; t[5] = cy; + + // Split arc into max 90 degree segments. + // The loop assumes an iteration per end point (including start and end), this +1. + ndivs = (int)(fabsf(da) / (NSVG_PI*0.5f) + 1.0f); + hda = (da / (float)ndivs) / 2.0f; + // Fix for ticket #179: division by 0: avoid cotangens around 0 (infinite) + if ((hda < 1e-3f) && (hda > -1e-3f)) + hda *= 0.5f; + else + hda = (1.0f - cosf(hda)) / sinf(hda); + kappa = fabsf(4.0f / 3.0f * hda); + if (da < 0.0f) + kappa = -kappa; + + for (i = 0; i <= ndivs; i++) { + a = a1 + da * ((float)i/(float)ndivs); + dx = cosf(a); + dy = sinf(a); + nsvg__xformPoint(&x, &y, dx*rx, dy*ry, t); // position + nsvg__xformVec(&tanx, &tany, -dy*rx * kappa, dx*ry * kappa, t); // tangent + if (i > 0) + nsvg__cubicBezTo(p, px+ptanx,py+ptany, x-tanx, y-tany, x, y); + px = x; + py = y; + ptanx = tanx; + ptany = tany; + } + + *cpx = x2; + *cpy = y2; +} + +static void nsvg__parsePath(NSVGparser* p, const char** attr) +{ + const char* s = NULL; + char cmd = '\0'; + float args[10]; + int nargs; + int rargs = 0; + char initPoint; + float cpx, cpy, cpx2, cpy2; + const char* tmp[4]; + char closedFlag; + int i; + char item[64]; + + for (i = 0; attr[i]; i += 2) { + if (strcmp(attr[i], "d") == 0) { + s = attr[i + 1]; + } else { + tmp[0] = attr[i]; + tmp[1] = attr[i + 1]; + tmp[2] = 0; + tmp[3] = 0; + nsvg__parseAttribs(p, tmp); + } + } + + if (s) { + nsvg__resetPath(p); + cpx = 0; cpy = 0; + cpx2 = 0; cpy2 = 0; + initPoint = 0; + closedFlag = 0; + nargs = 0; + + while (*s) { + item[0] = '\0'; + if ((cmd == 'A' || cmd == 'a') && (nargs == 3 || nargs == 4)) + s = nsvg__getNextPathItemWhenArcFlag(s, item); + if (!*item) + s = nsvg__getNextPathItem(s, item); + if (!*item) break; + if (cmd != '\0' && nsvg__isCoordinate(item)) { + if (nargs < 10) + args[nargs++] = (float)nsvg__atof(item); + if (nargs >= rargs) { + switch (cmd) { + case 'm': + case 'M': + nsvg__pathMoveTo(p, &cpx, &cpy, args, cmd == 'm' ? 1 : 0); + // Moveto can be followed by multiple coordinate pairs, + // which should be treated as linetos. + cmd = (cmd == 'm') ? 'l' : 'L'; + rargs = nsvg__getArgsPerElement(cmd); + cpx2 = cpx; cpy2 = cpy; + initPoint = 1; + break; + case 'l': + case 'L': + nsvg__pathLineTo(p, &cpx, &cpy, args, cmd == 'l' ? 1 : 0); + cpx2 = cpx; cpy2 = cpy; + break; + case 'H': + case 'h': + nsvg__pathHLineTo(p, &cpx, &cpy, args, cmd == 'h' ? 1 : 0); + cpx2 = cpx; cpy2 = cpy; + break; + case 'V': + case 'v': + nsvg__pathVLineTo(p, &cpx, &cpy, args, cmd == 'v' ? 1 : 0); + cpx2 = cpx; cpy2 = cpy; + break; + case 'C': + case 'c': + nsvg__pathCubicBezTo(p, &cpx, &cpy, &cpx2, &cpy2, args, cmd == 'c' ? 1 : 0); + break; + case 'S': + case 's': + nsvg__pathCubicBezShortTo(p, &cpx, &cpy, &cpx2, &cpy2, args, cmd == 's' ? 1 : 0); + break; + case 'Q': + case 'q': + nsvg__pathQuadBezTo(p, &cpx, &cpy, &cpx2, &cpy2, args, cmd == 'q' ? 1 : 0); + break; + case 'T': + case 't': + nsvg__pathQuadBezShortTo(p, &cpx, &cpy, &cpx2, &cpy2, args, cmd == 't' ? 1 : 0); + break; + case 'A': + case 'a': + nsvg__pathArcTo(p, &cpx, &cpy, args, cmd == 'a' ? 1 : 0); + cpx2 = cpx; cpy2 = cpy; + break; + default: + if (nargs >= 2) { + cpx = args[nargs-2]; + cpy = args[nargs-1]; + cpx2 = cpx; cpy2 = cpy; + } + break; + } + nargs = 0; + } + } else { + cmd = item[0]; + if (cmd == 'M' || cmd == 'm') { + // Commit path. + if (p->npts > 0) + nsvg__addPath(p, closedFlag); + // Start new subpath. + nsvg__resetPath(p); + closedFlag = 0; + nargs = 0; + } else if (initPoint == 0) { + // Do not allow other commands until initial point has been set (moveTo called once). + cmd = '\0'; + } + if (cmd == 'Z' || cmd == 'z') { + closedFlag = 1; + // Commit path. + if (p->npts > 0) { + // Move current point to first point + cpx = p->pts[0]; + cpy = p->pts[1]; + cpx2 = cpx; cpy2 = cpy; + nsvg__addPath(p, closedFlag); + } + // Start new subpath. + nsvg__resetPath(p); + nsvg__moveTo(p, cpx, cpy); + closedFlag = 0; + nargs = 0; + } + rargs = nsvg__getArgsPerElement(cmd); + if (rargs == -1) { + // Command not recognized + cmd = '\0'; + rargs = 0; + } + } + } + // Commit path. + if (p->npts) + nsvg__addPath(p, closedFlag); + } + + nsvg__addShape(p); +} + +static void nsvg__parseRect(NSVGparser* p, const char** attr) +{ + float x = 0.0f; + float y = 0.0f; + float w = 0.0f; + float h = 0.0f; + float rx = -1.0f; // marks not set + float ry = -1.0f; + int i; + + for (i = 0; attr[i]; i += 2) { + if (!nsvg__parseAttr(p, attr[i], attr[i + 1])) { + if (strcmp(attr[i], "x") == 0) x = nsvg__parseCoordinate(p, attr[i+1], nsvg__actualOrigX(p), nsvg__actualWidth(p)); + if (strcmp(attr[i], "y") == 0) y = nsvg__parseCoordinate(p, attr[i+1], nsvg__actualOrigY(p), nsvg__actualHeight(p)); + if (strcmp(attr[i], "width") == 0) w = nsvg__parseCoordinate(p, attr[i+1], 0.0f, nsvg__actualWidth(p)); + if (strcmp(attr[i], "height") == 0) h = nsvg__parseCoordinate(p, attr[i+1], 0.0f, nsvg__actualHeight(p)); + if (strcmp(attr[i], "rx") == 0) rx = fabsf(nsvg__parseCoordinate(p, attr[i+1], 0.0f, nsvg__actualWidth(p))); + if (strcmp(attr[i], "ry") == 0) ry = fabsf(nsvg__parseCoordinate(p, attr[i+1], 0.0f, nsvg__actualHeight(p))); + } + } + + if (rx < 0.0f && ry > 0.0f) rx = ry; + if (ry < 0.0f && rx > 0.0f) ry = rx; + if (rx < 0.0f) rx = 0.0f; + if (ry < 0.0f) ry = 0.0f; + if (rx > w/2.0f) rx = w/2.0f; + if (ry > h/2.0f) ry = h/2.0f; + + if (w != 0.0f && h != 0.0f) { + nsvg__resetPath(p); + + if (rx < 0.00001f || ry < 0.0001f) { + nsvg__moveTo(p, x, y); + nsvg__lineTo(p, x+w, y); + nsvg__lineTo(p, x+w, y+h); + nsvg__lineTo(p, x, y+h); + } else { + // Rounded rectangle + nsvg__moveTo(p, x+rx, y); + nsvg__lineTo(p, x+w-rx, y); + nsvg__cubicBezTo(p, x+w-rx*(1-NSVG_KAPPA90), y, x+w, y+ry*(1-NSVG_KAPPA90), x+w, y+ry); + nsvg__lineTo(p, x+w, y+h-ry); + nsvg__cubicBezTo(p, x+w, y+h-ry*(1-NSVG_KAPPA90), x+w-rx*(1-NSVG_KAPPA90), y+h, x+w-rx, y+h); + nsvg__lineTo(p, x+rx, y+h); + nsvg__cubicBezTo(p, x+rx*(1-NSVG_KAPPA90), y+h, x, y+h-ry*(1-NSVG_KAPPA90), x, y+h-ry); + nsvg__lineTo(p, x, y+ry); + nsvg__cubicBezTo(p, x, y+ry*(1-NSVG_KAPPA90), x+rx*(1-NSVG_KAPPA90), y, x+rx, y); + } + + nsvg__addPath(p, 1); + + nsvg__addShape(p); + } +} + +static void nsvg__parseCircle(NSVGparser* p, const char** attr) +{ + float cx = 0.0f; + float cy = 0.0f; + float r = 0.0f; + int i; + + for (i = 0; attr[i]; i += 2) { + if (!nsvg__parseAttr(p, attr[i], attr[i + 1])) { + if (strcmp(attr[i], "cx") == 0) cx = nsvg__parseCoordinate(p, attr[i+1], nsvg__actualOrigX(p), nsvg__actualWidth(p)); + if (strcmp(attr[i], "cy") == 0) cy = nsvg__parseCoordinate(p, attr[i+1], nsvg__actualOrigY(p), nsvg__actualHeight(p)); + if (strcmp(attr[i], "r") == 0) r = fabsf(nsvg__parseCoordinate(p, attr[i+1], 0.0f, nsvg__actualLength(p))); + } + } + + if (r > 0.0f) { + nsvg__resetPath(p); + + nsvg__moveTo(p, cx+r, cy); + nsvg__cubicBezTo(p, cx+r, cy+r*NSVG_KAPPA90, cx+r*NSVG_KAPPA90, cy+r, cx, cy+r); + nsvg__cubicBezTo(p, cx-r*NSVG_KAPPA90, cy+r, cx-r, cy+r*NSVG_KAPPA90, cx-r, cy); + nsvg__cubicBezTo(p, cx-r, cy-r*NSVG_KAPPA90, cx-r*NSVG_KAPPA90, cy-r, cx, cy-r); + nsvg__cubicBezTo(p, cx+r*NSVG_KAPPA90, cy-r, cx+r, cy-r*NSVG_KAPPA90, cx+r, cy); + + nsvg__addPath(p, 1); + + nsvg__addShape(p); + } +} + +static void nsvg__parseEllipse(NSVGparser* p, const char** attr) +{ + float cx = 0.0f; + float cy = 0.0f; + float rx = 0.0f; + float ry = 0.0f; + int i; + + for (i = 0; attr[i]; i += 2) { + if (!nsvg__parseAttr(p, attr[i], attr[i + 1])) { + if (strcmp(attr[i], "cx") == 0) cx = nsvg__parseCoordinate(p, attr[i+1], nsvg__actualOrigX(p), nsvg__actualWidth(p)); + if (strcmp(attr[i], "cy") == 0) cy = nsvg__parseCoordinate(p, attr[i+1], nsvg__actualOrigY(p), nsvg__actualHeight(p)); + if (strcmp(attr[i], "rx") == 0) rx = fabsf(nsvg__parseCoordinate(p, attr[i+1], 0.0f, nsvg__actualWidth(p))); + if (strcmp(attr[i], "ry") == 0) ry = fabsf(nsvg__parseCoordinate(p, attr[i+1], 0.0f, nsvg__actualHeight(p))); + } + } + + if (rx > 0.0f && ry > 0.0f) { + + nsvg__resetPath(p); + + nsvg__moveTo(p, cx+rx, cy); + nsvg__cubicBezTo(p, cx+rx, cy+ry*NSVG_KAPPA90, cx+rx*NSVG_KAPPA90, cy+ry, cx, cy+ry); + nsvg__cubicBezTo(p, cx-rx*NSVG_KAPPA90, cy+ry, cx-rx, cy+ry*NSVG_KAPPA90, cx-rx, cy); + nsvg__cubicBezTo(p, cx-rx, cy-ry*NSVG_KAPPA90, cx-rx*NSVG_KAPPA90, cy-ry, cx, cy-ry); + nsvg__cubicBezTo(p, cx+rx*NSVG_KAPPA90, cy-ry, cx+rx, cy-ry*NSVG_KAPPA90, cx+rx, cy); + + nsvg__addPath(p, 1); + + nsvg__addShape(p); + } +} + +static void nsvg__parseLine(NSVGparser* p, const char** attr) +{ + float x1 = 0.0; + float y1 = 0.0; + float x2 = 0.0; + float y2 = 0.0; + int i; + + for (i = 0; attr[i]; i += 2) { + if (!nsvg__parseAttr(p, attr[i], attr[i + 1])) { + if (strcmp(attr[i], "x1") == 0) x1 = nsvg__parseCoordinate(p, attr[i + 1], nsvg__actualOrigX(p), nsvg__actualWidth(p)); + if (strcmp(attr[i], "y1") == 0) y1 = nsvg__parseCoordinate(p, attr[i + 1], nsvg__actualOrigY(p), nsvg__actualHeight(p)); + if (strcmp(attr[i], "x2") == 0) x2 = nsvg__parseCoordinate(p, attr[i + 1], nsvg__actualOrigX(p), nsvg__actualWidth(p)); + if (strcmp(attr[i], "y2") == 0) y2 = nsvg__parseCoordinate(p, attr[i + 1], nsvg__actualOrigY(p), nsvg__actualHeight(p)); + } + } + + nsvg__resetPath(p); + + nsvg__moveTo(p, x1, y1); + nsvg__lineTo(p, x2, y2); + + nsvg__addPath(p, 0); + + nsvg__addShape(p); +} + +static void nsvg__parsePoly(NSVGparser* p, const char** attr, int closeFlag) +{ + int i; + const char* s; + float args[2]; + int nargs, npts = 0; + char item[64]; + + nsvg__resetPath(p); + + for (i = 0; attr[i]; i += 2) { + if (!nsvg__parseAttr(p, attr[i], attr[i + 1])) { + if (strcmp(attr[i], "points") == 0) { + s = attr[i + 1]; + nargs = 0; + while (*s) { + s = nsvg__getNextPathItem(s, item); + args[nargs++] = (float)nsvg__atof(item); + if (nargs >= 2) { + if (npts == 0) + nsvg__moveTo(p, args[0], args[1]); + else + nsvg__lineTo(p, args[0], args[1]); + nargs = 0; + npts++; + } + } + } + } + } + + nsvg__addPath(p, (char)closeFlag); + + nsvg__addShape(p); +} + +static void nsvg__parseSVG(NSVGparser* p, const char** attr) +{ + int i; + for (i = 0; attr[i]; i += 2) { + if (!nsvg__parseAttr(p, attr[i], attr[i + 1])) { + if (strcmp(attr[i], "width") == 0) { + p->image->width = nsvg__parseCoordinate(p, attr[i + 1], 0.0f, 0.0f); + } else if (strcmp(attr[i], "height") == 0) { + p->image->height = nsvg__parseCoordinate(p, attr[i + 1], 0.0f, 0.0f); + } else if (strcmp(attr[i], "viewBox") == 0) { + const char *s = attr[i + 1]; + char buf[64]; + s = nsvg__parseNumber(s, buf, 64); + p->viewMinx = nsvg__atof(buf); + while (*s && (nsvg__isspace(*s) || *s == '%' || *s == ',')) s++; + if (!*s) return; + s = nsvg__parseNumber(s, buf, 64); + p->viewMiny = nsvg__atof(buf); + while (*s && (nsvg__isspace(*s) || *s == '%' || *s == ',')) s++; + if (!*s) return; + s = nsvg__parseNumber(s, buf, 64); + p->viewWidth = nsvg__atof(buf); + while (*s && (nsvg__isspace(*s) || *s == '%' || *s == ',')) s++; + if (!*s) return; + s = nsvg__parseNumber(s, buf, 64); + p->viewHeight = nsvg__atof(buf); + } else if (strcmp(attr[i], "preserveAspectRatio") == 0) { + if (strstr(attr[i + 1], "none") != 0) { + // No uniform scaling + p->alignType = NSVG_ALIGN_NONE; + } else { + // Parse X align + if (strstr(attr[i + 1], "xMin") != 0) + p->alignX = NSVG_ALIGN_MIN; + else if (strstr(attr[i + 1], "xMid") != 0) + p->alignX = NSVG_ALIGN_MID; + else if (strstr(attr[i + 1], "xMax") != 0) + p->alignX = NSVG_ALIGN_MAX; + // Parse X align + if (strstr(attr[i + 1], "yMin") != 0) + p->alignY = NSVG_ALIGN_MIN; + else if (strstr(attr[i + 1], "yMid") != 0) + p->alignY = NSVG_ALIGN_MID; + else if (strstr(attr[i + 1], "yMax") != 0) + p->alignY = NSVG_ALIGN_MAX; + // Parse meet/slice + p->alignType = NSVG_ALIGN_MEET; + if (strstr(attr[i + 1], "slice") != 0) + p->alignType = NSVG_ALIGN_SLICE; + } + } + } + } +} + +static void nsvg__parseGradient(NSVGparser* p, const char** attr, signed char type) +{ + int i; + NSVGgradientData* grad = (NSVGgradientData*)malloc(sizeof(NSVGgradientData)); + if (grad == NULL) return; + memset(grad, 0, sizeof(NSVGgradientData)); + grad->units = NSVG_OBJECT_SPACE; + grad->type = type; + if (grad->type == NSVG_PAINT_LINEAR_GRADIENT) { + grad->linear.x1 = nsvg__coord(0.0f, NSVG_UNITS_PERCENT); + grad->linear.y1 = nsvg__coord(0.0f, NSVG_UNITS_PERCENT); + grad->linear.x2 = nsvg__coord(100.0f, NSVG_UNITS_PERCENT); + grad->linear.y2 = nsvg__coord(0.0f, NSVG_UNITS_PERCENT); + } else if (grad->type == NSVG_PAINT_RADIAL_GRADIENT) { + grad->radial.cx = nsvg__coord(50.0f, NSVG_UNITS_PERCENT); + grad->radial.cy = nsvg__coord(50.0f, NSVG_UNITS_PERCENT); + grad->radial.r = nsvg__coord(50.0f, NSVG_UNITS_PERCENT); + } + + nsvg__xformIdentity(grad->xform); + + for (i = 0; attr[i]; i += 2) { + if (strcmp(attr[i], "id") == 0) { + strncpy(grad->id, attr[i+1], 63); + grad->id[63] = '\0'; + } else if (!nsvg__parseAttr(p, attr[i], attr[i + 1])) { + if (strcmp(attr[i], "gradientUnits") == 0) { + if (strcmp(attr[i+1], "objectBoundingBox") == 0) + grad->units = NSVG_OBJECT_SPACE; + else + grad->units = NSVG_USER_SPACE; + } else if (strcmp(attr[i], "gradientTransform") == 0) { + nsvg__parseTransform(grad->xform, attr[i + 1]); + } else if (strcmp(attr[i], "cx") == 0) { + grad->radial.cx = nsvg__parseCoordinateRaw(attr[i + 1]); + } else if (strcmp(attr[i], "cy") == 0) { + grad->radial.cy = nsvg__parseCoordinateRaw(attr[i + 1]); + } else if (strcmp(attr[i], "r") == 0) { + grad->radial.r = nsvg__parseCoordinateRaw(attr[i + 1]); + } else if (strcmp(attr[i], "fx") == 0) { + grad->radial.fx = nsvg__parseCoordinateRaw(attr[i + 1]); + } else if (strcmp(attr[i], "fy") == 0) { + grad->radial.fy = nsvg__parseCoordinateRaw(attr[i + 1]); + } else if (strcmp(attr[i], "x1") == 0) { + grad->linear.x1 = nsvg__parseCoordinateRaw(attr[i + 1]); + } else if (strcmp(attr[i], "y1") == 0) { + grad->linear.y1 = nsvg__parseCoordinateRaw(attr[i + 1]); + } else if (strcmp(attr[i], "x2") == 0) { + grad->linear.x2 = nsvg__parseCoordinateRaw(attr[i + 1]); + } else if (strcmp(attr[i], "y2") == 0) { + grad->linear.y2 = nsvg__parseCoordinateRaw(attr[i + 1]); + } else if (strcmp(attr[i], "spreadMethod") == 0) { + if (strcmp(attr[i+1], "pad") == 0) + grad->spread = NSVG_SPREAD_PAD; + else if (strcmp(attr[i+1], "reflect") == 0) + grad->spread = NSVG_SPREAD_REFLECT; + else if (strcmp(attr[i+1], "repeat") == 0) + grad->spread = NSVG_SPREAD_REPEAT; + } else if (strcmp(attr[i], "xlink:href") == 0) { + const char *href = attr[i+1]; + strncpy(grad->ref, href+1, 62); + grad->ref[62] = '\0'; + } + } + } + + grad->next = p->gradients; + p->gradients = grad; +} + +static void nsvg__parseGradientStop(NSVGparser* p, const char** attr) +{ + NSVGattrib* curAttr = nsvg__getAttr(p); + NSVGgradientData* grad; + NSVGgradientStop* stop; + int i, idx; + + curAttr->stopOffset = 0; + curAttr->stopColor = 0; + curAttr->stopOpacity = 1.0f; + + for (i = 0; attr[i]; i += 2) { + nsvg__parseAttr(p, attr[i], attr[i + 1]); + } + + // Add stop to the last gradient. + grad = p->gradients; + if (grad == NULL) return; + + grad->nstops++; + grad->stops = (NSVGgradientStop*)realloc(grad->stops, sizeof(NSVGgradientStop)*grad->nstops); + if (grad->stops == NULL) return; + + // Insert + idx = grad->nstops-1; + for (i = 0; i < grad->nstops-1; i++) { + if (curAttr->stopOffset < grad->stops[i].offset) { + idx = i; + break; + } + } + if (idx != grad->nstops-1) { + for (i = grad->nstops-1; i > idx; i--) + grad->stops[i] = grad->stops[i-1]; + } + + stop = &grad->stops[idx]; + stop->color = curAttr->stopColor; + stop->color |= (unsigned int)(curAttr->stopOpacity*255) << 24; + stop->offset = curAttr->stopOffset; +} + +static void nsvg__startElement(void* ud, const char* el, const char** attr) +{ + NSVGparser* p = (NSVGparser*)ud; + + if (p->defsFlag) { + // Skip everything but gradients and styles in defs + if (strcmp(el, "linearGradient") == 0) { + nsvg__parseGradient(p, attr, NSVG_PAINT_LINEAR_GRADIENT); + } else if (strcmp(el, "radialGradient") == 0) { + nsvg__parseGradient(p, attr, NSVG_PAINT_RADIAL_GRADIENT); + } else if (strcmp(el, "stop") == 0) { + nsvg__parseGradientStop(p, attr); + } else if (strcmp(el, "style") == 0) { + p->styleFlag = 1; + } + return; + } + + if (strcmp(el, "g") == 0) { + nsvg__pushAttr(p); + nsvg__parseAttribs(p, attr); + } else if (strcmp(el, "path") == 0) { + if (p->pathFlag) // Do not allow nested paths. + return; + nsvg__pushAttr(p); + nsvg__parsePath(p, attr); + nsvg__popAttr(p); + } else if (strcmp(el, "rect") == 0) { + nsvg__pushAttr(p); + nsvg__parseRect(p, attr); + nsvg__popAttr(p); + } else if (strcmp(el, "circle") == 0) { + nsvg__pushAttr(p); + nsvg__parseCircle(p, attr); + nsvg__popAttr(p); + } else if (strcmp(el, "ellipse") == 0) { + nsvg__pushAttr(p); + nsvg__parseEllipse(p, attr); + nsvg__popAttr(p); + } else if (strcmp(el, "line") == 0) { + nsvg__pushAttr(p); + nsvg__parseLine(p, attr); + nsvg__popAttr(p); + } else if (strcmp(el, "polyline") == 0) { + nsvg__pushAttr(p); + nsvg__parsePoly(p, attr, 0); + nsvg__popAttr(p); + } else if (strcmp(el, "polygon") == 0) { + nsvg__pushAttr(p); + nsvg__parsePoly(p, attr, 1); + nsvg__popAttr(p); + } else if (strcmp(el, "linearGradient") == 0) { + nsvg__parseGradient(p, attr, NSVG_PAINT_LINEAR_GRADIENT); + } else if (strcmp(el, "radialGradient") == 0) { + nsvg__parseGradient(p, attr, NSVG_PAINT_RADIAL_GRADIENT); + } else if (strcmp(el, "stop") == 0) { + nsvg__parseGradientStop(p, attr); + } else if (strcmp(el, "defs") == 0) { + p->defsFlag = 1; + } else if (strcmp(el, "svg") == 0) { + nsvg__parseSVG(p, attr); + } else if (strcmp(el, "style") == 0) { + p->styleFlag = 1; + } +} + +static void nsvg__endElement(void* ud, const char* el) +{ + NSVGparser* p = (NSVGparser*)ud; + + if (strcmp(el, "g") == 0) { + nsvg__popAttr(p); + } else if (strcmp(el, "path") == 0) { + p->pathFlag = 0; + } else if (strcmp(el, "defs") == 0) { + p->defsFlag = 0; + } else if (strcmp(el, "style") == 0) { + p->styleFlag = 0; + } +} + +static char *nsvg__strndup(const char *s, size_t n) +{ + char *result = (char *)malloc(n + 1); + if (result == NULL) + return NULL; + + memcpy(result, s, n); + result[n] = '\0'; + return result; +} + +static void nsvg__content(void* ud, const char* s) +{ + NSVGparser* p = (NSVGparser*)ud; + if (!p->styleFlag) + return; + + // Parse all the styles inside the style block. Each style's content will be later processed using nsvg__parseStyle(). + // Note: We only support selector lists of simple class selectors (e.g. ".foo, .bar { ... }"). + while (*s) { + NSVGstyleDeclaration* styles[NSVG_MAX_CLASSES]; + int nstyles = 0; + const char* propsStart; + const char* propsEnd; + int i; + + // 1) Parse the selector list up to '{'. For each simple class selector ('.name'), + // allocate a new NSVGstyleDeclaration into the local staging array. Styles are + // only committed to p->styles in step 3 below, once their propertiesText has + // also been allocated successfully. + while (*s && *s != '{') { + const char* selStart; + const char* selEnd; + NSVGstyleDeclaration* style; + + while (*s && (nsvg__isspace(*s) || *s == ',')) + s++; + if (!*s || *s == '{') + break; + + selStart = s; + while (*s && !nsvg__isspace(*s) && *s != ',' && *s != '{') + s++; + selEnd = s; + + if (*selStart != '.' || nstyles >= NSVG_MAX_CLASSES) + continue; // unsupported selector, or staging array full + selStart++; // strip leading '.' + + style = (NSVGstyleDeclaration*)malloc(sizeof(NSVGstyleDeclaration)); + if (style == NULL) + continue; + style->className = nsvg__strndup(selStart, (size_t)(selEnd - selStart)); + if (style->className == NULL) { + free(style); + continue; + } + style->propertiesText = NULL; + style->next = NULL; + styles[nstyles++] = style; + } + if (!*s) { + // No '{' found - discard pending styles and stop. + for (i = 0; i < nstyles; i++) { + free(styles[i]->className); + free(styles[i]); + } + break; + } + s++; // advance past '{' + + // 2) Find the end of the properties block (up to '}'). + propsStart = s; + while (*s && *s != '}') + s++; + propsEnd = s; + + // 3) Allocate propertiesText for each pending style. Commit successful ones to + // p->styles (head-insertion); free any whose allocation fails. + for (i = 0; i < nstyles; i++) { + styles[i]->propertiesText = nsvg__strndup(propsStart, (size_t)(propsEnd - propsStart)); + if (styles[i]->propertiesText == NULL) { + free(styles[i]->className); + free(styles[i]); + } else { + styles[i]->next = p->styles; + p->styles = styles[i]; + } + } + + if (*s) + s++; // advance past '}' + } +} + +static void nsvg__imageBounds(NSVGparser* p, float* bounds) +{ + NSVGshape* shape; + shape = p->image->shapes; + if (shape == NULL) { + bounds[0] = bounds[1] = bounds[2] = bounds[3] = 0.0; + return; + } + bounds[0] = shape->bounds[0]; + bounds[1] = shape->bounds[1]; + bounds[2] = shape->bounds[2]; + bounds[3] = shape->bounds[3]; + for (shape = shape->next; shape != NULL; shape = shape->next) { + bounds[0] = nsvg__minf(bounds[0], shape->bounds[0]); + bounds[1] = nsvg__minf(bounds[1], shape->bounds[1]); + bounds[2] = nsvg__maxf(bounds[2], shape->bounds[2]); + bounds[3] = nsvg__maxf(bounds[3], shape->bounds[3]); + } +} + +static float nsvg__viewAlign(float content, float container, int type) +{ + if (type == NSVG_ALIGN_MIN) + return 0; + else if (type == NSVG_ALIGN_MAX) + return container - content; + // mid + return (container - content) * 0.5f; +} + +static void nsvg__scaleGradient(NSVGgradient* grad, float tx, float ty, float sx, float sy) +{ + float t[6]; + nsvg__xformSetTranslation(t, tx, ty); + nsvg__xformMultiply (grad->xform, t); + + nsvg__xformSetScale(t, sx, sy); + nsvg__xformMultiply (grad->xform, t); +} + +static void nsvg__scaleToViewbox(NSVGparser* p, const char* units) +{ + NSVGshape* shape; + NSVGpath* path; + float tx, ty, sx, sy, us, bounds[4], t[6], avgs; + int i; + float* pt; + + // Guess image size if not set completely. + nsvg__imageBounds(p, bounds); + + if (p->viewWidth == 0) { + if (p->image->width > 0) { + p->viewWidth = p->image->width; + } else { + p->viewMinx = bounds[0]; + p->viewWidth = bounds[2] - bounds[0]; + } + } + if (p->viewHeight == 0) { + if (p->image->height > 0) { + p->viewHeight = p->image->height; + } else { + p->viewMiny = bounds[1]; + p->viewHeight = bounds[3] - bounds[1]; + } + } + if (p->image->width == 0) + p->image->width = p->viewWidth; + if (p->image->height == 0) + p->image->height = p->viewHeight; + + tx = -p->viewMinx; + ty = -p->viewMiny; + sx = p->viewWidth > 0 ? p->image->width / p->viewWidth : 0; + sy = p->viewHeight > 0 ? p->image->height / p->viewHeight : 0; + // Unit scaling + us = 1.0f / nsvg__convertToPixels(p, nsvg__coord(1.0f, nsvg__parseUnits(units)), 0.0f, 1.0f); + + // Fix aspect ratio + if (p->alignType == NSVG_ALIGN_MEET) { + // fit whole image into viewbox + sx = sy = nsvg__minf(sx, sy); + tx += nsvg__viewAlign(p->viewWidth*sx, p->image->width, p->alignX) / sx; + ty += nsvg__viewAlign(p->viewHeight*sy, p->image->height, p->alignY) / sy; + } else if (p->alignType == NSVG_ALIGN_SLICE) { + // fill whole viewbox with image + sx = sy = nsvg__maxf(sx, sy); + tx += nsvg__viewAlign(p->viewWidth*sx, p->image->width, p->alignX) / sx; + ty += nsvg__viewAlign(p->viewHeight*sy, p->image->height, p->alignY) / sy; + } + + // Transform + sx *= us; + sy *= us; + avgs = (sx+sy) / 2.0f; + for (shape = p->image->shapes; shape != NULL; shape = shape->next) { + shape->bounds[0] = (shape->bounds[0] + tx) * sx; + shape->bounds[1] = (shape->bounds[1] + ty) * sy; + shape->bounds[2] = (shape->bounds[2] + tx) * sx; + shape->bounds[3] = (shape->bounds[3] + ty) * sy; + for (path = shape->paths; path != NULL; path = path->next) { + path->bounds[0] = (path->bounds[0] + tx) * sx; + path->bounds[1] = (path->bounds[1] + ty) * sy; + path->bounds[2] = (path->bounds[2] + tx) * sx; + path->bounds[3] = (path->bounds[3] + ty) * sy; + for (i =0; i < path->npts; i++) { + pt = &path->pts[i*2]; + pt[0] = (pt[0] + tx) * sx; + pt[1] = (pt[1] + ty) * sy; + } + } + + if (shape->fill.type == NSVG_PAINT_LINEAR_GRADIENT || shape->fill.type == NSVG_PAINT_RADIAL_GRADIENT) { + nsvg__scaleGradient(shape->fill.gradient, tx,ty, sx,sy); + memcpy(t, shape->fill.gradient->xform, sizeof(float)*6); + nsvg__xformInverse(shape->fill.gradient->xform, t); + } + if (shape->stroke.type == NSVG_PAINT_LINEAR_GRADIENT || shape->stroke.type == NSVG_PAINT_RADIAL_GRADIENT) { + nsvg__scaleGradient(shape->stroke.gradient, tx,ty, sx,sy); + memcpy(t, shape->stroke.gradient->xform, sizeof(float)*6); + nsvg__xformInverse(shape->stroke.gradient->xform, t); + } + + shape->strokeWidth *= avgs; + shape->strokeDashOffset *= avgs; + for (i = 0; i < shape->strokeDashCount; i++) + shape->strokeDashArray[i] *= avgs; + } +} + +static void nsvg__createGradients(NSVGparser* p) +{ + NSVGshape* shape; + + for (shape = p->image->shapes; shape != NULL; shape = shape->next) { + if (shape->fill.type == NSVG_PAINT_UNDEF) { + if (shape->fillGradient[0] != '\0') { + float inv[6], localBounds[4]; + nsvg__xformInverse(inv, shape->xform); + nsvg__getLocalBounds(localBounds, shape, inv); + shape->fill.gradient = nsvg__createGradient(p, shape->fillGradient, localBounds, shape->xform, &shape->fill.type); + } + if (shape->fill.type == NSVG_PAINT_UNDEF) { + shape->fill.type = NSVG_PAINT_NONE; + } + } + if (shape->stroke.type == NSVG_PAINT_UNDEF) { + if (shape->strokeGradient[0] != '\0') { + float inv[6], localBounds[4]; + nsvg__xformInverse(inv, shape->xform); + nsvg__getLocalBounds(localBounds, shape, inv); + shape->stroke.gradient = nsvg__createGradient(p, shape->strokeGradient, localBounds, shape->xform, &shape->stroke.type); + } + if (shape->stroke.type == NSVG_PAINT_UNDEF) { + shape->stroke.type = NSVG_PAINT_NONE; + } + } + } +} + +NSVGimage* nsvgParse(char* input, const char* units, float dpi) +{ + NSVGparser* p; + NSVGimage* ret = 0; + + p = nsvg__createParser(); + if (p == NULL) { + return NULL; + } + p->dpi = dpi; + + nsvg__parseXML(input, nsvg__startElement, nsvg__endElement, nsvg__content, p); + + // Create gradients after all definitions have been parsed + nsvg__createGradients(p); + + // Scale to viewBox + nsvg__scaleToViewbox(p, units); + + ret = p->image; + p->image = NULL; + + nsvg__deleteParser(p); + + return ret; +} + +NSVGimage* nsvgParseFromFile(const char* filename, const char* units, float dpi) +{ + FILE* fp = NULL; + size_t size; + char* data = NULL; + NSVGimage* image = NULL; + + fp = fopen(filename, "rb"); + if (!fp) goto error; + if (fseek(fp, 0, SEEK_END)) goto error; + size = ftell(fp); + if (size == -1l) goto error; + if (fseek(fp, 0, SEEK_SET)) goto error; + data = (char*)malloc(size+1); + if (data == NULL) goto error; + if (fread(data, 1, size, fp) != size) goto error; + data[size] = '\0'; // Must be null terminated. + fclose(fp); + image = nsvgParse(data, units, dpi); + free(data); + + return image; + +error: + if (fp) fclose(fp); + if (data) free(data); + if (image) nsvgDelete(image); + return NULL; +} + +NSVGpath* nsvgDuplicatePath(NSVGpath* p) +{ + NSVGpath* res = NULL; + + if (p == NULL) + return NULL; + + res = (NSVGpath*)malloc(sizeof(NSVGpath)); + if (res == NULL) goto error; + memset(res, 0, sizeof(NSVGpath)); + + res->pts = (float*)malloc(p->npts*2*sizeof(float)); + if (res->pts == NULL) goto error; + memcpy(res->pts, p->pts, p->npts * sizeof(float) * 2); + res->npts = p->npts; + + memcpy(res->bounds, p->bounds, sizeof(p->bounds)); + + res->closed = p->closed; + + return res; + +error: + if (res != NULL) { + free(res->pts); + free(res); + } + return NULL; +} + +void nsvgDelete(NSVGimage* image) +{ + NSVGshape *snext, *shape; + if (image == NULL) return; + shape = image->shapes; + while (shape != NULL) { + snext = shape->next; + nsvg__deletePaths(shape->paths); + nsvg__deletePaint(&shape->fill); + nsvg__deletePaint(&shape->stroke); + free(shape); + shape = snext; + } + free(image); +} + +#endif // NANOSVG_IMPLEMENTATION + +#endif // NANOSVG_H diff --git a/vendor/nanosvg/nanosvgrast.h b/vendor/nanosvg/nanosvgrast.h new file mode 100644 index 00000000..ba512c74 --- /dev/null +++ b/vendor/nanosvg/nanosvgrast.h @@ -0,0 +1,1472 @@ +/* + * Copyright (c) 2013-14 Mikko Mononen memon@inside.org + * + * This software is provided 'as-is', without any express or implied + * warranty. In no event will the authors be held liable for any damages + * arising from the use of this software. + * + * Permission is granted to anyone to use this software for any purpose, + * including commercial applications, and to alter it and redistribute it + * freely, subject to the following restrictions: + * + * 1. The origin of this software must not be misrepresented; you must not + * claim that you wrote the original software. If you use this software + * in a product, an acknowledgment in the product documentation would be + * appreciated but is not required. + * 2. Altered source versions must be plainly marked as such, and must not be + * misrepresented as being the original software. + * 3. This notice may not be removed or altered from any source distribution. + * + * The polygon rasterization is heavily based on stb_truetype rasterizer + * by Sean Barrett - http://nothings.org/ + * + */ + +#ifndef NANOSVGRAST_H +#define NANOSVGRAST_H + +#include "nanosvg.h" + +#ifndef NANOSVGRAST_CPLUSPLUS +#ifdef __cplusplus +extern "C" { +#endif +#endif + +typedef struct NSVGrasterizer NSVGrasterizer; + +/* Example Usage: + // Load SVG + NSVGimage* image; + image = nsvgParseFromFile("test.svg", "px", 96); + + // Create rasterizer (can be used to render multiple images). + struct NSVGrasterizer* rast = nsvgCreateRasterizer(); + // Allocate memory for image + unsigned char* img = malloc(w*h*4); + // Rasterize + nsvgRasterize(rast, image, 0,0,1, img, w, h, w*4); +*/ + +// Allocated rasterizer context. +NSVGrasterizer* nsvgCreateRasterizer(void); + +// Rasterizes SVG image, returns RGBA image (non-premultiplied alpha) +// r - pointer to rasterizer context +// image - pointer to image to rasterize +// tx,ty - image offset (applied after scaling) +// scale - image scale +// dst - pointer to destination image data, 4 bytes per pixel (RGBA) +// w - width of the image to render +// h - height of the image to render +// stride - number of bytes per scaleline in the destination buffer +void nsvgRasterize(NSVGrasterizer* r, + NSVGimage* image, float tx, float ty, float scale, + unsigned char* dst, int w, int h, int stride); + +// Deletes rasterizer context. +void nsvgDeleteRasterizer(NSVGrasterizer*); + + +#ifndef NANOSVGRAST_CPLUSPLUS +#ifdef __cplusplus +} +#endif +#endif + +#ifdef NANOSVGRAST_IMPLEMENTATION + +#include +#include +#include + +#define NSVG__SUBSAMPLES 5 +#define NSVG__FIXSHIFT 10 +#define NSVG__FIX (1 << NSVG__FIXSHIFT) +#define NSVG__FIXMASK (NSVG__FIX-1) +#define NSVG__MEMPAGE_SIZE 1024 + +typedef struct NSVGedge { + float x0,y0, x1,y1; + int dir; + struct NSVGedge* next; +} NSVGedge; + +typedef struct NSVGpoint { + float x, y; + float dx, dy; + float len; + float dmx, dmy; + unsigned char flags; +} NSVGpoint; + +typedef struct NSVGactiveEdge { + int x,dx; + float ey; + int dir; + struct NSVGactiveEdge *next; +} NSVGactiveEdge; + +typedef struct NSVGmemPage { + unsigned char mem[NSVG__MEMPAGE_SIZE]; + int size; + struct NSVGmemPage* next; +} NSVGmemPage; + +typedef struct NSVGcachedPaint { + signed char type; + char spread; + float xform[6]; + unsigned int colors[256]; +} NSVGcachedPaint; + +struct NSVGrasterizer +{ + float px, py; + + float tessTol; + float distTol; + + NSVGedge* edges; + int nedges; + int cedges; + + NSVGpoint* points; + int npoints; + int cpoints; + + NSVGpoint* points2; + int npoints2; + int cpoints2; + + NSVGactiveEdge* freelist; + NSVGmemPage* pages; + NSVGmemPage* curpage; + + unsigned char* scanline; + int cscanline; + + unsigned char* bitmap; + int width, height, stride; +}; + +NSVGrasterizer* nsvgCreateRasterizer(void) +{ + NSVGrasterizer* r = (NSVGrasterizer*)malloc(sizeof(NSVGrasterizer)); + if (r == NULL) goto error; + memset(r, 0, sizeof(NSVGrasterizer)); + + r->tessTol = 0.25f; + r->distTol = 0.01f; + + return r; + +error: + nsvgDeleteRasterizer(r); + return NULL; +} + +void nsvgDeleteRasterizer(NSVGrasterizer* r) +{ + NSVGmemPage* p; + + if (r == NULL) return; + + p = r->pages; + while (p != NULL) { + NSVGmemPage* next = p->next; + free(p); + p = next; + } + + if (r->edges) free(r->edges); + if (r->points) free(r->points); + if (r->points2) free(r->points2); + if (r->scanline) free(r->scanline); + + free(r); +} + +static NSVGmemPage* nsvg__nextPage(NSVGrasterizer* r, NSVGmemPage* cur) +{ + NSVGmemPage *newp; + + // If using existing chain, return the next page in chain + if (cur != NULL && cur->next != NULL) { + return cur->next; + } + + // Alloc new page + newp = (NSVGmemPage*)malloc(sizeof(NSVGmemPage)); + if (newp == NULL) return NULL; + memset(newp, 0, sizeof(NSVGmemPage)); + + // Add to linked list + if (cur != NULL) + cur->next = newp; + else + r->pages = newp; + + return newp; +} + +static void nsvg__resetPool(NSVGrasterizer* r) +{ + NSVGmemPage* p = r->pages; + while (p != NULL) { + p->size = 0; + p = p->next; + } + r->curpage = r->pages; +} + +static unsigned char* nsvg__alloc(NSVGrasterizer* r, int size) +{ + unsigned char* buf; + if (size > NSVG__MEMPAGE_SIZE) return NULL; + if (r->curpage == NULL || r->curpage->size+size > NSVG__MEMPAGE_SIZE) { + r->curpage = nsvg__nextPage(r, r->curpage); + } + buf = &r->curpage->mem[r->curpage->size]; + r->curpage->size += size; + return buf; +} + +static int nsvg__ptEquals(float x1, float y1, float x2, float y2, float tol) +{ + float dx = x2 - x1; + float dy = y2 - y1; + return dx*dx + dy*dy < tol*tol; +} + +static void nsvg__addPathPoint(NSVGrasterizer* r, float x, float y, int flags) +{ + NSVGpoint* pt; + + if (r->npoints > 0) { + pt = &r->points[r->npoints-1]; + if (nsvg__ptEquals(pt->x,pt->y, x,y, r->distTol)) { + pt->flags = (unsigned char)(pt->flags | flags); + return; + } + } + + if (r->npoints+1 > r->cpoints) { + r->cpoints = r->cpoints > 0 ? r->cpoints * 2 : 64; + r->points = (NSVGpoint*)realloc(r->points, sizeof(NSVGpoint) * r->cpoints); + if (r->points == NULL) return; + } + + pt = &r->points[r->npoints]; + pt->x = x; + pt->y = y; + pt->flags = (unsigned char)flags; + r->npoints++; +} + +static void nsvg__appendPathPoint(NSVGrasterizer* r, NSVGpoint pt) +{ + if (r->npoints+1 > r->cpoints) { + r->cpoints = r->cpoints > 0 ? r->cpoints * 2 : 64; + r->points = (NSVGpoint*)realloc(r->points, sizeof(NSVGpoint) * r->cpoints); + if (r->points == NULL) return; + } + r->points[r->npoints] = pt; + r->npoints++; +} + +static void nsvg__duplicatePoints(NSVGrasterizer* r) +{ + if (r->npoints > r->cpoints2) { + r->cpoints2 = r->npoints; + r->points2 = (NSVGpoint*)realloc(r->points2, sizeof(NSVGpoint) * r->cpoints2); + if (r->points2 == NULL) return; + } + + memcpy(r->points2, r->points, sizeof(NSVGpoint) * r->npoints); + r->npoints2 = r->npoints; +} + +static void nsvg__addEdge(NSVGrasterizer* r, float x0, float y0, float x1, float y1) +{ + NSVGedge* e; + + // Skip horizontal edges + if (y0 == y1) + return; + + if (r->nedges+1 > r->cedges) { + r->cedges = r->cedges > 0 ? r->cedges * 2 : 64; + r->edges = (NSVGedge*)realloc(r->edges, sizeof(NSVGedge) * r->cedges); + if (r->edges == NULL) return; + } + + e = &r->edges[r->nedges]; + r->nedges++; + + if (y0 < y1) { + e->x0 = x0; + e->y0 = y0; + e->x1 = x1; + e->y1 = y1; + e->dir = 1; + } else { + e->x0 = x1; + e->y0 = y1; + e->x1 = x0; + e->y1 = y0; + e->dir = -1; + } +} + +static float nsvg__normalize(float *x, float* y) +{ + float d = sqrtf((*x)*(*x) + (*y)*(*y)); + if (d > 1e-6f) { + float id = 1.0f / d; + *x *= id; + *y *= id; + } + return d; +} + +static float nsvg__absf(float x) { return x < 0 ? -x : x; } +static float nsvg__roundf(float x) { return (x >= 0) ? floorf(x + 0.5) : ceilf(x - 0.5); } + +static void nsvg__flattenCubicBez(NSVGrasterizer* r, + float x1, float y1, float x2, float y2, + float x3, float y3, float x4, float y4, + int level, int type) +{ + float x12,y12,x23,y23,x34,y34,x123,y123,x234,y234,x1234,y1234; + float dx,dy,d2,d3; + + if (level > 10) return; + + x12 = (x1+x2)*0.5f; + y12 = (y1+y2)*0.5f; + x23 = (x2+x3)*0.5f; + y23 = (y2+y3)*0.5f; + x34 = (x3+x4)*0.5f; + y34 = (y3+y4)*0.5f; + x123 = (x12+x23)*0.5f; + y123 = (y12+y23)*0.5f; + + dx = x4 - x1; + dy = y4 - y1; + d2 = nsvg__absf((x2 - x4) * dy - (y2 - y4) * dx); + d3 = nsvg__absf((x3 - x4) * dy - (y3 - y4) * dx); + + if ((d2 + d3)*(d2 + d3) < r->tessTol * (dx*dx + dy*dy)) { + nsvg__addPathPoint(r, x4, y4, type); + return; + } + + x234 = (x23+x34)*0.5f; + y234 = (y23+y34)*0.5f; + x1234 = (x123+x234)*0.5f; + y1234 = (y123+y234)*0.5f; + + nsvg__flattenCubicBez(r, x1,y1, x12,y12, x123,y123, x1234,y1234, level+1, 0); + nsvg__flattenCubicBez(r, x1234,y1234, x234,y234, x34,y34, x4,y4, level+1, type); +} + +static void nsvg__flattenShape(NSVGrasterizer* r, NSVGshape* shape, float scale) +{ + int i, j; + NSVGpath* path; + + for (path = shape->paths; path != NULL; path = path->next) { + r->npoints = 0; + // Flatten path + nsvg__addPathPoint(r, path->pts[0]*scale, path->pts[1]*scale, 0); + for (i = 0; i < path->npts-1; i += 3) { + float* p = &path->pts[i*2]; + nsvg__flattenCubicBez(r, p[0]*scale,p[1]*scale, p[2]*scale,p[3]*scale, p[4]*scale,p[5]*scale, p[6]*scale,p[7]*scale, 0, 0); + } + // Close path + nsvg__addPathPoint(r, path->pts[0]*scale, path->pts[1]*scale, 0); + // Build edges + for (i = 0, j = r->npoints-1; i < r->npoints; j = i++) + nsvg__addEdge(r, r->points[j].x, r->points[j].y, r->points[i].x, r->points[i].y); + } +} + +enum NSVGpointFlags +{ + NSVG_PT_CORNER = 0x01, + NSVG_PT_BEVEL = 0x02, + NSVG_PT_LEFT = 0x04 +}; + +static void nsvg__initClosed(NSVGpoint* left, NSVGpoint* right, NSVGpoint* p0, NSVGpoint* p1, float lineWidth) +{ + float w = lineWidth * 0.5f; + float dx = p1->x - p0->x; + float dy = p1->y - p0->y; + float len = nsvg__normalize(&dx, &dy); + float px = p0->x + dx*len*0.5f, py = p0->y + dy*len*0.5f; + float dlx = dy, dly = -dx; + float lx = px - dlx*w, ly = py - dly*w; + float rx = px + dlx*w, ry = py + dly*w; + left->x = lx; left->y = ly; + right->x = rx; right->y = ry; +} + +static void nsvg__buttCap(NSVGrasterizer* r, NSVGpoint* left, NSVGpoint* right, NSVGpoint* p, float dx, float dy, float lineWidth, int connect) +{ + float w = lineWidth * 0.5f; + float px = p->x, py = p->y; + float dlx = dy, dly = -dx; + float lx = px - dlx*w, ly = py - dly*w; + float rx = px + dlx*w, ry = py + dly*w; + + nsvg__addEdge(r, lx, ly, rx, ry); + + if (connect) { + nsvg__addEdge(r, left->x, left->y, lx, ly); + nsvg__addEdge(r, rx, ry, right->x, right->y); + } + left->x = lx; left->y = ly; + right->x = rx; right->y = ry; +} + +static void nsvg__squareCap(NSVGrasterizer* r, NSVGpoint* left, NSVGpoint* right, NSVGpoint* p, float dx, float dy, float lineWidth, int connect) +{ + float w = lineWidth * 0.5f; + float px = p->x - dx*w, py = p->y - dy*w; + float dlx = dy, dly = -dx; + float lx = px - dlx*w, ly = py - dly*w; + float rx = px + dlx*w, ry = py + dly*w; + + nsvg__addEdge(r, lx, ly, rx, ry); + + if (connect) { + nsvg__addEdge(r, left->x, left->y, lx, ly); + nsvg__addEdge(r, rx, ry, right->x, right->y); + } + left->x = lx; left->y = ly; + right->x = rx; right->y = ry; +} + +#ifndef NSVG_PI +#define NSVG_PI (3.14159265358979323846264338327f) +#endif + +static void nsvg__roundCap(NSVGrasterizer* r, NSVGpoint* left, NSVGpoint* right, NSVGpoint* p, float dx, float dy, float lineWidth, int ncap, int connect) +{ + int i; + float w = lineWidth * 0.5f; + float px = p->x, py = p->y; + float dlx = dy, dly = -dx; + float lx = 0, ly = 0, rx = 0, ry = 0, prevx = 0, prevy = 0; + + for (i = 0; i < ncap; i++) { + float a = (float)i/(float)(ncap-1)*NSVG_PI; + float ax = cosf(a) * w, ay = sinf(a) * w; + float x = px - dlx*ax - dx*ay; + float y = py - dly*ax - dy*ay; + + if (i > 0) + nsvg__addEdge(r, prevx, prevy, x, y); + + prevx = x; + prevy = y; + + if (i == 0) { + lx = x; ly = y; + } else if (i == ncap-1) { + rx = x; ry = y; + } + } + + if (connect) { + nsvg__addEdge(r, left->x, left->y, lx, ly); + nsvg__addEdge(r, rx, ry, right->x, right->y); + } + + left->x = lx; left->y = ly; + right->x = rx; right->y = ry; +} + +static void nsvg__bevelJoin(NSVGrasterizer* r, NSVGpoint* left, NSVGpoint* right, NSVGpoint* p0, NSVGpoint* p1, float lineWidth) +{ + float w = lineWidth * 0.5f; + float dlx0 = p0->dy, dly0 = -p0->dx; + float dlx1 = p1->dy, dly1 = -p1->dx; + float lx0 = p1->x - (dlx0 * w), ly0 = p1->y - (dly0 * w); + float rx0 = p1->x + (dlx0 * w), ry0 = p1->y + (dly0 * w); + float lx1 = p1->x - (dlx1 * w), ly1 = p1->y - (dly1 * w); + float rx1 = p1->x + (dlx1 * w), ry1 = p1->y + (dly1 * w); + + nsvg__addEdge(r, lx0, ly0, left->x, left->y); + nsvg__addEdge(r, lx1, ly1, lx0, ly0); + + nsvg__addEdge(r, right->x, right->y, rx0, ry0); + nsvg__addEdge(r, rx0, ry0, rx1, ry1); + + left->x = lx1; left->y = ly1; + right->x = rx1; right->y = ry1; +} + +static void nsvg__miterJoin(NSVGrasterizer* r, NSVGpoint* left, NSVGpoint* right, NSVGpoint* p0, NSVGpoint* p1, float lineWidth) +{ + float w = lineWidth * 0.5f; + float dlx0 = p0->dy, dly0 = -p0->dx; + float dlx1 = p1->dy, dly1 = -p1->dx; + float lx0, rx0, lx1, rx1; + float ly0, ry0, ly1, ry1; + + if (p1->flags & NSVG_PT_LEFT) { + lx0 = lx1 = p1->x - p1->dmx * w; + ly0 = ly1 = p1->y - p1->dmy * w; + nsvg__addEdge(r, lx1, ly1, left->x, left->y); + + rx0 = p1->x + (dlx0 * w); + ry0 = p1->y + (dly0 * w); + rx1 = p1->x + (dlx1 * w); + ry1 = p1->y + (dly1 * w); + nsvg__addEdge(r, right->x, right->y, rx0, ry0); + nsvg__addEdge(r, rx0, ry0, rx1, ry1); + } else { + lx0 = p1->x - (dlx0 * w); + ly0 = p1->y - (dly0 * w); + lx1 = p1->x - (dlx1 * w); + ly1 = p1->y - (dly1 * w); + nsvg__addEdge(r, lx0, ly0, left->x, left->y); + nsvg__addEdge(r, lx1, ly1, lx0, ly0); + + rx0 = rx1 = p1->x + p1->dmx * w; + ry0 = ry1 = p1->y + p1->dmy * w; + nsvg__addEdge(r, right->x, right->y, rx1, ry1); + } + + left->x = lx1; left->y = ly1; + right->x = rx1; right->y = ry1; +} + +static void nsvg__roundJoin(NSVGrasterizer* r, NSVGpoint* left, NSVGpoint* right, NSVGpoint* p0, NSVGpoint* p1, float lineWidth, int ncap) +{ + int i, n; + float w = lineWidth * 0.5f; + float dlx0 = p0->dy, dly0 = -p0->dx; + float dlx1 = p1->dy, dly1 = -p1->dx; + float a0 = atan2f(dly0, dlx0); + float a1 = atan2f(dly1, dlx1); + float da = a1 - a0; + float lx, ly, rx, ry; + + if (da < NSVG_PI) da += NSVG_PI*2; + if (da > NSVG_PI) da -= NSVG_PI*2; + + n = (int)ceilf((nsvg__absf(da) / NSVG_PI) * (float)ncap); + if (n < 2) n = 2; + if (n > ncap) n = ncap; + + lx = left->x; + ly = left->y; + rx = right->x; + ry = right->y; + + for (i = 0; i < n; i++) { + float u = (float)i/(float)(n-1); + float a = a0 + u*da; + float ax = cosf(a) * w, ay = sinf(a) * w; + float lx1 = p1->x - ax, ly1 = p1->y - ay; + float rx1 = p1->x + ax, ry1 = p1->y + ay; + + nsvg__addEdge(r, lx1, ly1, lx, ly); + nsvg__addEdge(r, rx, ry, rx1, ry1); + + lx = lx1; ly = ly1; + rx = rx1; ry = ry1; + } + + left->x = lx; left->y = ly; + right->x = rx; right->y = ry; +} + +static void nsvg__straightJoin(NSVGrasterizer* r, NSVGpoint* left, NSVGpoint* right, NSVGpoint* p1, float lineWidth) +{ + float w = lineWidth * 0.5f; + float lx = p1->x - (p1->dmx * w), ly = p1->y - (p1->dmy * w); + float rx = p1->x + (p1->dmx * w), ry = p1->y + (p1->dmy * w); + + nsvg__addEdge(r, lx, ly, left->x, left->y); + nsvg__addEdge(r, right->x, right->y, rx, ry); + + left->x = lx; left->y = ly; + right->x = rx; right->y = ry; +} + +static int nsvg__curveDivs(float r, float arc, float tol) +{ + float da = acosf(r / (r + tol)) * 2.0f; + int divs = (int)ceilf(arc / da); + if (divs < 2) divs = 2; + return divs; +} + +static void nsvg__expandStroke(NSVGrasterizer* r, NSVGpoint* points, int npoints, int closed, int lineJoin, int lineCap, float lineWidth) +{ + int ncap = nsvg__curveDivs(lineWidth*0.5f, NSVG_PI, r->tessTol); // Calculate divisions per half circle. + NSVGpoint left = {0,0,0,0,0,0,0,0}, right = {0,0,0,0,0,0,0,0}, firstLeft = {0,0,0,0,0,0,0,0}, firstRight = {0,0,0,0,0,0,0,0}; + NSVGpoint* p0, *p1; + int j, s, e; + + // Build stroke edges + if (closed) { + // Looping + p0 = &points[npoints-1]; + p1 = &points[0]; + s = 0; + e = npoints; + } else { + // Add cap + p0 = &points[0]; + p1 = &points[1]; + s = 1; + e = npoints-1; + } + + if (closed) { + nsvg__initClosed(&left, &right, p0, p1, lineWidth); + firstLeft = left; + firstRight = right; + } else { + // Add cap + float dx = p1->x - p0->x; + float dy = p1->y - p0->y; + nsvg__normalize(&dx, &dy); + if (lineCap == NSVG_CAP_BUTT) + nsvg__buttCap(r, &left, &right, p0, dx, dy, lineWidth, 0); + else if (lineCap == NSVG_CAP_SQUARE) + nsvg__squareCap(r, &left, &right, p0, dx, dy, lineWidth, 0); + else if (lineCap == NSVG_CAP_ROUND) + nsvg__roundCap(r, &left, &right, p0, dx, dy, lineWidth, ncap, 0); + } + + for (j = s; j < e; ++j) { + if (p1->flags & NSVG_PT_CORNER) { + if (lineJoin == NSVG_JOIN_ROUND) + nsvg__roundJoin(r, &left, &right, p0, p1, lineWidth, ncap); + else if (lineJoin == NSVG_JOIN_BEVEL || (p1->flags & NSVG_PT_BEVEL)) + nsvg__bevelJoin(r, &left, &right, p0, p1, lineWidth); + else + nsvg__miterJoin(r, &left, &right, p0, p1, lineWidth); + } else { + nsvg__straightJoin(r, &left, &right, p1, lineWidth); + } + p0 = p1++; + } + + if (closed) { + // Loop it + nsvg__addEdge(r, firstLeft.x, firstLeft.y, left.x, left.y); + nsvg__addEdge(r, right.x, right.y, firstRight.x, firstRight.y); + } else { + // Add cap + float dx = p1->x - p0->x; + float dy = p1->y - p0->y; + nsvg__normalize(&dx, &dy); + if (lineCap == NSVG_CAP_BUTT) + nsvg__buttCap(r, &right, &left, p1, -dx, -dy, lineWidth, 1); + else if (lineCap == NSVG_CAP_SQUARE) + nsvg__squareCap(r, &right, &left, p1, -dx, -dy, lineWidth, 1); + else if (lineCap == NSVG_CAP_ROUND) + nsvg__roundCap(r, &right, &left, p1, -dx, -dy, lineWidth, ncap, 1); + } +} + +static void nsvg__prepareStroke(NSVGrasterizer* r, float miterLimit, int lineJoin) +{ + int i, j; + NSVGpoint* p0, *p1; + + p0 = &r->points[r->npoints-1]; + p1 = &r->points[0]; + for (i = 0; i < r->npoints; i++) { + // Calculate segment direction and length + p0->dx = p1->x - p0->x; + p0->dy = p1->y - p0->y; + p0->len = nsvg__normalize(&p0->dx, &p0->dy); + // Advance + p0 = p1++; + } + + // calculate joins + p0 = &r->points[r->npoints-1]; + p1 = &r->points[0]; + for (j = 0; j < r->npoints; j++) { + float dlx0, dly0, dlx1, dly1, dmr2, cross; + dlx0 = p0->dy; + dly0 = -p0->dx; + dlx1 = p1->dy; + dly1 = -p1->dx; + // Calculate extrusions + p1->dmx = (dlx0 + dlx1) * 0.5f; + p1->dmy = (dly0 + dly1) * 0.5f; + dmr2 = p1->dmx*p1->dmx + p1->dmy*p1->dmy; + if (dmr2 > 0.000001f) { + float s2 = 1.0f / dmr2; + if (s2 > 600.0f) { + s2 = 600.0f; + } + p1->dmx *= s2; + p1->dmy *= s2; + } + + // Clear flags, but keep the corner. + p1->flags = (p1->flags & NSVG_PT_CORNER) ? NSVG_PT_CORNER : 0; + + // Keep track of left turns. + cross = p1->dx * p0->dy - p0->dx * p1->dy; + if (cross > 0.0f) + p1->flags |= NSVG_PT_LEFT; + + // Check to see if the corner needs to be beveled. + if (p1->flags & NSVG_PT_CORNER) { + if ((dmr2 * miterLimit*miterLimit) < 1.0f || lineJoin == NSVG_JOIN_BEVEL || lineJoin == NSVG_JOIN_ROUND) { + p1->flags |= NSVG_PT_BEVEL; + } + } + + p0 = p1++; + } +} + +static void nsvg__flattenShapeStroke(NSVGrasterizer* r, NSVGshape* shape, float scale) +{ + int i, j, closed; + NSVGpath* path; + NSVGpoint* p0, *p1; + float miterLimit = shape->miterLimit; + int lineJoin = shape->strokeLineJoin; + int lineCap = shape->strokeLineCap; + float lineWidth = shape->strokeWidth * scale; + + for (path = shape->paths; path != NULL; path = path->next) { + // Flatten path + r->npoints = 0; + nsvg__addPathPoint(r, path->pts[0]*scale, path->pts[1]*scale, NSVG_PT_CORNER); + for (i = 0; i < path->npts-1; i += 3) { + float* p = &path->pts[i*2]; + nsvg__flattenCubicBez(r, p[0]*scale,p[1]*scale, p[2]*scale,p[3]*scale, p[4]*scale,p[5]*scale, p[6]*scale,p[7]*scale, 0, NSVG_PT_CORNER); + } + if (r->npoints < 2) + continue; + + closed = path->closed; + + // If the first and last points are the same, remove the last, mark as closed path. + p0 = &r->points[r->npoints-1]; + p1 = &r->points[0]; + if (nsvg__ptEquals(p0->x,p0->y, p1->x,p1->y, r->distTol)) { + r->npoints--; + p0 = &r->points[r->npoints-1]; + closed = 1; + } + + if (shape->strokeDashCount > 0) { + int idash = 0, dashState = 1; + float totalDist = 0, dashLen, allDashLen, dashOffset; + NSVGpoint cur; + + if (closed) + nsvg__appendPathPoint(r, r->points[0]); + + // Duplicate points -> points2. + nsvg__duplicatePoints(r); + + r->npoints = 0; + cur = r->points2[0]; + nsvg__appendPathPoint(r, cur); + + // Figure out dash offset. + allDashLen = 0; + for (j = 0; j < shape->strokeDashCount; j++) + allDashLen += shape->strokeDashArray[j]; + if (shape->strokeDashCount & 1) + allDashLen *= 2.0f; + // Find location inside pattern + dashOffset = fmodf(shape->strokeDashOffset, allDashLen); + if (dashOffset < 0.0f) + dashOffset += allDashLen; + + while (dashOffset > shape->strokeDashArray[idash]) { + dashOffset -= shape->strokeDashArray[idash]; + idash = (idash + 1) % shape->strokeDashCount; + } + dashLen = (shape->strokeDashArray[idash] - dashOffset) * scale; + + for (j = 1; j < r->npoints2; ) { + float dx = r->points2[j].x - cur.x; + float dy = r->points2[j].y - cur.y; + float dist = sqrtf(dx*dx + dy*dy); + + if ((totalDist + dist) > dashLen) { + // Calculate intermediate point + float d = (dashLen - totalDist) / dist; + float x = cur.x + dx * d; + float y = cur.y + dy * d; + nsvg__addPathPoint(r, x, y, NSVG_PT_CORNER); + + // Stroke + if (r->npoints > 1 && dashState) { + nsvg__prepareStroke(r, miterLimit, lineJoin); + nsvg__expandStroke(r, r->points, r->npoints, 0, lineJoin, lineCap, lineWidth); + } + // Advance dash pattern + dashState = !dashState; + idash = (idash+1) % shape->strokeDashCount; + dashLen = shape->strokeDashArray[idash] * scale; + // Restart + cur.x = x; + cur.y = y; + cur.flags = NSVG_PT_CORNER; + totalDist = 0.0f; + r->npoints = 0; + nsvg__appendPathPoint(r, cur); + } else { + totalDist += dist; + cur = r->points2[j]; + nsvg__appendPathPoint(r, cur); + j++; + } + } + // Stroke any leftover path + if (r->npoints > 1 && dashState) { + nsvg__prepareStroke(r, miterLimit, lineJoin); + nsvg__expandStroke(r, r->points, r->npoints, 0, lineJoin, lineCap, lineWidth); + } + } else { + nsvg__prepareStroke(r, miterLimit, lineJoin); + nsvg__expandStroke(r, r->points, r->npoints, closed, lineJoin, lineCap, lineWidth); + } + } +} + +static int nsvg__cmpEdge(const void *p, const void *q) +{ + const NSVGedge* a = (const NSVGedge*)p; + const NSVGedge* b = (const NSVGedge*)q; + + if (a->y0 < b->y0) return -1; + if (a->y0 > b->y0) return 1; + return 0; +} + + +static NSVGactiveEdge* nsvg__addActive(NSVGrasterizer* r, NSVGedge* e, float startPoint) +{ + NSVGactiveEdge* z; + + if (r->freelist != NULL) { + // Restore from freelist. + z = r->freelist; + r->freelist = z->next; + } else { + // Alloc new edge. + z = (NSVGactiveEdge*)nsvg__alloc(r, sizeof(NSVGactiveEdge)); + if (z == NULL) return NULL; + } + + float dxdy = (e->x1 - e->x0) / (e->y1 - e->y0); +// STBTT_assert(e->y0 <= start_point); + // round dx down to avoid going too far + if (dxdy < 0) + z->dx = (int)(-nsvg__roundf(NSVG__FIX * -dxdy)); + else + z->dx = (int)nsvg__roundf(NSVG__FIX * dxdy); + z->x = (int)nsvg__roundf(NSVG__FIX * (e->x0 + dxdy * (startPoint - e->y0))); +// z->x -= off_x * FIX; + z->ey = e->y1; + z->next = 0; + z->dir = e->dir; + + return z; +} + +static void nsvg__freeActive(NSVGrasterizer* r, NSVGactiveEdge* z) +{ + z->next = r->freelist; + r->freelist = z; +} + +static void nsvg__fillScanline(unsigned char* scanline, int len, int x0, int x1, int maxWeight, int* xmin, int* xmax) +{ + int i = x0 >> NSVG__FIXSHIFT; + int j = x1 >> NSVG__FIXSHIFT; + if (i < *xmin) *xmin = i; + if (j > *xmax) *xmax = j; + if (i < len && j >= 0) { + if (i == j) { + // x0,x1 are the same pixel, so compute combined coverage + scanline[i] = (unsigned char)(scanline[i] + ((x1 - x0) * maxWeight >> NSVG__FIXSHIFT)); + } else { + if (i >= 0) // add antialiasing for x0 + scanline[i] = (unsigned char)(scanline[i] + (((NSVG__FIX - (x0 & NSVG__FIXMASK)) * maxWeight) >> NSVG__FIXSHIFT)); + else + i = -1; // clip + + if (j < len) // add antialiasing for x1 + scanline[j] = (unsigned char)(scanline[j] + (((x1 & NSVG__FIXMASK) * maxWeight) >> NSVG__FIXSHIFT)); + else + j = len; // clip + + for (++i; i < j; ++i) // fill pixels between x0 and x1 + scanline[i] = (unsigned char)(scanline[i] + maxWeight); + } + } +} + +// note: this routine clips fills that extend off the edges... ideally this +// wouldn't happen, but it could happen if the truetype glyph bounding boxes +// are wrong, or if the user supplies a too-small bitmap +static void nsvg__fillActiveEdges(unsigned char* scanline, int len, NSVGactiveEdge* e, int maxWeight, int* xmin, int* xmax, char fillRule) +{ + // non-zero winding fill + int x0 = 0, w = 0; + + if (fillRule == NSVG_FILLRULE_NONZERO) { + // Non-zero + while (e != NULL) { + if (w == 0) { + // if we're currently at zero, we need to record the edge start point + x0 = e->x; w += e->dir; + } else { + int x1 = e->x; w += e->dir; + // if we went to zero, we need to draw + if (w == 0) + nsvg__fillScanline(scanline, len, x0, x1, maxWeight, xmin, xmax); + } + e = e->next; + } + } else if (fillRule == NSVG_FILLRULE_EVENODD) { + // Even-odd + while (e != NULL) { + if (w == 0) { + // if we're currently at zero, we need to record the edge start point + x0 = e->x; w = 1; + } else { + int x1 = e->x; w = 0; + nsvg__fillScanline(scanline, len, x0, x1, maxWeight, xmin, xmax); + } + e = e->next; + } + } +} + +static float nsvg__clampf(float a, float mn, float mx) { + if (isnan(a)) + return mn; + return a < mn ? mn : (a > mx ? mx : a); +} + +static unsigned int nsvg__RGBA(unsigned char r, unsigned char g, unsigned char b, unsigned char a) +{ + return ((unsigned int)r) | ((unsigned int)g << 8) | ((unsigned int)b << 16) | ((unsigned int)a << 24); +} + +static unsigned int nsvg__lerpRGBA(unsigned int c0, unsigned int c1, float u) +{ + int iu = (int)(nsvg__clampf(u, 0.0f, 1.0f) * 256.0f); + int r = (((c0) & 0xff)*(256-iu) + (((c1) & 0xff)*iu)) >> 8; + int g = (((c0>>8) & 0xff)*(256-iu) + (((c1>>8) & 0xff)*iu)) >> 8; + int b = (((c0>>16) & 0xff)*(256-iu) + (((c1>>16) & 0xff)*iu)) >> 8; + int a = (((c0>>24) & 0xff)*(256-iu) + (((c1>>24) & 0xff)*iu)) >> 8; + return nsvg__RGBA((unsigned char)r, (unsigned char)g, (unsigned char)b, (unsigned char)a); +} + +static unsigned int nsvg__applyOpacity(unsigned int c, float u) +{ + int iu = (int)(nsvg__clampf(u, 0.0f, 1.0f) * 256.0f); + int r = (c) & 0xff; + int g = (c>>8) & 0xff; + int b = (c>>16) & 0xff; + int a = (((c>>24) & 0xff)*iu) >> 8; + return nsvg__RGBA((unsigned char)r, (unsigned char)g, (unsigned char)b, (unsigned char)a); +} + +static inline int nsvg__div255(int x) +{ + return ((x+1) * 257) >> 16; +} + +static void nsvg__scanlineSolid(unsigned char* dst, int count, unsigned char* cover, int x, int y, + float tx, float ty, float scale, NSVGcachedPaint* cache) +{ + + if (cache->type == NSVG_PAINT_COLOR) { + int i, cr, cg, cb, ca; + cr = cache->colors[0] & 0xff; + cg = (cache->colors[0] >> 8) & 0xff; + cb = (cache->colors[0] >> 16) & 0xff; + ca = (cache->colors[0] >> 24) & 0xff; + + for (i = 0; i < count; i++) { + int r,g,b; + int a = nsvg__div255((int)cover[0] * ca); + int ia = 255 - a; + // Premultiply + r = nsvg__div255(cr * a); + g = nsvg__div255(cg * a); + b = nsvg__div255(cb * a); + + // Blend over + r += nsvg__div255(ia * (int)dst[0]); + g += nsvg__div255(ia * (int)dst[1]); + b += nsvg__div255(ia * (int)dst[2]); + a += nsvg__div255(ia * (int)dst[3]); + + dst[0] = (unsigned char)r; + dst[1] = (unsigned char)g; + dst[2] = (unsigned char)b; + dst[3] = (unsigned char)a; + + cover++; + dst += 4; + } + } else if (cache->type == NSVG_PAINT_LINEAR_GRADIENT) { + // TODO: spread modes. + // TODO: plenty of opportunities to optimize. + float fx, fy, dx, gy; + float* t = cache->xform; + int i, cr, cg, cb, ca; + unsigned int c; + + fx = ((float)x - tx) / scale; + fy = ((float)y - ty) / scale; + dx = 1.0f / scale; + + for (i = 0; i < count; i++) { + int r,g,b,a,ia; + gy = fx*t[1] + fy*t[3] + t[5]; + c = cache->colors[(int)nsvg__clampf(gy*255.0f, 0, 255.0f)]; + cr = (c) & 0xff; + cg = (c >> 8) & 0xff; + cb = (c >> 16) & 0xff; + ca = (c >> 24) & 0xff; + + a = nsvg__div255((int)cover[0] * ca); + ia = 255 - a; + + // Premultiply + r = nsvg__div255(cr * a); + g = nsvg__div255(cg * a); + b = nsvg__div255(cb * a); + + // Blend over + r += nsvg__div255(ia * (int)dst[0]); + g += nsvg__div255(ia * (int)dst[1]); + b += nsvg__div255(ia * (int)dst[2]); + a += nsvg__div255(ia * (int)dst[3]); + + dst[0] = (unsigned char)r; + dst[1] = (unsigned char)g; + dst[2] = (unsigned char)b; + dst[3] = (unsigned char)a; + + cover++; + dst += 4; + fx += dx; + } + } else if (cache->type == NSVG_PAINT_RADIAL_GRADIENT) { + // TODO: spread modes. + // TODO: plenty of opportunities to optimize. + // TODO: focus (fx,fy) + float fx, fy, dx, gx, gy, gd; + float* t = cache->xform; + int i, cr, cg, cb, ca; + unsigned int c; + + fx = ((float)x - tx) / scale; + fy = ((float)y - ty) / scale; + dx = 1.0f / scale; + + for (i = 0; i < count; i++) { + int r,g,b,a,ia; + gx = fx*t[0] + fy*t[2] + t[4]; + gy = fx*t[1] + fy*t[3] + t[5]; + gd = sqrtf(gx*gx + gy*gy); + c = cache->colors[(int)nsvg__clampf(gd*255.0f, 0, 255.0f)]; + cr = (c) & 0xff; + cg = (c >> 8) & 0xff; + cb = (c >> 16) & 0xff; + ca = (c >> 24) & 0xff; + + a = nsvg__div255((int)cover[0] * ca); + ia = 255 - a; + + // Premultiply + r = nsvg__div255(cr * a); + g = nsvg__div255(cg * a); + b = nsvg__div255(cb * a); + + // Blend over + r += nsvg__div255(ia * (int)dst[0]); + g += nsvg__div255(ia * (int)dst[1]); + b += nsvg__div255(ia * (int)dst[2]); + a += nsvg__div255(ia * (int)dst[3]); + + dst[0] = (unsigned char)r; + dst[1] = (unsigned char)g; + dst[2] = (unsigned char)b; + dst[3] = (unsigned char)a; + + cover++; + dst += 4; + fx += dx; + } + } +} + +static void nsvg__rasterizeSortedEdges(NSVGrasterizer *r, float tx, float ty, float scale, NSVGcachedPaint* cache, char fillRule) +{ + NSVGactiveEdge *active = NULL; + int y, s; + int e = 0; + int maxWeight = (255 / NSVG__SUBSAMPLES); // weight per vertical scanline + int xmin, xmax; + + for (y = 0; y < r->height; y++) { + memset(r->scanline, 0, r->width); + xmin = r->width; + xmax = 0; + for (s = 0; s < NSVG__SUBSAMPLES; ++s) { + // find center of pixel for this scanline + float scany = (float)(y*NSVG__SUBSAMPLES + s) + 0.5f; + NSVGactiveEdge **step = &active; + + // update all active edges; + // remove all active edges that terminate before the center of this scanline + while (*step) { + NSVGactiveEdge *z = *step; + if (z->ey <= scany) { + *step = z->next; // delete from list +// NSVG__assert(z->valid); + nsvg__freeActive(r, z); + } else { + z->x += z->dx; // advance to position for current scanline + step = &((*step)->next); // advance through list + } + } + + // resort the list if needed + for (;;) { + int changed = 0; + step = &active; + while (*step && (*step)->next) { + if ((*step)->x > (*step)->next->x) { + NSVGactiveEdge* t = *step; + NSVGactiveEdge* q = t->next; + t->next = q->next; + q->next = t; + *step = q; + changed = 1; + } + step = &(*step)->next; + } + if (!changed) break; + } + + // insert all edges that start before the center of this scanline -- omit ones that also end on this scanline + while (e < r->nedges && r->edges[e].y0 <= scany) { + if (r->edges[e].y1 > scany) { + NSVGactiveEdge* z = nsvg__addActive(r, &r->edges[e], scany); + if (z == NULL) break; + // find insertion point + if (active == NULL) { + active = z; + } else if (z->x < active->x) { + // insert at front + z->next = active; + active = z; + } else { + // find thing to insert AFTER + NSVGactiveEdge* p = active; + while (p->next && p->next->x < z->x) + p = p->next; + // at this point, p->next->x is NOT < z->x + z->next = p->next; + p->next = z; + } + } + e++; + } + + // now process all active edges in non-zero fashion + if (active != NULL) + nsvg__fillActiveEdges(r->scanline, r->width, active, maxWeight, &xmin, &xmax, fillRule); + } + // Blit + if (xmin < 0) xmin = 0; + if (xmax > r->width-1) xmax = r->width-1; + if (xmin <= xmax) { + nsvg__scanlineSolid(&r->bitmap[y * r->stride] + xmin*4, xmax-xmin+1, &r->scanline[xmin], xmin, y, tx,ty, scale, cache); + } + } + +} + +static void nsvg__unpremultiplyAlpha(unsigned char* image, int w, int h, int stride) +{ + int x,y; + + // Unpremultiply + for (y = 0; y < h; y++) { + unsigned char *row = &image[y*stride]; + for (x = 0; x < w; x++) { + int r = row[0], g = row[1], b = row[2], a = row[3]; + if (a != 0) { + row[0] = (unsigned char)(r*255/a); + row[1] = (unsigned char)(g*255/a); + row[2] = (unsigned char)(b*255/a); + } + row += 4; + } + } + + // Defringe + for (y = 0; y < h; y++) { + unsigned char *row = &image[y*stride]; + for (x = 0; x < w; x++) { + int r = 0, g = 0, b = 0, a = row[3], n = 0; + if (a == 0) { + if (x-1 > 0 && row[-1] != 0) { + r += row[-4]; + g += row[-3]; + b += row[-2]; + n++; + } + if (x+1 < w && row[7] != 0) { + r += row[4]; + g += row[5]; + b += row[6]; + n++; + } + if (y-1 > 0 && row[-stride+3] != 0) { + r += row[-stride]; + g += row[-stride+1]; + b += row[-stride+2]; + n++; + } + if (y+1 < h && row[stride+3] != 0) { + r += row[stride]; + g += row[stride+1]; + b += row[stride+2]; + n++; + } + if (n > 0) { + row[0] = (unsigned char)(r/n); + row[1] = (unsigned char)(g/n); + row[2] = (unsigned char)(b/n); + } + } + row += 4; + } + } +} + + +static void nsvg__initPaint(NSVGcachedPaint* cache, NSVGpaint* paint, float opacity) +{ + int i, j; + NSVGgradient* grad; + + cache->type = paint->type; + + if (paint->type == NSVG_PAINT_COLOR) { + cache->colors[0] = nsvg__applyOpacity(paint->color, opacity); + return; + } + + grad = paint->gradient; + + cache->spread = grad->spread; + memcpy(cache->xform, grad->xform, sizeof(float)*6); + + if (grad->nstops == 0) { + for (i = 0; i < 256; i++) + cache->colors[i] = 0; + } else if (grad->nstops == 1) { + unsigned int color = nsvg__applyOpacity(grad->stops[0].color, opacity); + for (i = 0; i < 256; i++) + cache->colors[i] = color; + } else { + unsigned int ca, cb = 0; + float ua, ub, du, u; + int ia, ib, count; + + ca = nsvg__applyOpacity(grad->stops[0].color, opacity); + ua = nsvg__clampf(grad->stops[0].offset, 0, 1); + ub = nsvg__clampf(grad->stops[grad->nstops-1].offset, ua, 1); + ia = (int)(ua * 255.0f); + ib = (int)(ub * 255.0f); + for (i = 0; i < ia; i++) { + cache->colors[i] = ca; + } + + for (i = 0; i < grad->nstops-1; i++) { + ca = nsvg__applyOpacity(grad->stops[i].color, opacity); + cb = nsvg__applyOpacity(grad->stops[i+1].color, opacity); + ua = nsvg__clampf(grad->stops[i].offset, 0, 1); + ub = nsvg__clampf(grad->stops[i+1].offset, 0, 1); + ia = (int)(ua * 255.0f); + ib = (int)(ub * 255.0f); + count = ib - ia; + if (count <= 0) continue; + u = 0; + du = 1.0f / (float)count; + for (j = 0; j < count; j++) { + cache->colors[ia+j] = nsvg__lerpRGBA(ca,cb,u); + u += du; + } + } + + for (i = ib; i < 256; i++) + cache->colors[i] = cb; + } + +} + +/* +static void dumpEdges(NSVGrasterizer* r, const char* name) +{ + float xmin = 0, xmax = 0, ymin = 0, ymax = 0; + NSVGedge *e = NULL; + int i; + if (r->nedges == 0) return; + FILE* fp = fopen(name, "w"); + if (fp == NULL) return; + + xmin = xmax = r->edges[0].x0; + ymin = ymax = r->edges[0].y0; + for (i = 0; i < r->nedges; i++) { + e = &r->edges[i]; + xmin = nsvg__minf(xmin, e->x0); + xmin = nsvg__minf(xmin, e->x1); + xmax = nsvg__maxf(xmax, e->x0); + xmax = nsvg__maxf(xmax, e->x1); + ymin = nsvg__minf(ymin, e->y0); + ymin = nsvg__minf(ymin, e->y1); + ymax = nsvg__maxf(ymax, e->y0); + ymax = nsvg__maxf(ymax, e->y1); + } + + fprintf(fp, "", xmin, ymin, (xmax - xmin), (ymax - ymin)); + + for (i = 0; i < r->nedges; i++) { + e = &r->edges[i]; + fprintf(fp ,"", e->x0,e->y0, e->x1,e->y1); + } + + for (i = 0; i < r->npoints; i++) { + if (i+1 < r->npoints) + fprintf(fp ,"", r->points[i].x, r->points[i].y, r->points[i+1].x, r->points[i+1].y); + fprintf(fp ,"", r->points[i].x, r->points[i].y, r->points[i].flags == 0 ? "#f00" : "#0f0"); + } + + fprintf(fp, ""); + fclose(fp); +} +*/ + +void nsvgRasterize(NSVGrasterizer* r, + NSVGimage* image, float tx, float ty, float scale, + unsigned char* dst, int w, int h, int stride) +{ + NSVGshape *shape = NULL; + NSVGedge *e = NULL; + NSVGcachedPaint cache; + int i; + int j; + unsigned char paintOrder; + + r->bitmap = dst; + r->width = w; + r->height = h; + r->stride = stride; + + if (w > r->cscanline) { + r->cscanline = w; + r->scanline = (unsigned char*)realloc(r->scanline, w); + if (r->scanline == NULL) return; + } + + for (i = 0; i < h; i++) + memset(&dst[i*stride], 0, w*4); + + for (shape = image->shapes; shape != NULL; shape = shape->next) { + if (!(shape->flags & NSVG_FLAGS_VISIBLE)) + continue; + + for (j = 0; j < 3; j++) { + paintOrder = (shape->paintOrder >> (2 * j)) & 0x03; + + if (paintOrder == NSVG_PAINT_FILL && shape->fill.type != NSVG_PAINT_NONE) { + nsvg__resetPool(r); + r->freelist = NULL; + r->nedges = 0; + + nsvg__flattenShape(r, shape, scale); + + // Scale and translate edges + for (i = 0; i < r->nedges; i++) { + e = &r->edges[i]; + e->x0 = tx + e->x0; + e->y0 = (ty + e->y0) * NSVG__SUBSAMPLES; + e->x1 = tx + e->x1; + e->y1 = (ty + e->y1) * NSVG__SUBSAMPLES; + } + + // Rasterize edges + if (r->nedges != 0) + qsort(r->edges, r->nedges, sizeof(NSVGedge), nsvg__cmpEdge); + + // now, traverse the scanlines and find the intersections on each scanline, use non-zero rule + nsvg__initPaint(&cache, &shape->fill, shape->opacity); + + nsvg__rasterizeSortedEdges(r, tx,ty,scale, &cache, shape->fillRule); + } + if (paintOrder == NSVG_PAINT_STROKE && shape->stroke.type != NSVG_PAINT_NONE && (shape->strokeWidth * scale) > 0.01f) { + nsvg__resetPool(r); + r->freelist = NULL; + r->nedges = 0; + + nsvg__flattenShapeStroke(r, shape, scale); + + // dumpEdges(r, "edge.svg"); + + // Scale and translate edges + for (i = 0; i < r->nedges; i++) { + e = &r->edges[i]; + e->x0 = tx + e->x0; + e->y0 = (ty + e->y0) * NSVG__SUBSAMPLES; + e->x1 = tx + e->x1; + e->y1 = (ty + e->y1) * NSVG__SUBSAMPLES; + } + + // Rasterize edges + if (r->nedges != 0) + qsort(r->edges, r->nedges, sizeof(NSVGedge), nsvg__cmpEdge); + + // now, traverse the scanlines and find the intersections on each scanline, use non-zero rule + nsvg__initPaint(&cache, &shape->stroke, shape->opacity); + + nsvg__rasterizeSortedEdges(r, tx,ty,scale, &cache, NSVG_FILLRULE_NONZERO); + } + } + } + + nsvg__unpremultiplyAlpha(dst, w, h, stride); + + r->bitmap = NULL; + r->width = 0; + r->height = 0; + r->stride = 0; +} + +#endif // NANOSVGRAST_IMPLEMENTATION + +#endif // NANOSVGRAST_H From e81f55c11b795f08fb7b5364e257cefdc4bbcca9 Mon Sep 17 00:00:00 2001 From: Linming-XHL Date: Sat, 19 Sep 2026 09:54:05 +0800 Subject: [PATCH 322/334] fix. The non-Windows branch has addressed the Live2D texture atlas size limit. --- src/media/image.c | 50 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/media/image.c b/src/media/image.c index c45c6c34..6346a045 100644 --- a/src/media/image.c +++ b/src/media/image.c @@ -4,7 +4,9 @@ #include #include +#include #include +#include #include #include @@ -145,6 +147,40 @@ static void report_stage_progress(void *userdata, float progress) { stage->progress(stage->userdata, stage->start + stage->span * progress); } +/* stb decodes a model atlas at full resolution, so an oversized sheet still + costs its whole pixel buffer transiently. Constraining the pixels before the + alpha mask and the upload bounds what stays resident in the GL texture and + its mipmap chain for as long as the model is loaded. Leaves the image + untouched and reports failure when the resize cannot be allocated. */ +static bool constrain_model_texture(BongoCatImage *image, int limit) { + if (!image || !image->pixels || limit < 1 || + (image->width <= limit && image->height <= limit)) return true; + float scale = SDL_min((float)limit / (float)image->width, + (float)limit / (float)image->height); + int target_width = SDL_max(1, (int)lroundf((float)image->width * scale)); + int target_height = SDL_max(1, (int)lroundf((float)image->height * scale)); + size_t count = (size_t)target_width * (size_t)target_height; + unsigned char *pixels = count <= SIZE_MAX / 4 ? malloc(count * 4) : NULL; + if (!pixels || !stbir_resize_uint8_srgb(image->pixels, image->width, + image->height, image->width * 4, pixels, target_width, target_height, + target_width * 4, STBIR_RGBA)) { + free(pixels); + return false; + } + unsigned char *previous_pixels = image->pixels; + bool previous_stbi = image->pixels_stbi; + SDL_Surface *previous_surface = image->surface; + image->pixels = pixels; + image->pixels_stbi = false; + image->surface = NULL; + image->width = target_width; + image->height = target_height; + if (previous_surface) SDL_DestroySurface(previous_surface); + if (previous_stbi) stbi_image_free(previous_pixels); + else free(previous_pixels); + return true; +} + unsigned int bongo_cat_image_texture_model(const char *path, bool direct_decode, int *width, int *height, BongoCatImageAlphaMask *alpha, BongoCatImageProgress progress, void *userdata, BongoCatError *error) { @@ -188,6 +224,20 @@ unsigned int bongo_cat_image_texture_model(const char *path, bool direct_decode, (void)direct_decode; if (bongo_cat_image_decode_pixels_responsive(path, &image, staged, &stage, error) != BONGO_CAT_OK) return 0; + /* WIC scales an oversized atlas while decoding; stb cannot, so the same + bound is applied to the decoded pixels. Only the resident texture and + its mipmap chain shrink; the decode peak is unchanged. */ + int efficient_limit = SDL_min(hardware_limit, + BONGO_CAT_LIVE2D_EFFICIENT_TEXTURE_LIMIT); + if (image.width > efficient_limit || image.height > efficient_limit) { + if (constrain_model_texture(&image, efficient_limit)) + SDL_Log("Live2D texture constrained to %dx%d by a %d pixel " + "texture limit: %s", image.width, image.height, + efficient_limit, path); + else SDL_LogWarn(SDL_LOG_CATEGORY_RENDER, "Live2D texture kept at " + "%dx%d because constraining it to %d pixels failed: %s", + image.width, image.height, efficient_limit, path); + } #endif if (progress) progress(userdata, .30f); stage = (ImageProgressStage){progress, userdata, .30f, .30f}; From a7aced2fa0fbf9cd5bca4f9d0f6de3f2323e09f5 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 19 Sep 2026 11:40:11 +0800 Subject: [PATCH 323/334] per(about): Improved the interactions for the contributor list and wechat group --- src/ui/backend/ui_backend.c | 2 +- src/ui/preferences/about/preferences_about.h | 1 + .../about/preferences_about_contributors.c | 17 ++- .../about/preferences_about_feed.c | 48 ++++++-- .../about/preferences_about_lifecycle.c | 27 ++++- .../about/preferences_about_online.c | 44 +++++-- .../about/preferences_about_online.h | 2 +- .../about/preferences_about_text.c | 10 +- src/ui/preferences/preferences.c | 5 + src/ui/rendering/ui_paint.c | 14 ++- src/ui/rendering/ui_paint_cache.c | 25 +++- src/ui/rendering/ui_paint_cache.h | 1 + tests/ui/test_preferences_lifecycle.c | 113 +++++++++++++++++- 13 files changed, 262 insertions(+), 47 deletions(-) diff --git a/src/ui/backend/ui_backend.c b/src/ui/backend/ui_backend.c index d5355bf5..f99f55ca 100644 --- a/src/ui/backend/ui_backend.c +++ b/src/ui/backend/ui_backend.c @@ -271,7 +271,7 @@ bool bongo_cat_ui_render(BongoCatUIBackend *ui) { void bongo_cat_ui_trim_idle(BongoCatUIBackend *ui) { if (!ui) return; - bongo_cat_ui_paint_destroy(ui); + bongo_cat_ui_paint_cache_trim_idle(ui); trim_commands(ui); } diff --git a/src/ui/preferences/about/preferences_about.h b/src/ui/preferences/about/preferences_about.h index 6e319b48..41cd47f1 100644 --- a/src/ui/preferences/about/preferences_about.h +++ b/src/ui/preferences/about/preferences_about.h @@ -20,6 +20,7 @@ typedef struct BongoCatAboutState { BongoCatAboutFeed *contributors; bool contributors_attempted; bool portraits_loaded; + int portraits_next; Uint32 event_type; Uint64 qr_hide_at; struct nk_rect qr_anchor; diff --git a/src/ui/preferences/about/preferences_about_contributors.c b/src/ui/preferences/about/preferences_about_contributors.c index 885b84c2..005517ef 100644 --- a/src/ui/preferences/about/preferences_about_contributors.c +++ b/src/ui/preferences/about/preferences_about_contributors.c @@ -13,10 +13,13 @@ static void contributors_load(BongoCatPreferences *value) { BongoCatAboutState *s = &value->about; bongo_cat_about_refresh(value); if (s->contributors && !s->portraits_loaded) { - glDeleteTextures(BONGO_ABOUT_CONTRIBUTOR_CAP, s->portraits); - memset(s->portraits, 0, sizeof(s->portraits)); - s->portraits_loaded = true; - for (int i = 0; i < s->contributors->count; i++) { + if (!s->portraits_next) { + glDeleteTextures(BONGO_ABOUT_CONTRIBUTOR_CAP, s->portraits); + memset(s->portraits, 0, sizeof(s->portraits)); + } + /* Bound GL work per frame, including the maximum-size contributor list. */ + int end = NK_MIN(s->portraits_next + 4, s->contributors->count); + for (int i = s->portraits_next; i < end; i++) { unsigned char *pixels = s->contributors->people[i].pixels; if (!pixels) continue; glGenTextures(1, &s->portraits[i]); @@ -28,6 +31,9 @@ static void contributors_load(BongoCatPreferences *value) { glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, BONGO_ABOUT_AVATAR_SIZE, BONGO_ABOUT_AVATAR_SIZE, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels); } + s->portraits_next = end; + s->portraits_loaded = end == s->contributors->count; + if (!s->portraits_loaded) value->render_dirty = true; } } @@ -123,12 +129,12 @@ void bongo_cat_about_contributors(BongoCatPreferences *value, struct nk_context title_width = bounds.w - 32; } float title_x = bounds.x + (bounds.w - title_width) * .5f; + float prefix = 0; for (int i = 0; i < title_length;) { nk_rune rune; int bytes = nk_utf_decode(title + i, &rune, title_length - i); if (bytes <= 0) break; - float prefix = title_font->width(title_font->userdata, title_font->height, title, i); float glyph = title_font->width(title_font->userdata, title_font->height, title + i, bytes); struct nk_color color = bongo_cat_ui_color_mix(p.accent, p.pink, title_width ? prefix / title_width : 0); @@ -136,6 +142,7 @@ void bongo_cat_about_contributors(BongoCatPreferences *value, struct nk_context nk_rect(title_x + prefix, bounds.y + 44, glyph + 2, title_font->height), title + i, bytes, title_font, nk_rgba(0, 0, 0, 0), color); i += bytes; + prefix += glyph; } bongo_cat_about_paragraph(context, nk_rect(bounds.x + inset, bounds.y + desc_y, inner, grid_y - desc_y), description, body_font, p.muted, true, 28.8f); diff --git a/src/ui/preferences/about/preferences_about_feed.c b/src/ui/preferences/about/preferences_about_feed.c index ae847e94..d5e88a3f 100644 --- a/src/ui/preferences/about/preferences_about_feed.c +++ b/src/ui/preferences/about/preferences_about_feed.c @@ -48,7 +48,8 @@ static unsigned char *decode_uri(const char *uri, int *length) { return bytes; } -static unsigned char *portrait(const char *uri) { +static unsigned char *portrait(const char *uri, SDL_AtomicInt *cancel) { + if (SDL_GetAtomicInt(cancel)) return NULL; int length = 0, width = 0, height = 0, channels = 0; unsigned char *data = decode_uri(uri, &length); if (!data) return NULL; @@ -57,20 +58,51 @@ static unsigned char *portrait(const char *uri) { stbi_info_from_memory(data, length, &width, &height, &channels) != 0; if (!known || width <= 0 || height <= 0 || width > 4096 || height > 4096 || (size_t)width * (size_t)height > 4u * 1024u * 1024u) { free(data); return NULL; } - unsigned char *source = webp ? WebPDecodeRGBA(data, (size_t)length, &width, &height) : - stbi_load_from_memory(data, length, &width, &height, &channels, 4); + const int size = BONGO_ABOUT_AVATAR_SIZE; + if (SDL_GetAtomicInt(cancel)) { free(data); return NULL; } + /* Decode WebP directly at display resolution instead of allocating the + full source image and then discarding almost all of its pixels. */ + WebPDecoderConfig config; + unsigned char *source = NULL; + if (webp) { + if (!WebPInitDecoderConfig(&config)) { free(data); return NULL; } + source = malloc((size_t)size * (size_t)size * 4); + if (!source) { free(data); return NULL; } + config.output.colorspace = MODE_RGBA; + config.output.is_external_memory = 1; + config.output.u.RGBA.rgba = source; + config.output.u.RGBA.stride = size * 4; + config.output.u.RGBA.size = (size_t)size * (size_t)size * 4; + config.options.use_scaling = 1; + config.options.scaled_width = size; + config.options.scaled_height = size; + if (WebPDecode(data, (size_t)length, &config) == VP8_STATUS_OK) { + source = config.output.u.RGBA.rgba; + width = height = size; + } else { + WebPFreeDecBuffer(&config.output); + free(source); + source = NULL; + } + } else { + source = stbi_load_from_memory(data, length, &width, &height, &channels, 4); + } free(data); if (!source) return NULL; - const int size = BONGO_ABOUT_AVATAR_SIZE; - unsigned char *pixels = malloc((size_t)size * (size_t)size * 4); + if (SDL_GetAtomicInt(cancel)) { + if (webp) { WebPFreeDecBuffer(&config.output); free(source); } + else stbi_image_free(source); + return NULL; + } + unsigned char *pixels = webp ? source : malloc((size_t)size * (size_t)size * 4); if (pixels) for (int y = 0; y < size; y++) for (int x = 0; x < size; x++) { unsigned char *out = pixels + (y * size + x) * 4; int sx = x * width / size, sy = y * height / size; - memcpy(out, source + ((size_t)sy * (size_t)width + (size_t)sx) * 4, 4); + if (!webp) memcpy(out, source + ((size_t)sy * (size_t)width + (size_t)sx) * 4, 4); int dx = 2 * x + 1 - size, dy = 2 * y + 1 - size; if (dx * dx + dy * dy > size * size) out[3] = 0; } - if (webp) WebPFree(source); else stbi_image_free(source); + if (webp) WebPFreeDecBuffer(&config.output); else stbi_image_free(source); return pixels; } @@ -110,7 +142,7 @@ static void start(void *user, const char *element, const char **attrs) { p->feed->count < BONGO_ABOUT_CONTRIBUTOR_CAP) { const char *href = attribute(attrs, "href"); if (!*href) href = attribute(attrs, "xlink:href"); - p->person.pixels = portrait(href); + p->person.pixels = portrait(href, p->cancel); } } diff --git a/src/ui/preferences/about/preferences_about_lifecycle.c b/src/ui/preferences/about/preferences_about_lifecycle.c index c45357af..8fe7cea1 100644 --- a/src/ui/preferences/about/preferences_about_lifecycle.c +++ b/src/ui/preferences/about/preferences_about_lifecycle.c @@ -9,6 +9,18 @@ void bongo_cat_about_ensure_event(BongoCatPreferences *value) { value->about.event_type = SDL_RegisterEvents(1); } +static bool same_contributors(const BongoCatAboutFeed *a, const BongoCatAboutFeed *b) { + if (!a || !b || a->count != b->count) return false; + for (int i = 0; i < a->count; i++) { + const BongoCatAboutContributor *left = &a->people[i], *right = &b->people[i]; + if (strcmp(left->name, right->name) || strcmp(left->profile, right->profile) || + (!left->pixels != !right->pixels) || + (left->pixels && memcmp(left->pixels, right->pixels, + BONGO_ABOUT_AVATAR_SIZE * BONGO_ABOUT_AVATAR_SIZE * 4))) return false; + } + return true; +} + /* Collect CPU results even on other settings pages; GL uploads remain lazy. Cancelled jobs keep their slot until completion, bounding rapid reopen work. */ void bongo_cat_about_refresh(BongoCatPreferences *value) { @@ -23,15 +35,19 @@ void bongo_cat_about_refresh(BongoCatPreferences *value) { if (value->visible && job->status == 200 && !SDL_GetAtomicInt(&job->cancel)) { if (value->page == 3) value->render_dirty = true; if (job->kind == BONGO_ABOUT_WECHAT) { - free(s->qr_pixels); - s->qr_pixels = job->qr_pixels; - job->qr_pixels = NULL; - s->qr_texture_dirty = true; - } else { + if (!s->qr_pixels || !job->qr_pixels || + memcmp(s->qr_pixels, job->qr_pixels, 240 * 240 * 4)) { + free(s->qr_pixels); + s->qr_pixels = job->qr_pixels; + job->qr_pixels = NULL; + s->qr_texture_dirty = true; + } + } else if (!same_contributors(s->contributors, job->feed)) { bongo_cat_about_feed_free(s->contributors); s->contributors = job->feed; job->feed = NULL; s->portraits_loaded = false; + s->portraits_next = 0; } } bongo_cat_about_request_free(job); @@ -78,6 +94,7 @@ void bongo_cat_about_assets_clear(BongoCatPreferences *value, bool gl_ready) { s->wechat_icon_attempted = false; memset(s->portraits, 0, sizeof(s->portraits)); s->portraits_loaded = false; + s->portraits_next = 0; } void bongo_cat_about_clear(BongoCatPreferences *value, bool gl_ready) { diff --git a/src/ui/preferences/about/preferences_about_online.c b/src/ui/preferences/about/preferences_about_online.c index 69376e37..4aa3554d 100644 --- a/src/ui/preferences/about/preferences_about_online.c +++ b/src/ui/preferences/about/preferences_about_online.c @@ -15,21 +15,35 @@ #define RESPONSE_LIMIT (4u * 1024u * 1024u) #define CACHE_TTL_NS (SDL_NS_PER_SECOND * 86400LL) +/* Capacity excludes the terminator. Network growth is geometric and bounded; + disk reads reserve their exact known size without touching a 4 MiB buffer. */ +static bool reserve_response(BongoCatAboutRequest *job, size_t capacity) { + if (capacity > job->limit) return false; + if (job->response && capacity <= job->capacity) return true; + char *response = realloc(job->response, capacity + 1); + if (!response) return false; + job->response = response; + job->capacity = capacity; + job->response[job->length] = 0; + return true; +} + static bool cache_path(BongoCatAboutRequest *job, char *path, size_t capacity) { return job->cache_directory[0] && bongo_cat_path_join(path, capacity, job->cache_directory, job->kind == BONGO_ABOUT_CONTRIBUTORS ? "contributors-v1.svg" : "wechat-v1.svg"); } -/* SVG parsers modify their input. Keep the original bytes for the disk cache. */ -static bool decode_response(BongoCatAboutRequest *job) { +/* SVG parsers modify their input. Only network results need a preserved copy + for cache writes; disk reads can be parsed in place. */ +static bool decode_response(BongoCatAboutRequest *job, bool preserve_response) { if (SDL_GetAtomicInt(&job->cancel) || !job->length || memchr(job->response, 0, job->length) || !strstr(job->response, "response, "response, "length + 1); + char *copy = preserve_response ? malloc(job->length + 1) : job->response; if (!copy) return false; - memcpy(copy, job->response, job->length + 1); + if (preserve_response) memcpy(copy, job->response, job->length + 1); bool valid; if (job->kind == BONGO_ABOUT_CONTRIBUTORS) { job->feed = bongo_cat_about_feed_parse(copy, &job->cancel); @@ -42,7 +56,7 @@ static bool decode_response(BongoCatAboutRequest *job) { job->qr_pixels = bongo_cat_about_qr_pixels(copy); valid = job->qr_pixels != NULL; } - free(copy); + if (preserve_response) free(copy); return valid; } @@ -52,6 +66,7 @@ static bool cache_read(BongoCatAboutRequest *job) { SDL_PathInfo info; if (!SDL_GetPathInfo(path, &info) || info.type != SDL_PATHTYPE_FILE || !info.size || info.size > job->limit) return false; + if (!reserve_response(job, (size_t)info.size)) return false; FILE *file = bongo_cat_file_open(path, "rb"); if (!file) return false; size_t size = (size_t)info.size; @@ -59,7 +74,7 @@ static bool cache_read(BongoCatAboutRequest *job) { if (fclose(file) != 0) ok = false; job->length = size; job->response[size] = 0; - if (!ok || !decode_response(job)) { + if (!ok || !decode_response(job, false)) { job->length = 0; job->response[0] = 0; return false; @@ -99,6 +114,7 @@ static int complete(BongoCatAboutRequest *job) { free(job->response); job->response = NULL; job->length = 0; + job->capacity = 0; SDL_SetAtomicInt(&job->done, 1); SDL_Event event; memset(&event, 0, sizeof(event)); @@ -111,6 +127,13 @@ static int complete(BongoCatAboutRequest *job) { static bool append(BongoCatAboutRequest *job, const void *data, size_t size) { if (SDL_GetAtomicInt(&job->cancel) || size > job->limit - job->length) return false; + size_t needed = job->length + size; + if (!job->response || needed > job->capacity) { + size_t capacity = job->capacity ? job->capacity : 4096; + while (capacity < needed) + capacity = capacity > job->limit / 2 ? job->limit : capacity * 2; + if (!reserve_response(job, capacity)) return false; + } memcpy(job->response + job->length, data, size); job->length += size; job->response[job->length] = 0; @@ -134,8 +157,8 @@ static int progress(void *user, curl_off_t a, curl_off_t b, curl_off_t c, curl_o static int SDLCALL request_worker(void *user) { BongoCatAboutRequest *job = user; - job->response = calloc(job->limit + 1, 1); - if (!job->response) return complete(job); + /* Best effort: background assets should yield CPU time to interaction. */ + SDL_SetCurrentThreadPriority(SDL_THREAD_PRIORITY_LOW); /* Publish stale data before starting the separate background refresh. */ if (!job->network_only && cache_read(job)) return complete(job); if (SDL_GetAtomicInt(&job->cancel)) return complete(job); @@ -167,6 +190,8 @@ static int SDLCALL request_worker(void *user) { ok = WinHttpQueryHeaders(request, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, NULL, &status, &bytes, NULL) != FALSE; job->status = (int)status; + /* Error bodies cannot produce usable images; do not download them. */ + if (status != 200) ok = false; Uint64 deadline = SDL_GetTicks() + 15000; while (ok && !SDL_GetAtomicInt(&job->cancel)) { char buffer[4096]; @@ -195,6 +220,7 @@ static int SDLCALL request_worker(void *user) { curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, 3000L); curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, 15000L); curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); + curl_easy_setopt(curl, CURLOPT_FAILONERROR, 1L); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, receive); curl_easy_setopt(curl, CURLOPT_WRITEDATA, job); curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); @@ -211,7 +237,7 @@ static int SDLCALL request_worker(void *user) { if (!ok || SDL_GetAtomicInt(&job->cancel)) job->status = 0; if (job->status == 200) { - if (decode_response(job)) cache_write(job); + if (decode_response(job, true)) cache_write(job); else job->status = 0; } return complete(job); diff --git a/src/ui/preferences/about/preferences_about_online.h b/src/ui/preferences/about/preferences_about_online.h index fd5a0ae5..776e5e4c 100644 --- a/src/ui/preferences/about/preferences_about_online.h +++ b/src/ui/preferences/about/preferences_about_online.h @@ -19,7 +19,7 @@ typedef struct BongoCatAboutRequest { BongoCatAboutFeed *feed; unsigned char *qr_pixels; char *response; - size_t length; + size_t length, capacity; int status; char cache_directory[BONGO_CAT_PATH_CAP]; bool network_only, refresh_needed; diff --git a/src/ui/preferences/about/preferences_about_text.c b/src/ui/preferences/about/preferences_about_text.c index 9116130f..3dac096d 100644 --- a/src/ui/preferences/about/preferences_about_text.c +++ b/src/ui/preferences/about/preferences_about_text.c @@ -15,14 +15,17 @@ float bongo_cat_about_paragraph(struct nk_context *context, struct nk_rect bound const char *text, const struct nk_user_font *font, struct nk_color color, bool center, float leading) { float y = bounds.y; + int remaining = (int)strlen(text); while (*text && (!context || y + font->height <= bounds.y + bounds.h + 1)) { - int length = 0, remaining = (int)strlen(text); + int length = 0; float width = 0; while (length < remaining) { nk_rune rune; int bytes = nk_utf_decode(text + length, &rune, remaining - length); if (bytes <= 0) break; - float next = font->width(font->userdata, font->height, text, length + bytes); + /* Nuklear's atlas width is the sum of glyph advances (no kerning). + Measure each UTF-8 glyph once instead of every growing prefix. */ + float next = width + font->width(font->userdata, font->height, text + length, bytes); if (length && next > bounds.w) break; length += bytes; width = next; @@ -34,7 +37,8 @@ float bongo_cat_about_paragraph(struct nk_context *context, struct nk_rect bound text, length, font, nk_rgba(0, 0, 0, 0), color); y += leading; text += length; - while (*text == ' ' || *text == '\n') text++; + remaining -= length; + while (*text == ' ' || *text == '\n') { text++; remaining--; } } return y - bounds.y; } diff --git a/src/ui/preferences/preferences.c b/src/ui/preferences/preferences.c index 1b3de619..7d359079 100644 --- a/src/ui/preferences/preferences.c +++ b/src/ui/preferences/preferences.c @@ -120,6 +120,10 @@ static Uint32 event_window(const SDL_Event *event) { static void enter_model_drop(BongoCatPreferences *value) { value->page = 1; + /* Reveal the import card and cancel any pending scroll animation. */ + value->scroll_current[1] = 0.0f; + value->scroll_target[1] = 0.0f; + value->scroll_ready[1] = true; value->import_drop_active = true; value->render_dirty = true; } @@ -260,6 +264,7 @@ bool bongo_cat_preferences_event(BongoCatPreferences *value, const SDL_Event *ev return true; } if (event->type == SDL_EVENT_DROP_FILE) { + enter_model_drop(value); value->import_drop_active = false; bongo_cat_preferences_import_path(value->app, value->window, event->drop.data); return true; diff --git a/src/ui/rendering/ui_paint.c b/src/ui/rendering/ui_paint.c index 766c9b00..7c1901b3 100644 --- a/src/ui/rendering/ui_paint.c +++ b/src/ui/rendering/ui_paint.c @@ -54,6 +54,11 @@ static float rounded_distance(float x, float y, float width, float height, static bool texture_dimensions(struct nk_context *context, struct nk_rect bounds, int *width, int *height, float *scale_x, float *scale_y, BongoCatUIBackend **backend) { + /* Nuklear clips the eventual image, but rasterization/upload happens here. + Skip effects whose complete padded target is outside the visible clip. */ + struct nk_rect clip = nk_window_get_canvas(context)->clip; + if (bounds.x + bounds.w <= clip.x || bounds.y + bounds.h <= clip.y || + bounds.x >= clip.x + clip.w || bounds.y >= clip.y + clip.h) return false; *backend = bongo_cat_ui_backend_for_context(context); if (!*backend || !(*backend)->window) return false; float logical_width = 0.0f, logical_height = 0.0f; @@ -224,14 +229,15 @@ void bongo_cat_ui_paint_shadow(struct nk_context *context, float center_x = pad_x + bounds.w * sx * .5f; float center_y = pad_y + bounds.h * sy * .5f; float sigma = NK_MAX(1.0f, (float)key.first_parameter * .5f); + float inverse_sigma_squared = 1.0f / (sigma * sigma); + float radius = NK_CLAMP(0.0f, (float)(key.radius + key.second_parameter), + NK_MIN(shape_w, shape_h) * .5f); for (int y = 0; y < height; ++y) for (int x = 0; x < width; ++x) { float local_x = x + .5f - center_x + shape_w * .5f; float local_y = y + .5f - center_y + shape_h * .5f; - float distance = rounded_distance(local_x, local_y, - shape_w, shape_h, - (float)(key.radius + key.second_parameter)); + float distance = rounded_distance(local_x, local_y, shape_w, shape_h, radius); float alpha = .55f * - expf(-.5f * distance * distance / (sigma * sigma)); + expf(-.5f * distance * distance * inverse_sigma_squared); pixels[(size_t)y * width + x] = (unsigned char)(255.0f * clamp01(alpha) + .5f); } diff --git a/src/ui/rendering/ui_paint_cache.c b/src/ui/rendering/ui_paint_cache.c index 65b12c39..a706f65c 100644 --- a/src/ui/rendering/ui_paint_cache.c +++ b/src/ui/rendering/ui_paint_cache.c @@ -132,15 +132,28 @@ void bongo_cat_ui_paint_cache_draw(struct nk_context *context, void bongo_cat_ui_paint_cache_begin_frame(BongoCatUIBackend *backend) { if (!backend) return; - uint64_t marker = backend->paint_frame_marker; - if (marker) for (size_t i = 0; i < PAINT_CACHE_COUNT; ++i) { - BongoCatUIPaintTexture *item = &textures[i]; - if (item->backend == backend && item->texture && item->used < marker) - release_texture(item); - } + /* Reuse hover/animation variants across frames. LRU allocation still caps + this cache at 48 entries / 16 MiB and protects current-frame commands. */ backend->paint_frame_marker = use_counter + 1; } +void bongo_cat_ui_paint_cache_trim_idle(BongoCatUIBackend *backend) { + /* Keep small reusable effects, evict older variants first. No timer or + extra redraw is needed; closing the window releases everything. */ + size_t bytes = bongo_cat_ui_paint_cache_usage(backend, NULL); + while (bytes > 4u * 1024u * 1024u) { + BongoCatUIPaintTexture *oldest = NULL; + for (size_t i = 0; i < PAINT_CACHE_COUNT; i++) { + BongoCatUIPaintTexture *item = &textures[i]; + if (item->backend == backend && item->texture && + (!oldest || item->used < oldest->used)) oldest = item; + } + if (!oldest) break; + bytes -= oldest->bytes; + release_texture(oldest); + } +} + size_t bongo_cat_ui_paint_cache_usage(BongoCatUIBackend *backend, size_t *texture_count) { size_t bytes = 0, count = 0; diff --git a/src/ui/rendering/ui_paint_cache.h b/src/ui/rendering/ui_paint_cache.h index e4a6a1d0..26c04f6b 100644 --- a/src/ui/rendering/ui_paint_cache.h +++ b/src/ui/rendering/ui_paint_cache.h @@ -35,6 +35,7 @@ void bongo_cat_ui_paint_cache_draw(struct nk_context *context, struct nk_rect bounds, const BongoCatUIPaintTexture *item, struct nk_color tint); void bongo_cat_ui_paint_cache_begin_frame(BongoCatUIBackend *backend); +void bongo_cat_ui_paint_cache_trim_idle(BongoCatUIBackend *backend); size_t bongo_cat_ui_paint_cache_usage(BongoCatUIBackend *backend, size_t *texture_count); void bongo_cat_ui_paint_cache_destroy(BongoCatUIBackend *backend); diff --git a/tests/ui/test_preferences_lifecycle.c b/tests/ui/test_preferences_lifecycle.c index 1fd032a7..98295082 100644 --- a/tests/ui/test_preferences_lifecycle.c +++ b/tests/ui/test_preferences_lifecycle.c @@ -5,6 +5,9 @@ #include "runtime.h" #include "bongo_cat/file.h" #include "bongo_cat/path.h" +#include "preferences_about_internal.h" +#include "ui_paint_cache.h" +#include "ui_paint.h" #include #include @@ -36,14 +39,18 @@ static void about_disk_cache(BongoCatApp *app) { CHECK(bongo_cat_path_join(directory, sizeof(directory), root, "about")); CHECK(bongo_cat_path_create_directory(directory)); CHECK(bongo_cat_path_join(path, sizeof(path), directory, "wechat-v1.svg")); - const char svg[] = "" - ""; + /* Larger than the network's initial buffer, but well below the QR limit. */ + char svg[9000]; + snprintf(svg, sizeof(svg), + "" + "", 0); + size_t svg_size = strlen(svg); Uint32 event_type = SDL_RegisterEvents(1); for (int stale = 0; stale < 2; stale++) { FILE *file = bongo_cat_file_open(path, "wb"); CHECK(file != NULL); if (!file) return; - CHECK(fwrite(svg, 1, sizeof(svg) - 1, file) == sizeof(svg) - 1); + CHECK(fwrite(svg, 1, svg_size, file) == svg_size); CHECK(fflush(file) == 0); if (stale) { time_t old = time(NULL) - 2 * 86400; @@ -67,7 +74,7 @@ static void about_disk_cache(BongoCatApp *app) { CHECK(SDL_GetAtomicInt(&job->done)); CHECK(job->status == 200 && job->qr_pixels); CHECK(job->refresh_needed == (stale != 0)); - CHECK(!job->response); + CHECK(!job->response && !job->capacity); bongo_cat_about_request_free(job); CHECK(SDL_GetPathInfo(path, &after)); CHECK(before.modify_time == after.modify_time); @@ -75,7 +82,7 @@ static void about_disk_cache(BongoCatApp *app) { CHECK(file != NULL); if (file) { char bytes[sizeof(svg)] = {0}; - CHECK(fread(bytes, 1, sizeof(svg) - 1, file) == sizeof(svg) - 1); + CHECK(fread(bytes, 1, svg_size, file) == svg_size); CHECK(strcmp(bytes, svg) == 0); CHECK(fclose(file) == 0); } @@ -123,6 +130,48 @@ static void about_refresh_failure(BongoCatApp *app) { free(value); } +static void about_unchanged_contributors(BongoCatApp *app) { + BongoCatPreferences *value = calloc(1, sizeof(*value)); + CHECK(value != NULL); + if (!value) return; + value->app = app; + value->visible = true; + value->about.event_type = SDL_RegisterEvents(1); + value->about.qr_attempted = value->about.contributors_attempted = true; + for (int update = 0; update < 4; update++) { + BongoCatAboutRequest *job = calloc(1, sizeof(*job)); + CHECK(job != NULL); + if (!job) break; + job->feed = calloc(1, sizeof(*job->feed)); + CHECK(job->feed != NULL); + if (!job->feed) { free(job); break; } + job->feed->count = 1; + job->feed->people[0].pixels = calloc(BONGO_ABOUT_AVATAR_SIZE * BONGO_ABOUT_AVATAR_SIZE, 4); + CHECK(job->feed->people[0].pixels != NULL); + if (!job->feed->people[0].pixels) { bongo_cat_about_request_free(job); break; } + if (update >= 2) job->feed->people[0].pixels[0] = 255; + if (update == 3) strcpy(job->feed->people[0].name, "Renamed contributor"); + job->kind = BONGO_ABOUT_CONTRIBUTORS; + job->status = 200; + SDL_SetAtomicInt(&job->done, 1); + BongoCatAboutFeed *previous = value->about.contributors; + value->about.contributors_request = job; + value->about.portraits_loaded = true; + value->about.portraits_next = 1; + bongo_cat_about_refresh(value); + CHECK(!value->about.contributors_request); + if (update == 1) { + CHECK(value->about.contributors == previous); + CHECK(value->about.portraits_loaded && value->about.portraits_next == 1); + } else { + CHECK(value->about.contributors != previous); + CHECK(!value->about.portraits_loaded && value->about.portraits_next == 0); + } + } + bongo_cat_about_clear(value, false); + free(value); +} + static void about_session_cache(BongoCatPreferences *value) { BongoCatAboutState *s = &value->about; CHECK(s->contributors_attempted || (s->contributors_request && @@ -154,6 +203,58 @@ static void about_session_cache(BongoCatPreferences *value) { SDL_GL_MakeCurrent(value->app->window, value->app->gl_context); } +static float original_paragraph_height(const char *text, + const struct nk_user_font *font, float width, float leading) { + float height = 0; + while (*text) { + int length = 0, remaining = (int)strlen(text); + while (length < remaining) { + nk_rune rune; + int bytes = nk_utf_decode(text + length, &rune, remaining - length); + if (bytes <= 0) break; + float next = font->width(font->userdata, font->height, text, length + bytes); + if (length && next > width) break; + length += bytes; + if (rune == '\n') break; + } + if (!length) break; + height += leading; + text += length; + while (*text == ' ' || *text == '\n') text++; + } + return height; +} + +static void about_render_cost_regressions(BongoCatPreferences *value) { + const char *texts[] = {"Open source & community contributions", + "Every step of BongoCat comes from open source. Thank you to all our contributors.", + "\xe6\x84\x9f\xe8\xb0\xa2\xe6\x89\x80\xe6\x9c\x89\xe8\xb4\xa1\xe7\x8c\xae\xe8\x80\x85 BongoCat", + "First line\n Second line", "", "A"}; + const struct nk_user_font *font = value->ui.caption_font; + for (size_t i = 0; i < sizeof(texts) / sizeof(texts[0]); i++) + for (int width = 1; width <= 400; width += 3) { + float actual = bongo_cat_about_paragraph(NULL, nk_rect(0, 0, (float)width, 0), + texts[i], font, nk_rgb(0, 0, 0), false, 24); + CHECK(actual == original_paragraph_height(texts[i], font, (float)width, 24)); + } + SDL_GL_MakeCurrent(value->window, value->gl_context); + BongoCatUIPaintKey key = {BONGO_CAT_UI_PAINT_SHADOW, 8, 8, 4, 2, 0, 0, 0}; + unsigned char pixels[64] = {0}; + bongo_cat_ui_paint_cache_begin_frame(&value->ui); + BongoCatUIPaintTexture *item = bongo_cat_ui_paint_cache_get(&value->ui, &key); + CHECK(item && bongo_cat_ui_paint_cache_upload(item, pixels, true)); + /* One unused frame and idle trimming must not discard a small hover effect. */ + bongo_cat_ui_paint_cache_begin_frame(&value->ui); + bongo_cat_ui_paint_cache_begin_frame(&value->ui); + bongo_cat_ui_trim_idle(&value->ui); + item = bongo_cat_ui_paint_cache_get(&value->ui, &key); + CHECK(bongo_cat_ui_paint_cache_ready(item)); + CHECK(bongo_cat_ui_paint_cache_usage(&value->ui, NULL) <= 4u * 1024u * 1024u); + bongo_cat_ui_paint_destroy(&value->ui); + CHECK(bongo_cat_ui_paint_cache_usage(&value->ui, NULL) == 0); + SDL_GL_MakeCurrent(value->app->window, value->app->gl_context); +} + static void close_over_scrolled_models(BongoCatPreferences *value) { if (bongo_cat_ui_native_chrome()) return; struct nk_context *context = &value->ui.context; @@ -195,11 +296,13 @@ int main(int argc, char **argv) { BongoCatPreferences *value = app->preferences; about_disk_cache(app); about_refresh_failure(app); + about_unchanged_contributors(app); CHECK(value != NULL); if (value) { for (int cycle = 0; cycle < 3; ++cycle) { bongo_cat_preferences_show(value); CHECK(value->window && value->gl_context && value->ui_initialized); + if (!cycle) about_render_cost_regressions(value); about_session_cache(value); bongo_cat_preferences_close(value); CHECK(!value->about.contributors && !value->about.qr_pixels); From 43571bbfd5f2415904b593420cd9b69ae4f4bc93 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 19 Sep 2026 12:18:58 +0800 Subject: [PATCH 324/334] fix: github action --- vendor/nanosvg/nanosvgrast.h | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/vendor/nanosvg/nanosvgrast.h b/vendor/nanosvg/nanosvgrast.h index ba512c74..a3041d1d 100644 --- a/vendor/nanosvg/nanosvgrast.h +++ b/vendor/nanosvg/nanosvgrast.h @@ -767,9 +767,14 @@ static void nsvg__flattenShapeStroke(NSVGrasterizer* r, NSVGshape* shape, float closed = 1; } - if (shape->strokeDashCount > 0) { + float allDashLen = 0.0f; + for (j = 0; j < shape->strokeDashCount; j++) + allDashLen += shape->strokeDashArray[j]; + if (shape->strokeDashCount & 1) + allDashLen *= 2.0f; + if (shape->strokeDashCount > 0 && allDashLen > 0.0f) { int idash = 0, dashState = 1; - float totalDist = 0, dashLen, allDashLen, dashOffset; + float totalDist = 0, dashLen, dashOffset; NSVGpoint cur; if (closed) @@ -782,12 +787,6 @@ static void nsvg__flattenShapeStroke(NSVGrasterizer* r, NSVGshape* shape, float cur = r->points2[0]; nsvg__appendPathPoint(r, cur); - // Figure out dash offset. - allDashLen = 0; - for (j = 0; j < shape->strokeDashCount; j++) - allDashLen += shape->strokeDashArray[j]; - if (shape->strokeDashCount & 1) - allDashLen *= 2.0f; // Find location inside pattern dashOffset = fmodf(shape->strokeDashOffset, allDashLen); if (dashOffset < 0.0f) From f2eb50a538c1d0f798039a78dba00cb78d2f63f4 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 19 Sep 2026 12:30:52 +0800 Subject: [PATCH 325/334] =?UTF-8?q?fix=EF=BC=9A=20github=20action?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/ui/test_preferences_lifecycle.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ui/test_preferences_lifecycle.c b/tests/ui/test_preferences_lifecycle.c index 98295082..9458a634 100644 --- a/tests/ui/test_preferences_lifecycle.c +++ b/tests/ui/test_preferences_lifecycle.c @@ -5,7 +5,7 @@ #include "runtime.h" #include "bongo_cat/file.h" #include "bongo_cat/path.h" -#include "preferences_about_internal.h" +#include "about/preferences_about_internal.h" #include "ui_paint_cache.h" #include "ui_paint.h" From a96694c2544419dde8f71531af0c860f23176200 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 19 Sep 2026 13:00:41 +0800 Subject: [PATCH 326/334] feat: improved the error message shown when importing a model fails --- resources/assets/locales/de-DE.json | 2 +- resources/assets/locales/en-US.json | 2 +- resources/assets/locales/es-ES.json | 2 +- resources/assets/locales/fr-FR.json | 2 +- resources/assets/locales/ja-JP.json | 2 +- resources/assets/locales/ko-KR.json | 2 +- resources/assets/locales/pt-BR.json | 2 +- resources/assets/locales/ru-RU.json | 2 +- resources/assets/locales/zh-CN.json | 2 +- resources/assets/locales/zh-Hant.json | 2 +- .../model/import/model_import_progressive.c | 4 +- .../preferences/preferences_import_progress.c | 4 +- src/ui/preferences/preferences_model_glyphs.c | 3 + src/ui/preferences/preferences_model_import.c | 6 +- src/ui/preferences/preferences_notice.c | 98 +++++++++++++------ src/ui/preferences/preferences_present.c | 9 +- src/ui/preferences/preferences_state.h | 5 +- 17 files changed, 98 insertions(+), 51 deletions(-) diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index 45af3e89..8bf5d6a2 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -94,7 +94,7 @@ "deleteFailed": "Dieses Modell konnte nicht gelöscht werden", "importSuccess": "Erfolgreich importiert", "importExists": "Modell ist bereits vorhanden", - "importBatchResult": "Import erfolgreich: %zu; fehlgeschlagen: %zu. Fehlende Modelle: %s%s. Erster Fehler: %s", + "importBatchResult": "Import erfolgreich: %zu; fehlgeschlagen: %zu.\nFehlende Modelle: %s%s.\nErster Fehler: %s", "importMoreFailures": " und %zu weitere", "importUnknownSource": "unbekannte Quelle", "importInvalidSource": "Die ausgewählte Quelle ist nicht mehr vorhanden oder kann nicht verwendet werden", diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index 348dcb03..6d93b723 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -94,7 +94,7 @@ "deleteFailed": "Unable to delete this model", "importSuccess": "Imported Successfully", "importExists": "Model already exists", - "importBatchResult": "Import succeeded: %zu; failed: %zu. Failed models: %s%s. First failure: %s", + "importBatchResult": "Import succeeded: %zu; failed: %zu.\nFailed models: %s%s.\nFirst failure: %s", "importMoreFailures": " and %zu more", "importUnknownSource": "unknown source", "importInvalidSource": "The selected source no longer exists or cannot be used", diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index 209a7150..72f78eaa 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -94,7 +94,7 @@ "deleteFailed": "No se pudo eliminar este modelo", "importSuccess": "Importado correctamente", "importExists": "El modelo ya existe", - "importBatchResult": "Importación correcta: %zu; fallida: %zu. Modelos fallidos: %s%s. Primer error: %s", + "importBatchResult": "Importación correcta: %zu; fallida: %zu.\nModelos fallidos: %s%s.\nPrimer error: %s", "importMoreFailures": " y %zu más", "importUnknownSource": "fuente desconocida", "importInvalidSource": "La fuente seleccionada ya no existe o no se puede utilizar", diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index 78b51342..d5a5d22b 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -94,7 +94,7 @@ "deleteFailed": "Impossible de supprimer ce modèle", "importSuccess": "Importation réussie", "importExists": "Le modèle existe déjà", - "importBatchResult": "Importations réussies : %zu ; échecs : %zu. Modèles en échec : %s%s. Premier échec : %s", + "importBatchResult": "Importations réussies : %zu ; échecs : %zu.\nModèles en échec : %s%s.\nPremier échec : %s", "importMoreFailures": " et %zu autre(s)", "importUnknownSource": "source inconnue", "importInvalidSource": "La source sélectionnée n'existe plus ou ne peut pas être utilisée", diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index 8b63a54a..5ec817aa 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -94,7 +94,7 @@ "deleteFailed": "このモデルを削除できません", "importSuccess": "インポートしました", "importExists": "モデルは既に存在します", - "importBatchResult": "インポート成功: %zu件、失敗: %zu件。失敗したモデル: %s%s。最初の失敗理由: %s", + "importBatchResult": "インポート成功: %zu件、失敗: %zu件。\n失敗したモデル: %s%s。\n最初の失敗理由: %s", "importMoreFailures": "、他%zu件", "importUnknownSource": "不明なソース", "importInvalidSource": "選択したインポート元は存在しないか、使用できません", diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index d5eb4dbc..d57f0c11 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -94,7 +94,7 @@ "deleteFailed": "이 모델을 삭제할 수 없습니다", "importSuccess": "가져오기 완료", "importExists": "모델이 이미 존재합니다", - "importBatchResult": "가져오기 성공 %zu개, 실패 %zu개. 실패한 모델: %s%s. 첫 번째 실패 원인: %s", + "importBatchResult": "가져오기 성공 %zu개, 실패 %zu개.\n실패한 모델: %s%s.\n첫 번째 실패 원인: %s", "importMoreFailures": " 외 %zu개", "importUnknownSource": "알 수 없는 원본", "importInvalidSource": "선택한 원본이 더 이상 존재하지 않거나 사용할 수 없습니다", diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index c095a445..669632c2 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -94,7 +94,7 @@ "deleteFailed": "Não foi possível excluir este modelo", "importSuccess": "Importação bem-sucedida", "importExists": "O modelo já existe", - "importBatchResult": "Importados com sucesso: %zu; falhas: %zu. Modelos com falha: %s%s. Primeira falha: %s", + "importBatchResult": "Importados com sucesso: %zu; falhas: %zu.\nModelos com falha: %s%s.\nPrimeira falha: %s", "importMoreFailures": " e mais %zu", "importUnknownSource": "origem desconhecida", "importInvalidSource": "A origem selecionada não existe mais ou não pode ser usada", diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index 9760c35c..1c74d027 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -94,7 +94,7 @@ "deleteFailed": "Не удалось удалить эту модель", "importSuccess": "Успешно импортировано", "importExists": "Модель уже существует", - "importBatchResult": "Успешно импортировано: %zu; ошибок: %zu. Ошибки моделей: %s%s. Первая ошибка: %s", + "importBatchResult": "Успешно импортировано: %zu; ошибок: %zu.\nОшибки моделей: %s%s.\nПервая ошибка: %s", "importMoreFailures": " и ещё %zu", "importUnknownSource": "неизвестный источник", "importInvalidSource": "Выбранный источник больше не существует или не может быть использован", diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index 05e979f0..9ef57391 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -94,7 +94,7 @@ "deleteFailed": "无法删除此模型", "importSuccess": "导入成功", "importExists": "模型已存在,无需重复导入", - "importBatchResult": "导入成功 %zu 个,导入失败 %zu 个。失败模型:%s%s。首个失败原因:%s", + "importBatchResult": "导入成功 %zu 个,导入失败 %zu 个。\n失败模型:%s%s。\n首个失败原因:%s", "importMoreFailures": "等其余 %zu 个", "importUnknownSource": "未知来源", "importInvalidSource": "所选来源已不存在或无法使用", diff --git a/resources/assets/locales/zh-Hant.json b/resources/assets/locales/zh-Hant.json index d68ccad8..2807fd46 100644 --- a/resources/assets/locales/zh-Hant.json +++ b/resources/assets/locales/zh-Hant.json @@ -94,7 +94,7 @@ "deleteFailed": "無法刪除此模型", "importSuccess": "匯入成功", "importExists": "模型已存在,無需重複匯入", - "importBatchResult": "匯入成功 %zu 個,匯入失敗 %zu 個。失敗模型:%s%s。首個失敗原因:%s", + "importBatchResult": "匯入成功 %zu 個,匯入失敗 %zu 個。\n失敗模型:%s%s。\n首個失敗原因:%s", "importMoreFailures": "等其餘 %zu 個", "importUnknownSource": "未知來源", "importInvalidSource": "所選來源已不存在或無法使用", diff --git a/src/runtime/model/import/model_import_progressive.c b/src/runtime/model/import/model_import_progressive.c index 3e111ff1..b7b89fe3 100644 --- a/src/runtime/model/import/model_import_progressive.c +++ b/src/runtime/model/import/model_import_progressive.c @@ -92,8 +92,8 @@ static void record_failure_name(BongoCatImportBatchStats *stats, BONGO_CAT_IMPORT_FAILURE_NAME_CAP) return; const char *name = bongo_cat_path_name(source); if (!name || !name[0]) return; - snprintf(stats->failure_names[stats->failure_name_count++], - BONGO_CAT_ID_CAP, "%s", name); + SDL_utf8strlcpy(stats->failure_names[stats->failure_name_count++], name, + BONGO_CAT_ID_CAP); } static BongoCatResult install_one(BongoCatImportSession *session, diff --git a/src/ui/preferences/preferences_import_progress.c b/src/ui/preferences/preferences_import_progress.c index c0d13f90..a11ebdb1 100644 --- a/src/ui/preferences/preferences_import_progress.c +++ b/src/ui/preferences/preferences_import_progress.c @@ -13,8 +13,8 @@ void bongo_cat_preferences_import_record_failure(BongoCatImportJob *job, BONGO_CAT_IMPORT_FAILURE_NAME_CAP) return; const char *name = bongo_cat_path_name(source); if (!name || !name[0]) return; - snprintf(job->failed_names[job->failed_name_count++], BONGO_CAT_ID_CAP, - "%s", name); + SDL_utf8strlcpy(job->failed_names[job->failed_name_count++], name, + BONGO_CAT_ID_CAP); } void bongo_cat_preferences_import_merge_failures(BongoCatImportJob *job, diff --git a/src/ui/preferences/preferences_model_glyphs.c b/src/ui/preferences/preferences_model_glyphs.c index 4fee784c..3ae3cbca 100644 --- a/src/ui/preferences/preferences_model_glyphs.c +++ b/src/ui/preferences/preferences_model_glyphs.c @@ -92,6 +92,9 @@ void bongo_cat_preferences_model_glyphs(const BongoCatApp *app, app->settings.behavior_shortcuts[i].label); if (app->preferences) { const BongoCatPreferences *preferences = app->preferences; + for (size_t i = 0; i < sizeof(preferences->notices) / + sizeof(preferences->notices[0]); ++i) + add_text(ranges, capacity, &used, preferences->notices[i].message); for (size_t i = 0; i < preferences->pending_import_name_count; ++i) add_text(ranges, capacity, &used, preferences->pending_import_names[i]); diff --git a/src/ui/preferences/preferences_model_import.c b/src/ui/preferences/preferences_model_import.c index e6f68b77..90716152 100644 --- a/src/ui/preferences/preferences_model_import.c +++ b/src/ui/preferences/preferences_model_import.c @@ -42,8 +42,8 @@ void bongo_cat_preferences_import_complete(BongoCatApp *app, const char (*failed_names)[BONGO_CAT_ID_CAP], size_t failed_name_count) { if (!app || !app->preferences) return; bool failed = failed_count > 0 || result != BONGO_CAT_OK; - char batch_message[384]; - char failure_list[220] = ""; + char batch_message[1024]; + char failure_list[BONGO_CAT_IMPORT_FAILURE_NAME_CAP * (BONGO_CAT_ID_CAP + 2)] = ""; char more_failures[64] = ""; size_t shown_names = failed_names && failed_name_count > BONGO_CAT_IMPORT_FAILURE_NAME_CAP ? BONGO_CAT_IMPORT_FAILURE_NAME_CAP @@ -73,7 +73,7 @@ void bongo_cat_preferences_import_complete(BongoCatApp *app, ? error->code : result; snprintf(batch_message, sizeof(batch_message), tr(app, "pages.preference.model.hints.importBatchResult", - "Import succeeded: %zu; failed: %zu. Failed models: %s%s. First failure: %s"), + "Import succeeded: %zu; failed: %zu.\nFailed models: %s%s.\nFirst failure: %s"), succeeded_count, failed_count, failure_list, more_failures, import_failure_message(app, failure)); message = batch_message; diff --git a/src/ui/preferences/preferences_notice.c b/src/ui/preferences/preferences_notice.c index 01c5d8f5..62c382c0 100644 --- a/src/ui/preferences/preferences_notice.c +++ b/src/ui/preferences/preferences_notice.c @@ -1,5 +1,6 @@ #include "preferences_notice.h" #include "preferences_state.h" +#include "preferences_model_glyphs.h" #include "ui_animation.h" #include "ui_backend.h" #include "ui_catime.h" @@ -11,6 +12,17 @@ enum { NOTICE_DURATION_MS = 2600, NOTICE_ENTER_MS = 250 }; +static void update_notice_timer(BongoCatPreferences *value, + BongoCatPreferenceNotice *notice, uint64_t now) { + if (!notice->message[0]) return; + bool hovered = SDL_GetMouseFocus() == value->window && + nk_input_is_mouse_hovering_rect(&value->ui.context.input, notice->bounds); + if (notice->hovered || hovered) + notice->until_ns += now - notice->timer_updated_ns; + notice->timer_updated_ns = now; + notice->hovered = hovered; +} + void bongo_cat_preferences_notice_show(BongoCatApp *app, const char *message, bool error) { if (!app || !app->preferences || !message || !message[0]) return; @@ -19,15 +31,24 @@ void bongo_cat_preferences_notice_show(BongoCatApp *app, BongoCatPreferenceNotice *target = &value->notices[0]; for (size_t i = 0; i < sizeof(value->notices) / sizeof(value->notices[0]); ++i) { BongoCatPreferenceNotice *notice = &value->notices[i]; + update_notice_timer(value, notice, now); if (!notice->message[0] || notice->until_ns <= now) { target = notice; break; } - if (notice->started_ns < target->started_ns) target = notice; + if ((target->hovered && !notice->hovered) || + (notice->hovered == target->hovered && + notice->started_ns < target->started_ns)) target = notice; + } + memset(target, 0, sizeof(*target)); + SDL_utf8strlcpy(target->message, message, sizeof(target->message)); + if (!bongo_cat_preferences_model_glyphs_ready(value, target->message)) { + value->font_reload_pending = true; + value->font_reload_defer_once = false; } - snprintf(target->message, sizeof(target->message), "%s", message); target->error = error; target->started_ns = now; target->until_ns = now + NOTICE_DURATION_MS * 1000000ULL; + target->timer_updated_ns = now; value->render_dirty = true; } @@ -36,6 +57,7 @@ static size_t active_notices(BongoCatPreferences *value, uint64_t now, size_t count = 0; for (size_t i = 0; i < sizeof(value->notices) / sizeof(value->notices[0]); ++i) { BongoCatPreferenceNotice *notice = &value->notices[i]; + update_notice_timer(value, notice, now); if (notice->message[0] && notice->until_ns <= now) memset(notice, 0, sizeof(*notice)); if (notice->message[0]) items[count++] = notice; @@ -51,37 +73,40 @@ static size_t active_notices(BongoCatPreferences *value, uint64_t now, return count; } -static float fit_notice_text(const struct nk_user_font *font, - const char *source, float maximum, char *output, size_t capacity) { - snprintf(output, capacity, "%s", source); - float width = font->width(font->userdata, font->height, - output, nk_strlen(output)); - if (width <= maximum) return width; - size_t prefix = strlen(source); - if (prefix > capacity - 4) prefix = capacity - 4; - while (prefix) { - prefix--; - while (prefix && ((unsigned char)source[prefix] & 0xc0) == 0x80) - prefix--; - memcpy(output, source, prefix); - memcpy(output + prefix, "...", 4); - width = font->width(font->userdata, font->height, - output, nk_strlen(output)); - if (width <= maximum) return width; +static int notice_line(const struct nk_user_font *font, const char *text, + float maximum, float *width) { + int length = 0, remaining = nk_strlen(text); + *width = 0; + while (length < remaining && text[length] != '\n') { + nk_rune rune; + int bytes = nk_utf_decode(text + length, &rune, remaining - length); + if (bytes <= 0) bytes = 1; + float next_width = font->width(font->userdata, font->height, + text, length + bytes); + if (length && next_width > maximum) break; + length += bytes; + *width = next_width; } - snprintf(output, capacity, "..."); - return font->width(font->userdata, font->height, output, 3); + return length; } -static void draw_notice(BongoCatPreferences *value, +static float draw_notice(BongoCatPreferences *value, struct nk_context *context, BongoCatPreferenceNotice *notice, float width, float y, uint64_t now) { BongoCatUIPalette p = bongo_cat_ui_palette( bongo_cat_ui_dark(context)); const struct nk_user_font *font = bongo_cat_ui_label_font(context); - char label_text[sizeof(notice->message)]; - float text_width = fit_notice_text(font, notice->message, - NK_MAX(40.0f, width - 100.0f), label_text, sizeof(label_text)); + float maximum = NK_MAX(40.0f, width - 100.0f); + float text_width = 0; + size_t lines = 0; + for (const char *cursor = notice->message; *cursor; ++lines) { + float line_width; + cursor += notice_line(font, cursor, maximum, &line_width); + text_width = NK_MAX(text_width, line_width); + if (*cursor == '\n') cursor++; + } + float line_height = font->height + 5.0f; + float toast_height = NK_MAX(41.0f, lines * line_height + 15.0f); float toast_width = NK_MIN(text_width + 36.0f, width - 64.0f); float elapsed = (float)(now - notice->started_ns) / (NOTICE_ENTER_MS * 1000000.0f); @@ -90,7 +115,8 @@ static void draw_notice(BongoCatPreferences *value, float opacity = NK_CLAMP(0.0f, progress, 1.0f); float shown_width = toast_width * (.9f + .1f * progress); struct nk_rect bounds = nk_rect((width - shown_width) * .5f, - y - 12.0f * (1.0f - progress), shown_width, 41.0f); + y - 12.0f * (1.0f - progress), shown_width, toast_height); + notice->bounds = bounds; struct nk_color tone = notice->error ? p.danger : p.pink; if (p.effects) bongo_cat_ui_paint_shadow(context, bounds, 20, 0, 8, notice->error ? 25.0f : 22.0f, 0, nk_rgba(tone.r, tone.g, tone.b, @@ -98,12 +124,19 @@ static void draw_notice(BongoCatPreferences *value, struct nk_command_buffer *canvas = nk_window_get_canvas(context); nk_fill_rect(canvas, bounds, 20, nk_rgba(tone.r, tone.g, tone.b, (nk_byte)(255 * opacity))); - struct nk_rect label = nk_rect(bounds.x + (bounds.w - text_width) * .5f, - bounds.y + (bounds.h - font->height) * .5f, - NK_MIN(text_width + 1, bounds.w - 20), font->height); - nk_draw_text(canvas, label, label_text, nk_strlen(label_text), font, - nk_rgba(0, 0, 0, 0), nk_rgba(255, 255, 255, (nk_byte)(255 * opacity))); + float label_y = bounds.y + (bounds.h - lines * line_height + 5.0f) * .5f; + for (const char *cursor = notice->message; *cursor; label_y += line_height) { + float line_width; + int length = notice_line(font, cursor, maximum, &line_width); + struct nk_rect label = nk_rect(bounds.x + (bounds.w - text_width) * .5f, + label_y, NK_MIN(text_width + 1, bounds.w - 20), font->height); + nk_draw_text(canvas, label, cursor, length, font, + nk_rgba(0, 0, 0, 0), nk_rgba(255, 255, 255, (nk_byte)(255 * opacity))); + cursor += length; + if (*cursor == '\n') cursor++; + } value->render_dirty = true; + return toast_height; } void bongo_cat_preferences_notice_draw(BongoCatPreferences *value, @@ -113,6 +146,7 @@ void bongo_cat_preferences_notice_draw(BongoCatPreferences *value, uint64_t now = SDL_GetTicksNS(); BongoCatPreferenceNotice *items[4]; size_t count = active_notices(value, now, items); + float y = 20.0f; for (size_t i = 0; i < count; ++i) - draw_notice(value, context, items[i], width, 20.0f + i * 51.0f, now); + y += draw_notice(value, context, items[i], width, y, now) + 10.0f; } diff --git a/src/ui/preferences/preferences_present.c b/src/ui/preferences/preferences_present.c index c1f74d7e..6a0a37cf 100644 --- a/src/ui/preferences/preferences_present.c +++ b/src/ui/preferences/preferences_present.c @@ -1,6 +1,7 @@ #include "preferences_state.h" #include "preferences_render_internal.h" #include "preferences_controls.h" +#include "preferences_model_glyphs.h" #include "ui_animation.h" #include "ui_paint.h" #include "bongo_cat/memory_policy.h" @@ -38,7 +39,13 @@ void bongo_cat_preferences_render(BongoCatPreferences *value) { bongo_cat_preferences_refresh_raster(value); bongo_cat_preferences_reload_language(value); } - if (value->font_reload_pending && !importing && !refreshing_models) { + bool notice_font_reload = false; + for (size_t i = 0; i < sizeof(value->notices) / sizeof(value->notices[0]); ++i) + if (value->notices[i].message[0] && value->notices[i].until_ns > now && + !bongo_cat_preferences_model_glyphs_ready(value, value->notices[i].message)) + notice_font_reload = true; + if (value->font_reload_pending && + (notice_font_reload || (!importing && !refreshing_models))) { if (value->font_reload_defer_once) { value->font_reload_defer_once = false; value->render_dirty = true; diff --git a/src/ui/preferences/preferences_state.h b/src/ui/preferences/preferences_state.h index 3a8b2495..86b0c37c 100644 --- a/src/ui/preferences/preferences_state.h +++ b/src/ui/preferences/preferences_state.h @@ -16,9 +16,12 @@ #include "modal_frame.h" typedef struct BongoCatPreferenceNotice { - char message[384]; + char message[1024]; uint64_t started_ns; uint64_t until_ns; + uint64_t timer_updated_ns; + struct nk_rect bounds; + bool hovered; bool error; } BongoCatPreferenceNotice; From 16c401364898b602f7a08fb132d27bedb35a35c6 Mon Sep 17 00:00:00 2001 From: yuanziyi2022 Date: Sat, 19 Sep 2026 13:01:00 +0800 Subject: [PATCH 327/334] chore: refresh group QR code (2026-09-19) --- docs/wechat.png | Bin 0 -> 66130 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/wechat.png diff --git a/docs/wechat.png b/docs/wechat.png new file mode 100644 index 0000000000000000000000000000000000000000..2ca68c5e4d67e95a2fd9a6ab30d0267e313a69ee GIT binary patch literal 66130 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 z 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 literal 0 HcmV?d00001 From fea6606014253ba87fea792138464b1c6a9bfe72 Mon Sep 17 00:00:00 2001 From: vladelaina Date: Sat, 19 Sep 2026 13:48:43 +0800 Subject: [PATCH 328/334] fix: fixed an inmport issue casued by multiple moc3.json files --- .../import/mver/model_import_mver_discovery.c | 9 +++ tests/model_import/test_mver_manifest.c | 67 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/src/runtime/model/import/mver/model_import_mver_discovery.c b/src/runtime/model/import/mver/model_import_mver_discovery.c index 341190f6..74c62300 100644 --- a/src/runtime/model/import/mver/model_import_mver_discovery.c +++ b/src/runtime/model/import/mver/model_import_mver_discovery.c @@ -90,6 +90,15 @@ static bool mode_uses_live2d(yyjson_val *mode) { } static bool model_at(const char *directory, char *setting, size_t capacity) { + /* Mver explicitly loads cat.model3.json. Authored packages may also keep + the original Live2D export beside it; that is not an ambiguous entry. */ + char path[BONGO_CAT_PATH_CAP]; + if (bongo_cat_path_join(path, sizeof(path), directory, "cat.model3.json") && + bongo_cat_path_is_file(path)) { + int written = snprintf(setting, capacity, "cat.model3.json"); + return written >= 0 && (size_t)written < capacity && + bongo_cat_import_mver_manifest_valid(directory, setting); + } return bongo_cat_path_find_unique_suffix(directory, ".model3.json", setting, capacity) == 1 && bongo_cat_import_mver_manifest_valid(directory, setting); diff --git a/tests/model_import/test_mver_manifest.c b/tests/model_import/test_mver_manifest.c index d8e5e2b2..0ed257f7 100644 --- a/tests/model_import/test_mver_manifest.c +++ b/tests/model_import/test_mver_manifest.c @@ -1,5 +1,6 @@ #include "model_import.h" #include "model_import_manifest.h" +#include "mver/model_import_mver.h" #include "mver/model_import_mver_manifest.h" #include "model_storage.h" #include "test_mver_import_internal.h" @@ -18,7 +19,73 @@ static const char *broken = "\"Groups\":[{\"Target\":\"Parameter\",\"Name\":\"EyeBlink\"," "\"Ids\":[\"ParamEyeLOpen\"]}]}"; +static void multiple_mver_manifests(void) { + char *temporary = SDL_GetCurrentDirectory(); + BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); + CHECK(temporary && discovery); + if (!temporary || !discovery) { + SDL_free(temporary); + free(discovery); + return; + } + char root[BONGO_CAT_PATH_CAP], package[BONGO_CAT_PATH_CAP]; + char model[BONGO_CAT_PATH_CAP], canonical[BONGO_CAT_PATH_CAP]; + char exported[BONGO_CAT_PATH_CAP], extra[BONGO_CAT_PATH_CAP]; + char models[BONGO_CAT_PATH_CAP], stored[BONGO_CAT_PATH_CAP]; + snprintf(root, sizeof(root), "%s/bongocat-multiple-manifests-%llu", + temporary, (unsigned long long)SDL_GetTicksNS()); + CHECK(SDL_CreateDirectory(root)); + CHECK(child(package, sizeof(package), root, "source", true)); + CHECK(mver_fixture(package)); + CHECK(child(model, sizeof(model), package, "img/standard/cat_model", false)); + CHECK(child(canonical, sizeof(canonical), model, "cat.model3.json", false)); + CHECK(child(exported, sizeof(exported), model, "ys.model3.json", false)); + /* The export can carry different behavior; Mver still uses cat.model3.json. */ + static const char export_json[] = + "{\"Version\":3,\"FileReferences\":{\"Moc\":\"cat.moc3\"," + "\"Textures\":[\"texture.png\"],\"Expressions\":[" + "{\"Name\":\"unused\",\"File\":\"unused.exp3.json\"}]}}"; + CHECK(write_text(exported, export_json)); + BongoCatError error = {0}; + CHECK(bongo_cat_import_discover(root, discovery, &error)); + CHECK(discovery->count == 1); + CHECK(strcmp(discovery->candidates[0].setting, "cat.model3.json") == 0); + CHECK(child(models, sizeof(models), root, "models", true)); + BongoCatImportReceipt receipt = {0}; + CHECK(bongo_cat_import_install(package, models, &receipt, &error) == BONGO_CAT_OK); + CHECK(receipt.count == 1 && receipt.installed_count == 1); + CHECK(child(stored, sizeof(stored), models, receipt.ids[0], false)); + CHECK(bongo_cat_import_discover(stored, discovery, &error)); + CHECK(discovery->count == 1); + CHECK(strcmp(discovery->candidates[0].setting, "cat.model3.json") == 0); + CHECK(child(extra, sizeof(extra), stored, + "img/standard/cat_model/ys.model3.json", false)); + size_t length = 0; + char *copied = SDL_LoadFile(extra, &length); + CHECK(copied && length == strlen(export_json) && + memcmp(copied, export_json, length) == 0); + SDL_free(copied); + CHECK(bongo_cat_path_is_file(canonical) && bongo_cat_path_is_file(exported)); + + /* Never hide a broken canonical entry by selecting a different export. */ + CHECK(write_text(canonical, "{}")); + memset(discovery, 0, sizeof(*discovery)); + CHECK(bongo_cat_import_mver_discover_exact(package, discovery, &error) == -1); + CHECK(SDL_RemovePath(canonical)); + memset(discovery, 0, sizeof(*discovery)); + CHECK(bongo_cat_import_mver_discover_exact(package, discovery, &error) == 1); + CHECK(strcmp(discovery->candidates[0].setting, "ys.model3.json") == 0); + CHECK(child(extra, sizeof(extra), model, "other.model3.json", false)); + CHECK(write_text(extra, export_json)); + memset(discovery, 0, sizeof(*discovery)); + CHECK(bongo_cat_import_mver_discover_exact(package, discovery, &error) == -1); + CHECK(bongo_cat_model_remove_tree(root, NULL)); + SDL_free(temporary); + free(discovery); +} + void test_mver_manifest(void) { + multiple_mver_manifests(); char *temporary = SDL_GetCurrentDirectory(); CHECK(temporary != NULL); if (!temporary) return; From 858ac6f5dd77a6b57c5f300504902b19ea85bc5c Mon Sep 17 00:00:00 2001 From: vladelaina Date: Wed, 30 Sep 2026 17:22:43 +0800 Subject: [PATCH 329/334] Update README.md --- README.md | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/README.md b/README.md index fd391755..b1fc7ed0 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,3 @@ -> [!TIP] -> -> ### 💼 Looking for Remote Work Opportunities -> -> I'm currently looking for a **remote development position**. If your team is looking for a developer and you think my experience or open-source work could be a good fit, feel free to contact me at: -> -> -> 我目前正在寻找一份远程开发相关的工作。如果您的团队正在寻找开发者,并且认为我的经验或开源项目经历可能适合你们,欢迎通过以下邮箱联系我: -> -> 📧 [**vladelaina@gmail.com**](mailto:vladelaina@gmail.com)

From e4bdfe4d6ef4833a3460a24e214a83a58dadf025 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=B4=A0=E8=89=B2=E5=BC=A7=E7=BA=BF?= Date: Thu, 1 Oct 2026 13:45:18 +0800 Subject: [PATCH 330/334] Update README.zh-CN.md --- docs/README.zh-CN.md | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index e2877d63..2cec2281 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -60,24 +60,24 @@ BongoCat 使用 CMake,需要 C11 编译器、C++17 编译器、CMake 3.24 或 ### 🔧 配置与构建 -在 Linux 和 macOS 上,请使用 Ninja 这样的单配置生成器: +- **在 Linux 和 macOS 上**,请使用类似 Ninja 这样的单配置生成器: -```bash -cmake -S . -B build -G Ninja \ - -DCMAKE_BUILD_TYPE=Release \ - -DBONGO_CAT_FETCH_DEPS=ON -cmake --build build --parallel -``` - -在 Windows 上,请从 Visual Studio 2022 开发者命令行(或 MSVC 可用的其他命令行)运行: + ```bash + cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=ON + cmake --build build --parallel + ``` -```powershell -cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` - -DBONGO_CAT_FETCH_DEPS=ON -cmake --build build --config Release --parallel -``` +- **在 Windows 上**,请从 Visual Studio 2022 开发者命令行(或 MSVC 可用的其他命令行)运行: -可执行文件位于:Linux 的 `build/BongoCat`,macOS 的 `build/BongoCat.app/Contents/MacOS/BongoCat`,Visual Studio 构建的 Windows 版本为 `build/Release/BongoCat.exe`。 + ```powershell + cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` + -DBONGO_CAT_FETCH_DEPS=ON + cmake --build build --config Release --parallel + ``` +> [!TIP] +> 可执行文件位于:Linux 的 `build/BongoCat`,macOS 的 `build/BongoCat.app/Contents/MacOS/BongoCat`,Visual Studio 构建的 Windows 版本为 `build/Release/BongoCat.exe`。 ### 🧪 测试 @@ -226,7 +226,7 @@ flowchart TB ### 🔒 BongoCat 会记录我的键盘或鼠标输入吗? -不会。BongoCat 在本地处理键盘和鼠标输入,用于驱动动画和快捷键。它不会记录或上传按键、鼠标操作或其他交互数据。配置也只保存在本地,应用不包含广告、分析工具或用户跟踪代码。执行更新检查时只会请求公开的版本元数据,不会发送输入、配置或使用数据。 +不会。BongoCat 仅在本地即时处理键盘和鼠标输入,用于渲染动画和触发快捷键。它不会记录或上传按键、鼠标操作或其他交互数据。配置也只保存在本地。BongoCat 不包含广告、分析工具或用户跟踪代码。执行更新检查时只会请求公开的版本元数据,不会发送输入、配置或使用数据。 ### Linux Wayland 输入 From ab2f2bdbc8bbfc7b54e3a4a8d8eefd0447ce5504 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=B4=A0=E8=89=B2=E5=BC=A7=E7=BA=BF?= Date: Thu, 1 Oct 2026 14:06:07 +0800 Subject: [PATCH 331/334] Update README.zh-CN.md --- docs/README.zh-CN.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 2cec2281..dacccbbd 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -76,8 +76,12 @@ BongoCat 使用 CMake,需要 C11 编译器、C++17 编译器、CMake 3.24 或 -DBONGO_CAT_FETCH_DEPS=ON cmake --build build --config Release --parallel ``` -> [!TIP] -> 可执行文件位于:Linux 的 `build/BongoCat`,macOS 的 `build/BongoCat.app/Contents/MacOS/BongoCat`,Visual Studio 构建的 Windows 版本为 `build/Release/BongoCat.exe`。 +> [!NOTE] +> 可执行文件位置: +> +> - Linux:`build/BongoCat` +> - macOS:`build/BongoCat.app/Contents/MacOS/BongoCat` +> - Windows(Visual Studio 构建):`build/Release/BongoCat.exe` ### 🧪 测试 From 955cd20ebb2fe8d0d792c368465b8f33dc348e31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=B4=A0=E8=89=B2=E5=BC=A7=E7=BA=BF?= Date: Thu, 1 Oct 2026 14:28:50 +0800 Subject: [PATCH 332/334] Update README.zh-CN.md --- docs/README.zh-CN.md | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index dacccbbd..d56ebe09 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -6,8 +6,11 @@

💘 C/C++ × SDL3 × OpenGL,搅拌在一起,尽情敲击!Bong~ Bongo Cat!!!

+ +

支持的操作系统:Windows、macOS、Linux

+

- 选择语言 ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia + English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia

@@ -19,10 +22,11 @@

-> [!TIP] +> [!NOTE] > 演示中使用的模型来自 [宇痕冫](https://space.bilibili.com/348616056)。 -> -> 🎁 想找**免费**模型?我们与才华横溢的模型创作者合作,为您带来丰富多样的免费模型,同时持续探索更多有趣的桌面体验!欢迎访问我们的官方网站:[bongocat.pet](https://bongocat.pet/models) + +>[!TIP] +> 想找**免费**模型?我们与才华横溢的模型创作者们合作,为您带来丰富多样的免费模型,持续探索更多有趣的桌面体验!请访问我们的官方网站:[bongocat.pet](https://bongocat.pet/models)

@@ -33,10 +37,10 @@

## 📥 下载 +- ### Microsoft Store + - - -- GitHub Releases +- ### GitHub Releases 从 [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest) 下载最新版本。 @@ -46,7 +50,7 @@ BongoCat 使用 CMake,需要 C11 编译器、C++17 编译器、CMake 3.24 或 请在项目根目录(包含 `CMakeLists.txt` 的目录)运行以下命令。 -### 📋 平台前置条件 +### 平台前置条件 - **Windows:** Visual Studio 2022(安装“使用 C++ 的桌面开发”工作负载)和 CMake。请使用 MSVC 生成器;MinGW 可构建诊断后端,但不支持 Cubism SDK。 - **macOS:** Xcode Command Line Tools、CMake 和 Ninja。如果目标架构与主机默认架构不同,请通过 `CMAKE_OSX_ARCHITECTURES` 指定。 @@ -58,9 +62,9 @@ BongoCat 使用 CMake,需要 C11 编译器、C++17 编译器、CMake 3.24 或 libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev libcurl4-openssl-dev ``` -### 🔧 配置与构建 +### 配置与构建 -- **在 Linux 和 macOS 上**,请使用类似 Ninja 这样的单配置生成器: +- **在 Linux 和 macOS 上**,请使用类似 Ninja 这样的单配置生成器运行: ```bash cmake -S . -B build -G Ninja \ @@ -83,7 +87,7 @@ BongoCat 使用 CMake,需要 C11 编译器、C++17 编译器、CMake 3.24 或 > - macOS:`build/BongoCat.app/Contents/MacOS/BongoCat` > - Windows(Visual Studio 构建):`build/Release/BongoCat.exe` -### 🧪 测试 +### 测试 CTest 目标默认启用。构建后运行: @@ -97,7 +101,7 @@ ctest --test-dir build --output-on-failure ctest --test-dir build -C Release --output-on-failure ``` -### 🎭 Live2D / Cubism SDK(可选) +### Live2D / Cubism SDK(可选) 如果未找到 Cubism SDK,CMake 会发出警告并构建诊断后端。该后端用于启动和平台诊断,不提供 Live2D 模型渲染。要构建完整运行时,请安装兼容的 Cubism SDK for Native,将其放置在 `vendor/CubismSdkForNative`,或显式传入路径: @@ -110,7 +114,7 @@ cmake -S . -B build -G Ninja \ SDK 必须包含 Core 库、Framework 源码,以及 `cmake/Cubism.cmake` 所要求布局中的 OpenGL GLEW 第三方目录。Windows Cubism 构建需要 Visual Studio 2022。`BONGO_CAT_REQUIRE_CUBISM=ON` 会在 SDK 不可用时使配置失败,而不是静默选择诊断后端。 -### ⚙️ CMake 选项 +### CMake 选项 | 选项 | 默认值 | 说明 | | --- | --- | --- | @@ -228,7 +232,7 @@ flowchart TB ## ❓ 常见问题 -### 🔒 BongoCat 会记录我的键盘或鼠标输入吗? +### BongoCat 会记录我的键盘或鼠标输入吗? 不会。BongoCat 仅在本地即时处理键盘和鼠标输入,用于渲染动画和触发快捷键。它不会记录或上传按键、鼠标操作或其他交互数据。配置也只保存在本地。BongoCat 不包含广告、分析工具或用户跟踪代码。执行更新检查时只会请求公开的版本元数据,不会发送输入、配置或使用数据。 @@ -246,7 +250,7 @@ BONGOCAT_ENABLE_EVDEV=1 ./build/BongoCat 退出程序才会停止监听,隐藏宠物不会停止;不带该变量重新启动即可恢复默认后端。 鼠标跟随使用未经加速的设备位移,窗口定位、点击穿透和置顶仍取决于 Wayland 合成器。 -### 🖼️ 为什么使用 OpenGL 而不是 Vulkan? +### 为什么使用 OpenGL 而不是 Vulkan? 这不是因为 Vulkan 不好,而是 BongoCat 不需要那种程度的复杂性。应用主要渲染一个 Live2D 模型、少量 UI 图层和透明桌面窗口,OpenGL 已能轻松满足需求,并且能自然地与 SDL3 及 Cubism 的 OpenGL 渲染器配合。迁移到 Vulkan 将需要在三个桌面平台维护更多渲染和同步代码,却不会为用户带来明显提升。对于 BongoCat 当前的工作负载,OpenGL 让渲染器更精简、更易调试和维护,同时仍能提供所需性能。 From e6f9b044bcbabbe8da886ce44b1157d2dd2965bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=B4=A0=E8=89=B2=E5=BC=A7=E7=BA=BF?= Date: Thu, 1 Oct 2026 15:03:31 +0800 Subject: [PATCH 333/334] Update README.zh-CN.md --- docs/README.zh-CN.md | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index d56ebe09..25e7e535 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -148,24 +148,23 @@ BongoCat 源代码和本地运行时采用 [AGPL-3.0-only](../LICENSE) 许可证 当前原生版本基于 C/C++、SDL3 和 OpenGL 构建。下图重点展示运行时数据流;构建与打包细节请参阅 CMake 文件。 -### 🔄 运行时所有权与帧调度 +### 运行时所有权与帧调度 每个进程拥有一个 `BongoCatApp` 和一个主线程事件/渲染循环。平台监听器在输入边界处停止: -```text -平台监听器(键盘/指针) - | - v - C11 输入状态(原子边沿队列 + 合并后的指针位置) - | - v - 主线程应用 <----- SDL3 事件 - | - v - 模型参数、覆盖层和 UI 状态 - | - v - 模型更新 -> OpenGL 合成 -> 平台呈现 +```mermaid +flowchart TB + Listener["平台监听器(键盘/指针)"] --> InputState["C11 输入状态(原子边沿队列 + 合并后的指针位置)"] + InputState --> MainThread["主线程应用"] + SDLEvent["SDL3 事件"] --> MainThread + MainThread --> ModelState["模型参数、覆盖层和 UI 状态"] + + subgraph Render["渲染阶段"] + direction LR + ModelUpdate["模型更新"] --> Compose["OpenGL 合成"] --> Present["平台呈现"] + end + + ModelState --> Render ``` Windows Raw Input 接收器、macOS Quartz 事件 tap 和 Linux XInput2 监听器运行在主循环之外。按键和鼠标按钮边沿进入有界原子队列,移动量单独合并,并通过 SDL 事件唤醒主线程,避免高频移动挤出按键事件。Windows 使用独立消息窗口,以 `RIDEV_INPUTSINK | RIDEV_DEVNOTIFY` 订阅后台键鼠输入并保留普通窗口消息;其他程序隐藏或锁定光标时,模型使用设备上报的移动量,桌面跟随则读取 SDL 提供的系统光标位置。接收器分别管理各设备的按下状态,在设备拔出或输入桌面切换时清理。Windows 不再安装输入钩子或使用 DirectInput,也不向游戏发送输入。SDL3 窗口、偏好设置和手柄事件仍在主线程处理,平台监听器不会直接调用 Live2D、覆盖层或 UI 代码。 @@ -252,10 +251,10 @@ BONGOCAT_ENABLE_EVDEV=1 ./build/BongoCat ### 为什么使用 OpenGL 而不是 Vulkan? -这不是因为 Vulkan 不好,而是 BongoCat 不需要那种程度的复杂性。应用主要渲染一个 Live2D 模型、少量 UI 图层和透明桌面窗口,OpenGL 已能轻松满足需求,并且能自然地与 SDL3 及 Cubism 的 OpenGL 渲染器配合。迁移到 Vulkan 将需要在三个桌面平台维护更多渲染和同步代码,却不会为用户带来明显提升。对于 BongoCat 当前的工作负载,OpenGL 让渲染器更精简、更易调试和维护,同时仍能提供所需性能。 +不是因为 Vulkan 不好,而是 BongoCat 不需要那种程度的复杂性。应用主要渲染一个 Live2D 模型、少量 UI 图层和透明桌面窗口,OpenGL 已能轻松满足,并且能自然地与 SDL3 及 Cubism 的 OpenGL 渲染器配合。迁移到 Vulkan 将需要在三个桌面平台维护更多渲染和同步代码,却不会为用户带来明显提升。对于 BongoCat 当前的工作负载,OpenGL 让渲染器更精简、更易调试和维护,同时仍能提供所需性能。 ## 🙏 特别感谢 -> [!TIP] +> [!NOTE] > BongoCat 的每一步都得益于开源精神。我们衷心感谢所有社区贡献者的无私奉献(按贡献日期先后排序列于下方)。正是你们的支持,让桌面陪伴更加自由与真诚。❤️‍🔥 From e427c06f3c178ad11bb1455949b9a668cf41ff8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=B4=A0=E8=89=B2=E5=BC=A7=E7=BA=BF?= Date: Thu, 1 Oct 2026 16:40:46 +0800 Subject: [PATCH 334/334] =?UTF-8?q?=E6=9B=B4=E6=8D=A2=E4=BA=86=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=A0=8F=E5=9B=BE=E6=A0=87=E4=BB=A5=E5=8C=B9=E9=85=8D?= =?UTF-8?q?macOS=E7=9A=84=E4=B8=80=E8=88=AC=E8=A7=86=E8=A7=89=E9=A3=8E?= =?UTF-8?q?=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resources/assets/bongocat.png | Bin 97460 -> 49306 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/resources/assets/bongocat.png b/resources/assets/bongocat.png index 7194d588124bb270657ffabd6be7baade52d8439..317021463fe9f9fe85e294362ec9386f9a5f893d 100644 GIT binary patch literal 49306 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≻I9e;SZ5aDE=%qS<3+XZ^vZ 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 literal 97460 zcmeEt`#%$2{6CdcLM6Az-rZ2SBr(^mQb`%zDVJeHxy&W^*(yb@3+2+Vr09Z~F!#xQ zZcAii7@Pb3GPBJt-#*_TKYzpLhx2%y*Lgh7IgiKdTwdq(dOlCwEgMVeeMk0*iHS*D zUB7ZiOl-%$>JG6zyZ;GR*Z}XJ5D&Uzd0DKaS9R{6vdi-l

e9xcUpmH4Oc^}qfw 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

^!;u)=`CI^VT{V*E`qf(z= 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

(9~$&32-He^0;Gt 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%0A-%qM+l$!WkmX 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 a6gs~3BI=bdZ*~CiabHtkqvEb@`2Pc7-CIZi